Skip to content
32 changes: 32 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<CustomizableNavItemId, boolean>> = {},
) {
return {
...(Object.fromEntries(
CUSTOMIZABLE_NAV_ITEM_IDS.map((id) => [id, true]),
) as Record<CustomizableNavItemId, boolean>),
...overrides,
};
}

function renderDialog(available = availability()) {
return render(
<Theme>
<CustomizeSidebarDialog
open
onOpenChange={vi.fn()}
available={available}
/>
</Theme>,
);
}

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();
});
});
102 changes: 102 additions & 0 deletions packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<CustomizableNavItemId, boolean>;
}

export function CustomizeSidebarDialog({
open,
onOpenChange,
available,
}: CustomizeSidebarDialogProps) {
const navItemOverrides = useSidebarStore((s) => s.navItemOverrides);
const setNavItemVisible = useSidebarStore((s) => s.setNavItemVisible);

return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content maxWidth="360px">
<Dialog.Title>Customize sidebar</Dialog.Title>
<Dialog.Description className="text-gray-10 text-sm">
Choose which items appear in your sidebar. Unchecked items live under
More.
</Dialog.Description>

<Flex direction="column" gap="3" mt="4">
{CUSTOMIZABLE_NAV_ITEMS.filter(
({ id }) => available?.[id] !== false,
).map(({ id, label, analyticsId }) => {
const ItemIcon = ITEM_ICONS[id];
const visible = isNavItemVisible(navItemOverrides, id);
return (
<Text key={id} as="label" size="2">
<Flex gap="2" align="center">
<Checkbox
checked={visible}
onCheckedChange={(checked) => {
const nextVisible = checked === true;
setNavItemVisible(id, nextVisible);
track(ANALYTICS_EVENTS.SIDEBAR_CUSTOMIZED, {
item: analyticsId,
visible: nextVisible,
});
}}
/>
<ItemIcon size={16} />
{label}
</Flex>
</Text>
);
})}
</Flex>

<Flex mt="4" justify="end">
<Dialog.Close>
<Button size="1" variant="solid">
Done
</Button>
</Dialog.Close>
</Flex>
</Dialog.Content>
</Dialog.Root>
);
}
6 changes: 5 additions & 1 deletion packages/ui/src/features/sidebar/components/SidebarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading