diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 76e714c3e..c1e875f56 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -47,7 +47,8 @@ export type CommandMenuAction = | "toggle-theme" | "toggle-left-sidebar" | "open-review-panel" - | "open-task"; + | "open-task" + | "open-channel"; // Event property interfaces export interface TaskListViewProperties { diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 2bb1cf2fa..96d99b81b 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -57,7 +57,7 @@ import { toast } from "@posthog/ui/primitives/toast"; import * as Collapsible from "@radix-ui/react-collapsible"; import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { hostClient } from "../hostClient"; function NavButton({ @@ -338,8 +338,14 @@ function ChannelSection({ const { data: tasks } = useTasks(); const { tasks: filedTasks } = useChannelTasks(channel.id); const base = `/website/${channel.id}`; - // Channels always start collapsed on load; expansion is session-only. - const [open, setOpen] = useState(false); + const isActive = pathname === base || pathname.startsWith(`${base}/`); + // Channels start collapsed; expansion is session-only. Navigating into a + // channel (sidebar, cmd-k, deep link) auto-expands it so the active channel + // is always open, while leaving manual collapse/expand intact afterward. + const [open, setOpen] = useState(isActive); + useEffect(() => { + if (isActive) setOpen(true); + }, [isActive]); return ( diff --git a/packages/ui/src/features/canvas/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index a04347e97..350bcbbab 100644 --- a/packages/ui/src/features/canvas/hooks/useChannels.ts +++ b/packages/ui/src/features/canvas/hooks/useChannels.ts @@ -2,7 +2,7 @@ import type { Schemas } from "@posthog/api-client"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; const CHANNELS_POLL_INTERVAL_MS = 30_000; const CHANNELS_QUERY_KEY = ["canvas-channels"] as const; @@ -37,10 +37,16 @@ export function useChannels(options?: { enabled?: boolean }): { refetchInterval: CHANNELS_POLL_INTERVAL_MS, }, ); - const channels = (query.data ?? []) - .filter((fs) => fs.type === "folder") - .map(toChannel) - .sort((a, b) => a.name.localeCompare(b.name)); + // Memoize so the array reference is stable while the underlying data is + // unchanged — callers depend on `channels` in their own memos/effects. + const channels = useMemo( + () => + (query.data ?? []) + .filter((fs) => fs.type === "folder") + .map(toChannel) + .sort((a, b) => a.name.localeCompare(b.name)), + [query.data], + ); return { channels, isLoading: query.isLoading }; } diff --git a/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts b/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts new file mode 100644 index 000000000..10cf6c687 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts @@ -0,0 +1,41 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; + +/** + * Map of taskId → the channel it's filed to. A task is filed to at most one + * channel (ChannelTasksService moves the row rather than duplicating it), so + * the mapping is unambiguous. Fans out one `channelTasks.list` query per + * channel; results are shared with the channel sidebar's per-section queries + * through the react-query cache. + * + * Takes the already-fetched `channels` rather than subscribing to `useChannels` + * itself, so the caller owns the single subscription and a stable reference. + */ +export function useTaskChannelMap( + channels: Channel[], + options?: { enabled?: boolean }, +): Map { + const enabled = options?.enabled ?? true; + const trpc = useHostTRPC(); + const results = useQueries({ + queries: channels.map((channel) => + trpc.channelTasks.list.queryOptions( + { channelId: channel.id }, + { enabled, staleTime: 5_000 }, + ), + ), + }); + return useMemo(() => { + const map = new Map(); + results.forEach((res, i) => { + const channel = channels[i]; + if (!channel) return; + for (const record of res.data ?? []) { + if (record.taskId) map.set(record.taskId, channel); + } + }); + return map; + }, [results, channels]); +} diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index 20824a7f4..0af46d9ea 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -1,3 +1,4 @@ +import { HashIcon } from "@phosphor-icons/react"; import { Autocomplete, AutocompleteCollection, @@ -10,13 +11,17 @@ import { Dialog, DialogContent, } from "@posthog/quill"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS, type CommandMenuAction, } from "@posthog/shared/analytics-events"; 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 { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { CommandKeyHints } from "@posthog/ui/features/command/CommandKeyHints"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useFolders } from "@posthog/ui/features/folders/useFolders"; import { closeSettings, @@ -26,6 +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 } 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"; @@ -49,6 +55,8 @@ interface CommandMenuProps { type Command = { id: string; label: string; + /** Muted trailing detail shown after a middot, e.g. a task's channel. */ + detail?: string; keywords?: string; icon: React.ReactNode; action: CommandMenuAction; @@ -89,6 +97,16 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const openSettingsDialog = openSettings; const closeSettingsDialog = closeSettings; const { folders } = useFolders(); + // Channels (and the task→channel detail) are a Project Bluebird feature. 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 taskChannelMap = useTaskChannelMap(channels, { + enabled: open && bluebirdEnabled, + }); const { theme, setTheme } = useThemeStore(); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); const view = useAppView(); @@ -254,24 +272,50 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return [ { label: "Tasks", - items: tasks.map((task) => ({ - id: `task-${task.id}`, - label: task.title, - icon: , - action: "open-task" as CommandMenuAction, + items: tasks.map((task) => { + const channel = taskChannelMap.get(task.id); + return { + id: `task-${task.id}`, + label: task.title, + detail: channel?.name, + // Include the channel name so searching it surfaces filed tasks. + keywords: channel?.name, + icon: , + action: "open-task" as CommandMenuAction, + onRun: () => { + closeSettingsDialog(); + void openTask(task); + }, + }; + }), + }, + ]; + }, [tasks, taskChannelMap, closeSettingsDialog]); + + const channelSections = useMemo(() => { + if (channels.length === 0) return []; + return [ + { + label: "Channels", + items: channels.map((channel) => ({ + id: `channel-${channel.id}`, + label: channel.name, + keywords: "channel", + icon: , + action: "open-channel" as CommandMenuAction, onRun: () => { closeSettingsDialog(); - void openTask(task); + navigateToChannel(channel.id); }, })), }, ]; - }, [tasks, closeSettingsDialog]); + }, [channels, closeSettingsDialog]); - // Commands and tasks share a single filterable list. + // Commands, channels, and tasks share a single filterable list. const sections = useMemo( - () => [...commandSections, ...taskSections], - [commandSections, taskSections], + () => [...commandSections, ...channelSections, ...taskSections], + [commandSections, channelSections, taskSections], ); const allCommands = useMemo( @@ -314,7 +358,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }} > @@ -343,6 +391,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { {cmd.label} + {cmd.detail && ( + + · #{cmd.detail} + + )} )} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 331b597bc..a2bde2216 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -32,6 +32,13 @@ export function navigateToTaskPending(key: string): void { }); } +export function navigateToChannel(channelId: string): void { + void getRouterOrNull()?.navigate({ + to: "/website/$channelId", + params: { channelId }, + }); +} + export function navigateToFolderSettings(folderId: string): void { void getRouterOrNull()?.navigate({ to: "/folders/$folderId",