From 21d96e36aa8afa15b53a2afd6a3946241d8142c0 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 10:30:30 -0700 Subject: [PATCH 1/2] fix(canvas): open channel-filed task notifications in /website Clicking a "task needs action" notification (or a posthog-code://task deep link) always navigated to /code/tasks/$taskId, even when the task was filed to a Project Bluebird channel. It now routes filed tasks to their channel view at /website/$channelId/tasks/$taskId, mirroring the command palette. - openTask() takes an optional { channelId } and navigates to the channel route when present, keeping all other side effects (cache seed, analytics, workspace binding) identical. - useTaskDeepLink resolves channel membership (gated by PROJECT_BLUEBIRD_FLAG) via useChannels + useTaskChannelMap, held in a ref so the open-task subscription isn't recreated on each channel poll. - CommandMenu collapsed onto the same parameterized openTask helper. Generated-By: PostHog Code Task-Id: 81a5307a-63f0-4aae-8376-60ef3faa0f42 --- .../ui/src/features/command/CommandMenu.tsx | 15 +++---- .../deep-links/useTaskDeepLink.test.tsx | 45 ++++++++++++++++++- .../features/deep-links/useTaskDeepLink.ts | 31 ++++++++++++- packages/ui/src/router/useOpenTask.ts | 25 ++++++++--- 4 files changed, 98 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index a4bcc055e1..360c0bae62 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -31,10 +31,7 @@ import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { - navigateToChannel, - navigateToChannelTask, -} from "@posthog/ui/router/navigationBridge"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; @@ -290,11 +287,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { // Bluebird: a task filed to a channel opens in the channel- // organized view under /website, keeping the channels chrome. // Otherwise fall back to the /code task detail. - if (bluebirdEnabled && channel) { - navigateToChannelTask(channel.id, task.id); - } else { - void openTask(task); - } + const channelTarget = + bluebirdEnabled && channel + ? { channelId: channel.id } + : undefined; + void openTask(task, channelTarget); }, }; }), diff --git a/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx index f7b721a64d..7b1ede03ff 100644 --- a/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx @@ -13,6 +13,10 @@ const getPendingDeepLink = vi.hoisted(() => vi.fn().mockResolvedValue(null)); const onOpenTask = vi.hoisted(() => vi.fn(() => ({ unsubscribe: vi.fn() }))); const routerOpenTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const markAsViewed = vi.hoisted(() => vi.fn()); +const bluebirdState = vi.hoisted(() => ({ enabled: true })); +const channelMapState = vi.hoisted(() => ({ + map: new Map(), +})); vi.mock("@posthog/host-router/react", () => ({ useHostTRPCClient: () => ({ @@ -41,6 +45,15 @@ vi.mock("@posthog/ui/shell/logger", () => ({ vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn() }, })); +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => bluebirdState.enabled, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: [], isLoading: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannelMap", () => ({ + useTaskChannelMap: () => channelMapState.map, +})); import { useTaskDeepLink } from "./useTaskDeepLink"; @@ -57,6 +70,8 @@ describe("useTaskDeepLink", () => { beforeEach(() => { vi.clearAllMocks(); getPendingDeepLink.mockResolvedValue(null); + bluebirdState.enabled = true; + channelMapState.map = new Map(); }); it("opens a pending cold-start deep link through the bridge and navigates", async () => { @@ -65,11 +80,39 @@ describe("useTaskDeepLink", () => { await waitFor(() => expect(openTask).toHaveBeenCalledWith("t1", undefined)); await waitFor(() => - expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }), + expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, undefined), ); expect(markAsViewed).toHaveBeenCalledWith("t1"); }); + it("routes a channel-filed task to its channel view", async () => { + channelMapState.map = new Map([ + ["t1", { id: "chan-1", name: "marketing", path: "/marketing" }], + ]); + getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); + renderHook(() => useTaskDeepLink(), { wrapper }); + + await waitFor(() => + expect(routerOpenTask).toHaveBeenCalledWith( + { id: "t1" }, + { channelId: "chan-1" }, + ), + ); + }); + + it("ignores channel membership when the bluebird flag is off", async () => { + bluebirdState.enabled = false; + channelMapState.map = new Map([ + ["t1", { id: "chan-1", name: "marketing", path: "/marketing" }], + ]); + getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); + renderHook(() => useTaskDeepLink(), { wrapper }); + + await waitFor(() => + expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, undefined), + ); + }); + it("subscribes to warm-start open-task events", () => { renderHook(() => useTaskDeepLink(), { wrapper }); expect(onOpenTask).toHaveBeenCalledTimes(1); diff --git a/packages/ui/src/features/deep-links/useTaskDeepLink.ts b/packages/ui/src/features/deep-links/useTaskDeepLink.ts index efd6c4f72b..fe1e31fc78 100644 --- a/packages/ui/src/features/deep-links/useTaskDeepLink.ts +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.ts @@ -4,8 +4,12 @@ import { } 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"; @@ -31,6 +35,23 @@ export function useTaskDeepLink() { ); 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( @@ -67,7 +88,13 @@ export function useTaskDeepLink() { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); markAsViewed(taskId); - void openTaskHelper(task); + 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}` : ""}`, @@ -77,7 +104,7 @@ export function useTaskDeepLink() { toast.error("Failed to open task"); } }, - [markAsViewed, queryClient, taskService], + [markAsViewed, queryClient, taskService, bluebirdEnabled], ); // Check for pending deep link on mount (for cold start via deep link) diff --git a/packages/ui/src/router/useOpenTask.ts b/packages/ui/src/router/useOpenTask.ts index bf166d39e2..392e6e9ca9 100644 --- a/packages/ui/src/router/useOpenTask.ts +++ b/packages/ui/src/router/useOpenTask.ts @@ -16,14 +16,23 @@ import { useCallback } from "react"; import * as nav from "./navigationBridge"; /** - * Opens a task: navigates to /code/tasks/$taskId and ensures a workspace - * exists. Workspace binding is delegated to the host-provided - * NavigationTaskBinder (the refactor's abstraction over folder/workspace - * registration); if it reports a stale folder, we redirect to folder settings. + * Opens a task: navigates to its detail route and ensures a workspace exists. + * Workspace binding is delegated to the host-provided NavigationTaskBinder (the + * refactor's abstraction over folder/workspace registration); if it reports a + * stale folder, we redirect to folder settings. + * + * When `opts.channelId` is provided (the task is filed to a Project Bluebird + * channel), navigation targets the channel-organized view under /website, + * keeping the channels chrome; otherwise it targets /code/tasks/$taskId. Every + * other side effect is identical — channel tasks still need workspace + * provisioning so TaskDetail resolves a cwd. * * Replaces the old `navigationStore.navigateToTask` action. */ -export async function openTask(task: Task): Promise { +export async function openTask( + task: Task, + opts?: { channelId?: string }, +): Promise { // Seed the detail cache so the route loader resolves from cache and never // fetches — critical for optimistic/local/cloud-pending tasks that the API // can't yet return, which would otherwise hang the route in its pending state. @@ -31,7 +40,11 @@ export async function openTask(task: Task): Promise { taskDetailQuery(task.id).queryKey, task, ); - nav.navigateToTaskDetail(task.id); + if (opts?.channelId) { + nav.navigateToChannelTask(opts.channelId, task.id); + } else { + nav.navigateToTaskDetail(task.id); + } setActiveTaskContext(task); track(ANALYTICS_EVENTS.TASK_VIEWED, { task_id: task.id }); From bb441408aa9e790189379f77059497434060e7ee Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Fri, 19 Jun 2026 10:51:16 -0700 Subject: [PATCH 2/2] test(canvas): parameterise task deep-link routing dispatch cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the repeated cold-start routing assertions into a single it.each covering unfiled, channel-filed, and flag-off cases, and add a warm-start row that drives the onOpenTask subscription callback — closing that coverage gap. Follows the project's parameterised-test convention. Generated-By: PostHog Code Task-Id: 81a5307a-63f0-4aae-8376-60ef3faa0f42 --- .../deep-links/useTaskDeepLink.test.tsx | 89 ++++++++++++------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx index 7b1ede03ff..f8987ebaa6 100644 --- a/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx @@ -10,7 +10,14 @@ const openTask = vi.hoisted(() => }), ); const getPendingDeepLink = vi.hoisted(() => vi.fn().mockResolvedValue(null)); -const onOpenTask = vi.hoisted(() => vi.fn(() => ({ unsubscribe: vi.fn() }))); +const onOpenTask = vi.hoisted(() => + vi.fn( + ( + _input?: unknown, + _opts?: { onData?: (data: { taskId: string }) => void }, + ) => ({ unsubscribe: vi.fn() }), + ), +); const routerOpenTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const markAsViewed = vi.hoisted(() => vi.fn()); const bluebirdState = vi.hoisted(() => ({ enabled: true })); @@ -74,43 +81,59 @@ describe("useTaskDeepLink", () => { channelMapState.map = new Map(); }); - it("opens a pending cold-start deep link through the bridge and navigates", async () => { - getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); - renderHook(() => useTaskDeepLink(), { wrapper }); - - await waitFor(() => expect(openTask).toHaveBeenCalledWith("t1", undefined)); - await waitFor(() => - expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, undefined), - ); - expect(markAsViewed).toHaveBeenCalledWith("t1"); - }); - - it("routes a channel-filed task to its channel view", async () => { - channelMapState.map = new Map([ - ["t1", { id: "chan-1", name: "marketing", path: "/marketing" }], - ]); - getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); - renderHook(() => useTaskDeepLink(), { wrapper }); + const marketing = { id: "chan-1", name: "marketing", path: "/marketing" }; - await waitFor(() => - expect(routerOpenTask).toHaveBeenCalledWith( - { id: "t1" }, - { channelId: "chan-1" }, - ), - ); - }); + // Both entry points (cold-start pending link, warm-start subscription event) + // run the same routing dispatch: a channel-filed task opens in its /website + // channel view, otherwise it falls back to /code — and only when the bluebird + // flag is on. + it.each([ + { + name: "cold-start unfiled task → /code", + trigger: "pending" as const, + enabled: true, + channel: null, + expected: undefined, + }, + { + name: "cold-start channel-filed task → its channel view", + trigger: "pending" as const, + enabled: true, + channel: marketing, + expected: { channelId: "chan-1" }, + }, + { + name: "cold-start filed task with flag off → /code", + trigger: "pending" as const, + enabled: false, + channel: marketing, + expected: undefined, + }, + { + name: "warm-start channel-filed task → its channel view", + trigger: "warm" as const, + enabled: true, + channel: marketing, + expected: { channelId: "chan-1" }, + }, + ])("$name", async ({ trigger, enabled, channel, expected }) => { + bluebirdState.enabled = enabled; + if (channel) channelMapState.map = new Map([["t1", channel]]); - it("ignores channel membership when the bluebird flag is off", async () => { - bluebirdState.enabled = false; - channelMapState.map = new Map([ - ["t1", { id: "chan-1", name: "marketing", path: "/marketing" }], - ]); - getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); - renderHook(() => useTaskDeepLink(), { wrapper }); + if (trigger === "pending") { + getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); + renderHook(() => useTaskDeepLink(), { wrapper }); + } else { + renderHook(() => useTaskDeepLink(), { wrapper }); + // Drive the warm-start path through the subscription's onData callback. + onOpenTask.mock.calls[0]?.[1]?.onData?.({ taskId: "t1" }); + } await waitFor(() => - expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, undefined), + expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, expected), ); + expect(openTask).toHaveBeenCalledWith("t1", undefined); + expect(markAsViewed).toHaveBeenCalledWith("t1"); }); it("subscribes to warm-start open-task events", () => {