diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 15c8e0dcca..967cb75048 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -242,6 +242,34 @@ export interface CommandMenuActionProperties { channel_id?: string; } +export type SidebarNavItem = + | "new_task" + | "home" + | "search" + | "inbox" + | "agents" + | "skills" + | "mcp_servers" + | "command_center" + | "contexts" + | "activity" + | "configure" + | "loops" + | "more" + | "customize_sidebar"; + +export interface SidebarNavItemClickedProperties { + item: SidebarNavItem; + /** True when the row was clicked inside the expanded More section. */ + in_more: boolean; +} + +export interface SidebarCustomizedProperties { + item: SidebarNavItem; + /** True when the item was promoted to the top level, false when moved under More. */ + visible: boolean; +} + export interface BrainrotActivatedProperties { /** Grid layout preset, e.g. "2x2". */ layout: string; @@ -1110,6 +1138,8 @@ export const ANALYTICS_EVENTS = { BRAINROT_ACTIVATED: "Brainrot activated", SKILL_BUTTON_TRIGGERED: "Skill button triggered", POSTHOG_WEB_OPENED: "PostHog web opened", + SIDEBAR_NAV_ITEM_CLICKED: "Sidebar nav item clicked", + SIDEBAR_CUSTOMIZED: "Sidebar customized", // Permission events PERMISSION_RESPONDED: "Permission responded", @@ -1268,6 +1298,8 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.BRAINROT_ACTIVATED]: BrainrotActivatedProperties; [ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED]: SkillButtonTriggeredProperties; [ANALYTICS_EVENTS.POSTHOG_WEB_OPENED]: never; + [ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED]: SidebarNavItemClickedProperties; + [ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED]: SidebarCustomizedProperties; // Permission events [ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties; diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx new file mode 100644 index 0000000000..c2a56ed80d --- /dev/null +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx @@ -0,0 +1,85 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { track } = vi.hoisted(() => ({ track: vi.fn() })); + +vi.mock("@posthog/ui/shell/analytics", () => ({ track })); + +import { + CUSTOMIZABLE_NAV_ITEM_IDS, + type CustomizableNavItemId, +} from "@posthog/ui/features/sidebar/constants"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { CustomizeSidebarDialog } from "./CustomizeSidebarDialog"; + +function availability( + overrides: Partial> = {}, +) { + return { + ...(Object.fromEntries( + CUSTOMIZABLE_NAV_ITEM_IDS.map((id) => [id, true]), + ) as Record), + ...overrides, + }; +} + +function renderDialog(available = availability()) { + return render( + + + , + ); +} + +describe("CustomizeSidebarDialog", () => { + beforeEach(() => { + track.mockReset(); + useSidebarStore.setState({ navItemOverrides: {} }); + }); + + it("unchecking a visible item demotes it and tracks the change", async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByRole("checkbox", { name: "MCP servers" })); + + expect(useSidebarStore.getState().navItemOverrides["mcp-servers"]).toBe( + false, + ); + expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { + item: "mcp_servers", + visible: false, + }); + }); + + it("checking a hidden item promotes it and tracks the change", async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click(screen.getByRole("checkbox", { name: "Search" })); + + expect(useSidebarStore.getState().navItemOverrides.search).toBe(true); + expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { + item: "search", + visible: true, + }); + }); + + it("omits items marked unavailable", () => { + renderDialog(availability({ loops: false })); + + expect( + screen.queryByRole("checkbox", { name: "Loops" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { name: "Configure" }), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx new file mode 100644 index 0000000000..11188f1f39 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -0,0 +1,102 @@ +import { + Bell, + EnvelopeSimple, + HashIcon, + Lightbulb, + Lightning, + MagnifyingGlass, + Plugs, + RepeatIcon, + Robot, + SlidersHorizontal, +} from "@phosphor-icons/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + CUSTOMIZABLE_NAV_ITEMS, + type CustomizableNavItemId, + isNavItemVisible, +} from "@posthog/ui/features/sidebar/constants"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { track } from "@posthog/ui/shell/analytics"; +import { Button, Checkbox, Dialog, Flex, Text } from "@radix-ui/themes"; + +const ITEM_ICONS: Record< + CustomizableNavItemId, + React.ComponentType<{ size?: number | string }> +> = { + search: MagnifyingGlass, + inbox: EnvelopeSimple, + agents: Robot, + skills: Lightbulb, + "mcp-servers": Plugs, + "command-center": Lightning, + contexts: HashIcon, + activity: Bell, + configure: SlidersHorizontal, + loops: RepeatIcon, +}; + +interface CustomizeSidebarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + // Items gated off by feature flags stay out of the dialog too, so it never + // offers a checkbox for a nav row the user can't have. + available?: Record; +} + +export function CustomizeSidebarDialog({ + open, + onOpenChange, + available, +}: CustomizeSidebarDialogProps) { + const navItemOverrides = useSidebarStore((s) => s.navItemOverrides); + const setNavItemVisible = useSidebarStore((s) => s.setNavItemVisible); + + return ( + + + Customize sidebar + + Choose which items appear in your sidebar. Unchecked items live under + More. + + + + {CUSTOMIZABLE_NAV_ITEMS.filter( + ({ id }) => available?.[id] !== false, + ).map(({ id, label, analyticsId }) => { + const ItemIcon = ITEM_ICONS[id]; + const visible = isNavItemVisible(navItemOverrides, id); + return ( + + + { + const nextVisible = checked === true; + setNavItemVisible(id, nextVisible); + track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { + item: analyticsId, + visible: nextVisible, + }); + }} + /> + + {label} + + + ); + })} + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx index 453623abe9..6a7506cb48 100644 --- a/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -11,6 +11,10 @@ import { useCallback } from "react"; export const INDENT_SIZE = 8; +export function getSidebarItemPaddingLeft(depth: number): string { + return `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`; +} + interface SidebarItemProps { depth: number; icon?: React.ReactNode; @@ -102,7 +106,7 @@ export function SidebarItem({ draggable={draggable} onDragStart={onDragStart} style={{ - paddingLeft: `${depth * INDENT_SIZE + 8 + (depth > 0 ? 4 : 0)}px`, + paddingLeft: getSidebarItemPaddingLeft(depth), paddingRight: "8px", }} onClick={onClick} diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx new file mode 100644 index 0000000000..edfda181c1 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx @@ -0,0 +1,212 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + +const { + track, + useAppView, + navigateToInbox, + navigateToAgents, + navigateToSkills, + navigateToMcpServers, + navigateToCommandCenter, + navigateToActivity, + openCommandMenu, +} = vi.hoisted(() => ({ + track: vi.fn(), + useAppView: vi.fn(), + navigateToInbox: vi.fn(), + navigateToAgents: vi.fn(), + navigateToSkills: vi.fn(), + navigateToMcpServers: vi.fn(), + navigateToCommandCenter: vi.fn(), + navigateToActivity: vi.fn(), + openCommandMenu: vi.fn(), +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ track })); +vi.mock("@posthog/ui/router/useAppView", () => ({ useAppView })); +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: () => true, +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToActivity, + navigateToAgents, + navigateToCommandCenter, + navigateToHome: vi.fn(), + navigateToInbox, + navigateToLoops: vi.fn(), + navigateToMcpServers, + navigateToSkills, + navigateToWebsiteCommandCenter: vi.fn(), + navigateToWebsiteHome: vi.fn(), + navigateToWebsiteMcpServers: vi.fn(), + navigateToWebsiteSkills: vi.fn(), +})); +vi.mock("@posthog/ui/router/useOpenTask", () => ({ openTaskInput: vi.fn() })); +vi.mock("@posthog/ui/shell/commandMenuStore", () => ({ + useCommandMenuStore: (selector: (s: { open: () => void }) => unknown) => + selector({ open: openCommandMenu }), +})); +vi.mock("@posthog/ui/features/command-center/commandCenterStore", () => ({ + useCommandCenterStore: ( + selector: (s: { cells: (string | null)[] }) => unknown, + ) => selector({ cells: [] }), +})); +vi.mock("@posthog/ui/features/inbox/hooks/useInboxAllReports", () => ({ + useInboxAllReports: () => ({ counts: { pulls: 0 } }), +})); +vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ + useTasks: () => ({ data: [] }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useMentionActivity", () => ({ + useMentionActivity: () => ({ items: [] }), +})); +vi.mock("@posthog/ui/features/canvas/stores/activitySeenStore", () => ({ + useActivitySeenStore: ( + selector: (s: { lastSeenAt: number | null }) => unknown, + ) => selector({ lastSeenAt: null }), +})); +vi.mock("@tanstack/react-router", () => ({ + useRouterState: () => false, +})); + +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { SidebarNavSection } from "./SidebarNavSection"; + +function renderNav() { + return render( + + + , + ); +} + +describe("SidebarNavSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + useAppView.mockReturnValue({ type: "home" }); + useSidebarStore.setState({ navItemOverrides: {}, channelsEnabled: true }); + }); + + it.each([ + ["search", "Search"], + ["inbox", "Inbox"], + ["agents", "Agents"], + ["skills", "Skills"], + ["mcp-servers", "MCP servers"], + ["command-center", "Command Center"], + ["contexts", "Channels"], + ["activity", "Activity"], + ["configure", "Configure"], + ["loops", "Loops"], + ] as const)( + "moves %s from the top level into More when hidden", + async (id, label) => { + const user = userEvent.setup(); + useSidebarStore.setState({ navItemOverrides: { [id]: false } }); + renderNav(); + + expect(screen.queryByText(label)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "More" })); + + expect(screen.getByText(label)).toBeInTheDocument(); + }, + ); + + it.each([ + ["inbox", "inbox", "Inbox"], + ["agents", "agents", "Agents"], + ["skills", "skills", "Skills"], + ["mcp-servers", "mcp-servers", "MCP servers"], + ["command-center", "command-center", "Command Center"], + ["activity", "activity", "Activity"], + ["loops", "loops", "Loops"], + ] as const)( + "active hidden %s takes over the collapsed More row", + (id, viewType, label) => { + useAppView.mockReturnValue({ type: viewType }); + useSidebarStore.setState({ navItemOverrides: { [id]: false } }); + renderNav(); + + expect( + screen.queryByRole("button", { name: "More" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); + }, + ); + + it("never lets hidden search take over the More row", () => { + useSidebarStore.setState({ navItemOverrides: { search: false } }); + renderNav(); + + expect(screen.getByRole("button", { name: "More" })).toBeInTheDocument(); + }); + + it("tracks top-level clicks with in_more false", async () => { + const user = userEvent.setup(); + renderNav(); + + await user.click(screen.getByRole("button", { name: /Inbox/ })); + + expect(navigateToInbox).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, + { item: "inbox", in_more: false }, + ); + }); + + it("tracks clicks inside the expanded More section with in_more true", async () => { + const user = userEvent.setup(); + useSidebarStore.setState({ navItemOverrides: { inbox: false } }); + renderNav(); + + await user.click(screen.getByRole("button", { name: "More" })); + await user.click(screen.getByRole("button", { name: /Inbox/ })); + + expect(navigateToInbox).toHaveBeenCalledTimes(1); + expect(track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, + { item: "inbox", in_more: true }, + ); + }); + + it.each([ + [false, true, "enter_space"], + [true, false, "leave_space"], + ] as const)( + "toggling contexts from %s tracks the toggle and %s", + async (initial, expected, spaceAction) => { + const user = userEvent.setup(); + useSidebarStore.setState({ channelsEnabled: initial }); + renderNav(); + + await user.click(screen.getByRole("switch")); + + expect(useSidebarStore.getState().channelsEnabled).toBe(expected); + expect(track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, + { item: "contexts", in_more: false }, + ); + expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "toggle_channels", + surface: "nav", + }); + expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: spaceAction, + surface: "nav", + }); + }, + ); +}); diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index e4530a806b..0775edd3b2 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -1,22 +1,34 @@ -import { HashIcon } from "@phosphor-icons/react"; -import { Badge, Switch } from "@posthog/quill"; import { LOOPS_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + ANALYTICS_EVENTS, + type SidebarNavItem, +} from "@posthog/shared/analytics-events"; import { HOME_TAB_FLAG } from "@posthog/shared/constants"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { + CUSTOMIZABLE_NAV_ITEM_IDS, + CUSTOMIZABLE_NAV_ITEMS, + type CustomizableNavItemId, + isNavItemVisible, +} from "@posthog/ui/features/sidebar/constants"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToActivity, + navigateToAgents, navigateToCommandCenter, navigateToHome, navigateToInbox, navigateToLoops, + navigateToMcpServers, + navigateToSkills, navigateToWebsiteCommandCenter, navigateToWebsiteHome, + navigateToWebsiteMcpServers, + navigateToWebsiteSkills, } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; @@ -24,14 +36,22 @@ import { track } from "@posthog/ui/shell/analytics"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { Box, Flex } from "@radix-ui/themes"; import { useRouterState } from "@tanstack/react-router"; +import { Fragment, type ReactNode, useState } from "react"; +import { CustomizeSidebarDialog } from "./CustomizeSidebarDialog"; import { ActivityItem } from "./items/ActivityItem"; +import { AgentsItem } from "./items/AgentsItem"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { ConfigureItem } from "./items/ConfigureItem"; +import { ContextsItem } from "./items/ContextsItem"; +import { CustomizeSidebarItem } from "./items/CustomizeSidebarItem"; import { HomeItem } from "./items/HomeItem"; import { InboxItem } from "./items/InboxItem"; import { LoopsItem } from "./items/LoopsItem"; +import { McpServersItem } from "./items/McpServersItem"; +import { MoreItem } from "./items/MoreItem"; import { NewTaskItem } from "./items/NewTaskItem"; import { SearchItem } from "./items/SearchItem"; +import { SkillsItem } from "./items/SkillsItem"; const SIDEBAR_INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -48,9 +68,11 @@ interface SidebarNavSectionProps { // and the Channels pane. It is fully self-contained — every item's active // state, badge count, and click handler is wired here — so it can be dropped // into either layout. In the Channels space, destinations with a /website -// mirror (Home and Command Center) stay in that space; Inbox and New task have -// no mirror yet and jump back to Code. Configure opens the shared settings UI. -// Search opens the command menu in place. +// mirror (Home, Skills, MCP servers, Command Center) stay in that space; +// Inbox, Agents and New task have no mirror yet and jump back to Code. +// Configure opens the shared settings UI. Search opens the command menu in +// place and defaults to the collapsible More row; the Customize sidebar +// dialog controls which items show at the top level. export function SidebarNavSection({ commandCenterActiveCount: providedActiveCount, }: SidebarNavSectionProps = {}) { @@ -79,6 +101,10 @@ export function SidebarNavSection({ const goNewTask = () => openTaskInput(inChannels ? { space: "website" } : undefined); const goHome = inChannels ? navigateToWebsiteHome : navigateToHome; + const goSkills = inChannels ? navigateToWebsiteSkills : navigateToSkills; + const goMcpServers = inChannels + ? navigateToWebsiteMcpServers + : navigateToMcpServers; const goCommandCenter = inChannels ? navigateToWebsiteCommandCenter : navigateToCommandCenter; @@ -90,8 +116,11 @@ export function SidebarNavSection({ const isHomeViewActive = view.type === "home"; const isActivityActive = view.type === "activity"; const isInboxActive = view.type === "inbox"; + const isAgentsActive = view.type === "agents"; const isLoopsActive = view.type === "loops"; const isCommandCenterActive = view.type === "command-center"; + const isSkillsActive = view.type === "skills"; + const isMcpServersActive = view.type === "mcp-servers"; // Open pull requests in the inbox — the main CTA, and the same count the inbox // Pull requests tab shows, so the badge and the tab always agree. @@ -126,97 +155,219 @@ export function SidebarNavSection({ const openCommandMenu = useCommandMenuStore((s) => s.open); + // depth 1 means the row was clicked inside the expanded More section. + const withNavTrack = + (item: SidebarNavItem, action: () => void, depth: 0 | 1 = 0) => + () => { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item, + in_more: depth === 1, + }); + action(); + }; + + const navItemOverrides = useSidebarStore((s) => s.navItemOverrides); + const hidden = new Set( + CUSTOMIZABLE_NAV_ITEM_IDS.filter( + (id) => !isNavItemVisible(navItemOverrides, id), + ), + ); + const [moreExpanded, setMoreExpanded] = useState(false); + const [customizeOpen, setCustomizeOpen] = useState(false); + + // While More is collapsed, an active item hidden under it takes over the + // More row so the current page stays visible. Search, Contexts and Configure + // never do: none of them is a routed page. + const moreItemActive: Record = { + search: false, + inbox: isInboxActive, + agents: isAgentsActive, + skills: isSkillsActive, + "mcp-servers": isMcpServersActive, + "command-center": isCommandCenterActive, + contexts: false, + activity: isActivityActive, + configure: false, + loops: isLoopsActive, + }; + + const navItemAvailable: Record = { + search: true, + inbox: true, + agents: true, + skills: true, + "mcp-servers": true, + "command-center": true, + contexts: bluebirdEnabled, + // Activity (the mentions feed) is a channels surface, so it only appears + // once channels are enabled. + activity: channelsEnabled, + configure: true, + loops: loopsEnabled, + }; + + const activeHiddenItem = CUSTOMIZABLE_NAV_ITEMS.find( + ({ id }) => navItemAvailable[id] && hidden.has(id) && moreItemActive[id], + ); + const takeoverLabel = + !moreExpanded && activeHiddenItem ? activeHiddenItem.label : null; + + const handleChannelsToggle = (depth: 0 | 1) => (checked: boolean) => { + setChannelsEnabled(checked); + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item: "contexts", + in_more: depth === 1, + }); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "toggle_channels", + surface: "nav", + }); + // This toggle replaced the old Code/Channels space boundary; keep firing + // the legacy enter/leave events so space-adoption dashboards stay continuous. + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: checked ? "enter_space" : "leave_space", + surface: "nav", + }); + }; + + // One renderer per customizable item, used for both the top level (depth 0) + // and the expanded More section (depth 1) so the two never drift apart. + const renderNavItem: Record< + CustomizableNavItemId, + (depth: 0 | 1) => ReactNode + > = { + search: (depth) => ( + + ), + inbox: (depth) => ( + + ), + agents: (depth) => ( + + ), + skills: (depth) => ( + + ), + "mcp-servers": (depth) => ( + + ), + "command-center": (depth) => ( + + ), + contexts: (depth) => ( + + ), + activity: (depth) => ( + + ), + configure: (depth) => ( + openSettings("agents"), depth)} + /> + ), + loops: (depth) => ( + + ), + }; + + const topLevelItems = CUSTOMIZABLE_NAV_ITEMS.filter( + ({ id }) => navItemAvailable[id] && !hidden.has(id), + ); + const moreItems = CUSTOMIZABLE_NAV_ITEMS.filter( + ({ id }) => navItemAvailable[id] && hidden.has(id), + ); + return ( - + {homeTabEnabled && ( - + )} - - - - - - - - - - openSettings("agents")} /> - - - {loopsEnabled ? ( - - - - ) : null} + {topLevelItems.map(({ id }) => ( + {renderNavItem[id](0)} + ))} - - - {/* "Channels" is a toggle laid out as a nav row: the # label and Alpha - badge on the left, a Switch on the right. It flips the channels - feature rather than routing — enabling it reveals the Canvas row - below and swaps the sidebar body to the channel tree. A - {/* Activity (the mentions feed) is a channels surface, so it only appears - once channels are enabled — sitting directly under the toggle that - reveals it. */} - {channelsEnabled && ( - - - - )} + ); } diff --git a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx index 5ccd296933..a9a4f23a25 100644 --- a/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/ActivityItem.tsx @@ -9,12 +9,17 @@ import { SidebarCountBadge } from "./SidebarCountBadge"; interface ActivityItemProps { isActive: boolean; onClick: () => void; + depth?: number; } // The Activity nav row with its unread-mentions dot. Owns the mentions // subscription so the query mounts once here; the badge counts thread mentions // newer than the last time the Activity page was opened. -export function ActivityItem({ isActive, onClick }: ActivityItemProps) { +export function ActivityItem({ + isActive, + onClick, + depth = 0, +}: ActivityItemProps) { const { items } = useMentionActivity(); const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); const unseen = useMemo( @@ -23,7 +28,7 @@ export function ActivityItem({ isActive, onClick }: ActivityItemProps) { ); return ( } label={ <> diff --git a/packages/ui/src/features/sidebar/components/items/AgentsItem.tsx b/packages/ui/src/features/sidebar/components/items/AgentsItem.tsx new file mode 100644 index 0000000000..fab49e5cb0 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/AgentsItem.tsx @@ -0,0 +1,20 @@ +import { Robot } from "@phosphor-icons/react"; +import { SidebarItem } from "../SidebarItem"; + +interface AgentsItemProps { + isActive: boolean; + onClick: () => void; + depth?: number; +} + +export function AgentsItem({ isActive, onClick, depth = 0 }: AgentsItemProps) { + return ( + } + label="Agents" + isActive={isActive} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx b/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx index e782456acb..7852a5c741 100644 --- a/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx @@ -5,6 +5,7 @@ interface CommandCenterItemProps { isActive: boolean; onClick: () => void; activeCount?: number; + depth?: number; } function formatActiveCount(count: number): string { @@ -16,10 +17,11 @@ export function CommandCenterItem({ isActive, onClick, activeCount, + depth = 0, }: CommandCenterItemProps) { return ( } label="Command Center" isActive={isActive} diff --git a/packages/ui/src/features/sidebar/components/items/ConfigureItem.tsx b/packages/ui/src/features/sidebar/components/items/ConfigureItem.tsx index c63562953b..9c51be3f19 100644 --- a/packages/ui/src/features/sidebar/components/items/ConfigureItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/ConfigureItem.tsx @@ -3,12 +3,13 @@ import { SidebarItem } from "../SidebarItem"; interface ConfigureItemProps { onClick: () => void; + depth?: number; } -export function ConfigureItem({ onClick }: ConfigureItemProps) { +export function ConfigureItem({ onClick, depth = 0 }: ConfigureItemProps) { return ( } label="Configure" onClick={onClick} diff --git a/packages/ui/src/features/sidebar/components/items/ContextsItem.tsx b/packages/ui/src/features/sidebar/components/items/ContextsItem.tsx new file mode 100644 index 0000000000..547002d668 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/ContextsItem.tsx @@ -0,0 +1,38 @@ +import { HashIcon } from "@phosphor-icons/react"; +import { Badge, Switch } from "@posthog/quill"; +import { getSidebarItemPaddingLeft } from "../SidebarItem"; + +interface ContextsItemProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + depth?: number; +} + +// A