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..f8987ebaa6 100644 --- a/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx @@ -10,9 +10,20 @@ 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 })); +const channelMapState = vi.hoisted(() => ({ + map: new Map(), +})); vi.mock("@posthog/host-router/react", () => ({ useHostTRPCClient: () => ({ @@ -41,6 +52,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,16 +77,62 @@ 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 () => { - getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); - renderHook(() => useTaskDeepLink(), { wrapper }); + const marketing = { id: "chan-1", name: "marketing", path: "/marketing" }; + + // 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]]); + + 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(openTask).toHaveBeenCalledWith("t1", undefined)); await waitFor(() => - expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }), + expect(routerOpenTask).toHaveBeenCalledWith({ id: "t1" }, expected), ); + expect(openTask).toHaveBeenCalledWith("t1", undefined); expect(markAsViewed).toHaveBeenCalledWith("t1"); }); 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 });