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
3 changes: 2 additions & 1 deletion packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions packages/ui/src/features/canvas/components/ChannelsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 (
<Box className="group/chan relative">
Expand Down
16 changes: 11 additions & 5 deletions packages/ui/src/features/canvas/hooks/useChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}

Expand Down
41 changes: 41 additions & 0 deletions packages/ui/src/features/canvas/hooks/useTaskChannelMap.ts
Original file line number Diff line number Diff line change
@@ -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<string, Channel> {
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<string, Channel>();
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]);
Comment thread
raquelmsmith marked this conversation as resolved.
}
75 changes: 64 additions & 11 deletions packages/ui/src/features/command/CommandMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HashIcon } from "@phosphor-icons/react";
import {
Autocomplete,
AutocompleteCollection,
Expand All @@ -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,
Expand All @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: <TaskCommandIcon task={task} />,
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: <TaskCommandIcon task={task} />,
action: "open-task" as CommandMenuAction,
onRun: () => {
closeSettingsDialog();
void openTask(task);
},
};
}),
},
];
}, [tasks, taskChannelMap, closeSettingsDialog]);

const channelSections = useMemo<CommandSection[]>(() => {
if (channels.length === 0) return [];
return [
{
label: "Channels",
items: channels.map((channel) => ({
id: `channel-${channel.id}`,
label: channel.name,
keywords: "channel",
icon: <HashIcon size={12} className="text-gray-11" />,
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(
Expand Down Expand Up @@ -314,7 +358,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
}}
>
<AutocompleteInput
placeholder="Search commands and tasks…"
placeholder={
bluebirdEnabled
? "Search commands, channels, and tasks…"
: "Search commands and tasks…"
}
autoFocus
showClear
/>
Expand Down Expand Up @@ -343,6 +391,11 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
<span className="wrap-break-word min-w-0 whitespace-normal">
{cmd.label}
</span>
{cmd.detail && (
<span className="shrink-0 text-gray-9">
· #{cmd.detail}
</span>
)}
</AutocompleteItem>
)}
</AutocompleteCollection>
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/router/navigationBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading