From 0c5e3ebabaf7c6f60dd82403f7405a3003bc0d6d Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 13 Jul 2026 20:54:06 -0700 Subject: [PATCH 1/9] add sidebar more tab, customize and tracking --- packages/shared/src/analytics-events.ts | 30 +++ .../components/CustomizeSidebarDialog.tsx | 86 +++++++++ .../sidebar/components/SidebarNavSection.tsx | 175 ++++++++++++++++-- .../sidebar/components/items/AgentsItem.tsx | 19 ++ .../components/items/CustomizeSidebarItem.tsx | 21 +++ .../components/items/McpServersItem.tsx | 24 +++ .../sidebar/components/items/MoreItem.tsx | 32 ++++ .../sidebar/components/items/SearchItem.tsx | 5 +- .../sidebar/components/items/SkillsItem.tsx | 20 ++ packages/ui/src/features/sidebar/constants.ts | 10 + .../src/features/sidebar/sidebarStore.test.ts | 52 ++++++ .../ui/src/features/sidebar/sidebarStore.ts | 24 ++- packages/ui/src/router/navigationBridge.ts | 20 ++ 13 files changed, 502 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/AgentsItem.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/CustomizeSidebarItem.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/McpServersItem.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/MoreItem.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/SkillsItem.tsx create mode 100644 packages/ui/src/features/sidebar/sidebarStore.test.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 15c8e0dcca..3c1a3850fc 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -242,6 +242,32 @@ export interface CommandMenuActionProperties { channel_id?: string; } +export type SidebarNavItem = + | "new_task" + | "home" + | "search" + | "inbox" + | "agents" + | "skills" + | "mcp_servers" + | "command_center" + | "contexts" + | "activity" + | "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 +1136,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 +1296,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.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx new file mode 100644 index 0000000000..796ddcda4d --- /dev/null +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -0,0 +1,86 @@ +import { + type Icon, + Lightbulb, + MagnifyingGlass, + Plugs, +} from "@phosphor-icons/react"; +import { + ANALYTICS_EVENTS, + type SidebarNavItem, +} from "@posthog/shared/analytics-events"; +import { + MORE_NAV_ITEMS, + type MoreNavItemId, +} 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 = { + search: MagnifyingGlass, + skills: Lightbulb, + "mcp-servers": Plugs, +}; + +const ITEM_ANALYTICS_IDS: Record = { + search: "search", + skills: "skills", + "mcp-servers": "mcp_servers", +}; + +interface CustomizeSidebarDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CustomizeSidebarDialog({ + open, + onOpenChange, +}: CustomizeSidebarDialogProps) { + const hiddenNavItems = useSidebarStore((s) => s.hiddenNavItems); + const setNavItemHidden = useSidebarStore((s) => s.setNavItemHidden); + + return ( + + + Customize sidebar + + Choose which items appear in your sidebar. Unchecked items live under + More. + + + + {MORE_NAV_ITEMS.map(({ id, label }) => { + const ItemIcon = ITEM_ICONS[id]; + const visible = !hiddenNavItems.includes(id); + return ( + + + { + const nextVisible = checked === true; + setNavItemHidden(id, !nextVisible); + track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { + item: ITEM_ANALYTICS_IDS[id], + visible: nextVisible, + }); + }} + /> + + {label} + + + ); + })} + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index e4530a806b..864354f8dc 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 { + MORE_NAV_ITEMS, + type MoreNavItemId, +} 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,21 @@ 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 { 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 { 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 +67,12 @@ 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. Search, Skills and MCP servers are tucked under the collapsible More +// row by default; the Customize sidebar dialog promotes them back to 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,26 +155,64 @@ export function SidebarNavSection({ const openCommandMenu = useCommandMenuStore((s) => s.open); + // Every nav row reports which item was clicked so per-item usage is + // measurable; in_more distinguishes clicks inside the expanded More section. + const withNavTrack = + (item: SidebarNavItem, action: () => void, inMore = false) => + () => { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item, + in_more: inMore, + }); + action(); + }; + + const hiddenNavItems = useSidebarStore((s) => s.hiddenNavItems); + const hidden = new Set(hiddenNavItems); + 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 in the nav. + const moreItemActive: Record = { + search: false, + skills: isSkillsActive, + "mcp-servers": isMcpServersActive, + }; + const activeHiddenItem = MORE_NAV_ITEMS.find( + ({ id }) => hidden.has(id) && moreItemActive[id], + ); + const takeoverLabel = + !moreExpanded && activeHiddenItem ? activeHiddenItem.label : null; + return ( - + {homeTabEnabled && ( - + )} - - - + {!hidden.has("search") && ( + + + + )} @@ -160,14 +227,87 @@ export function SidebarNavSection({ ) : null} - + + + + + {!hidden.has("skills") && ( + + + + )} + + {!hidden.has("mcp-servers") && ( + + + + )} + + + {/* Everything the user shoved off the top level lives here, plus the + Customize entry point. Collapsed by default; when a hidden item is + the active page it takes over the More row (see takeoverLabel). */} + + setMoreExpanded((e) => !e))} + /> + + {moreExpanded && ( + <> + {hidden.has("search") && ( + + )} + {hidden.has("skills") && ( + + )} + {hidden.has("mcp-servers") && ( + + )} + setCustomizeOpen(true), + true, + )} + /> + + )} + + {/* "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 @@ -190,6 +330,10 @@ export function SidebarNavSection({ checked={channelsEnabled} onCheckedChange={(checked) => { setChannelsEnabled(checked); + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item: "contexts", + in_more: false, + }); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "toggle_channels", surface: "nav", @@ -213,10 +357,15 @@ export function SidebarNavSection({ )} + + ); } 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..7e935e2557 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/AgentsItem.tsx @@ -0,0 +1,19 @@ +import { Robot } from "@phosphor-icons/react"; +import { SidebarItem } from "../SidebarItem"; + +interface AgentsItemProps { + isActive: boolean; + onClick: () => void; +} + +export function AgentsItem({ isActive, onClick }: AgentsItemProps) { + return ( + } + label="Agents" + isActive={isActive} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/components/items/CustomizeSidebarItem.tsx b/packages/ui/src/features/sidebar/components/items/CustomizeSidebarItem.tsx new file mode 100644 index 0000000000..4a72d02b38 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/CustomizeSidebarItem.tsx @@ -0,0 +1,21 @@ +import { SlidersHorizontal } from "@phosphor-icons/react"; +import { SidebarItem } from "../SidebarItem"; + +interface CustomizeSidebarItemProps { + onClick: () => void; + depth?: number; +} + +export function CustomizeSidebarItem({ + onClick, + depth = 0, +}: CustomizeSidebarItemProps) { + return ( + } + label="Customize sidebar" + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/components/items/McpServersItem.tsx b/packages/ui/src/features/sidebar/components/items/McpServersItem.tsx new file mode 100644 index 0000000000..0e3c046523 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/McpServersItem.tsx @@ -0,0 +1,24 @@ +import { Plugs } from "@phosphor-icons/react"; +import { SidebarItem } from "../SidebarItem"; + +interface McpServersItemProps { + isActive: boolean; + onClick: () => void; + depth?: number; +} + +export function McpServersItem({ + isActive, + onClick, + depth = 0, +}: McpServersItemProps) { + return ( + } + label="MCP servers" + isActive={isActive} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/components/items/MoreItem.tsx b/packages/ui/src/features/sidebar/components/items/MoreItem.tsx new file mode 100644 index 0000000000..56a085c3d4 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/MoreItem.tsx @@ -0,0 +1,32 @@ +import { CaretDown } from "@phosphor-icons/react"; +import { cn } from "@posthog/quill"; +import { SidebarItem } from "../SidebarItem"; + +interface MoreItemProps { + expanded: boolean; + // Label of the active hidden item, shown in place of "More" while + // collapsed so the current page stays visible in the nav. + activeItemLabel?: string | null; + onClick: () => void; +} + +export function MoreItem({ + expanded, + activeItemLabel, + onClick, +}: MoreItemProps) { + return ( + + } + label={activeItemLabel ?? "More"} + isActive={Boolean(activeItemLabel)} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/components/items/SearchItem.tsx b/packages/ui/src/features/sidebar/components/items/SearchItem.tsx index daa4b9cda4..85ee680878 100644 --- a/packages/ui/src/features/sidebar/components/items/SearchItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/SearchItem.tsx @@ -5,12 +5,13 @@ import { SidebarKbdHint } from "./SidebarKbdHint"; interface SearchItemProps { onClick: () => void; + depth?: number; } -export function SearchItem({ onClick }: SearchItemProps) { +export function SearchItem({ onClick, depth = 0 }: SearchItemProps) { return ( } label="Search" onClick={onClick} diff --git a/packages/ui/src/features/sidebar/components/items/SkillsItem.tsx b/packages/ui/src/features/sidebar/components/items/SkillsItem.tsx new file mode 100644 index 0000000000..b1da33c6e1 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/SkillsItem.tsx @@ -0,0 +1,20 @@ +import { Lightbulb } from "@phosphor-icons/react"; +import { SidebarItem } from "../SidebarItem"; + +interface SkillsItemProps { + isActive: boolean; + onClick: () => void; + depth?: number; +} + +export function SkillsItem({ isActive, onClick, depth = 0 }: SkillsItemProps) { + return ( + } + label="Skills" + isActive={isActive} + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index 8058b5952c..ca5dc81f4c 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -1 +1,11 @@ export const SIDEBAR_MIN_WIDTH = 240; + +export const MORE_NAV_ITEMS = [ + { id: "search", label: "Search" }, + { id: "skills", label: "Skills" }, + { id: "mcp-servers", label: "MCP servers" }, +] as const; + +export type MoreNavItemId = (typeof MORE_NAV_ITEMS)[number]["id"]; + +export const MORE_NAV_ITEM_IDS = MORE_NAV_ITEMS.map((item) => item.id); diff --git a/packages/ui/src/features/sidebar/sidebarStore.test.ts b/packages/ui/src/features/sidebar/sidebarStore.test.ts new file mode 100644 index 0000000000..4a660ca669 --- /dev/null +++ b/packages/ui/src/features/sidebar/sidebarStore.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { MORE_NAV_ITEM_IDS } from "./constants"; +import { useSidebarStore } from "./sidebarStore"; + +describe("sidebarStore hiddenNavItems", () => { + beforeEach(() => { + useSidebarStore.setState({ hiddenNavItems: [...MORE_NAV_ITEM_IDS] }); + }); + + it("hides every moreable item by default", () => { + expect(useSidebarStore.getState().hiddenNavItems).toEqual([ + "search", + "skills", + "mcp-servers", + ]); + }); + + it.each(MORE_NAV_ITEM_IDS)( + "setNavItemHidden(%s, false) promotes only that item", + (item) => { + useSidebarStore.getState().setNavItemHidden(item, false); + + const hidden = useSidebarStore.getState().hiddenNavItems; + expect(hidden).not.toContain(item); + expect(hidden).toHaveLength(MORE_NAV_ITEM_IDS.length - 1); + }, + ); + + it.each(MORE_NAV_ITEM_IDS)( + "setNavItemHidden(%s, true) is idempotent", + (item) => { + useSidebarStore.getState().setNavItemHidden(item, false); + useSidebarStore.getState().setNavItemHidden(item, true); + useSidebarStore.getState().setNavItemHidden(item, true); + + const hidden = useSidebarStore.getState().hiddenNavItems; + expect(hidden.filter((id) => id === item)).toHaveLength(1); + }, + ); + + it.each(MORE_NAV_ITEM_IDS)( + "promoting %s leaves the other items hidden", + (item) => { + useSidebarStore.getState().setNavItemHidden(item, false); + + const hidden = useSidebarStore.getState().hiddenNavItems; + for (const other of MORE_NAV_ITEM_IDS) { + if (other !== item) expect(hidden).toContain(other); + } + }, + ); +}); diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index ea36dd2b20..81d215f889 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -2,7 +2,11 @@ import { ALL_WORKSPACE_MODES } from "@posthog/core/sidebar/buildSidebarData"; import type { WorkspaceMode } from "@posthog/shared"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { SIDEBAR_MIN_WIDTH } from "./constants"; +import { + MORE_NAV_ITEM_IDS, + type MoreNavItemId, + SIDEBAR_MIN_WIDTH, +} from "./constants"; interface SidebarStoreState { open: boolean; @@ -21,6 +25,9 @@ interface SidebarStoreState { // the task list, Canvas nav item appears). Off by default — Code merged into // the Bluebird chrome ships with channels hidden until the user opts in. channelsEnabled: boolean; + // Nav items tucked under the collapsible More row. Everything moreable + // starts hidden; the Customize sidebar dialog promotes items back out. + hiddenNavItems: MoreNavItemId[]; } interface SidebarStoreActions { @@ -41,6 +48,7 @@ interface SidebarStoreActions { setShowInternal: (showInternal: boolean) => void; toggleTaskType: (mode: WorkspaceMode) => void; setChannelsEnabled: (channelsEnabled: boolean) => void; + setNavItemHidden: (item: MoreNavItemId, hidden: boolean) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -61,6 +69,7 @@ export const useSidebarStore = create()( showInternal: false, taskTypeFilter: [...ALL_WORKSPACE_MODES], channelsEnabled: false, + hiddenNavItems: [...MORE_NAV_ITEM_IDS], setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -118,6 +127,14 @@ export const useSidebarStore = create()( : [...state.taskTypeFilter, mode], })), setChannelsEnabled: (channelsEnabled) => set({ channelsEnabled }), + setNavItemHidden: (item, hidden) => + set((state) => ({ + hiddenNavItems: hidden + ? state.hiddenNavItems.includes(item) + ? state.hiddenNavItems + : [...state.hiddenNavItems, item] + : state.hiddenNavItems.filter((id) => id !== item), + })), }), { name: "sidebar-storage", @@ -134,6 +151,7 @@ export const useSidebarStore = create()( showInternal: state.showInternal, taskTypeFilter: state.taskTypeFilter, channelsEnabled: state.channelsEnabled, + hiddenNavItems: state.hiddenNavItems, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -149,6 +167,7 @@ export const useSidebarStore = create()( showInternal?: boolean; taskTypeFilter?: WorkspaceMode[]; channelsEnabled?: boolean; + hiddenNavItems?: MoreNavItemId[]; }; return { ...current, @@ -171,6 +190,9 @@ export const useSidebarStore = create()( persistedState.taskTypeFilter ?? current.taskTypeFilter, channelsEnabled: persistedState.channelsEnabled ?? current.channelsEnabled, + hiddenNavItems: ( + persistedState.hiddenNavItems ?? current.hiddenNavItems + ).filter((id) => MORE_NAV_ITEM_IDS.includes(id)), }; }, }, diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 14ea7c16c3..ae3c4f22de 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -177,6 +177,10 @@ export function navigateToEditLoop(loopId: string): void { }); } +export function navigateToAgents(): void { + void getRouterOrNull()?.navigate({ to: "/code/agents" }); +} + export function navigateToApproval(requestId: string): void { void getRouterOrNull()?.navigate({ to: "/code/agents/applications/approvals", @@ -195,6 +199,14 @@ export function navigateToCommandCenter(): void { track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); } +export function navigateToSkills(): void { + void getRouterOrNull()?.navigate({ to: "/skills" }); +} + +export function navigateToMcpServers(): void { + void getRouterOrNull()?.navigate({ to: "/mcp-servers" }); +} + // Channels-space mirrors. These render the same shared views as their /code (or // top-level) counterparts but under /website, so navigating from the channels // sidebar keeps the channels chrome instead of switching back to Code. The @@ -213,6 +225,14 @@ export function navigateToCanvas(): void { void getRouterOrNull()?.navigate({ to: "/website" }); } +export function navigateToWebsiteSkills(): void { + void getRouterOrNull()?.navigate({ to: "/website/skills" }); +} + +export function navigateToWebsiteMcpServers(): void { + void getRouterOrNull()?.navigate({ to: "/website/mcp-servers" }); +} + export function navigateToWebsiteCommandCenter(): void { void getRouterOrNull()?.navigate({ to: "/website/command-center" }); // Parity with navigateToCommandCenter's analytics tracking. From 5b9d81b6866c7fa7f61b9230ff35e3ae7100dc3e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 12:24:11 -0700 Subject: [PATCH 2/9] add usage nav item to sidebar more section --- packages/shared/src/analytics-events.ts | 1 + .../components/CustomizeSidebarDialog.tsx | 11 +++-- .../sidebar/components/SidebarNavSection.tsx | 34 ++++++++++++--- .../sidebar/components/items/UsageItem.tsx | 18 ++++++++ packages/ui/src/features/sidebar/constants.ts | 1 + .../src/features/sidebar/sidebarStore.test.ts | 43 ++++++++++--------- .../ui/src/features/sidebar/sidebarStore.ts | 31 ++++++------- 7 files changed, 93 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/features/sidebar/components/items/UsageItem.tsx diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 3c1a3850fc..d515b82457 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -250,6 +250,7 @@ export type SidebarNavItem = | "agents" | "skills" | "mcp_servers" + | "usage" | "command_center" | "contexts" | "activity" diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx index 796ddcda4d..0026323c6c 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -1,4 +1,5 @@ import { + CreditCard, type Icon, Lightbulb, MagnifyingGlass, @@ -20,12 +21,14 @@ const ITEM_ICONS: Record = { search: MagnifyingGlass, skills: Lightbulb, "mcp-servers": Plugs, + usage: CreditCard, }; const ITEM_ANALYTICS_IDS: Record = { search: "search", skills: "skills", "mcp-servers": "mcp_servers", + usage: "usage", }; interface CustomizeSidebarDialogProps { @@ -37,8 +40,8 @@ export function CustomizeSidebarDialog({ open, onOpenChange, }: CustomizeSidebarDialogProps) { - const hiddenNavItems = useSidebarStore((s) => s.hiddenNavItems); - const setNavItemHidden = useSidebarStore((s) => s.setNavItemHidden); + const promotedNavItems = useSidebarStore((s) => s.promotedNavItems); + const setNavItemVisible = useSidebarStore((s) => s.setNavItemVisible); return ( @@ -52,7 +55,7 @@ export function CustomizeSidebarDialog({ {MORE_NAV_ITEMS.map(({ id, label }) => { const ItemIcon = ITEM_ICONS[id]; - const visible = !hiddenNavItems.includes(id); + const visible = promotedNavItems.includes(id); return ( @@ -60,7 +63,7 @@ export function CustomizeSidebarDialog({ checked={visible} onCheckedChange={(checked) => { const nextVisible = checked === true; - setNavItemHidden(id, !nextVisible); + setNavItemVisible(id, nextVisible); track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { item: ITEM_ANALYTICS_IDS[id], visible: nextVisible, diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 864354f8dc..0e99f9a269 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -11,6 +11,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { + MORE_NAV_ITEM_IDS, MORE_NAV_ITEMS, type MoreNavItemId, } from "@posthog/ui/features/sidebar/constants"; @@ -24,6 +25,7 @@ import { navigateToInbox, navigateToLoops, navigateToMcpServers, + navigateToSettings, navigateToSkills, navigateToWebsiteCommandCenter, navigateToWebsiteHome, @@ -51,6 +53,7 @@ import { MoreItem } from "./items/MoreItem"; import { NewTaskItem } from "./items/NewTaskItem"; import { SearchItem } from "./items/SearchItem"; import { SkillsItem } from "./items/SkillsItem"; +import { UsageItem } from "./items/UsageItem"; const SIDEBAR_INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -70,9 +73,9 @@ interface SidebarNavSectionProps { // 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. Search, Skills and MCP servers are tucked under the collapsible More -// row by default; the Customize sidebar dialog promotes them back to the top -// level. +// place. Search, Skills, MCP servers and Usage are tucked under the +// collapsible More row by default; the Customize sidebar dialog promotes them +// back to the top level. export function SidebarNavSection({ commandCenterActiveCount: providedActiveCount, }: SidebarNavSectionProps = {}) { @@ -167,17 +170,24 @@ export function SidebarNavSection({ action(); }; - const hiddenNavItems = useSidebarStore((s) => s.hiddenNavItems); - const hidden = new Set(hiddenNavItems); + const promotedNavItems = useSidebarStore((s) => s.promotedNavItems); + const hidden = new Set( + MORE_NAV_ITEM_IDS.filter((id) => !promotedNavItems.includes(id)), + ); const [moreExpanded, setMoreExpanded] = useState(false); const [customizeOpen, setCustomizeOpen] = useState(false); + const goUsage = () => navigateToSettings("plan-usage"); + // While More is collapsed, an active item hidden under it takes over the - // More row so the current page stays visible in the nav. + // More row so the current page stays visible in the nav. Search and Usage + // never take over: one opens the command menu in place, the other leaves + // for the settings chrome. const moreItemActive: Record = { search: false, skills: isSkillsActive, "mcp-servers": isMcpServersActive, + usage: false, }; const activeHiddenItem = MORE_NAV_ITEMS.find( ({ id }) => hidden.has(id) && moreItemActive[id], @@ -252,6 +262,12 @@ export function SidebarNavSection({ )} + {!hidden.has("usage") && ( + + + + )} + )} + {hidden.has("usage") && ( + + )} void; + depth?: number; +} + +export function UsageItem({ onClick, depth = 0 }: UsageItemProps) { + return ( + } + label="Usage" + onClick={onClick} + /> + ); +} diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index ca5dc81f4c..c1e3dcc83e 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -4,6 +4,7 @@ export const MORE_NAV_ITEMS = [ { id: "search", label: "Search" }, { id: "skills", label: "Skills" }, { id: "mcp-servers", label: "MCP servers" }, + { id: "usage", label: "Usage" }, ] as const; export type MoreNavItemId = (typeof MORE_NAV_ITEMS)[number]["id"]; diff --git a/packages/ui/src/features/sidebar/sidebarStore.test.ts b/packages/ui/src/features/sidebar/sidebarStore.test.ts index 4a660ca669..938099b07a 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.test.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.test.ts @@ -2,51 +2,52 @@ import { beforeEach, describe, expect, it } from "vitest"; import { MORE_NAV_ITEM_IDS } from "./constants"; import { useSidebarStore } from "./sidebarStore"; -describe("sidebarStore hiddenNavItems", () => { +describe("sidebarStore promotedNavItems", () => { beforeEach(() => { - useSidebarStore.setState({ hiddenNavItems: [...MORE_NAV_ITEM_IDS] }); + useSidebarStore.setState({ promotedNavItems: [] }); }); - it("hides every moreable item by default", () => { - expect(useSidebarStore.getState().hiddenNavItems).toEqual([ + it("keeps every moreable item under More by default", () => { + expect(useSidebarStore.getState().promotedNavItems).toEqual([]); + expect(MORE_NAV_ITEM_IDS).toEqual([ "search", "skills", "mcp-servers", + "usage", ]); }); it.each(MORE_NAV_ITEM_IDS)( - "setNavItemHidden(%s, false) promotes only that item", + "setNavItemVisible(%s, true) promotes only that item", (item) => { - useSidebarStore.getState().setNavItemHidden(item, false); + useSidebarStore.getState().setNavItemVisible(item, true); - const hidden = useSidebarStore.getState().hiddenNavItems; - expect(hidden).not.toContain(item); - expect(hidden).toHaveLength(MORE_NAV_ITEM_IDS.length - 1); + expect(useSidebarStore.getState().promotedNavItems).toEqual([item]); }, ); it.each(MORE_NAV_ITEM_IDS)( - "setNavItemHidden(%s, true) is idempotent", + "setNavItemVisible(%s, true) is idempotent", (item) => { - useSidebarStore.getState().setNavItemHidden(item, false); - useSidebarStore.getState().setNavItemHidden(item, true); - useSidebarStore.getState().setNavItemHidden(item, true); + useSidebarStore.getState().setNavItemVisible(item, true); + useSidebarStore.getState().setNavItemVisible(item, true); - const hidden = useSidebarStore.getState().hiddenNavItems; - expect(hidden.filter((id) => id === item)).toHaveLength(1); + expect(useSidebarStore.getState().promotedNavItems).toEqual([item]); }, ); it.each(MORE_NAV_ITEM_IDS)( - "promoting %s leaves the other items hidden", + "setNavItemVisible(%s, false) demotes only that item", (item) => { - useSidebarStore.getState().setNavItemHidden(item, false); - - const hidden = useSidebarStore.getState().hiddenNavItems; - for (const other of MORE_NAV_ITEM_IDS) { - if (other !== item) expect(hidden).toContain(other); + for (const id of MORE_NAV_ITEM_IDS) { + useSidebarStore.getState().setNavItemVisible(id, true); } + + useSidebarStore.getState().setNavItemVisible(item, false); + + const promoted = useSidebarStore.getState().promotedNavItems; + expect(promoted).not.toContain(item); + expect(promoted).toHaveLength(MORE_NAV_ITEM_IDS.length - 1); }, ); }); diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index 81d215f889..9e03d006c3 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -25,9 +25,10 @@ interface SidebarStoreState { // the task list, Canvas nav item appears). Off by default — Code merged into // the Bluebird chrome ships with channels hidden until the user opts in. channelsEnabled: boolean; - // Nav items tucked under the collapsible More row. Everything moreable - // starts hidden; the Customize sidebar dialog promotes items back out. - hiddenNavItems: MoreNavItemId[]; + // Moreable nav items the user promoted out of the More row via the + // Customize sidebar dialog. Persisting promotions (not hidden items) keeps + // any newly shipped moreable item under More by default. + promotedNavItems: MoreNavItemId[]; } interface SidebarStoreActions { @@ -48,7 +49,7 @@ interface SidebarStoreActions { setShowInternal: (showInternal: boolean) => void; toggleTaskType: (mode: WorkspaceMode) => void; setChannelsEnabled: (channelsEnabled: boolean) => void; - setNavItemHidden: (item: MoreNavItemId, hidden: boolean) => void; + setNavItemVisible: (item: MoreNavItemId, visible: boolean) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; @@ -69,7 +70,7 @@ export const useSidebarStore = create()( showInternal: false, taskTypeFilter: [...ALL_WORKSPACE_MODES], channelsEnabled: false, - hiddenNavItems: [...MORE_NAV_ITEM_IDS], + promotedNavItems: [], setOpen: (open) => set({ open, hasUserSetOpen: true }), setOpenAuto: (open) => set((state) => (state.hasUserSetOpen ? state : { open })), @@ -127,13 +128,13 @@ export const useSidebarStore = create()( : [...state.taskTypeFilter, mode], })), setChannelsEnabled: (channelsEnabled) => set({ channelsEnabled }), - setNavItemHidden: (item, hidden) => + setNavItemVisible: (item, visible) => set((state) => ({ - hiddenNavItems: hidden - ? state.hiddenNavItems.includes(item) - ? state.hiddenNavItems - : [...state.hiddenNavItems, item] - : state.hiddenNavItems.filter((id) => id !== item), + promotedNavItems: visible + ? state.promotedNavItems.includes(item) + ? state.promotedNavItems + : [...state.promotedNavItems, item] + : state.promotedNavItems.filter((id) => id !== item), })), }), { @@ -151,7 +152,7 @@ export const useSidebarStore = create()( showInternal: state.showInternal, taskTypeFilter: state.taskTypeFilter, channelsEnabled: state.channelsEnabled, - hiddenNavItems: state.hiddenNavItems, + promotedNavItems: state.promotedNavItems, }), merge: (persisted, current) => { const persistedState = persisted as { @@ -167,7 +168,7 @@ export const useSidebarStore = create()( showInternal?: boolean; taskTypeFilter?: WorkspaceMode[]; channelsEnabled?: boolean; - hiddenNavItems?: MoreNavItemId[]; + promotedNavItems?: MoreNavItemId[]; }; return { ...current, @@ -190,8 +191,8 @@ export const useSidebarStore = create()( persistedState.taskTypeFilter ?? current.taskTypeFilter, channelsEnabled: persistedState.channelsEnabled ?? current.channelsEnabled, - hiddenNavItems: ( - persistedState.hiddenNavItems ?? current.hiddenNavItems + promotedNavItems: ( + persistedState.promotedNavItems ?? current.promotedNavItems ).filter((id) => MORE_NAV_ITEM_IDS.includes(id)), }; }, From 19777d67d1ba2678b4f8a372f5c9cfb316a969e0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 13:43:21 -0700 Subject: [PATCH 3/9] make nav customizable, move more row last --- .../components/CustomizeSidebarDialog.tsx | 23 ++- .../sidebar/components/SidebarNavSection.tsx | 189 ++++++++++-------- .../sidebar/components/items/ActivityItem.tsx | 9 +- .../sidebar/components/items/AgentsItem.tsx | 5 +- .../components/items/CommandCenterItem.tsx | 4 +- packages/ui/src/features/sidebar/constants.ts | 39 +++- .../src/features/sidebar/sidebarStore.test.ts | 99 +++++---- .../ui/src/features/sidebar/sidebarStore.ts | 29 ++- 8 files changed, 256 insertions(+), 141 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx index 0026323c6c..4fad26e648 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -1,34 +1,49 @@ import { + Bell, CreditCard, - type Icon, Lightbulb, + Lightning, MagnifyingGlass, Plugs, + Robot, } from "@phosphor-icons/react"; import { ANALYTICS_EVENTS, type SidebarNavItem, } from "@posthog/shared/analytics-events"; import { + isNavItemVisible, MORE_NAV_ITEMS, type MoreNavItemId, } 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"; +import { SquircleDashed } from "lucide-react"; -const ITEM_ICONS: Record = { +const ITEM_ICONS: Record< + MoreNavItemId, + React.ComponentType<{ size?: number | string }> +> = { search: MagnifyingGlass, + agents: Robot, skills: Lightbulb, "mcp-servers": Plugs, usage: CreditCard, + "command-center": Lightning, + contexts: SquircleDashed, + activity: Bell, }; const ITEM_ANALYTICS_IDS: Record = { search: "search", + agents: "agents", skills: "skills", "mcp-servers": "mcp_servers", usage: "usage", + "command-center": "command_center", + contexts: "contexts", + activity: "activity", }; interface CustomizeSidebarDialogProps { @@ -40,7 +55,7 @@ export function CustomizeSidebarDialog({ open, onOpenChange, }: CustomizeSidebarDialogProps) { - const promotedNavItems = useSidebarStore((s) => s.promotedNavItems); + const navItemOverrides = useSidebarStore((s) => s.navItemOverrides); const setNavItemVisible = useSidebarStore((s) => s.setNavItemVisible); return ( @@ -55,7 +70,7 @@ export function CustomizeSidebarDialog({ {MORE_NAV_ITEMS.map(({ id, label }) => { const ItemIcon = ITEM_ICONS[id]; - const visible = promotedNavItems.includes(id); + const visible = isNavItemVisible(navItemOverrides, id); return ( diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 0e99f9a269..ed6ca05549 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -11,6 +11,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { + isNavItemVisible, MORE_NAV_ITEM_IDS, MORE_NAV_ITEMS, type MoreNavItemId, @@ -170,9 +171,9 @@ export function SidebarNavSection({ action(); }; - const promotedNavItems = useSidebarStore((s) => s.promotedNavItems); + const navItemOverrides = useSidebarStore((s) => s.navItemOverrides); const hidden = new Set( - MORE_NAV_ITEM_IDS.filter((id) => !promotedNavItems.includes(id)), + MORE_NAV_ITEM_IDS.filter((id) => !isNavItemVisible(navItemOverrides, id)), ); const [moreExpanded, setMoreExpanded] = useState(false); const [customizeOpen, setCustomizeOpen] = useState(false); @@ -180,14 +181,18 @@ export function SidebarNavSection({ const goUsage = () => navigateToSettings("plan-usage"); // While More is collapsed, an active item hidden under it takes over the - // More row so the current page stays visible in the nav. Search and Usage - // never take over: one opens the command menu in place, the other leaves - // for the settings chrome. + // More row so the current page stays visible in the nav. Search, Usage and + // Contexts never take over: Search opens the command menu in place, Usage + // leaves for the settings chrome and Contexts is a toggle, not a page. const moreItemActive: Record = { search: false, + agents: isAgentsActive, skills: isSkillsActive, "mcp-servers": isMcpServersActive, usage: false, + "command-center": isCommandCenterActive, + contexts: false, + activity: isActivityActive, }; const activeHiddenItem = MORE_NAV_ITEMS.find( ({ id }) => hidden.has(id) && moreItemActive[id], @@ -195,6 +200,44 @@ export function SidebarNavSection({ const takeoverLabel = !moreExpanded && activeHiddenItem ? activeHiddenItem.label : null; + const contextsToggle = (depth: 0 | 1) => ( + + ); + return ( @@ -237,12 +280,14 @@ export function SidebarNavSection({ ) : null} - - - + {!hidden.has("agents") && ( + + + + )} {!hidden.has("skills") && ( @@ -268,22 +313,41 @@ export function SidebarNavSection({ )} - - - + {!hidden.has("command-center") && ( + + + + )} + + {/* "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 Activity row + and swaps the sidebar body to the channel tree. A )} - - - + {!hidden.has("inbox") && ( + + + + )} openSettings("agents")} /> @@ -362,6 +367,14 @@ export function SidebarNavSection({ onClick={withNavTrack("search", openCommandMenu, true)} /> )} + {hidden.has("inbox") && ( + + )} {hidden.has("agents") && ( void; pullRequestCount?: number; + depth?: number; } export function InboxItem({ isActive, onClick, pullRequestCount = 0, + depth = 0, }: InboxItemProps) { return (
} diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index b1cebb54bc..88aa7874ee 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -1,29 +1,34 @@ export const SIDEBAR_MIN_WIDTH = 240; -export const MORE_NAV_ITEMS = [ +export const CUSTOMIZABLE_NAV_ITEMS = [ { id: "search", label: "Search", defaultVisible: false }, + { id: "inbox", label: "Inbox", defaultVisible: true }, { id: "agents", label: "Agents", defaultVisible: true }, - { id: "skills", label: "Skills", defaultVisible: false }, - { id: "mcp-servers", label: "MCP servers", defaultVisible: false }, + { id: "skills", label: "Skills", defaultVisible: true }, + { id: "mcp-servers", label: "MCP servers", defaultVisible: true }, { id: "usage", label: "Usage", defaultVisible: false }, { id: "command-center", label: "Command Center", defaultVisible: true }, { id: "contexts", label: "Contexts", defaultVisible: true }, { id: "activity", label: "Activity", defaultVisible: true }, ] as const; -export type MoreNavItemId = (typeof MORE_NAV_ITEMS)[number]["id"]; +export type CustomizableNavItemId = + (typeof CUSTOMIZABLE_NAV_ITEMS)[number]["id"]; -export const MORE_NAV_ITEM_IDS = MORE_NAV_ITEMS.map((item) => item.id); +export const CUSTOMIZABLE_NAV_ITEM_IDS = CUSTOMIZABLE_NAV_ITEMS.map( + (item) => item.id, +); -export type NavItemOverrides = Partial>; +export type NavItemOverrides = Partial>; -const DEFAULT_VISIBILITY: Record = Object.fromEntries( - MORE_NAV_ITEMS.map((item) => [item.id, item.defaultVisible]), -) as Record; +const DEFAULT_VISIBILITY: Record = + Object.fromEntries( + CUSTOMIZABLE_NAV_ITEMS.map((item) => [item.id, item.defaultVisible]), + ) as Record; export function isNavItemVisible( overrides: NavItemOverrides, - id: MoreNavItemId, + id: CustomizableNavItemId, ): boolean { return overrides[id] ?? DEFAULT_VISIBILITY[id]; } @@ -35,7 +40,7 @@ export function sanitizeNavItemOverrides(value: unknown): NavItemOverrides { return {}; } const overrides: NavItemOverrides = {}; - for (const id of MORE_NAV_ITEM_IDS) { + for (const id of CUSTOMIZABLE_NAV_ITEM_IDS) { const entry = (value as Record)[id]; if (typeof entry === "boolean") overrides[id] = entry; } diff --git a/packages/ui/src/features/sidebar/sidebarStore.test.ts b/packages/ui/src/features/sidebar/sidebarStore.test.ts index f1b26b075b..a2be8e4136 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.test.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { isNavItemVisible, MORE_NAV_ITEMS } from "./constants"; +import { CUSTOMIZABLE_NAV_ITEMS, isNavItemVisible } from "./constants"; import { useSidebarStore } from "./sidebarStore"; describe("sidebarStore navItemOverrides", () => { @@ -8,13 +8,15 @@ describe("sidebarStore navItemOverrides", () => { }); it.each( - MORE_NAV_ITEMS.map((item) => [item.id, item.defaultVisible] as const), + CUSTOMIZABLE_NAV_ITEMS.map( + (item) => [item.id, item.defaultVisible] as const, + ), )("%s is visible=%s by default", (id, defaultVisible) => { const overrides = useSidebarStore.getState().navItemOverrides; expect(isNavItemVisible(overrides, id)).toBe(defaultVisible); }); - it.each(MORE_NAV_ITEMS.map((item) => item.id))( + it.each(CUSTOMIZABLE_NAV_ITEMS.map((item) => item.id))( "setNavItemVisible(%s) overrides in both directions", (id) => { useSidebarStore.getState().setNavItemVisible(id, true); @@ -33,7 +35,7 @@ describe("sidebarStore navItemOverrides", () => { useSidebarStore.getState().setNavItemVisible("agents", false); const overrides = useSidebarStore.getState().navItemOverrides; - for (const item of MORE_NAV_ITEMS) { + for (const item of CUSTOMIZABLE_NAV_ITEMS) { if (item.id === "agents") continue; expect(isNavItemVisible(overrides, item.id)).toBe(item.defaultVisible); } diff --git a/packages/ui/src/features/sidebar/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts index 2e9093d94f..52e5f0f57e 100644 --- a/packages/ui/src/features/sidebar/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -3,7 +3,7 @@ import type { WorkspaceMode } from "@posthog/shared"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { - type MoreNavItemId, + type CustomizableNavItemId, type NavItemOverrides, SIDEBAR_MIN_WIDTH, sanitizeNavItemOverrides, @@ -27,7 +27,7 @@ interface SidebarStoreState { // the Bluebird chrome ships with channels hidden until the user opts in. channelsEnabled: boolean; // Per-item visibility overrides from the Customize sidebar dialog. Items - // absent from the map follow their MORE_NAV_ITEMS defaultVisible, so newly + // absent from the map follow their CUSTOMIZABLE_NAV_ITEMS defaultVisible, so newly // shipped moreable items keep their intended default for existing users. navItemOverrides: NavItemOverrides; } @@ -50,7 +50,7 @@ interface SidebarStoreActions { setShowInternal: (showInternal: boolean) => void; toggleTaskType: (mode: WorkspaceMode) => void; setChannelsEnabled: (channelsEnabled: boolean) => void; - setNavItemVisible: (item: MoreNavItemId, visible: boolean) => void; + setNavItemVisible: (item: CustomizableNavItemId, visible: boolean) => void; } type SidebarStore = SidebarStoreState & SidebarStoreActions; From 9529748231d6083728570a9e226e0fb33196350a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 14 Jul 2026 17:16:02 -0700 Subject: [PATCH 5/9] dedupe sidebar nav rendering, remove usage item --- packages/shared/src/analytics-events.ts | 1 - .../components/CustomizeSidebarDialog.tsx | 31 +- .../sidebar/components/SidebarItem.tsx | 6 +- .../sidebar/components/SidebarNavSection.tsx | 312 +++++++----------- .../sidebar/components/items/ContextsItem.tsx | 38 +++ .../sidebar/components/items/UsageItem.tsx | 18 - packages/ui/src/features/sidebar/constants.ts | 61 +++- 7 files changed, 221 insertions(+), 246 deletions(-) create mode 100644 packages/ui/src/features/sidebar/components/items/ContextsItem.tsx delete mode 100644 packages/ui/src/features/sidebar/components/items/UsageItem.tsx diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index d515b82457..3c1a3850fc 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -250,7 +250,6 @@ export type SidebarNavItem = | "agents" | "skills" | "mcp_servers" - | "usage" | "command_center" | "contexts" | "activity" diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx index ed23315c34..fc261b4def 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -1,17 +1,14 @@ import { Bell, - CreditCard, EnvelopeSimple, + HashIcon, Lightbulb, Lightning, MagnifyingGlass, Plugs, Robot, } from "@phosphor-icons/react"; -import { - ANALYTICS_EVENTS, - type SidebarNavItem, -} from "@posthog/shared/analytics-events"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { CUSTOMIZABLE_NAV_ITEMS, type CustomizableNavItemId, @@ -20,7 +17,6 @@ import { 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"; -import { SquircleDashed } from "lucide-react"; const ITEM_ICONS: Record< CustomizableNavItemId, @@ -31,24 +27,11 @@ const ITEM_ICONS: Record< agents: Robot, skills: Lightbulb, "mcp-servers": Plugs, - usage: CreditCard, "command-center": Lightning, - contexts: SquircleDashed, + contexts: HashIcon, activity: Bell, }; -const ITEM_ANALYTICS_IDS: Record = { - search: "search", - inbox: "inbox", - agents: "agents", - skills: "skills", - "mcp-servers": "mcp_servers", - usage: "usage", - "command-center": "command_center", - contexts: "contexts", - activity: "activity", -}; - interface CustomizeSidebarDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -71,7 +54,7 @@ export function CustomizeSidebarDialog({ - {CUSTOMIZABLE_NAV_ITEMS.map(({ id, label }) => { + {CUSTOMIZABLE_NAV_ITEMS.map(({ id, label, analyticsId }) => { const ItemIcon = ITEM_ICONS[id]; const visible = isNavItemVisible(navItemOverrides, id); return ( @@ -83,7 +66,7 @@ export function CustomizeSidebarDialog({ const nextVisible = checked === true; setNavItemVisible(id, nextVisible); track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, { - item: ITEM_ANALYTICS_IDS[id], + item: analyticsId, visible: nextVisible, }); }} @@ -98,7 +81,9 @@ export function CustomizeSidebarDialog({ - + 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.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index cf6a7519db..7d8aae5cef 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -1,5 +1,3 @@ -import { HashIcon } from "@phosphor-icons/react"; -import { Badge, Switch } from "@posthog/quill"; import { LOOPS_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS, @@ -26,7 +24,6 @@ import { navigateToInbox, navigateToLoops, navigateToMcpServers, - navigateToSettings, navigateToSkills, navigateToWebsiteCommandCenter, navigateToWebsiteHome, @@ -39,12 +36,13 @@ 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 { useState } from "react"; +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"; @@ -54,7 +52,6 @@ import { MoreItem } from "./items/MoreItem"; import { NewTaskItem } from "./items/NewTaskItem"; import { SearchItem } from "./items/SearchItem"; import { SkillsItem } from "./items/SkillsItem"; -import { UsageItem } from "./items/UsageItem"; const SIDEBAR_INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -74,9 +71,8 @@ interface SidebarNavSectionProps { // 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. Search, Skills, MCP servers and Usage are tucked under the -// collapsible More row by default; the Customize sidebar dialog promotes them -// back to the top level. +// 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 = {}) { @@ -159,14 +155,13 @@ export function SidebarNavSection({ const openCommandMenu = useCommandMenuStore((s) => s.open); - // Every nav row reports which item was clicked so per-item usage is - // measurable; in_more distinguishes clicks inside the expanded More section. + // depth 1 means the row was clicked inside the expanded More section. const withNavTrack = - (item: SidebarNavItem, action: () => void, inMore = false) => + (item: SidebarNavItem, action: () => void, depth: 0 | 1 = 0) => () => { track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { item, - in_more: inMore, + in_more: depth === 1, }); action(); }; @@ -180,65 +175,127 @@ export function SidebarNavSection({ const [moreExpanded, setMoreExpanded] = useState(false); const [customizeOpen, setCustomizeOpen] = useState(false); - const goUsage = () => navigateToSettings("plan-usage"); - // While More is collapsed, an active item hidden under it takes over the - // More row so the current page stays visible in the nav. Search, Usage and - // Contexts never take over: Search opens the command menu in place, Usage - // leaves for the settings chrome and Contexts is a toggle, not a page. + // More row so the current page stays visible. Search and Contexts never do: + // neither is a routed page. const moreItemActive: Record = { search: false, inbox: isInboxActive, agents: isAgentsActive, skills: isSkillsActive, "mcp-servers": isMcpServersActive, - usage: false, "command-center": isCommandCenterActive, contexts: false, activity: isActivityActive, }; + + 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, + }; + const activeHiddenItem = CUSTOMIZABLE_NAV_ITEMS.find( - ({ id }) => hidden.has(id) && moreItemActive[id], + ({ id }) => navItemAvailable[id] && hidden.has(id) && moreItemActive[id], ); const takeoverLabel = !moreExpanded && activeHiddenItem ? activeHiddenItem.label : null; - const contextsToggle = (depth: 0 | 1) => ( - + ), + }; + + 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 ( @@ -259,21 +316,9 @@ export function SidebarNavSection({ )} - {!hidden.has("search") && ( - - - - )} - - {!hidden.has("inbox") && ( - - - - )} + {topLevelItems.map(({ id }) => ( + {renderNavItem[id](0)} + ))} openSettings("agents")} /> @@ -285,73 +330,8 @@ export function SidebarNavSection({ ) : null} - {!hidden.has("agents") && ( - - - - )} - - {!hidden.has("skills") && ( - - - - )} - - {!hidden.has("mcp-servers") && ( - - - - )} - - {!hidden.has("usage") && ( - - - - )} - - {!hidden.has("command-center") && ( - - - - )} - - {/* "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 Activity row - and swaps the sidebar body to the channel tree. A