From 32bef75e653a0758d13edb6573d97499343cca63 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 25 Jun 2026 13:29:36 +0100 Subject: [PATCH 1/2] feat(notifications): focus-aware notification bus on quill toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every app notification through one bus that delivers by focus + what you're looking at: - viewing the exact target (task/canvas) → suppress - app focused but elsewhere → in-app toast (with a click-through) - app unfocused → native OS notification (click deep-links to the target) Core changes: - NotificationBus (renderer) replaces TaskNotificationService; pure, tested routeNotification() decides the tier from a generic NotificationTarget union (task | canvas) defined in @posthog/platform. - Native click routing generalized via a new OpenTargetLinkService + deep-link router onOpenTarget/getPendingOpenTarget; the renderer consumer drains pending on every (re)subscribe so a click during a reconnect/HMR gap still routes. - Task notifications (prompt-complete, permission) migrated through the bus; canvas-generation-done re-emits through it (detection kept). - Toast primitive rebuilt on @posthog/quill (provider-managed dismiss, stacking, auto-dismiss); all direct-sonner callers migrated and sonner dropped. - New Settings → Notifications section: defaults, OS-permission banner, reset-to-defaults, and a per-tier test harness. Canvas copy gated on the bluebird flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/code/src/main/di/bindings.ts | 5 + apps/code/src/main/di/container.ts | 7 + apps/code/src/main/di/tokens.ts | 3 + apps/code/src/renderer/desktop-services.ts | 30 +- packages/core/src/links/identifiers.ts | 6 + packages/core/src/links/open-target-link.ts | 63 +++ .../src/notification/notification.test.ts | 24 +- .../core/src/notification/notification.ts | 30 +- .../src/routers/deep-link.router.ts | 28 ++ .../src/routers/notification-target.test.ts | 32 ++ .../src/routers/notification.router.ts | 19 +- packages/platform/src/notifications.ts | 10 +- packages/ui/package.json | 1 - .../canvas/freeform/FreeformCanvasView.tsx | 32 +- .../freeform/canvasGenerationStatus.test.ts | 154 ++++++- .../canvas/freeform/canvasGenerationStatus.ts | 85 +++- .../freeform/useCanvasGenerationToasts.ts | 164 ++++++++ .../canvas/hooks/useGenerateFreeformCanvas.ts | 15 +- .../stores/canvasGenerationTrackerStore.ts | 47 +++ .../connectivity/connectivityToast.ts | 20 +- .../features/deep-links/useHandleOpenTask.ts | 98 +++++ .../deep-links/useOpenTargetDeepLink.test.tsx | 108 +++++ .../deep-links/useOpenTargetDeepLink.ts | 62 +++ .../features/deep-links/useTaskDeepLink.ts | 100 +---- packages/ui/src/features/focus/focusToast.tsx | 16 +- .../components/TaskActionsMenu.tsx | 2 +- .../components/ConfigureAgentsSection.tsx | 3 +- .../inbox/components/DataSourceSetup.tsx | 2 +- .../inbox/hooks/useDiscussReport.test.tsx | 7 +- .../inbox/hooks/useInboxBulkActions.ts | 2 +- .../inbox/hooks/useInboxCloudTaskRunner.ts | 3 +- .../inbox/hooks/useInboxReports.test.tsx | 4 +- .../features/inbox/hooks/useInboxReports.ts | 2 +- .../inbox/hooks/useInboxRestoreReport.ts | 2 +- .../inbox/hooks/useOpenInboxReport.ts | 2 +- .../inbox/hooks/useSignalEvaluations.ts | 2 +- .../inbox/hooks/useSignalSourceToggles.ts | 2 +- .../hooks/useSignalTeamConfigMutations.ts | 2 +- .../hooks/useSignalUserAutonomyMutations.ts | 2 +- .../inbox/utils/copyInboxReportLink.ts | 2 +- .../hooks/useMcpInstallationTools.ts | 2 +- .../mcp-servers/hooks/useMcpServers.ts | 2 +- .../src/features/notifications/identifiers.ts | 5 +- .../notifications/notifications.module.ts | 4 +- .../notifications/notifications.test.ts | 218 +++++----- .../features/notifications/notifications.ts | 130 ++++-- .../notifications/routeNotification.test.ts | 98 +++++ .../notifications/routeNotification.ts | 37 ++ .../components/ScoutFindingShareButton.tsx | 2 +- .../scouts/hooks/useScoutConfigMutations.ts | 2 +- ...onServiceHost.recovery.integration.test.ts | 5 +- .../sessions/sessionServiceHost.test.ts | 5 +- .../features/sessions/sessionServiceHost.ts | 6 +- .../settings/components/SettingsPanel.tsx | 5 + .../settings/sections/GeneralSettings.tsx | 177 +------- .../sections/NotificationsSettings.tsx | 394 ++++++++++++++++++ .../ui/src/features/settings/settingsStore.ts | 16 +- packages/ui/src/features/settings/types.ts | 2 + .../features/workspace/useFocusWorkspace.tsx | 19 +- packages/ui/src/primitives/toast.test.ts | 98 +++++ packages/ui/src/primitives/toast.tsx | 231 ++++------ packages/ui/src/router/routes/__root.tsx | 2 + packages/ui/src/shell/App.tsx | 46 +- pnpm-lock.yaml | 14 - 64 files changed, 1988 insertions(+), 730 deletions(-) create mode 100644 packages/core/src/links/open-target-link.ts create mode 100644 packages/host-router/src/routers/notification-target.test.ts create mode 100644 packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts create mode 100644 packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts create mode 100644 packages/ui/src/features/deep-links/useHandleOpenTask.ts create mode 100644 packages/ui/src/features/deep-links/useOpenTargetDeepLink.test.tsx create mode 100644 packages/ui/src/features/deep-links/useOpenTargetDeepLink.ts create mode 100644 packages/ui/src/features/notifications/routeNotification.test.ts create mode 100644 packages/ui/src/features/notifications/routeNotification.ts create mode 100644 packages/ui/src/features/settings/sections/NotificationsSettings.tsx create mode 100644 packages/ui/src/primitives/toast.test.ts diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index fb5e58adb9..f25e3366e0 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -49,11 +49,13 @@ import type { APPROVAL_LINK_SERVICE, INBOX_LINK_SERVICE, NEW_TASK_LINK_SERVICE, + OPEN_TARGET_LINK_SERVICE, SCOUT_LINK_SERVICE, TASK_LINK_SERVICE, } from "@posthog/core/links/identifiers"; import type { InboxLinkService } from "@posthog/core/links/inbox-link"; import type { NewTaskLinkService } from "@posthog/core/links/new-task-link"; +import type { OpenTargetLinkService } from "@posthog/core/links/open-target-link"; import type { ScoutLinkService } from "@posthog/core/links/scout-link"; import type { TaskLinkService } from "@posthog/core/links/task-link"; import type { @@ -254,6 +256,7 @@ import type { LLM_GATEWAY_SERVICE as MAIN_LLM_GATEWAY_SERVICE, MCP_APPS_SERVICE as MAIN_MCP_APPS_SERVICE, NEW_TASK_LINK_SERVICE as MAIN_NEW_TASK_LINK_SERVICE, + OPEN_TARGET_LINK_SERVICE as MAIN_OPEN_TARGET_LINK_SERVICE, POSTHOG_PLUGIN_SERVICE as MAIN_POSTHOG_PLUGIN_SERVICE, PROCESS_TRACKING_SERVICE as MAIN_PROCESS_TRACKING_SERVICE, PROVISIONING_SERVICE as MAIN_PROVISIONING_SERVICE, @@ -405,11 +408,13 @@ export interface MainBindings { [MAIN_SCOUT_LINK_SERVICE]: ScoutLinkService; [MAIN_NEW_TASK_LINK_SERVICE]: NewTaskLinkService; [MAIN_APPROVAL_LINK_SERVICE]: ApprovalLinkService; + [MAIN_OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService; [TASK_LINK_SERVICE]: TaskLinkService; [INBOX_LINK_SERVICE]: InboxLinkService; [SCOUT_LINK_SERVICE]: ScoutLinkService; [NEW_TASK_LINK_SERVICE]: NewTaskLinkService; [APPROVAL_LINK_SERVICE]: ApprovalLinkService; + [OPEN_TARGET_LINK_SERVICE]: OpenTargetLinkService; // Watcher registry [MAIN_WATCHER_REGISTRY_SERVICE]: WatcherRegistryService; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 3c3a7b4e07..4b3528ad64 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -51,11 +51,13 @@ import { APPROVAL_LINK_SERVICE, INBOX_LINK_SERVICE, NEW_TASK_LINK_SERVICE, + OPEN_TARGET_LINK_SERVICE, SCOUT_LINK_SERVICE, TASK_LINK_SERVICE, } from "@posthog/core/links/identifiers"; import { InboxLinkService } from "@posthog/core/links/inbox-link"; import { NewTaskLinkService } from "@posthog/core/links/new-task-link"; +import { OpenTargetLinkService } from "@posthog/core/links/open-target-link"; import { ScoutLinkService } from "@posthog/core/links/scout-link"; import { TaskLinkService } from "@posthog/core/links/task-link"; import { @@ -266,6 +268,7 @@ import { LLM_GATEWAY_SERVICE as MAIN_LLM_GATEWAY_SERVICE, MCP_APPS_SERVICE as MAIN_MCP_APPS_SERVICE, NEW_TASK_LINK_SERVICE as MAIN_NEW_TASK_LINK_SERVICE, + OPEN_TARGET_LINK_SERVICE as MAIN_OPEN_TARGET_LINK_SERVICE, POSTHOG_PLUGIN_SERVICE as MAIN_POSTHOG_PLUGIN_SERVICE, PROCESS_TRACKING_SERVICE as MAIN_PROCESS_TRACKING_SERVICE, PROVISIONING_SERVICE as MAIN_PROVISIONING_SERVICE, @@ -621,6 +624,10 @@ container.bind(MAIN_NEW_TASK_LINK_SERVICE).to(NewTaskLinkService); container.bind(NEW_TASK_LINK_SERVICE).toService(MAIN_NEW_TASK_LINK_SERVICE); container.bind(MAIN_APPROVAL_LINK_SERVICE).to(ApprovalLinkService); container.bind(APPROVAL_LINK_SERVICE).toService(MAIN_APPROVAL_LINK_SERVICE); +container.bind(MAIN_OPEN_TARGET_LINK_SERVICE).to(OpenTargetLinkService); +container + .bind(OPEN_TARGET_LINK_SERVICE) + .toService(MAIN_OPEN_TARGET_LINK_SERVICE); container.load(watcherRegistryModule); container .bind(MAIN_WATCHER_REGISTRY_SERVICE) diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index fc491b27e3..385cfed8c3 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -106,6 +106,9 @@ export const NEW_TASK_LINK_SERVICE = Symbol.for( export const APPROVAL_LINK_SERVICE = Symbol.for( "posthog.host.main.approval-link.service", ); +export const OPEN_TARGET_LINK_SERVICE = Symbol.for( + "posthog.host.main.open-target-link.service", +); export const WATCHER_REGISTRY_SERVICE = Symbol.for( "posthog.host.main.watcher-registry.service", ); diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 02fb9ddaa3..217712fbb7 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -39,6 +39,7 @@ import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { type INotifications, NOTIFICATIONS_SERVICE, + type NotificationTarget, } from "@posthog/platform/notifications"; import type { CloudRegion } from "@posthog/shared"; import { @@ -72,7 +73,7 @@ import { type AgentPromptSender, } from "@posthog/ui/features/sessions/agentPromptSender"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; -import { getAppViewSnapshot } from "@posthog/ui/router/useAppView"; +import { getCurrentMatches } from "@posthog/ui/router/navigationBridge"; import { HEDGEHOG_MODE_HOST } from "@posthog/ui/shell/hedgehogModeHost"; import { posthogFeatureFlags } from "@posthog/ui/shell/posthogAnalyticsImpl"; import { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/shell/queryClient"; @@ -207,9 +208,30 @@ container container.bind(ACTIVE_VIEW_PROVIDER).toConstantValue({ hasFocus: () => document.hasFocus(), - getActiveTaskId: () => { - const view = getAppViewSnapshot(); - return view.type === "task-detail" ? view.taskId : undefined; + // Read the active leaf route directly: AppView collapses the channel routes + // and drops channelId/dashboardId, which we need to identify a canvas target. + getActiveTarget: (): NotificationTarget | undefined => { + const matches = getCurrentMatches(); + const last = matches[matches.length - 1]; + if (!last) return undefined; + const params = last.params as Record; + switch (last.routeId) { + case "/code/tasks/$taskId": + case "/website/$channelId/tasks/$taskId": + return params.taskId + ? { kind: "task", taskId: params.taskId } + : undefined; + case "/website/$channelId/dashboards/$dashboardId": + return params.channelId && params.dashboardId + ? { + kind: "canvas", + channelId: params.channelId, + dashboardId: params.dashboardId, + } + : undefined; + default: + return undefined; + } }, }); diff --git a/packages/core/src/links/identifiers.ts b/packages/core/src/links/identifiers.ts index 053e283004..8223fb3f21 100644 --- a/packages/core/src/links/identifiers.ts +++ b/packages/core/src/links/identifiers.ts @@ -14,3 +14,9 @@ export const NEW_TASK_LINK_SERVICE = Symbol.for( export const APPROVAL_LINK_SERVICE = Symbol.for( "posthog.core.approvalLinkService", ); +// Carries notification-click "open this target" intent from main → renderer. +// Unlike the link services above, it registers no OS URL-scheme handler — it +// exists purely so a clicked native notification can navigate to its target. +export const OPEN_TARGET_LINK_SERVICE = Symbol.for( + "posthog.core.openTargetLinkService", +); diff --git a/packages/core/src/links/open-target-link.ts b/packages/core/src/links/open-target-link.ts new file mode 100644 index 0000000000..9a9a59c468 --- /dev/null +++ b/packages/core/src/links/open-target-link.ts @@ -0,0 +1,63 @@ +import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import type { NotificationTarget } from "@posthog/platform/notifications"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { LinkLogger } from "./identifiers"; + +export const OpenTargetLinkEvent = { + Open: "open", +} as const; + +export interface OpenTargetLinkEvents { + [OpenTargetLinkEvent.Open]: NotificationTarget; +} + +// Carries "open this target" intents (from a clicked native notification) out of +// the main process to the renderer, which navigates by target kind. Mirrors +// TaskLinkService's pending-replay + window-focus, but registers NO OS +// URL-scheme handler — notification clicks are its only source, so it stays +// target-generic without entangling URL parsing. +@injectable() +export class OpenTargetLinkService extends TypedEventEmitter { + private pending: NotificationTarget | null = null; + private readonly log: LinkLogger; + + constructor( + @inject(MAIN_WINDOW_SERVICE) + private readonly mainWindow: IMainWindow, + @inject(ROOT_LOGGER) + rootLogger: RootLogger, + ) { + super(); + this.log = rootLogger.scope("open-target-link-service"); + } + + // Called from the notification click handler (main process). Emits to the + // renderer if it's listening, else queues for replay once it subscribes. + open(target: NotificationTarget): void { + if (this.listenerCount(OpenTargetLinkEvent.Open) > 0) { + this.log.info("Emitting open-target event", { kind: target.kind }); + this.emit(OpenTargetLinkEvent.Open, target); + } else { + this.log.info("Queueing open-target (renderer not ready)", { + kind: target.kind, + }); + this.pending = target; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + } + + consumePending(): NotificationTarget | null { + const pending = this.pending; + this.pending = null; + return pending; + } +} diff --git a/packages/core/src/notification/notification.test.ts b/packages/core/src/notification/notification.test.ts index e925e57243..4cd752c377 100644 --- a/packages/core/src/notification/notification.test.ts +++ b/packages/core/src/notification/notification.test.ts @@ -1,6 +1,5 @@ import type { INotifier, NotifyOptions } from "@posthog/platform/notifier"; import { describe, expect, it, vi } from "vitest"; -import { TaskLinkEvent } from "../links/task-link"; import { NotificationService } from "./notification"; function makeLogger() { @@ -35,10 +34,10 @@ function createDeps(supported = true) { focus: vi.fn(), }; - const taskLinkService = { emit: vi.fn() }; + const openTargetLink = { open: vi.fn() }; const service = new NotificationService( - taskLinkService as never, + openTargetLink as never, notifier, mainWindow as never, makeLogger(), @@ -48,7 +47,7 @@ function createDeps(supported = true) { service, notifier, mainWindow, - taskLinkService, + openTargetLink, getLastNotify: () => lastNotify, getFocusHandler: () => focusHandler, }; @@ -82,24 +81,23 @@ describe("NotificationService.send", () => { expect(mainWindow.focus).toHaveBeenCalled(); }); - it("emits OpenTask on click when a taskId is provided", () => { - const { service, taskLinkService, getLastNotify } = createDeps(); + it("opens the target on click when one is provided", () => { + const { service, openTargetLink, getLastNotify } = createDeps(); - service.send("Title", "Body", false, "task-9"); + const target = { kind: "task" as const, taskId: "task-9" }; + service.send("Title", "Body", false, target); getLastNotify()?.onClick?.(); - expect(taskLinkService.emit).toHaveBeenCalledWith(TaskLinkEvent.OpenTask, { - taskId: "task-9", - }); + expect(openTargetLink.open).toHaveBeenCalledWith(target); }); - it("does not emit OpenTask on click without a taskId", () => { - const { service, taskLinkService, getLastNotify } = createDeps(); + it("does not open a target on click when none is provided", () => { + const { service, openTargetLink, getLastNotify } = createDeps(); service.send("Title", "Body", false); getLastNotify()?.onClick?.(); - expect(taskLinkService.emit).not.toHaveBeenCalled(); + expect(openTargetLink.open).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/notification/notification.ts b/packages/core/src/notification/notification.ts index 9def67937b..eb3978c05b 100644 --- a/packages/core/src/notification/notification.ts +++ b/packages/core/src/notification/notification.ts @@ -7,10 +7,11 @@ import { type IMainWindow, MAIN_WINDOW_SERVICE, } from "@posthog/platform/main-window"; +import type { NotificationTarget } from "@posthog/platform/notifications"; import { type INotifier, NOTIFIER_SERVICE } from "@posthog/platform/notifier"; import { inject, injectable, postConstruct } from "inversify"; -import { TASK_LINK_SERVICE } from "../links/identifiers"; -import { TaskLinkEvent, type TaskLinkService } from "../links/task-link"; +import { OPEN_TARGET_LINK_SERVICE } from "../links/identifiers"; +import type { OpenTargetLinkService } from "../links/open-target-link"; @injectable() export class NotificationService { @@ -18,8 +19,8 @@ export class NotificationService { private readonly log: ScopedLogger; constructor( - @inject(TASK_LINK_SERVICE) - private readonly taskLinkService: TaskLinkService, + @inject(OPEN_TARGET_LINK_SERVICE) + private readonly openTargetLink: OpenTargetLinkService, @inject(NOTIFIER_SERVICE) private readonly notifier: INotifier, @inject(MAIN_WINDOW_SERVICE) @@ -35,7 +36,12 @@ export class NotificationService { this.mainWindow.onFocus(() => this.clearDockBadge()); } - send(title: string, body: string, silent: boolean, taskId?: string): void { + send( + title: string, + body: string, + silent: boolean, + target?: NotificationTarget, + ): void { if (!this.notifier.isSupported()) { this.log.warn("Notifications not supported on this platform"); return; @@ -48,20 +54,24 @@ export class NotificationService { onClick: () => { this.log.info("Notification clicked, focusing window", { title, - taskId, + target: target?.kind, }); if (this.mainWindow.isMinimized()) { this.mainWindow.restore(); } this.mainWindow.focus(); - if (taskId) { - this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); - this.log.info("Notification clicked, navigating to task", { taskId }); + if (target) { + // Window focus is handled inside open(); we still focus above so a + // targetless notification raises the app too. + this.openTargetLink.open(target); + this.log.info("Notification clicked, navigating to target", { + kind: target.kind, + }); } }, }); - this.log.info("Notification sent", { title, body, silent, taskId }); + this.log.info("Notification sent", { title, body, silent, target }); } showDockBadge(): void { diff --git a/packages/host-router/src/routers/deep-link.router.ts b/packages/host-router/src/routers/deep-link.router.ts index 64324ff5dd..a03545077e 100644 --- a/packages/host-router/src/routers/deep-link.router.ts +++ b/packages/host-router/src/routers/deep-link.router.ts @@ -7,6 +7,7 @@ import { APPROVAL_LINK_SERVICE, INBOX_LINK_SERVICE, NEW_TASK_LINK_SERVICE, + OPEN_TARGET_LINK_SERVICE, SCOUT_LINK_SERVICE, TASK_LINK_SERVICE, } from "@posthog/core/links/identifiers"; @@ -20,6 +21,10 @@ import { type NewTaskLinkPayload, type NewTaskLinkService, } from "@posthog/core/links/new-task-link"; +import { + OpenTargetLinkEvent, + type OpenTargetLinkService, +} from "@posthog/core/links/open-target-link"; import { ScoutLinkEvent, type ScoutLinkPayload, @@ -31,6 +36,7 @@ import { type TaskLinkService, } from "@posthog/core/links/task-link"; import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { NotificationTarget } from "@posthog/platform/notifications"; export const deepLinkRouter = router({ onOpenTask: publicProcedure.subscription(async function* (opts) { @@ -128,4 +134,26 @@ export const deepLinkRouter = router({ .consumePendingDeepLink(); }, ), + + // Generic "open this target" intents from clicked native notifications. The + // renderer subscribes and navigates by target kind (task / canvas / …). + onOpenTarget: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get( + OPEN_TARGET_LINK_SERVICE, + ); + const iterable = service.toIterable(OpenTargetLinkEvent.Open, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + getPendingOpenTarget: publicProcedure.query( + ({ ctx }): NotificationTarget | null => { + return ctx.container + .get(OPEN_TARGET_LINK_SERVICE) + .consumePending(); + }, + ), }); diff --git a/packages/host-router/src/routers/notification-target.test.ts b/packages/host-router/src/routers/notification-target.test.ts new file mode 100644 index 0000000000..c62ec84382 --- /dev/null +++ b/packages/host-router/src/routers/notification-target.test.ts @@ -0,0 +1,32 @@ +import type { NotificationTarget } from "@posthog/platform/notifications"; +import { describe, expect, it } from "vitest"; +import type { z } from "zod"; +import { notificationTargetSchema } from "./notification.router"; + +// The tRPC input schema and the platform union are maintained by hand in two +// places; this asserts they stay structurally identical (assignable both ways). +type SchemaTarget = z.infer; + +// Compile-time parity: each must be assignable to the other. +const _toPlatform: NotificationTarget = {} as SchemaTarget; +const _toSchema: SchemaTarget = {} as NotificationTarget; +void _toPlatform; +void _toSchema; + +describe("notificationTargetSchema", () => { + it("parses both target kinds and rejects unknown kinds", () => { + expect( + notificationTargetSchema.parse({ kind: "task", taskId: "t1" }).kind, + ).toBe("task"); + expect( + notificationTargetSchema.parse({ + kind: "canvas", + channelId: "c1", + dashboardId: "d1", + }).kind, + ).toBe("canvas"); + expect( + notificationTargetSchema.safeParse({ kind: "nope", id: "x" }).success, + ).toBe(false); + }); +}); diff --git a/packages/host-router/src/routers/notification.router.ts b/packages/host-router/src/routers/notification.router.ts index bec04c7091..6c93a8c08f 100644 --- a/packages/host-router/src/routers/notification.router.ts +++ b/packages/host-router/src/routers/notification.router.ts @@ -3,6 +3,21 @@ import type { NotificationService } from "@posthog/core/notification/notificatio import { publicProcedure, router } from "@posthog/host-trpc/trpc"; import { z } from "zod"; +// Mirrors the `NotificationTarget` union in @posthog/platform/notifications. +// Kept in lockstep by a type-parity test (notification-target.test.ts). +export const notificationTargetSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("task"), + taskId: z.string(), + taskRunId: z.string().optional(), + }), + z.object({ + kind: z.literal("canvas"), + channelId: z.string(), + dashboardId: z.string(), + }), +]); + export const notificationRouter = router({ send: publicProcedure .input( @@ -10,13 +25,13 @@ export const notificationRouter = router({ title: z.string(), body: z.string(), silent: z.boolean(), - taskId: z.string().optional(), + target: notificationTargetSchema.optional(), }), ) .mutation(({ ctx, input }) => ctx.container .get(NOTIFICATION_SERVICE) - .send(input.title, input.body, input.silent, input.taskId), + .send(input.title, input.body, input.silent, input.target), ), showDockBadge: publicProcedure.mutation(({ ctx }) => ctx.container diff --git a/packages/platform/src/notifications.ts b/packages/platform/src/notifications.ts index 288b8a714b..c820a6dd03 100644 --- a/packages/platform/src/notifications.ts +++ b/packages/platform/src/notifications.ts @@ -1,8 +1,16 @@ +// What a notification is about, for both relevance ("is the user already looking +// at this?") and click navigation (deep-link to it). Lives here, beside the +// capability it describes, because @posthog/platform must not import internal +// packages — so core, host-router, and ui all import the type from here. +export type NotificationTarget = + | { kind: "task"; taskId: string; taskRunId?: string } + | { kind: "canvas"; channelId: string; dashboardId: string }; + export interface NotificationOptions { title: string; body: string; silent: boolean; - taskId?: string; + target?: NotificationTarget; } export interface INotifications { diff --git a/packages/ui/package.json b/packages/ui/package.json index 91e2c75cf8..9df30c443f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -101,7 +101,6 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "semver": "^7.6.0", - "sonner": "^2.0.7", "tailwindcss": "^4.2.2", "tailwindcss-scroll-mask": "^0.0.3", "tippy.js": "^6.3.7", diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx index 3508d4bc49..fd513125e1 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx @@ -17,8 +17,10 @@ import { EmptyMedia, EmptyTitle, } from "@posthog/quill"; -import { isTerminalStatus } from "@posthog/shared/domain-types"; -import { isCanvasGenerationRunning } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; +import { + isCanvasGenerating, + isCanvasGenerationRunning, +} from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useFreeformChatStore, @@ -114,24 +116,14 @@ export function FreeformCanvasView({ session: genSession, }); // Whether the agent is actively producing the canvas right now. Drives the - // "Generating…" UI (notice, composer, undo/redo). A local session stays - // "connected" after its single generation prompt completes, so key off the - // pending prompt, not the connection — otherwise the notice never clears. A - // terminal run record always wins so a stuck session can't strand the notice. - const isGenerating = (() => { - if (!genTaskId) return false; - if (genTaskLoading) return true; - if (genTask?.latest_run?.environment === "cloud") { - const cloudStatus = - genSession?.cloudStatus ?? genTask?.latest_run?.status ?? null; - return !isTerminalStatus(cloudStatus); - } - if (isTerminalStatus(genTask?.latest_run?.status)) return false; - return ( - genSession?.status === "connecting" || - genSession?.isPromptPending === true - ); - })(); + // "Generating…" UI (notice, composer, undo/redo). Shares the tested helper + // with the completion-toast watcher so both read the same signal. + const isGenerating = isCanvasGenerating({ + genTaskId, + genTaskLoading, + latestRun: genTask?.latest_run, + session: genSession, + }); // Poll the record while the session is alive so a just-published canvas // appears (the publish lands while the prompt is still pending). diff --git a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts index 45212196ed..53655410bc 100644 --- a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts +++ b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts @@ -1,10 +1,25 @@ import type { AgentSession } from "@posthog/shared"; import type { TaskRun, TaskRunStatus } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import { isCanvasGenerationRunning } from "./canvasGenerationStatus"; +import { + hasCanvasGenerationStarted, + isCanvasGenerating, + isCanvasGenerationRunning, + resolveCanvasGenerationStatus, +} from "./canvasGenerationStatus"; type Run = Pick; -type Session = Pick; +type Session = Pick; +type GenSession = Session; + +const genSession = ( + status: AgentSession["status"], + opts?: { cloudStatus?: TaskRunStatus; isPromptPending?: boolean }, +): GenSession => ({ + status, + cloudStatus: opts?.cloudStatus, + isPromptPending: opts?.isPromptPending ?? false, +}); const run = (environment: "local" | "cloud", status: TaskRunStatus): Run => ({ environment, @@ -13,7 +28,7 @@ const run = (environment: "local" | "cloud", status: TaskRunStatus): Run => ({ const session = ( status: AgentSession["status"], cloudStatus?: TaskRunStatus, -): Session => ({ status, cloudStatus }); +): Session => ({ status, cloudStatus, isPromptPending: false }); describe("isCanvasGenerationRunning", () => { it("is not running when there is no generation task", () => { @@ -117,3 +132,136 @@ describe("isCanvasGenerationRunning", () => { ).toBe(expected); }); }); + +describe("isCanvasGenerating", () => { + it("is not generating without a task, and assumes generating while loading", () => { + expect( + isCanvasGenerating({ + genTaskId: null, + genTaskLoading: false, + latestRun: undefined, + session: undefined, + }), + ).toBe(false); + expect( + isCanvasGenerating({ + genTaskId: "t1", + genTaskLoading: true, + latestRun: undefined, + session: undefined, + }), + ).toBe(true); + }); + + it.each<[string, Run, GenSession | undefined, boolean]>([ + ["cloud in_progress", run("cloud", "in_progress"), undefined, true], + [ + "cloud cloudStatus completed clears it", + run("cloud", "in_progress"), + genSession("connected", { cloudStatus: "completed" }), + false, + ], + // Local: keys off the pending prompt, NOT the connection — a session that + // lingers connected after the prompt finishes is no longer generating. + [ + "local prompt pending", + run("local", "in_progress"), + genSession("connected", { isPromptPending: true }), + true, + ], + [ + "local connected but prompt settled", + run("local", "in_progress"), + genSession("connected", { isPromptPending: false }), + false, + ], + [ + "local still connecting", + run("local", "in_progress"), + genSession("connecting"), + true, + ], + [ + "local terminal run record wins", + run("local", "completed"), + genSession("connected", { isPromptPending: true }), + false, + ], + ])("%s", (_label, latestRun, sess, expected) => { + expect( + isCanvasGenerating({ + genTaskId: "t1", + genTaskLoading: false, + latestRun, + session: sess, + }), + ).toBe(expected); + }); +}); + +describe("hasCanvasGenerationStarted", () => { + it.each<[string, Run | undefined, GenSession | undefined, boolean]>([ + // The create→connect gap: task exists, no live session, not yet in_progress. + ["not started yet", run("local", "queued"), undefined, false], + [ + "local prompt pending", + run("local", "queued"), + genSession("connected", { isPromptPending: true }), + true, + ], + [ + "local session connecting", + run("local", "queued"), + genSession("connecting"), + true, + ], + ["run in_progress", run("local", "in_progress"), undefined, true], + // A session that lingers connected after the prompt settled still counts as + // started — it's the arming latch, not the running signal. + [ + "local connected, prompt settled", + run("local", "completed"), + genSession("connected", { isPromptPending: false }), + true, + ], + ["cloud in_progress", run("cloud", "in_progress"), undefined, true], + ["cloud queued", run("cloud", "queued"), undefined, true], + ["cloud not_started", run("cloud", "not_started"), undefined, false], + ])("%s", (_label, latestRun, sess, expected) => { + expect(hasCanvasGenerationStarted({ latestRun, session: sess })).toBe( + expected, + ); + }); +}); + +describe("resolveCanvasGenerationStatus", () => { + it.each<[string, Run | undefined, GenSession | undefined, string]>([ + ["local completed", run("local", "completed"), undefined, "completed"], + ["local failed", run("local", "failed"), undefined, "failed"], + ["local cancelled", run("local", "cancelled"), undefined, "cancelled"], + // A local run that finished via the session before its record flipped + // terminal still counts as a successful completion. + [ + "local non-terminal record", + run("local", "in_progress"), + undefined, + "completed", + ], + [ + "cloud reads cloudStatus first", + run("cloud", "in_progress"), + genSession("connected", { cloudStatus: "failed" }), + "failed", + ], + [ + "cloud falls back to run record", + run("cloud", "completed"), + undefined, + "completed", + ], + ])("%s", (_label, latestRun, sess, expected) => { + expect(resolveCanvasGenerationStatus({ latestRun, session: sess })).toBe( + expected, + ); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts index 036e014586..f28cdd53d2 100644 --- a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts +++ b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts @@ -1,5 +1,9 @@ import type { AgentSession } from "@posthog/shared"; -import { isTerminalStatus, type TaskRun } from "@posthog/shared/domain-types"; +import { + isTerminalStatus, + type TaskRun, + type TaskRunStatus, +} from "@posthog/shared/domain-types"; export interface CanvasGenerationStatusInput { /** The canvas's in-flight generation task id, or null if none. */ @@ -9,9 +13,16 @@ export interface CanvasGenerationStatusInput { /** The task's latest run record (carries environment + persisted status). */ latestRun: Pick | undefined; /** The live ACP session for the task, if one is connected in this client. */ - session: Pick | undefined; + session: + | Pick + | undefined; } +export type CanvasTerminalStatus = Extract< + TaskRunStatus, + "completed" | "failed" | "cancelled" +>; + // Whether a canvas generation task is still actively running. // // Cloud and local report progress through different channels: @@ -41,3 +52,73 @@ export function isCanvasGenerationRunning({ if (isTerminalStatus(latestRun?.status)) return false; return session?.status === "connecting" || session?.status === "connected"; } + +// Whether the agent is ACTIVELY producing the canvas right now. This is the +// signal that drives the "Generating…" UI and the completion toast, and it's +// finer-grained than isCanvasGenerationRunning: a local ACP session lingers +// "connected" after its single generation prompt finishes, so here a local run +// keys off the pending prompt rather than the connection — the signal clears the +// moment generation actually ends, not whenever the session disconnects. +export function isCanvasGenerating({ + genTaskId, + genTaskLoading, + latestRun, + session, +}: CanvasGenerationStatusInput): boolean { + if (!genTaskId) return false; + if (genTaskLoading) return true; + + if (latestRun?.environment === "cloud") { + const cloudStatus = session?.cloudStatus ?? latestRun.status ?? null; + return !isTerminalStatus(cloudStatus); + } + + if (isTerminalStatus(latestRun?.status)) return false; + return session?.status === "connecting" || session?.isPromptPending === true; +} + +// Whether there's concrete evidence the generation run has actually started, as +// opposed to merely having been created. The completion-toast watcher arms on +// this so the brief gap between creating the task and its live session +// connecting — during which the local "generating" signal momentarily reads +// false — can never be mistaken for a finished run. +export function hasCanvasGenerationStarted({ + latestRun, + session, +}: { + latestRun: Pick | undefined; + session: + | Pick + | undefined; +}): boolean { + if (session?.isPromptPending) return true; + if (session?.status === "connecting" || session?.status === "connected") { + return true; + } + if (latestRun?.status === "in_progress") return true; + if (latestRun?.environment === "cloud") { + const status = session?.cloudStatus ?? latestRun?.status; + return status === "in_progress" || status === "queued"; + } + return false; +} + +// The terminal status to report once generation has finished. Cloud runs carry +// it on the live session's cloudStatus (falling back to the persisted run); +// local runs read the persisted run record. Anything that isn't an explicit +// failure/cancellation is treated as a successful completion — a local run whose +// record hasn't flipped terminal yet still finished by producing its canvas. +export function resolveCanvasGenerationStatus({ + latestRun, + session, +}: { + latestRun: Pick | undefined; + session: Pick | undefined; +}): CanvasTerminalStatus { + const status = + latestRun?.environment === "cloud" + ? (session?.cloudStatus ?? latestRun?.status) + : latestRun?.status; + if (status === "failed" || status === "cancelled") return status; + return "completed"; +} diff --git a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts new file mode 100644 index 0000000000..dc2abbe844 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts @@ -0,0 +1,164 @@ +import { useServiceOptional } from "@posthog/di/react"; +import { + type CanvasTerminalStatus, + hasCanvasGenerationStarted, + isCanvasGenerating, + resolveCanvasGenerationStatus, +} from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; +import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; +import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; +import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; +import { useQueries } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; + +// Poll cadence for the run status of a tracked generation task. Matches the +// canvas record poll in FreeformCanvasView so the toast and the in-view state +// land together. +const POLL_MS = 4000; + +// Hand a finished canvas generation to the notification bus, which decides +// whether to suppress (user is on the canvas), toast (focused elsewhere), or +// fire a native OS notification (app backgrounded) — and threads the canvas +// target so any click lands back on the canvas. +function emitCanvasGenerationNotification( + bus: NotificationBus, + entry: { channelId: string; dashboardId: string; name: string }, + status: CanvasTerminalStatus, +): void { + const name = entry.name.trim() || "Canvas"; + const target = { + kind: "canvas" as const, + channelId: entry.channelId, + dashboardId: entry.dashboardId, + }; + + if (status === "completed") { + bus.notify({ + body: `${name} is ready`, + target, + toast: { level: "success", description: "Generation finished." }, + }); + } else if (status === "failed") { + bus.notify({ + body: `${name} generation failed`, + target, + toast: { + level: "error", + description: "The agent couldn't finish building this canvas.", + }, + }); + } + // "cancelled" is user-initiated — stay silent. +} + +// Watches every canvas generation started in this client (registered in the +// tracker store) and fires a toast — with a link to the canvas — the moment each +// one stops generating. Mounted on the persistent channel layout so it keeps +// watching after the user navigates to another canvas: the whole point is to +// call them back when a backgrounded generation lands. +// +// Completion is read from the same signal the canvas view uses (isCanvasGenerating: +// the live ACP session for local runs, cloudStatus for cloud) rather than the +// dashboard's generationTaskId, which is never cleared for freeform canvases. +export function useCanvasGenerationToasts(): void { + const tracked = useCanvasGenerationTrackerStore((s) => s.tracked); + const untrack = useCanvasGenerationTrackerStore((s) => s.untrack); + // The bus is a container singleton (stable identity); capture in a ref so the + // status-keyed effect reads it without listing it as a dependency. Optional so + // hosts that don't bind it (web) simply no-op instead of throwing. + const bus = useServiceOptional(NotificationBus); + const busRef = useRef(bus); + busRef.current = bus; + + const taskIds = useMemo(() => Object.keys(tracked), [tracked]); + + const details = useQueries({ + queries: taskIds.map((id) => ({ + ...taskDetailQuery(id), + refetchInterval: POLL_MS, + })), + }); + + // The live ACP sessions — for local runs this, not the run record, is what + // tells us generation has actually finished. + const sessions = useSessionStore((s) => s.sessions); + const taskIdIndex = useSessionStore((s) => s.taskIdIndex); + + // Compute the "still generating?" signal per tracked task each render. + const states = taskIds.map((id, i) => { + const runId = taskIdIndex[id]; + const session = runId ? sessions[runId] : undefined; + const latestRun = details[i]?.data?.latest_run; + const generating = isCanvasGenerating({ + genTaskId: id, + genTaskLoading: details[i]?.isLoading ?? false, + latestRun, + session, + }); + return { id, generating, latestRun, session }; + }); + + // A stable signature so the transition effect only runs on real changes. + const sig = states + .map( + (s) => + `${s.id}:${s.generating ? 1 : 0}:${s.latestRun?.status ?? ""}:${s.session?.status ?? ""}:${s.session?.cloudStatus ?? ""}:${s.session?.isPromptPending ? 1 : 0}`, + ) + .join("|"); + + const statesRef = useRef(states); + statesRef.current = states; + // Tasks we've confirmed actually started running — only an armed task can + // toast on finishing, so the create→connect gap can't fire a false toast. + const armedRef = useRef>(new Set()); + // Tasks already toasted, so a re-run can never double-fire. + const toastedRef = useRef>(new Set()); + + // biome-ignore lint/correctness/useExhaustiveDependencies: sig is the trigger; states/store are read fresh (states via ref) when it changes. + useEffect(() => { + for (const st of statesRef.current) { + if ( + hasCanvasGenerationStarted({ + latestRun: st.latestRun, + session: st.session, + }) + ) { + armedRef.current.add(st.id); + } + + // A task only toasts once it has demonstrably run and is no longer + // generating. + if ( + !armedRef.current.has(st.id) || + st.generating || + toastedRef.current.has(st.id) + ) { + continue; + } + + toastedRef.current.add(st.id); + const entry = useCanvasGenerationTrackerStore.getState().tracked[st.id]; + if (entry && busRef.current) { + emitCanvasGenerationNotification( + busRef.current, + entry, + resolveCanvasGenerationStatus({ + latestRun: st.latestRun, + session: st.session, + }), + ); + } + // Stop tracking (and polling) this task now that it's done. + untrack(st.id); + } + }, [sig, untrack]); +} + +// Renders nothing; exists only to host useCanvasGenerationToasts so the frequent +// session-driven re-renders it subscribes to stay isolated here instead of +// re-rendering whatever layout mounts it. +export function CanvasGenerationToaster(): null { + useCanvasGenerationToasts(); + return null; +} diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index f01465a2e7..8a883a3678 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -13,6 +13,7 @@ import { useDashboardMutations, } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; @@ -89,6 +90,11 @@ export function useGenerateFreeformCanvas(args: { // are best-effort: a failure here shouldn't undo a started task. void fileTask(channelId, task.id, task.title).catch(() => {}); void setGenerationTask(dashboardId, task.id).catch(() => {}); + // Track this run so a toast (with a link back here) fires when it + // finishes, even after the user navigates to another canvas. + useCanvasGenerationTrackerStore + .getState() + .track({ taskId: task.id, dashboardId, channelId, name }); // Repo-less tasks create no workspace row, so the usual workspace.create // invalidation never fires — refresh the cache so the task view resolves // its scratch cwd instead of showing the repo-picker prompt. @@ -103,7 +109,14 @@ export function useGenerateFreeformCanvas(args: { .generateCanvasName(opts.instruction) .then(async (generated) => { const title = generated?.trim(); - if (title) await renameDashboard(dashboardId, title); + if (title) { + await renameDashboard(dashboardId, title); + // Keep the tracked generation's name in sync so its completion + // toast reads the real title, not "Untitled canvas". + useCanvasGenerationTrackerStore + .getState() + .updateName(task.id, title); + } }) .catch(() => {}); } diff --git a/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts b/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts new file mode 100644 index 0000000000..62b6780c1d --- /dev/null +++ b/packages/ui/src/features/canvas/stores/canvasGenerationTrackerStore.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; + +// A freeform canvas generation we kicked off in this client and want to toast +// about when it finishes. Registered at start (not derived from the dashboard +// record, whose generationTaskId is never cleared), so the watcher only ever +// announces generations the user actually started this session — never a stale +// association on reload. +export interface TrackedCanvasGeneration { + taskId: string; + dashboardId: string; + channelId: string; + name: string; +} + +interface CanvasGenerationTrackerState { + // Keyed by taskId. + tracked: Record; + track: (entry: TrackedCanvasGeneration) => void; + // Keep the display name fresh when a freshly-created canvas is auto-renamed + // from its prompt after generation has already started. + updateName: (taskId: string, name: string) => void; + untrack: (taskId: string) => void; +} + +export const useCanvasGenerationTrackerStore = + create((set) => ({ + tracked: {}, + track: (entry) => + set((s) => ({ tracked: { ...s.tracked, [entry.taskId]: entry } })), + updateName: (taskId, name) => + set((s) => + s.tracked[taskId] + ? { + tracked: { + ...s.tracked, + [taskId]: { ...s.tracked[taskId], name }, + }, + } + : s, + ), + untrack: (taskId) => + set((s) => { + if (!s.tracked[taskId]) return s; + const { [taskId]: _removed, ...rest } = s.tracked; + return { tracked: rest }; + }), + })); diff --git a/packages/ui/src/features/connectivity/connectivityToast.ts b/packages/ui/src/features/connectivity/connectivityToast.ts index 2f85f641df..9e1e2a7b61 100644 --- a/packages/ui/src/features/connectivity/connectivityToast.ts +++ b/packages/ui/src/features/connectivity/connectivityToast.ts @@ -1,21 +1,29 @@ import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; -import { toast as sonnerToast } from "sonner"; import { toast } from "../../primitives/toast"; -const TOAST_ID = "connectivity-offline"; const OFFLINE_DEBOUNCE_MS = 5_000; +// The live offline toast's id, tracked so re-entry never stacks a second one and +// reconnect dismisses exactly this toast. +let offlineToastId: string | null = null; + export function showOfflineToast() { - toast.error("No internet connection", { - id: TOAST_ID, + if (offlineToastId) return; + offlineToastId = toast.error("No internet connection", { duration: Number.POSITIVE_INFINITY, description: "PostHog Code features that need the network are paused until you reconnect.", }); } +function dismissOfflineToast() { + if (!offlineToastId) return; + toast.dismiss(offlineToastId); + offlineToastId = null; +} + // Debounces flaky transitions: only surfaces a toast when continuously offline -// for OFFLINE_DEBOUNCE_MS. The stable id guarantees the toast never stacks. +// for OFFLINE_DEBOUNCE_MS. A single tracked toast id guarantees it never stacks. export function initializeConnectivityToast() { let pendingTimer: ReturnType | null = null; let wasOnline = connectivityStore.getState().isOnline; @@ -39,7 +47,7 @@ export function initializeConnectivityToast() { }, OFFLINE_DEBOUNCE_MS); } else { clearPending(); - sonnerToast.dismiss(TOAST_ID); + dismissOfflineToast(); } }); diff --git a/packages/ui/src/features/deep-links/useHandleOpenTask.ts b/packages/ui/src/features/deep-links/useHandleOpenTask.ts new file mode 100644 index 0000000000..d327ec7206 --- /dev/null +++ b/packages/ui/src/features/deep-links/useHandleOpenTask.ts @@ -0,0 +1,98 @@ +import { + TASK_SERVICE, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useTaskChannelMap } from "@posthog/ui/features/canvas/hooks/useTaskChannelMap"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; +import { toast } from "@posthog/ui/primitives/toast"; +import { openTask as openTaskHelper } from "@posthog/ui/router/useOpenTask"; +import { logger } from "@posthog/ui/shell/logger"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; + +const log = logger.scope("open-task"); + +/** + * Opens a task from a deep link / notification click, provisioning its + * workspace via the TASK_SERVICE saga (so it works even when the task isn't + * loaded yet). Returns a stable callback shared by the task URL-scheme deep link + * (`useTaskDeepLink`) and the generic notification-target consumer + * (`useOpenTargetDeepLink`). + */ +export function useHandleOpenTask(): ( + taskId: string, + taskRunId?: string, +) => Promise { + const taskService = useService(TASK_SERVICE); + const { markAsViewed } = useTaskViewed(); + const queryClient = useQueryClient(); + + // A task filed to a Project Bluebird channel opens in the channel-organized + // view under /website. Gate the channel fetches behind the flag. + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const { channels } = useChannels({ enabled: bluebirdEnabled }); + const channelMap = useTaskChannelMap(channels, { enabled: bluebirdEnabled }); + // Mirror the latest map into a ref so the stable callback can read it without + // listing the map in its deps — otherwise it'd be recreated on every poll. + const channelMapRef = useRef(channelMap); + useEffect(() => { + channelMapRef.current = channelMap; + }, [channelMap]); + + return useCallback( + async (taskId: string, taskRunId?: string) => { + log.info( + `Opening task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, + ); + try { + const result = await taskService.openTask(taskId, taskRunId); + if (!result.success) { + log.error("Failed to open task from deep link", { + taskId, + taskRunId, + error: result.error, + failedStep: result.failedStep, + }); + toast.error(`Failed to open task: ${result.error}`); + return; + } + + const { task } = result.data; + queryClient.setQueryData(taskKeys.list(), (old) => { + if (!old) return [task]; + const existingIndex = old.findIndex((t) => t.id === task.id); + if (existingIndex >= 0) { + const updated = [...old]; + updated[existingIndex] = task; + return updated; + } + return [task, ...old]; + }); + queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); + + markAsViewed(taskId); + const channel = bluebirdEnabled + ? channelMapRef.current.get(task.id) + : undefined; + void openTaskHelper( + task, + channel ? { channelId: channel.id } : undefined, + ); + log.info(`Opened task from deep link: ${taskId}`); + } catch (error) { + log.error("Unexpected error opening task from deep link:", error); + toast.error("Failed to open task"); + } + }, + [markAsViewed, queryClient, taskService, bluebirdEnabled], + ); +} diff --git a/packages/ui/src/features/deep-links/useOpenTargetDeepLink.test.tsx b/packages/ui/src/features/deep-links/useOpenTargetDeepLink.test.tsx new file mode 100644 index 0000000000..34a4adf768 --- /dev/null +++ b/packages/ui/src/features/deep-links/useOpenTargetDeepLink.test.tsx @@ -0,0 +1,108 @@ +import type { NotificationTarget } from "@posthog/platform/notifications"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openTask = vi.hoisted(() => + vi.fn().mockResolvedValue({ + success: true, + data: { task: { id: "t1" }, workspace: null }, + }), +); +const getPendingOpenTarget = vi.hoisted(() => vi.fn().mockResolvedValue(null)); +const onOpenTarget = vi.hoisted(() => + vi.fn( + ( + _input?: unknown, + _opts?: { onData?: (data: NotificationTarget) => void }, + ) => ({ unsubscribe: vi.fn() }), + ), +); +const routerOpenTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const navigateToChannelDashboard = vi.hoisted(() => vi.fn()); +const markAsViewed = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPCClient: () => ({ + deepLink: { + getPendingOpenTarget: { query: getPendingOpenTarget }, + onOpenTarget: { subscribe: onOpenTarget }, + }, + }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannelDashboard, +})); +vi.mock("@posthog/ui/router/useOpenTask", () => ({ openTask: routerOpenTask })); +vi.mock("@posthog/ui/features/sidebar/useTaskViewed", () => ({ + useTaskViewed: () => ({ markAsViewed }), +})); +vi.mock("@posthog/di/react", () => ({ useService: () => ({ openTask }) })); +vi.mock("@posthog/ui/shell/logger", () => ({ + logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }) }, +})); +vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn() } })); +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => false, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: [], isLoading: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannelMap", () => ({ + useTaskChannelMap: () => new Map(), +})); + +import { useOpenTargetDeepLink } from "./useOpenTargetDeepLink"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +const taskTarget: NotificationTarget = { kind: "task", taskId: "t1" }; +const canvasTarget: NotificationTarget = { + kind: "canvas", + channelId: "chan-1", + dashboardId: "dash-1", +}; + +describe("useOpenTargetDeepLink", () => { + beforeEach(() => { + vi.clearAllMocks(); + getPendingOpenTarget.mockResolvedValue(null); + }); + + it("routes a warm-start task target through the open-task saga", async () => { + renderHook(() => useOpenTargetDeepLink(), { wrapper }); + onOpenTarget.mock.calls[0]?.[1]?.onData?.(taskTarget); + await waitFor(() => expect(openTask).toHaveBeenCalledWith("t1", undefined)); + expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, undefined); + }); + + it("routes a warm-start canvas target to its dashboard", () => { + renderHook(() => useOpenTargetDeepLink(), { wrapper }); + onOpenTarget.mock.calls[0]?.[1]?.onData?.(canvasTarget); + expect(navigateToChannelDashboard).toHaveBeenCalledWith("chan-1", "dash-1"); + }); + + it("drains a pending target queued before the listener was live", async () => { + getPendingOpenTarget.mockResolvedValue(canvasTarget); + renderHook(() => useOpenTargetDeepLink(), { wrapper }); + await waitFor(() => + expect(navigateToChannelDashboard).toHaveBeenCalledWith( + "chan-1", + "dash-1", + ), + ); + }); + + it("subscribes once to warm-start open-target events", () => { + renderHook(() => useOpenTargetDeepLink(), { wrapper }); + expect(onOpenTarget).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/features/deep-links/useOpenTargetDeepLink.ts b/packages/ui/src/features/deep-links/useOpenTargetDeepLink.ts new file mode 100644 index 0000000000..304f4ec415 --- /dev/null +++ b/packages/ui/src/features/deep-links/useOpenTargetDeepLink.ts @@ -0,0 +1,62 @@ +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { NotificationTarget } from "@posthog/platform/notifications"; +import { useHandleOpenTask } from "@posthog/ui/features/deep-links/useHandleOpenTask"; +import { navigateToChannelDashboard } from "@posthog/ui/router/navigationBridge"; +import { logger } from "@posthog/ui/shell/logger"; +import { useCallback, useEffect } from "react"; + +const log = logger.scope("open-target-deep-link"); + +/** + * Consumes generic "open this target" intents emitted when a native + * notification is clicked (any tier, any producer) and navigates by target + * kind. Sibling of {@link useTaskDeepLink}, which handles the task URL scheme. + */ +export function useOpenTargetDeepLink() { + const client = useHostTRPCClient(); + const handleOpenTask = useHandleOpenTask(); + + const handleTarget = useCallback( + (target: NotificationTarget) => { + log.info("Opening notification target", { kind: target.kind }); + switch (target.kind) { + case "task": + handleOpenTask(target.taskId, target.taskRunId); + break; + case "canvas": + navigateToChannelDashboard(target.channelId, target.dashboardId); + break; + } + }, + [handleOpenTask], + ); + + useEffect(() => { + let cancelled = false; + + // Warm path: receive clicks while the app is running. + const subscription = client.deepLink.onOpenTarget.subscribe(undefined, { + onData: (target) => { + if (target && !cancelled) handleTarget(target); + }, + }); + + // Drain anything queued while no listener was live — a cold start (app + // launched by the click) OR a click that lands in the gap after mount / + // an HMR or socket reconnect, before the subscription's listener registers. + // `getPendingOpenTarget` consumes (clears) it, so repeated drains are safe. + void client.deepLink.getPendingOpenTarget + .query() + .then((pending) => { + if (pending && !cancelled) handleTarget(pending); + }) + .catch((error) => + log.error("Failed to drain pending open-target", error), + ); + + return () => { + cancelled = true; + subscription.unsubscribe(); + }; + }, [client, handleTarget]); +} diff --git a/packages/ui/src/features/deep-links/useTaskDeepLink.ts b/packages/ui/src/features/deep-links/useTaskDeepLink.ts index fe1e31fc78..f7f2ddef07 100644 --- a/packages/ui/src/features/deep-links/useTaskDeepLink.ts +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.ts @@ -1,111 +1,23 @@ -import { - TASK_SERVICE, - type TaskService, -} from "@posthog/core/task-detail/taskService"; -import { useService } from "@posthog/di/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; -import type { Task } from "@posthog/shared/domain-types"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useTaskChannelMap } from "@posthog/ui/features/canvas/hooks/useTaskChannelMap"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; -import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; -import { toast } from "@posthog/ui/primitives/toast"; -import { openTask as openTaskHelper } from "@posthog/ui/router/useOpenTask"; +import { useHandleOpenTask } from "@posthog/ui/features/deep-links/useHandleOpenTask"; import { logger } from "@posthog/ui/shell/logger"; -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; const log = logger.scope("task-deep-link"); /** - * Subscribes to open-existing-task deep link events and opens the task. Uses - * the TASK_SERVICE bridge (createTask/openTask) to provision the workspace via - * the saga pattern, so this hook no longer depends on the renderer TaskService. + * Subscribes to open-existing-task deep link events (the `posthog://task/...` + * URL scheme) and opens the task. The open logic is shared with the generic + * notification-target consumer via {@link useHandleOpenTask}. */ export function useTaskDeepLink() { const client = useHostTRPCClient(); - const taskService = useService(TASK_SERVICE); - const { markAsViewed } = useTaskViewed(); - const queryClient = useQueryClient(); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); const hasFetchedPending = useRef(false); - - // A task filed to a Project Bluebird channel opens in the channel-organized - // view under /website, keeping the channels chrome — mirroring CommandMenu. - // Gate the channel fetches behind the flag so they never reach ungated users. - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); - const { channels } = useChannels({ enabled: bluebirdEnabled }); - const channelMap = useTaskChannelMap(channels, { enabled: bluebirdEnabled }); - // Mirror the latest map into a ref so the stable `handleOpenTask` callback can - // read it without listing the map in its deps — otherwise the callback (and - // the onOpenTask subscription below) would be recreated on every channel poll. - const channelMapRef = useRef(channelMap); - useEffect(() => { - channelMapRef.current = channelMap; - }, [channelMap]); - - const handleOpenTask = useCallback( - async (taskId: string, taskRunId?: string) => { - log.info( - `Opening task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, - ); - - try { - const result = await taskService.openTask(taskId, taskRunId); - - if (!result.success) { - log.error("Failed to open task from deep link", { - taskId, - taskRunId, - error: result.error, - failedStep: result.failedStep, - }); - toast.error(`Failed to open task: ${result.error}`); - return; - } - - const { task } = result.data; - - queryClient.setQueryData(taskKeys.list(), (old) => { - if (!old) return [task]; - const existingIndex = old.findIndex((t) => t.id === task.id); - if (existingIndex >= 0) { - const updated = [...old]; - updated[existingIndex] = task; - return updated; - } - return [task, ...old]; - }); - - queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - - markAsViewed(taskId); - const channel = bluebirdEnabled - ? channelMapRef.current.get(task.id) - : undefined; - void openTaskHelper( - task, - channel ? { channelId: channel.id } : undefined, - ); - - log.info( - `Successfully opened task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, - ); - } catch (error) { - log.error("Unexpected error opening task from deep link:", error); - toast.error("Failed to open task"); - } - }, - [markAsViewed, queryClient, taskService, bluebirdEnabled], - ); + const handleOpenTask = useHandleOpenTask(); // Check for pending deep link on mount (for cold start via deep link) useEffect(() => { diff --git a/packages/ui/src/features/focus/focusToast.tsx b/packages/ui/src/features/focus/focusToast.tsx index e8d153d865..684c3f5526 100644 --- a/packages/ui/src/features/focus/focusToast.tsx +++ b/packages/ui/src/features/focus/focusToast.tsx @@ -1,4 +1,3 @@ -import { Text } from "@radix-ui/themes"; import { toast } from "../../primitives/toast"; import type { FocusSagaResult } from "./focusStore"; @@ -7,14 +6,9 @@ export function showFocusSuccessToast( result: FocusSagaResult, ): void { const showStashMessage = !!result.session?.mainStashRef && !result.wasSwap; - toast.success( - <> - Now editing {branchName} - , - { - description: showStashMessage - ? "Your local changes were stashed and will be restored when you return." - : undefined, - }, - ); + toast.success(`Now editing ${branchName}`, { + description: showStashMessage + ? "Your local changes were stashed and will be restored when you return." + : undefined, + }); } diff --git a/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx b/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx index cfa9b5e998..945bdc605d 100644 --- a/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx +++ b/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx @@ -21,8 +21,8 @@ import type { PrActionType } from "@posthog/shared"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; import { ChevronDown } from "lucide-react"; -import { toast } from "sonner"; import { Tooltip } from "../../../primitives/Tooltip"; +import { toast } from "../../../primitives/toast"; import { useLocalRepoPath } from "../../workspace/useLocalRepoPath"; import { getPrActionIcon } from "../prIcon"; import { diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 6acdd14eb8..032c415a9d 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -41,7 +41,7 @@ import { } from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { Badge } from "@posthog/ui/primitives/Badge"; -import { toast } from "@posthog/ui/primitives/toast"; +import { toast as sonnerToast, toast } from "@posthog/ui/primitives/toast"; import { openTask } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; @@ -49,7 +49,6 @@ import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { type ReactNode, useCallback, useMemo, useState } from "react"; -import { toast as sonnerToast } from "sonner"; const AUTONOMY_SETUP_PROMPT = `Set up PostHog Self-driving for this product. diff --git a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx index 0cac564c41..7f17bb836b 100644 --- a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx @@ -11,10 +11,10 @@ import { useGithubRepositories, useRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; +import { toast } from "@posthog/ui/primitives/toast"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; import { useMutation } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; type DataSourceType = "github" | "linear" | "zendesk" | "pganalyze"; diff --git a/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx b/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx index b0a9b2ae26..d5d8cf84dd 100644 --- a/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx +++ b/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx @@ -52,9 +52,12 @@ vi.mock("@posthog/ui/shell/logger", () => ({ logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }) }, })); vi.mock("@posthog/ui/primitives/toast", () => ({ - toast: { error: toastError, loading: vi.fn(() => "toast-1") }, + toast: { + error: toastError, + loading: vi.fn(() => "toast-1"), + dismiss: vi.fn(), + }, })); -vi.mock("sonner", () => ({ toast: { dismiss: vi.fn() } })); import { useDiscussReport } from "./useDiscussReport"; diff --git a/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts b/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts index da0a02de4a..f79ca69633 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts @@ -4,9 +4,9 @@ import type { DismissReportDialogResult } from "@posthog/ui/features/inbox/compo import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports"; import { useInboxReportSelectionStore } from "@posthog/ui/features/inbox/stores/inboxReportSelectionStore"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; -import { toast } from "sonner"; type BulkActionName = "suppress" | "snooze" | "delete" | "reingest"; diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts index 4e4a766fe0..788d2d9854 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts @@ -15,13 +15,12 @@ import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDef import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; -import { toast } from "@posthog/ui/primitives/toast"; +import { toast as sonnerToast, toast } from "@posthog/ui/primitives/toast"; import { openTask } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import { toast as sonnerToast } from "sonner"; /** Variant-specific copy used in the toasts/errors emitted by the runner. */ export interface InboxCloudTaskCopy { diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx b/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx index 225d93b13b..db9e20cb4b 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx @@ -17,7 +17,9 @@ vi.mock("@posthog/ui/features/auth/authClient", () => ({ useOptionalAuthenticatedClient: () => mockClient, })); -vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: vi.fn() }, +})); import { reportKeys, useUpdateSuggestedReviewers } from "./useInboxReports"; diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.ts b/packages/ui/src/features/inbox/hooks/useInboxReports.ts index 97cf8b8922..fb98e314c8 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.ts @@ -21,9 +21,9 @@ import { useInboxAvailableSuggestedReviewersStore } from "@posthog/ui/features/i import { useAuthenticatedInfiniteQuery } from "@posthog/ui/hooks/useAuthenticatedInfiniteQuery"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; -import { toast } from "sonner"; const REPORTS_PAGE_SIZE = 100; diff --git a/packages/ui/src/features/inbox/hooks/useInboxRestoreReport.ts b/packages/ui/src/features/inbox/hooks/useInboxRestoreReport.ts index 4a0922e24c..323996defc 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxRestoreReport.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxRestoreReport.ts @@ -1,7 +1,7 @@ import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; /** Thrown when the report is no longer archived by the time Restore runs. */ class ReportNoLongerArchivedError extends Error { diff --git a/packages/ui/src/features/inbox/hooks/useOpenInboxReport.ts b/packages/ui/src/features/inbox/hooks/useOpenInboxReport.ts index f1639f44f9..72c83210dc 100644 --- a/packages/ui/src/features/inbox/hooks/useOpenInboxReport.ts +++ b/packages/ui/src/features/inbox/hooks/useOpenInboxReport.ts @@ -3,6 +3,7 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports"; import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/stores/inboxSignalsFilterStore"; +import { toast } from "@posthog/ui/primitives/toast"; import { navigateToInboxDismissedDetail, navigateToInboxPullRequestDetail, @@ -11,7 +12,6 @@ import { import { logger } from "@posthog/ui/shell/logger"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; -import { toast } from "sonner"; const log = logger.scope("open-inbox-report"); diff --git a/packages/ui/src/features/inbox/hooks/useSignalEvaluations.ts b/packages/ui/src/features/inbox/hooks/useSignalEvaluations.ts index a2756d80aa..cabeeb12b7 100644 --- a/packages/ui/src/features/inbox/hooks/useSignalEvaluations.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalEvaluations.ts @@ -3,9 +3,9 @@ import { getCloudUrlFromRegion } from "@posthog/shared"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useEvaluations } from "@posthog/ui/features/inbox/hooks/useEvaluations"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; /** * Read-and-toggle hook for LLM-Analytics evaluations exposed in the Inbox diff --git a/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts b/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts index c1a3808e00..c882f187ce 100644 --- a/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalSourceToggles.ts @@ -5,10 +5,10 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import type { SignalSourceValues } from "@posthog/ui/features/inbox/components/SignalSourceToggles"; import { useExternalDataSources } from "@posthog/ui/features/inbox/hooks/useExternalDataSources"; import { useSignalSourceConfigs } from "@posthog/ui/features/inbox/hooks/useSignalSourceConfigs"; +import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; type SourceProduct = SignalSourceConfig["source_product"]; type SourceType = SignalSourceConfig["source_type"]; diff --git a/packages/ui/src/features/inbox/hooks/useSignalTeamConfigMutations.ts b/packages/ui/src/features/inbox/hooks/useSignalTeamConfigMutations.ts index e1fe05dd89..eddad1f344 100644 --- a/packages/ui/src/features/inbox/hooks/useSignalTeamConfigMutations.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalTeamConfigMutations.ts @@ -1,9 +1,9 @@ import { signalsConfigKeys } from "@posthog/core/inbox/inboxQuery"; import type { SignalTeamConfig } from "@posthog/shared/types"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; -import { toast } from "sonner"; const TEAM_CONFIG_QUERY_KEY = signalsConfigKeys.teamConfig; diff --git a/packages/ui/src/features/inbox/hooks/useSignalUserAutonomyMutations.ts b/packages/ui/src/features/inbox/hooks/useSignalUserAutonomyMutations.ts index f7caffe46f..815169e6e8 100644 --- a/packages/ui/src/features/inbox/hooks/useSignalUserAutonomyMutations.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalUserAutonomyMutations.ts @@ -4,9 +4,9 @@ import type { SignalUserAutonomyConfig, } from "@posthog/shared/types"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; -import { toast } from "sonner"; const USER_AUTONOMY_QUERY_KEY = signalsConfigKeys.userAutonomyConfig; diff --git a/packages/ui/src/features/inbox/utils/copyInboxReportLink.ts b/packages/ui/src/features/inbox/utils/copyInboxReportLink.ts index 0bd3cb86c1..487735929a 100644 --- a/packages/ui/src/features/inbox/utils/copyInboxReportLink.ts +++ b/packages/ui/src/features/inbox/utils/copyInboxReportLink.ts @@ -1,6 +1,6 @@ import { buildInboxDeeplink } from "@posthog/shared/deeplink"; import type { SignalReport } from "@posthog/shared/types"; -import { toast } from "sonner"; +import { toast } from "@posthog/ui/primitives/toast"; /** * Copy a deep link (`://inbox/{reportId}`) for an inbox report to the diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts index 4caa73d3b7..5039e17d48 100644 --- a/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts @@ -7,10 +7,10 @@ import { shouldAutoRefreshTools } from "@posthog/core/mcp-servers/toolRefresh"; import { useHostTRPC } from "@posthog/host-router/react"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useEffect, useRef } from "react"; -import { toast } from "sonner"; import { mcpKeys } from "./useMcpServers"; interface UseMcpInstallationToolsOptions { diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts index 50ca0dac80..7e915e7073 100644 --- a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts @@ -12,10 +12,10 @@ import { import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; export const mcpKeys = { servers: ["mcp", "servers"] as const, diff --git a/packages/ui/src/features/notifications/identifiers.ts b/packages/ui/src/features/notifications/identifiers.ts index 3343baeedb..f23e8bd3cc 100644 --- a/packages/ui/src/features/notifications/identifiers.ts +++ b/packages/ui/src/features/notifications/identifiers.ts @@ -1,3 +1,4 @@ +import type { NotificationTarget } from "@posthog/platform/notifications"; import type { CompletionSound } from "@posthog/ui/features/settings/settingsStore"; export interface NotificationSettings { @@ -18,7 +19,9 @@ export const NOTIFICATION_SETTINGS_PROVIDER = Symbol.for( export interface IActiveView { hasFocus(): boolean; - getActiveTaskId(): string | undefined; + // What the user is currently looking at, if it's a notifiable target (a task + // or canvas). Used to suppress notifications for the thing already on screen. + getActiveTarget(): NotificationTarget | undefined; } export const ACTIVE_VIEW_PROVIDER = Symbol.for( diff --git a/packages/ui/src/features/notifications/notifications.module.ts b/packages/ui/src/features/notifications/notifications.module.ts index 2866da3800..f70a558d49 100644 --- a/packages/ui/src/features/notifications/notifications.module.ts +++ b/packages/ui/src/features/notifications/notifications.module.ts @@ -1,6 +1,6 @@ import { ContainerModule } from "inversify"; -import { TaskNotificationService } from "./notifications"; +import { NotificationBus } from "./notifications"; export const notificationsUiModule = new ContainerModule(({ bind }) => { - bind(TaskNotificationService).toSelf().inSingletonScope(); + bind(NotificationBus).toSelf().inSingletonScope(); }); diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts index c4906818bd..ce4ac2c585 100644 --- a/packages/ui/src/features/notifications/notifications.test.ts +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -1,31 +1,46 @@ import "reflect-metadata"; +import type { NotificationTarget } from "@posthog/platform/notifications"; import { describe, expect, it, vi } from "vitest"; vi.mock("@posthog/ui/utils/sounds", () => ({ playCompletionSound: vi.fn(), })); +const toastMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), +})); +vi.mock("@posthog/ui/primitives/toast", () => ({ toast: toastMock })); + import { playCompletionSound } from "@posthog/ui/utils/sounds"; import type { IActiveView, INotificationSettings, NotificationSettings, } from "./identifiers"; -import { TaskNotificationService } from "./notifications"; +import { NotificationBus } from "./notifications"; const TASK_ID = "task-123"; const OTHER_TASK_ID = "task-999"; +const taskTarget = (id: string): NotificationTarget => ({ + kind: "task", + taskId: id, +}); -function makeService(overrides?: { +function makeBus(overrides?: { settings?: Partial; hasFocus?: boolean; - activeTaskId?: string; + activeTarget?: NotificationTarget; }) { const notify = vi.fn(); const showUnreadIndicator = vi.fn(); const requestAttention = vi.fn(); const play = vi.mocked(playCompletionSound); play.mockClear(); + toastMock.success.mockClear(); + toastMock.error.mockClear(); + toastMock.warning.mockClear(); const settings: NotificationSettings = { desktopNotifications: true, @@ -39,131 +54,116 @@ function makeService(overrides?: { const settingsPort: INotificationSettings = { get: () => settings }; const viewPort: IActiveView = { hasFocus: () => overrides?.hasFocus ?? false, - getActiveTaskId: () => overrides?.activeTaskId, + getActiveTarget: () => overrides?.activeTarget, }; - const service = new TaskNotificationService( + const bus = new NotificationBus( { notify, showUnreadIndicator, requestAttention }, settingsPort, viewPort, ); - return { service, notify, showUnreadIndicator, requestAttention, play }; + return { bus, notify, showUnreadIndicator, requestAttention, play }; } -describe("TaskNotificationService", () => { - describe("shouldNotify gating (via notifyPermissionRequest)", () => { - const cases = [ - { - name: "window unfocused → notifies", - hasFocus: false, - activeTaskId: TASK_ID, - taskId: TASK_ID, - shouldNotify: true, - }, - { - name: "focused on the same task → does not notify", - hasFocus: true, - activeTaskId: TASK_ID, - taskId: TASK_ID, - shouldNotify: false, - }, - { - name: "focused on a different task → notifies", - hasFocus: true, - activeTaskId: OTHER_TASK_ID, - taskId: TASK_ID, - shouldNotify: true, - }, - { - name: "focused, no active task → notifies", - hasFocus: true, - activeTaskId: undefined, - taskId: TASK_ID, - shouldNotify: true, - }, - { - name: "focused with no taskId supplied → does not notify", - hasFocus: true, - activeTaskId: undefined, - taskId: undefined, - shouldNotify: false, - }, - ] as const; - - it.each(cases)( - "$name", - ({ hasFocus, activeTaskId, taskId, shouldNotify }) => { - const { service, notify, play } = makeService({ - hasFocus, - activeTaskId, - }); - service.notifyPermissionRequest("My task", taskId); - expect(notify).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - expect(play).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - }, - ); +describe("NotificationBus tier routing (via notifyPermissionRequest)", () => { + it("app unfocused → native notification", () => { + const { bus, notify } = makeBus({ + hasFocus: false, + activeTarget: taskTarget(TASK_ID), + }); + bus.notifyPermissionRequest("My task", TASK_ID); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastMock.warning).not.toHaveBeenCalled(); }); - describe("notifyPromptComplete", () => { - it.each([ - { stopReason: "tool_use", shouldNotify: false }, - { stopReason: "max_tokens", shouldNotify: false }, - { stopReason: "end_turn", shouldNotify: true }, - ])( - "stop reason '$stopReason' → notifies=$shouldNotify", - ({ stopReason, shouldNotify }) => { - const { service, notify } = makeService({ hasFocus: false }); - service.notifyPromptComplete("My task", stopReason, TASK_ID); - expect(notify).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - }, - ); + it("focused on the same task → suppressed (nothing)", () => { + const { bus, notify, play } = makeBus({ + hasFocus: true, + activeTarget: taskTarget(TASK_ID), + }); + bus.notifyPermissionRequest("My task", TASK_ID); + expect(notify).not.toHaveBeenCalled(); + expect(toastMock.warning).not.toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + }); + + it.each([ + ["viewing a different task", taskTarget(OTHER_TASK_ID)], + ["viewing nothing relevant", undefined], + ])("focused, %s → in-app toast (not native)", (_label, activeTarget) => { + const { bus, notify } = makeBus({ hasFocus: true, activeTarget }); + bus.notifyPermissionRequest("My task", TASK_ID); + expect(notify).not.toHaveBeenCalled(); + expect(toastMock.warning).toHaveBeenCalledTimes(1); }); +}); + +describe("notifyPromptComplete", () => { + it.each([ + { stopReason: "tool_use", delivered: false }, + { stopReason: "max_tokens", delivered: false }, + { stopReason: "end_turn", delivered: true }, + ])( + "stop reason '$stopReason' → delivered=$delivered", + ({ stopReason, delivered }) => { + const { bus, notify } = makeBus({ hasFocus: false }); + bus.notifyPromptComplete("My task", stopReason, TASK_ID); + expect(notify).toHaveBeenCalledTimes(delivered ? 1 : 0); + }, + ); +}); - describe("settings gating", () => { - it("skips desktop notification when desktopNotifications is off", () => { - const { service, notify, showUnreadIndicator, requestAttention } = - makeService({ - hasFocus: false, - settings: { desktopNotifications: false }, - }); - service.notifyPermissionRequest("My task", TASK_ID); - expect(notify).not.toHaveBeenCalled(); - expect(showUnreadIndicator).toHaveBeenCalledTimes(1); - expect(requestAttention).toHaveBeenCalledTimes(1); +describe("native tier settings gating (app unfocused)", () => { + it("skips the OS notification when desktopNotifications is off, still dings dock", () => { + const { bus, notify, showUnreadIndicator, requestAttention } = makeBus({ + hasFocus: false, + settings: { desktopNotifications: false }, }); + bus.notifyPermissionRequest("My task", TASK_ID); + expect(notify).not.toHaveBeenCalled(); + expect(showUnreadIndicator).toHaveBeenCalledTimes(1); + expect(requestAttention).toHaveBeenCalledTimes(1); + }); - it("marks the notification silent when a custom sound plays", () => { - const { service, notify } = makeService({ - hasFocus: false, - settings: { completionSound: "meep" }, - }); - service.notifyPermissionRequest("My task", TASK_ID); - expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ silent: true }), - ); + it("marks the OS notification silent when a custom sound plays", () => { + const { bus, notify } = makeBus({ + hasFocus: false, + settings: { completionSound: "meep" }, }); + bus.notifyPermissionRequest("My task", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: true }), + ); + }); - it("is not silent when completionSound is none", () => { - const { service, notify } = makeService({ - hasFocus: false, - settings: { completionSound: "none" }, - }); - service.notifyPromptComplete("My task", "end_turn", TASK_ID); - expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ silent: false }), - ); + it("is not silent when completionSound is none", () => { + const { bus, notify } = makeBus({ + hasFocus: false, + settings: { completionSound: "none" }, }); + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: false }), + ); + }); + + it("truncates long titles in the body", () => { + const { bus, notify } = makeBus({ hasFocus: false }); + bus.notifyPromptComplete("x".repeat(80), "end_turn", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: `"${"x".repeat(50)}..." finished` }), + ); + }); +}); - it("truncates long titles", () => { - const { service, notify } = makeService({ hasFocus: false }); - const longTitle = "x".repeat(80); - service.notifyPromptComplete(longTitle, "end_turn", TASK_ID); - expect(notify).toHaveBeenCalledWith( - expect.objectContaining({ - body: `"${"x".repeat(50)}..." finished`, - }), - ); +describe("sound", () => { + it("plays on the toast tier too (not just native)", () => { + const { bus, play } = makeBus({ + hasFocus: true, + activeTarget: taskTarget(OTHER_TASK_ID), }); + bus.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(play).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts index 0c862b194b..26022cfee4 100644 --- a/packages/ui/src/features/notifications/notifications.ts +++ b/packages/ui/src/features/notifications/notifications.ts @@ -1,7 +1,13 @@ import { type INotifications, NOTIFICATIONS_SERVICE, + type NotificationTarget, } from "@posthog/platform/notifications"; +import { toast } from "@posthog/ui/primitives/toast"; +import { + navigateToChannelDashboard, + navigateToTaskDetail, +} from "@posthog/ui/router/navigationBridge"; import { playCompletionSound } from "@posthog/ui/utils/sounds"; import { inject, injectable } from "inversify"; import { @@ -10,11 +16,36 @@ import { type INotificationSettings, NOTIFICATION_SETTINGS_PROVIDER, } from "./identifiers"; +import { routeNotification } from "./routeNotification"; const MAX_TITLE_LENGTH = 50; +// In-app toast presentation for the focused-but-elsewhere tier. Only levels that +// support an action link are allowed (the bus derives the action from `target`). +type ToastLevel = "success" | "error" | "warning"; + +export interface NotificationDescriptor { + // Native title; defaults to "PostHog Code". + title?: string; + body: string; + // What the notification is about — drives suppression (am I viewing it?) and + // click navigation for both the toast and native tiers. + target?: NotificationTarget; + toast?: { + level?: ToastLevel; + description?: string; + duration?: number; + }; + silent?: boolean; +} + +// The single channel every app notification flows through. Reads focus + the +// active route, decides suppress / toast / native (see routeNotification), and +// dispatches accordingly. Native delivery + dock effects are gated by the user's +// notification settings; the in-app toast always shows (it's non-intrusive and +// only appears while the app is focused). @injectable() -export class TaskNotificationService { +export class NotificationBus { constructor( @inject(NOTIFICATIONS_SERVICE) private readonly notifications: INotifications, @@ -24,49 +55,86 @@ export class TaskNotificationService { private readonly view: IActiveView, ) {} + notify(descriptor: NotificationDescriptor): void { + const channel = routeNotification({ + appFocused: this.view.hasFocus(), + viewingTarget: this.view.getActiveTarget(), + notificationTarget: descriptor.target, + }); + if (channel === "suppress") return; + + const settings = this.settings.get(); + // Sound fires on both delivered tiers (toast + native), not on suppress — + // matching the pre-bus behavior where any non-suppressed notification rang. + playCompletionSound(settings.completionSound, settings.completionVolume); + + if (channel === "toast") { + this.showToast(descriptor); + return; + } + + // native + const willPlayCustomSound = settings.completionSound !== "none"; + if (settings.desktopNotifications) { + this.notifications.notify({ + title: descriptor.title ?? "PostHog Code", + body: descriptor.body, + silent: descriptor.silent ?? willPlayCustomSound, + target: descriptor.target, + }); + } + if (settings.dockBadgeNotifications) + this.notifications.showUnreadIndicator(); + if (settings.dockBounceNotifications) this.notifications.requestAttention(); + } + + // --- Task-specific producers (delegate to notify) --- + notifyPromptComplete( taskTitle: string, stopReason: string, taskId?: string, ): void { if (stopReason !== "end_turn") return; - this.dispatch(`"${this.truncateTitle(taskTitle)}" finished`, taskId); + this.notify({ + body: `"${this.truncateTitle(taskTitle)}" finished`, + target: taskId ? { kind: "task", taskId } : undefined, + toast: { level: "success" }, + }); } notifyPermissionRequest(taskTitle: string, taskId?: string): void { - this.dispatch( - `"${this.truncateTitle(taskTitle)}" needs your input`, - taskId, - ); + this.notify({ + body: `"${this.truncateTitle(taskTitle)}" needs your input`, + target: taskId ? { kind: "task", taskId } : undefined, + toast: { level: "warning" }, + }); } - private dispatch(body: string, taskId?: string): void { - if (!this.shouldNotify(taskId)) return; - - const settings = this.settings.get(); - const willPlayCustomSound = settings.completionSound !== "none"; - playCompletionSound(settings.completionSound, settings.completionVolume); - - if (settings.desktopNotifications) { - this.notifications.notify({ - title: "PostHog Code", - body, - silent: willPlayCustomSound, - taskId, - }); - } - if (settings.dockBadgeNotifications) { - this.notifications.showUnreadIndicator(); - } - if (settings.dockBounceNotifications) { - this.notifications.requestAttention(); - } + private showToast(descriptor: NotificationDescriptor): void { + const level = descriptor.toast?.level ?? "success"; + toast[level](descriptor.title ?? descriptor.body, { + description: descriptor.toast?.description, + duration: descriptor.toast?.duration, + action: this.deriveAction(descriptor.target), + }); } - private shouldNotify(taskId?: string): boolean { - if (!this.view.hasFocus()) return true; - if (!taskId) return false; - return this.view.getActiveTaskId() !== taskId; + private deriveAction( + target: NotificationTarget | undefined, + ): { label: string; onClick: () => void } | undefined { + if (!target) return undefined; + if (target.kind === "task") { + return { + label: "View task", + onClick: () => navigateToTaskDetail(target.taskId), + }; + } + return { + label: "View canvas", + onClick: () => + navigateToChannelDashboard(target.channelId, target.dashboardId), + }; } private truncateTitle(title: string): string { diff --git a/packages/ui/src/features/notifications/routeNotification.test.ts b/packages/ui/src/features/notifications/routeNotification.test.ts new file mode 100644 index 0000000000..f0ce5455be --- /dev/null +++ b/packages/ui/src/features/notifications/routeNotification.test.ts @@ -0,0 +1,98 @@ +import type { NotificationTarget } from "@posthog/platform/notifications"; +import { describe, expect, it } from "vitest"; +import { + type NotificationChannel, + routeNotification, + targetsEqual, +} from "./routeNotification"; + +const task = (id: string): NotificationTarget => ({ kind: "task", taskId: id }); +const canvas = (id: string): NotificationTarget => ({ + kind: "canvas", + channelId: "chan", + dashboardId: id, +}); + +describe("targetsEqual", () => { + it.each< + [ + string, + NotificationTarget | undefined, + NotificationTarget | undefined, + boolean, + ] + >([ + ["same task", task("t1"), task("t1"), true], + ["different task", task("t1"), task("t2"), false], + ["same canvas", canvas("d1"), canvas("d1"), true], + ["different canvas", canvas("d1"), canvas("d2"), false], + ["cross-kind", task("t1"), canvas("d1"), false], + ["one undefined", undefined, task("t1"), false], + ["both undefined", undefined, undefined, false], + ])("%s", (_l, a, b, expected) => { + expect(targetsEqual(a, b)).toBe(expected); + }); +}); + +describe("routeNotification", () => { + it.each< + [string, Parameters[0], NotificationChannel] + >([ + [ + "unfocused → native (even when viewing the target)", + { + appFocused: false, + viewingTarget: task("t1"), + notificationTarget: task("t1"), + }, + "native", + ], + [ + "focused, viewing the exact target → suppress", + { + appFocused: true, + viewingTarget: task("t1"), + notificationTarget: task("t1"), + }, + "suppress", + ], + [ + "focused, viewing a different target → toast", + { + appFocused: true, + viewingTarget: task("t2"), + notificationTarget: task("t1"), + }, + "toast", + ], + [ + "focused, viewing nothing relevant → toast", + { + appFocused: true, + viewingTarget: undefined, + notificationTarget: canvas("d1"), + }, + "toast", + ], + [ + "focused, viewing the same canvas → suppress", + { + appFocused: true, + viewingTarget: canvas("d1"), + notificationTarget: canvas("d1"), + }, + "suppress", + ], + [ + "focused, targetless notification → toast (can't be 'already viewing it')", + { + appFocused: true, + viewingTarget: task("t1"), + notificationTarget: undefined, + }, + "toast", + ], + ])("%s", (_l, args, expected) => { + expect(routeNotification(args)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/notifications/routeNotification.ts b/packages/ui/src/features/notifications/routeNotification.ts new file mode 100644 index 0000000000..8b2d2ee99f --- /dev/null +++ b/packages/ui/src/features/notifications/routeNotification.ts @@ -0,0 +1,37 @@ +import type { NotificationTarget } from "@posthog/platform/notifications"; + +// Whether two targets point at the same thing (same kind + ids). +export function targetsEqual( + a: NotificationTarget | undefined, + b: NotificationTarget | undefined, +): boolean { + if (!a || !b || a.kind !== b.kind) return false; + if (a.kind === "task" && b.kind === "task") return a.taskId === b.taskId; + if (a.kind === "canvas" && b.kind === "canvas") { + return a.channelId === b.channelId && a.dashboardId === b.dashboardId; + } + return false; +} + +export type NotificationChannel = "suppress" | "toast" | "native"; + +// The focus-aware routing decision, the heart of the notification bus: +// - app unfocused (user in another OS app) → native OS notification +// - app focused, already looking at the target → suppress (they can see it) +// - app focused, looking elsewhere → in-app toast +// +// Pure so it's exhaustively unit-tested without the DI graph. +export function routeNotification(args: { + appFocused: boolean; + viewingTarget: NotificationTarget | undefined; + notificationTarget: NotificationTarget | undefined; +}): NotificationChannel { + if (!args.appFocused) return "native"; + if ( + args.notificationTarget && + targetsEqual(args.viewingTarget, args.notificationTarget) + ) { + return "suppress"; + } + return "toast"; +} diff --git a/packages/ui/src/features/scouts/components/ScoutFindingShareButton.tsx b/packages/ui/src/features/scouts/components/ScoutFindingShareButton.tsx index ba0b8d4b02..c382c4f568 100644 --- a/packages/ui/src/features/scouts/components/ScoutFindingShareButton.tsx +++ b/packages/ui/src/features/scouts/components/ScoutFindingShareButton.tsx @@ -1,8 +1,8 @@ import { LinkIcon } from "@phosphor-icons/react"; import type { ScoutEmission } from "@posthog/api-client/posthog-client"; import { ANALYTICS_EVENTS, buildScoutDeeplink } from "@posthog/shared"; +import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { toast } from "sonner"; /** * Per-finding "Share" CTA on a scout emission card: copies a canonical diff --git a/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts b/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts index 7699f229b5..dcd742e2e3 100644 --- a/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts @@ -2,10 +2,10 @@ import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { getScoutOrigin } from "@posthog/core/scouts/scoutPresentation"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useRef } from "react"; -import { toast } from "sonner"; import { useAuthStateValue } from "../../auth/store"; import { scoutQueryKeys } from "./scoutQueryKeys"; diff --git a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts index f2ad8ec4d3..a439448af9 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts @@ -186,10 +186,7 @@ vi.mock("@posthog/di/container", () => ({ setQueriesData: vi.fn(), }; } - if ( - typeof token === "function" && - token.name === "TaskNotificationService" - ) { + if (typeof token === "function" && token.name === "NotificationBus") { return mockNotificationService; } throw new Error(`resolveService: unmocked token ${String(token)}`); diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 82a27d257f..fd4f97ee34 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -271,10 +271,7 @@ vi.mock("@posthog/di/container", () => ({ setQueriesData: vi.fn(), }; } - if ( - typeof token === "function" && - token.name === "TaskNotificationService" - ) { + if (typeof token === "function" && token.name === "NotificationBus") { return mockNotificationService; } throw new Error(`resolveService: unmocked token ${String(token)}`); diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index 8dd499b724..aa99aa6cc3 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -23,7 +23,7 @@ import { import { fetchAuthState } from "@posthog/ui/features/auth/authQueries"; import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; -import { TaskNotificationService } from "@posthog/ui/features/notifications/notifications"; +import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; import { useSessionAdapterStore } from "@posthog/ui/features/sessions/sessionAdapterStore"; import { getPersistedConfigOptions, @@ -79,12 +79,12 @@ function buildSessionServiceDeps(): SessionServiceDeps { }, buildPermissionToolMetadata, notifyPermissionRequest: (taskTitle, taskId) => - resolveService(TaskNotificationService).notifyPermissionRequest( + resolveService(NotificationBus).notifyPermissionRequest( taskTitle, taskId, ), notifyPromptComplete: (taskTitle, stopReason, taskId) => - resolveService(TaskNotificationService).notifyPromptComplete( + resolveService(NotificationBus).notifyPromptComplete( taskTitle, stopReason, taskId, diff --git a/packages/ui/src/features/settings/components/SettingsPanel.tsx b/packages/ui/src/features/settings/components/SettingsPanel.tsx index 8496d7218a..48cbdb04af 100644 --- a/packages/ui/src/features/settings/components/SettingsPanel.tsx +++ b/packages/ui/src/features/settings/components/SettingsPanel.tsx @@ -1,6 +1,7 @@ import { ArrowLeft, ArrowsClockwise, + Bell, CaretRight, Code, CreditCard, @@ -33,6 +34,7 @@ import { DiscordSettings } from "@posthog/ui/features/settings/sections/DiscordS import { EnvironmentsSettings } from "@posthog/ui/features/settings/sections/environments/EnvironmentsSettings"; import { GeneralSettings } from "@posthog/ui/features/settings/sections/GeneralSettings"; import { GitHubSettings } from "@posthog/ui/features/settings/sections/GitHubSettings"; +import { NotificationsSettings } from "@posthog/ui/features/settings/sections/NotificationsSettings"; import { PersonalizationSettings } from "@posthog/ui/features/settings/sections/PersonalizationSettings"; import { PlanUsageSettings } from "@posthog/ui/features/settings/sections/PlanUsageSettings"; import { ShortcutsSettings } from "@posthog/ui/features/settings/sections/ShortcutsSettings"; @@ -58,6 +60,7 @@ interface SidebarItem { const SIDEBAR_ITEMS: SidebarItem[] = [ { id: "general", label: "General", icon: }, + { id: "notifications", label: "Notifications", icon: }, { id: "plan-usage", label: "Plan & usage", icon: }, { id: "workspaces", label: "Workspaces", icon: }, { id: "worktrees", label: "Worktrees", icon: }, @@ -80,6 +83,7 @@ const SIDEBAR_ITEMS: SidebarItem[] = [ const CATEGORY_TITLES: Record = { general: "General", + notifications: "Notifications", "plan-usage": "Plan & usage", workspaces: "Workspaces", worktrees: "Worktrees", @@ -99,6 +103,7 @@ const CATEGORY_TITLES: Record = { const CATEGORY_COMPONENTS: Record = { general: GeneralSettings, + notifications: NotificationsSettings, "plan-usage": PlanUsageSettings, workspaces: WorkspacesSettings, worktrees: WorktreesSettings, diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 5f06458933..9a21c2232b 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -10,7 +10,6 @@ import { import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { type AutoConvertLongText, - type CompletionSound, type DefaultInitialTaskMode, type DefaultMessagingMode, type DefaultReasoningEffort, @@ -21,19 +20,9 @@ import { import { track } from "@posthog/ui/shell/analytics"; import type { ThemePreference } from "@posthog/ui/shell/themeStore"; import { useThemeStore } from "@posthog/ui/shell/themeStore"; -import { playCompletionSound } from "@posthog/ui/utils/sounds"; -import { - Button, - Flex, - Link, - Select, - Slider, - Switch, - Text, -} from "@radix-ui/themes"; +import { Button, Flex, Link, Select, Switch, Text } from "@radix-ui/themes"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; -import { toast } from "sonner"; export function GeneralSettings() { const hostTRPC = useHostTRPC(); @@ -76,11 +65,6 @@ export function GeneralSettings() { // Chat state const { - desktopNotifications, - dockBadgeNotifications, - dockBounceNotifications, - completionSound, - completionVolume, autoConvertLongText, defaultInitialTaskMode, defaultMessagingMode, @@ -90,11 +74,6 @@ export function GeneralSettings() { conversationCollapseMode, hedgehogMode, slotMachineMode, - setDesktopNotifications, - setDockBadgeNotifications, - setDockBounceNotifications, - setCompletionSound, - setCompletionVolume, setAutoConvertLongText, setDefaultInitialTaskMode, setDefaultMessagingMode, @@ -106,38 +85,6 @@ export function GeneralSettings() { setSlotMachineMode, } = useSettingsStore(); - // Sync toggle off if the user denied notification permission at the OS level - useEffect(() => { - if (window.Notification?.permission === "denied" && desktopNotifications) { - setDesktopNotifications(false); - } - }, [desktopNotifications, setDesktopNotifications]); - - const notificationPermission = window.Notification?.permission; - const notificationsDenied = notificationPermission === "denied"; - - const handleDesktopNotificationsChange = useCallback( - async (checked: boolean) => { - if (checked) { - const permission = await window.Notification?.requestPermission?.(); - if (permission !== "granted") { - toast.info("Notifications are blocked", { - description: - "Allow PostHog Code notifications in System Settings > Notifications", - }); - return; - } - } - track(ANALYTICS_EVENTS.SETTING_CHANGED, { - setting_name: "desktop_notifications", - new_value: checked, - old_value: desktopNotifications, - }); - setDesktopNotifications(checked); - }, - [desktopNotifications, setDesktopNotifications], - ); - // Appearance handlers const handleThemeChange = useCallback( (value: ThemePreference) => { @@ -152,22 +99,6 @@ export function GeneralSettings() { ); // Chat handlers - const handleCompletionSoundChange = useCallback( - (value: CompletionSound) => { - track(ANALYTICS_EVENTS.SETTING_CHANGED, { - setting_name: "completion_sound", - new_value: value, - old_value: completionSound, - }); - setCompletionSound(value); - }, - [completionSound, setCompletionSound], - ); - - const handleTestSound = useCallback(() => { - playCompletionSound(completionSound, completionVolume); - }, [completionSound, completionVolume]); - const handleAutoConvertLongTextChange = useCallback( (value: AutoConvertLongText) => { track(ANALYTICS_EVENTS.SETTING_CHANGED, { @@ -320,112 +251,6 @@ export function GeneralSettings() { - {/* Notifications */} - - Notifications - - - {notificationsDenied && ( - - Notifications are blocked by macOS. To enable them, open System - Settings > Notifications > PostHog Code and turn on Allow - Notifications. - - )} - - - - - - - - - - - - - - - - - handleCompletionSoundChange(value as CompletionSound) - } - size="1" - > - - - None - Guitar solo - I'm ready - Cute noise - Meep - Meep (smol) - Bubbles - Drop - Knock - Ring - Shoot - Slide - Switch - Wilhelm scream - ICQ - - - {completionSound !== "none" && ( - - )} - - - - {completionSound !== "none" && ( - - - setCompletionVolume(value)} - min={0} - max={100} - step={1} - size="1" - className="w-[120px]" - /> - - {completionVolume}% - - - - )} - {/* Input */} Input diff --git a/packages/ui/src/features/settings/sections/NotificationsSettings.tsx b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx new file mode 100644 index 0000000000..8c2f100967 --- /dev/null +++ b/packages/ui/src/features/settings/sections/NotificationsSettings.tsx @@ -0,0 +1,394 @@ +import { useServiceOptional } from "@posthog/di/react"; +import { + type INotifications, + NOTIFICATIONS_SERVICE, +} from "@posthog/platform/notifications"; +import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { + type CompletionSound, + NOTIFICATION_DEFAULTS, + useSettingsStore, +} from "@posthog/ui/features/settings/settingsStore"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; +import { playCompletionSound } from "@posthog/ui/utils/sounds"; +import { Button, Flex, Select, Slider, Switch, Text } from "@radix-ui/themes"; +import { useCallback, useEffect } from "react"; + +export function NotificationsSettings() { + const { + desktopNotifications, + dockBadgeNotifications, + dockBounceNotifications, + completionSound, + completionVolume, + setDesktopNotifications, + setDockBadgeNotifications, + setDockBounceNotifications, + setCompletionSound, + setCompletionVolume, + } = useSettingsStore(); + + // Optional so non-desktop hosts (web) that don't bind these simply disable the + // native test buttons instead of throwing. + const bus = useServiceOptional(NotificationBus); + const notifications = useServiceOptional( + NOTIFICATIONS_SERVICE, + ); + + // Canvases only exist behind the bluebird flag, so only mention them when on. + const canvasEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + + // The most recent task, used to demo a real deep-link notification. + const { data: tasks } = useTasks(); + const deepLinkTask = tasks?.[0]; + + // Sync the toggle off if the user denied notification permission at the OS + // level (otherwise it claims to be on but the OS silently drops everything). + useEffect(() => { + if (window.Notification?.permission === "denied" && desktopNotifications) { + setDesktopNotifications(false); + } + }, [desktopNotifications, setDesktopNotifications]); + + const notificationsDenied = window.Notification?.permission === "denied"; + + const handleDesktopNotificationsChange = useCallback( + async (checked: boolean) => { + if (checked) { + const permission = await window.Notification?.requestPermission?.(); + if (permission !== "granted") { + toast.info("Notifications are blocked", { + description: + "Allow notifications for PostHog Code in your system settings.", + }); + return; + } + } + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "desktop_notifications", + new_value: checked, + old_value: desktopNotifications, + }); + setDesktopNotifications(checked); + }, + [desktopNotifications, setDesktopNotifications], + ); + + const handleCompletionSoundChange = useCallback( + (value: CompletionSound) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "completion_sound", + new_value: value, + old_value: completionSound, + }); + setCompletionSound(value); + }, + [completionSound, setCompletionSound], + ); + + const resetToDefaults = useCallback(() => { + setDesktopNotifications(NOTIFICATION_DEFAULTS.desktopNotifications); + setDockBadgeNotifications(NOTIFICATION_DEFAULTS.dockBadgeNotifications); + setDockBounceNotifications(NOTIFICATION_DEFAULTS.dockBounceNotifications); + setCompletionSound(NOTIFICATION_DEFAULTS.completionSound); + setCompletionVolume(NOTIFICATION_DEFAULTS.completionVolume); + toast.success("Notification settings reset to defaults"); + }, [ + setDesktopNotifications, + setDockBadgeNotifications, + setDockBounceNotifications, + setCompletionSound, + setCompletionVolume, + ]); + + return ( + + {notificationsDenied && ( + + Notifications are blocked in your system settings. Enable + notifications for PostHog Code to receive them. + + )} + + + Defaults + + + + + + + + + + + + + + + + + + + handleCompletionSoundChange(value as CompletionSound) + } + size="1" + > + + + None + Guitar solo + I'm ready + Cute noise + Meep + Meep (smol) + Bubbles + Drop + Knock + Ring + Shoot + Slide + Switch + Wilhelm scream + ICQ + + + {completionSound !== "none" && ( + + )} + + + + {completionSound !== "none" && ( + + + setCompletionVolume(value)} + min={0} + max={100} + step={1} + size="1" + className="w-[120px]" + /> + + {completionVolume}% + + + + )} + + + + ); +} + +// Fires each delivery channel directly (bypassing the focus-aware routing, since +// you're focused on Settings) so each tier can be verified in isolation. +function NotificationTestHarness({ + bus, + notifications, + deepLinkTask, + canvasEnabled, +}: { + bus: NotificationBus | null; + notifications: INotifications | null; + deepLinkTask: Task | undefined; + canvasEnabled: boolean; +}) { + const nativeUnavailable = !notifications; + + const testToast = () => + bus?.notify({ + body: "Test notification", + toast: { + level: "success", + description: "This is what an in-app toast looks like.", + }, + }); + + // A toast carrying a target renders a "View" action that deep-links — the + // in-app counterpart of clicking a native notification. + const testToastDeepLink = () => { + if (!bus || !deepLinkTask) return; + bus.notify({ + body: `"${deepLinkTask.title}"`, + target: { kind: "task", taskId: deepLinkTask.id }, + toast: { + level: "success", + description: "Click “View task” to deep-link to it.", + }, + }); + }; + + const testNative = () => + notifications?.notify({ + title: "PostHog Code", + body: "This is a native OS notification.", + silent: false, + }); + + const testNativeDeepLink = () => { + if (!notifications || !deepLinkTask) return; + notifications.notify({ + title: "PostHog Code", + body: `Click to open "${deepLinkTask.title}"`, + silent: false, + target: { kind: "task", taskId: deepLinkTask.id }, + }); + }; + + return ( + <> + + Test + + + Fire each delivery channel directly to check it works end to end. + {nativeUnavailable + ? " Native notifications aren't available on this host." + : ""} + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 4d6fca2101..f480c7fb45 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -158,6 +158,16 @@ interface SettingsStore { // ---------- Store ---------- +// Single source of truth for notification setting defaults — used both as the +// store's initial values and by the Notifications settings "Reset to defaults". +export const NOTIFICATION_DEFAULTS = { + desktopNotifications: true, + dockBadgeNotifications: true, + dockBounceNotifications: false, + completionSound: "none" as CompletionSound, + completionVolume: 80, +}; + export const useSettingsStore = create()( persist( (set, get) => ({ @@ -210,11 +220,7 @@ export const useSettingsStore = create()( setDefaultMessagingMode: (mode) => set({ defaultMessagingMode: mode }), // Notifications - desktopNotifications: true, - dockBadgeNotifications: true, - dockBounceNotifications: false, - completionSound: "none", - completionVolume: 80, + ...NOTIFICATION_DEFAULTS, setDesktopNotifications: (enabled) => set({ desktopNotifications: enabled }), setDockBadgeNotifications: (enabled) => diff --git a/packages/ui/src/features/settings/types.ts b/packages/ui/src/features/settings/types.ts index bb389c58c0..325494103b 100644 --- a/packages/ui/src/features/settings/types.ts +++ b/packages/ui/src/features/settings/types.ts @@ -1,5 +1,6 @@ export type SettingsCategory = | "general" + | "notifications" | "plan-usage" | "workspaces" | "worktrees" @@ -18,6 +19,7 @@ export type SettingsCategory = export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ "general", + "notifications", "plan-usage", "workspaces", "worktrees", diff --git a/packages/ui/src/features/workspace/useFocusWorkspace.tsx b/packages/ui/src/features/workspace/useFocusWorkspace.tsx index 7c3cb1c037..a67cd93209 100644 --- a/packages/ui/src/features/workspace/useFocusWorkspace.tsx +++ b/packages/ui/src/features/workspace/useFocusWorkspace.tsx @@ -3,7 +3,6 @@ import { canFocusWorkspace, focusTerminalKey, } from "@posthog/core/workspace/focusWorkspace"; -import { Text } from "@radix-ui/themes"; import { useCallback, useMemo } from "react"; import { toast } from "../../primitives/toast"; import { @@ -49,19 +48,11 @@ export function useFocusWorkspace(taskId: string) { const result = await disableFocus(); if (result.success) { useTerminalStore.getState().clearTerminalState(terminalKey); - toast.success( - <> - Returned to{" "} - - {focusSession.originalBranch} - - , - { - description: - result.stashPopWarning ?? - (hadStash ? "Your stashed changes were restored." : undefined), - }, - ); + toast.success(`Returned to ${focusSession.originalBranch}`, { + description: + result.stashPopWarning ?? + (hadStash ? "Your stashed changes were restored." : undefined), + }); } else { toast.error(`Could not return to ${focusSession.originalBranch}`, { description: result.error, diff --git a/packages/ui/src/primitives/toast.test.ts b/packages/ui/src/primitives/toast.test.ts new file mode 100644 index 0000000000..034cc2186d --- /dev/null +++ b/packages/ui/src/primitives/toast.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const quill = vi.hoisted(() => { + let n = 0; + const make = () => + vi.fn( + (_opts?: { onClose?: () => void } & Record) => `q${++n}`, + ); + return { + success: make(), + error: make(), + info: make(), + warning: make(), + loading: make(), + update: vi.fn(), + dismiss: vi.fn(), + _reset: () => { + n = 0; + }, + }; +}); + +vi.mock("@posthog/quill", () => ({ toast: quill })); + +import { toast } from "./toast"; + +beforeEach(() => { + vi.clearAllMocks(); + quill._reset(); + // clearAllMocks resets the level fns to undefined returns; restore ids. + let n = 0; + for (const key of [ + "success", + "error", + "info", + "warning", + "loading", + ] as const) { + quill[key].mockImplementation(() => `q${++n}`); + } +}); + +describe("toast wrapper", () => { + it("creates without an id and forwards title/description/timeout", () => { + toast.success("Saved", { description: "All good", duration: 1000 }); + expect(quill.success).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Saved", + description: "All good", + timeout: 1000, + }), + ); + expect(quill.update).not.toHaveBeenCalled(); + }); + + it("upserts a stable id: first call creates, repeat call updates the same toast", () => { + // Regression: a fresh caller-chosen id must CREATE (not no-op via update). + toast.success("Task archived", { id: "archive-1" }); + expect(quill.success).toHaveBeenCalledTimes(1); + expect(quill.update).not.toHaveBeenCalled(); + + toast.error("Now failed", { id: "archive-1" }); + // Same id → updates the previously-created quill toast, no new create. + expect(quill.error).not.toHaveBeenCalled(); + expect(quill.update).toHaveBeenCalledWith( + "q1", + expect.objectContaining({ type: "error", title: "Now failed" }), + ); + }); + + it("dismiss(stableId) resolves through the registry to the quill id", () => { + toast.success("Archived", { id: "archive-2" }); + toast.dismiss("archive-2"); + expect(quill.dismiss).toHaveBeenCalledWith("q1"); + }); + + it("dismiss falls back to the raw id for unregistered (loading-returned) ids", () => { + const id = toast.loading("Working…"); // returns the raw quill id + toast.dismiss(id); + expect(quill.dismiss).toHaveBeenCalledWith(id); + }); + + it("maps duration Infinity to timeout 0 (base-ui 'never auto-dismiss')", () => { + toast.error("Offline", { duration: Number.POSITIVE_INFINITY }); + expect(quill.error).toHaveBeenCalledWith( + expect.objectContaining({ timeout: 0 }), + ); + }); + + it("after a toast closes, its id frees up to create again", () => { + toast.success("First", { id: "dup" }); + quill.success.mock.calls[0]?.[0]?.onClose?.(); // simulate the toast closing + toast.success("Second", { id: "dup" }); + // Recreated (two creates), not updated. + expect(quill.success).toHaveBeenCalledTimes(2); + expect(quill.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/primitives/toast.tsx b/packages/ui/src/primitives/toast.tsx index a9fa90f0de..2fb7790504 100644 --- a/packages/ui/src/primitives/toast.tsx +++ b/packages/ui/src/primitives/toast.tsx @@ -1,175 +1,92 @@ -import { - CheckIcon, - InfoIcon, - WarningCircleIcon, - WarningIcon, - XIcon, -} from "@phosphor-icons/react"; -import { Card, Flex, IconButton, Spinner, Text } from "@radix-ui/themes"; -import type { ReactNode } from "react"; -import { toast as sonnerToast } from "sonner"; +import { toast as quillToast } from "@posthog/quill"; -interface ToastAction { +// Thin wrapper over quill's toast so the whole app shares one import and a +// stable `(title, options)` signature. Quill (base-ui under the hood) owns +// rendering, stacking, auto-dismiss, hover-to-pause, and the close button — +// which is why this exists instead of a hand-rolled sonner custom toast. + +export interface ToastAction { label: string; onClick: () => void; } -interface ToastProps { - id: string | number; - type: "loading" | "success" | "error" | "info" | "warning"; - title: ReactNode; +export interface ToastOptions { description?: string; + // A caller-chosen stable id: upserts (creates or replaces) the toast with + // that id so it never stacks — matching sonner's `{ id }`. quill itself can't + // pick an id at create time, so the wrapper maps it (see idRegistry). + id?: string; action?: ToastAction; + // Auto-dismiss delay in ms. Maps to quill's `timeout`. Omit for the provider + // default; loading toasts never auto-dismiss regardless. + duration?: number; } -function ToastComponent(props: ToastProps) { - const { id, type, title, description, action } = props; - - const getIcon = () => { - switch (type) { - case "loading": - return ; - case "success": - return ; - case "error": - return ( - - ); - case "info": - return ; - case "warning": - return ; - } - }; +// The second argument may be a bare description string (sonner-style shorthand) +// or the full options object. +type Detail = string | ToastOptions; - return ( - - - {getIcon()} - - - {title} - - {action && ( - { - action.onClick(); - sonnerToast.dismiss(id); - }} - className="cursor-pointer font-medium text-[13px]" - > - {action.label} - - )} - {type !== "loading" && ( - sonnerToast.dismiss(id)} - > - - - )} - - - {description && ( - - {description} - - )} - - - - ); -} +type Level = "success" | "error" | "info" | "warning" | "loading"; -export const toast = { - dismiss: (id?: string | number) => sonnerToast.dismiss(id), +// Maps a caller-chosen stable id → quill's generated id, so `{ id }` behaves as +// an upsert: the first call creates a quill toast and records the mapping; a +// repeat call (or a different level) updates that same toast instead of +// stacking; `dismiss(id)` resolves through here. Entries self-clean on close. +const idRegistry = new Map(); - loading: (title: ReactNode, description?: string) => { - return sonnerToast.custom((id) => ( - - )); - }, +function normalize(detail?: Detail): ToastOptions { + return typeof detail === "string" ? { description: detail } : (detail ?? {}); +} - success: ( - title: ReactNode, - options?: { - description?: string; - id?: string | number; - action?: ToastAction; - duration?: number; - }, - ) => { - return sonnerToast.custom( - (id) => ( - - ), - { id: options?.id, duration: options?.duration }, - ); - }, +function emit( + level: Level, + title: string, + detail: Detail | undefined, + defaultTimeout?: number, +): string { + const o = normalize(detail); + // base-ui auto-dismisses any non-loading toast with `timeout > 0`; it has no + // Infinity special-case (Infinity would fire immediately), so a request to + // never auto-dismiss maps to `0`. + const requested = o.duration ?? defaultTimeout; + const timeout = requested === Number.POSITIVE_INFINITY ? 0 : requested; + const fields = { + title, + description: o.description, + timeout, + action: o.action, + }; - error: ( - title: ReactNode, - options?: { description?: string; id?: string | number; duration?: number }, - ) => { - return sonnerToast.custom( - (id) => ( - - ), - { id: options?.id, duration: options?.duration ?? 5000 }, - ); - }, + if (o.id !== undefined) { + const stableId = o.id; + const existing = idRegistry.get(stableId); + if (existing !== undefined) { + quillToast.update(existing, { type: level, ...fields }); + return stableId; + } + const quillId = quillToast[level]({ + ...fields, + onClose: () => { + if (idRegistry.get(stableId) === quillId) idRegistry.delete(stableId); + }, + }); + idRegistry.set(stableId, quillId); + return stableId; + } - info: (title: ReactNode, description?: string) => { - return sonnerToast.custom((id) => ( - - )); - }, + return quillToast[level](fields); +} - warning: ( - title: ReactNode, - options?: { - description?: string; - id?: string | number; - duration?: number; - action?: ToastAction; - }, - ) => { - return sonnerToast.custom( - (id) => ( - - ), - { id: options?.id, duration: options?.duration }, - ); +export const toast = { + success: (title: string, detail?: Detail) => emit("success", title, detail), + // Errors linger a touch longer than the default, matching prior behavior. + error: (title: string, detail?: Detail) => emit("error", title, detail, 5000), + info: (title: string, detail?: Detail) => emit("info", title, detail), + warning: (title: string, detail?: Detail) => emit("warning", title, detail), + loading: (title: string, detail?: Detail) => emit("loading", title, detail), + dismiss: (id?: string) => { + if (id === undefined) return; + quillToast.dismiss(idRegistry.get(id) ?? id); + idRegistry.delete(id); }, }; diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 1a3dcb5552..4fb8cfb7be 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -24,6 +24,7 @@ import { import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; +import { useOpenTargetDeepLink } from "@posthog/ui/features/deep-links/useOpenTargetDeepLink"; import { useTaskDeepLink } from "@posthog/ui/features/deep-links/useTaskDeepLink"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepLink"; @@ -191,6 +192,7 @@ function RootLayout() { useIntegrations(); useTaskDeepLink(); + useOpenTargetDeepLink(); useInboxDeepLink(); useScoutDeepLink(); const approvalDeepLink = useApprovalDeepLink(); diff --git a/packages/ui/src/shell/App.tsx b/packages/ui/src/shell/App.tsx index a847c92ff6..c73d516024 100644 --- a/packages/ui/src/shell/App.tsx +++ b/packages/ui/src/shell/App.tsx @@ -1,3 +1,4 @@ +import { ToastProvider } from "@posthog/quill"; import { EXTERNAL_LINKS, isNotAuthenticatedError } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { AiApprovalScreen } from "@posthog/ui/features/ai-approval/AiApprovalScreen"; @@ -11,6 +12,7 @@ import { InviteCodeScreen } from "@posthog/ui/features/auth/components/InviteCod import { ScopeReauthPrompt } from "@posthog/ui/features/auth/components/ScopeReauthPrompt"; import { useAuthSession } from "@posthog/ui/features/auth/useAuthSession"; import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; +import { CanvasGenerationToaster } from "@posthog/ui/features/canvas/freeform/useCanvasGenerationToasts"; import { AddDirectoryDialog } from "@posthog/ui/features/folder-picker/AddDirectoryDialog"; import { OnboardingFlow } from "@posthog/ui/features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; @@ -27,7 +29,6 @@ import { Flex, Spinner, Text } from "@radix-ui/themes"; import { RouterProvider } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useRef, useState } from "react"; -import { Toaster } from "sonner"; function App() { const { isBootstrapped } = useAuthSession(); @@ -160,6 +161,10 @@ function App() { transition={{ duration: 0.5, delay: showTransition ? 0.5 : 0 }} > + {/* Surfaces a toast when a backgrounded canvas generation finishes, + from anywhere in the app. Sibling of the router so it stays mounted + across every route (not just the canvas space). Renders null. */} + ); }; @@ -167,25 +172,26 @@ function App() { const content = renderContent(); return ( - - {isAuthenticated ? ( - {content} - ) : ( - content - )} - - - - - + + + {isAuthenticated ? ( + {content} + ) : ( + content + )} + + + + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7194a670..a8bb9dab47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1325,9 +1325,6 @@ importers: semver: specifier: ^7.6.0 version: 7.7.3 - sonner: - specifier: ^2.0.7 - version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -12153,12 +12150,6 @@ packages: solid-js@1.9.13: resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} - sonner@2.0.7: - resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} - peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -26061,11 +26052,6 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - sonner@2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0): - dependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - source-map-js@1.2.1: {} source-map-support@0.5.21: From 64df5655aa8df75426159fef00c3d68ff42e03e0 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 26 Jun 2026 17:07:48 +0100 Subject: [PATCH 2/2] refactor(notifications): generic targetsEqual via exhaustive targetKey Address review: collapse the per-kind equality chain into a single targetKey() identity function. New target kinds are now a compile error in one exhaustive switch instead of a silent false. Keeps `kind` (click navigation in deriveAction dispatches on it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../notifications/routeNotification.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/notifications/routeNotification.ts b/packages/ui/src/features/notifications/routeNotification.ts index 8b2d2ee99f..79b38397b2 100644 --- a/packages/ui/src/features/notifications/routeNotification.ts +++ b/packages/ui/src/features/notifications/routeNotification.ts @@ -1,16 +1,23 @@ import type { NotificationTarget } from "@posthog/platform/notifications"; -// Whether two targets point at the same thing (same kind + ids). +// Stable identity string for a target. A new kind is a compile error here (the +// switch is exhaustive), so equality and key-based lookups stay in one place. +export function targetKey(target: NotificationTarget): string { + switch (target.kind) { + case "task": + return `task:${target.taskId}`; + case "canvas": + return `canvas:${target.channelId}:${target.dashboardId}`; + } +} + +// Whether two targets point at the same thing. export function targetsEqual( a: NotificationTarget | undefined, b: NotificationTarget | undefined, ): boolean { - if (!a || !b || a.kind !== b.kind) return false; - if (a.kind === "task" && b.kind === "task") return a.taskId === b.taskId; - if (a.kind === "canvas" && b.kind === "canvas") { - return a.channelId === b.channelId && a.dashboardId === b.dashboardId; - } - return false; + if (!a || !b) return false; + return targetKey(a) === targetKey(b); } export type NotificationChannel = "suppress" | "toast" | "native";