Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions packages/ui/src/features/command/CommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
},
};
}),
Expand Down
78 changes: 72 additions & 6 deletions packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { id: string; name: string; path: string }>(),
}));

vi.mock("@posthog/host-router/react", () => ({
useHostTRPCClient: () => ({
Expand Down Expand Up @@ -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";

Expand All @@ -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");
});

Expand Down
31 changes: 29 additions & 2 deletions packages/ui/src/features/deep-links/useTaskDeepLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
Expand Down Expand Up @@ -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}` : ""}`,
Expand All @@ -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)
Expand Down
25 changes: 19 additions & 6 deletions packages/ui/src/router/useOpenTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,35 @@ 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<void> {
export async function openTask(
task: Task,
opts?: { channelId?: string },
): Promise<void> {
// 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.
resolveService<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT).setQueryData(
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 });

Expand Down
Loading