From c84cab5986738aad51d6b2235c5ea68c0322402f Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 17:28:32 -0700 Subject: [PATCH 1/3] feat(command): add channels to cmd-k and show task filing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channels were only reachable by clicking the sidebar. Surface them in the command palette as a searchable "Channels" section that navigates to /website/$channelId, and auto-expand the channel in the sidebar when its route is active. Task entries now show the channel they're filed to as a muted "· #channel" suffix (searchable by channel name). Generated-By: PostHog Code Task-Id: bc6e7269-db09-4ef7-b3a4-41f0ca03abaf --- packages/shared/src/analytics-events.ts | 3 +- .../canvas/components/ChannelsList.tsx | 12 +++- .../canvas/hooks/useTaskChannelMap.ts | 41 +++++++++++++ .../ui/src/features/command/CommandMenu.tsx | 61 +++++++++++++++---- packages/ui/src/router/navigationBridge.ts | 7 +++ 5 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 76e714c3ea..c1e875f563 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 2bb1cf2fa1..96d99b81be 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/useTaskChannelMap.ts b/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts new file mode 100644 index 0000000000..3c16f0d915 --- /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, + useChannels, +} 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. + */ +export function useTaskChannelMap(options?: { + enabled?: boolean; +}): Map { + const enabled = options?.enabled ?? true; + const { channels } = useChannels({ enabled }); + 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 20824a7f43..1d48a18458 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, @@ -15,6 +16,8 @@ import { 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 { useFolders } from "@posthog/ui/features/folders/useFolders"; @@ -26,6 +29,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 +53,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 +95,8 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const openSettingsDialog = openSettings; const closeSettingsDialog = closeSettings; const { folders } = useFolders(); + const { channels } = useChannels(); + const taskChannelMap = useTaskChannelMap({ enabled: open }); const { theme, setTheme } = useThemeStore(); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); const view = useAppView(); @@ -254,24 +262,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 +348,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }} > @@ -343,6 +377,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 331b597bca..a2bde22168 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", From 3c05a92a5d99cbaa21a345b6c197892cdc55b0e2 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 17:36:26 -0700 Subject: [PATCH 2/3] refactor(command): stabilize channel refs for task-channel map Address review: memoize the derived `channels` array in useChannels so its reference is stable while the data is unchanged, and have useTaskChannelMap take channels as a parameter instead of opening a second useChannels subscription. Removes the duplicate subscription and the per-render Map rebuild that cascaded into taskSections recomputing. Generated-By: PostHog Code Task-Id: bc6e7269-db09-4ef7-b3a4-41f0ca03abaf --- .../ui/src/features/canvas/hooks/useChannels.ts | 16 +++++++++++----- .../features/canvas/hooks/useTaskChannelMap.ts | 16 ++++++++-------- packages/ui/src/features/command/CommandMenu.tsx | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/features/canvas/hooks/useChannels.ts b/packages/ui/src/features/canvas/hooks/useChannels.ts index a04347e975..350bcbbaba 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 index 3c16f0d915..10cf6c6873 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts @@ -1,8 +1,5 @@ import { useHostTRPC } from "@posthog/host-router/react"; -import { - type Channel, - useChannels, -} from "@posthog/ui/features/canvas/hooks/useChannels"; +import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -12,12 +9,15 @@ import { useMemo } from "react"; * 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(options?: { - enabled?: boolean; -}): Map { +export function useTaskChannelMap( + channels: Channel[], + options?: { enabled?: boolean }, +): Map { const enabled = options?.enabled ?? true; - const { channels } = useChannels({ enabled }); const trpc = useHostTRPC(); const results = useQueries({ queries: channels.map((channel) => diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index 1d48a18458..3f7ae06be1 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -96,7 +96,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const closeSettingsDialog = closeSettings; const { folders } = useFolders(); const { channels } = useChannels(); - const taskChannelMap = useTaskChannelMap({ enabled: open }); + const taskChannelMap = useTaskChannelMap(channels, { enabled: open }); const { theme, setTheme } = useThemeStore(); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); const view = useAppView(); From a3288b6ae73f8e12aeae01f7e9173324140bed25 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 16 Jun 2026 08:14:09 -0700 Subject: [PATCH 3/3] feat(command): gate cmd-k channels behind project-bluebird MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The command palette surfaced the Channels group, the task→channel "filed to" detail, and the channels-aware placeholder to every user. Channels are a Project Bluebird feature, so gate the channel fetches behind the flag — mirroring the pattern in useTaskContextMenu — so neither useChannels nor useTaskChannelMap fetches for ungated users and none of the channel UI renders. Generated-By: PostHog Code Task-Id: 36e3d2e8-9ed9-4307-a592-087b3c46206d --- .../ui/src/features/command/CommandMenu.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index 3f7ae06be1..0af46d9eae 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -11,6 +11,7 @@ import { Dialog, DialogContent, } from "@posthog/quill"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS, type CommandMenuAction, @@ -20,6 +21,7 @@ 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, @@ -95,8 +97,16 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const openSettingsDialog = openSettings; const closeSettingsDialog = closeSettings; const { folders } = useFolders(); - const { channels } = useChannels(); - const taskChannelMap = useTaskChannelMap(channels, { enabled: open }); + // 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(); @@ -348,7 +358,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }} >