From 5e074ca562d6137884c8ec32c31486fbd7317096 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:00:53 +0200 Subject: [PATCH 1/8] feat(channels): add the flag-gated spaces layout Activate the complete channel-scoped sidebar, switcher, navigation, shortcuts, and shell chrome behind the spaces flag. The existing Thread panel remains in place until the next stack PR. Generated-By: PostHog Code Task-Id: 6190e713-9b80-43d9-a05c-5e3b1ecdf297 --- packages/core/src/command-center/grid.test.ts | 32 ++ packages/core/src/command-center/grid.ts | 15 + .../browser-tabs/TabShortcutFallback.test.tsx | 30 ++ .../browser-tabs/TabShortcutFallback.tsx | 26 ++ .../canvas/components/ChannelBreadcrumb.tsx | 7 +- .../canvas/components/ChannelFeedView.tsx | 27 +- .../canvas/components/ChannelHeader.tsx | 64 ++- .../canvas/components/ChannelHotkeys.test.tsx | 96 +++++ .../canvas/components/ChannelHotkeys.tsx | 53 +++ .../features/canvas/components/ChannelNav.tsx | 131 ++++++ .../canvas/components/ChannelSidebar.tsx | 382 ++++++++++++++++++ .../canvas/components/ChannelSwitcher.tsx | 302 ++++++++++++++ .../canvas/components/ChannelsList.tsx | 74 ++-- .../components/ChannelsSidebar.test.tsx | 261 ++++++++++++ .../canvas/components/ChannelsSidebar.tsx | 103 ++++- .../features/canvas/components/NewTaskFab.tsx | 83 ++++ .../canvas/components/channelGlyph.test.tsx | 19 + .../canvas/components/channelGlyph.tsx | 41 ++ .../canvas/components/channelsSidebarStore.ts | 6 +- .../canvas/freeform/useHomeCanvasView.ts | 5 +- .../canvas/hooks/useStarredChannelSlots.ts | 44 ++ .../hooks/useTrackChannelsSpaceViewed.ts | 46 +++ .../useCommandCenterActiveCount.ts | 31 ++ .../ui/src/features/command/CommandMenu.tsx | 7 +- .../command/KeyboardShortcutsSheet.tsx | 9 +- .../command/keyboard-shortcuts.test.ts | 60 +++ .../features/command/keyboard-shortcuts.ts | 39 +- .../settings/components/SettingsPanel.tsx | 4 + .../settings/settingsVisibility.test.ts | 12 + .../features/settings/settingsVisibility.ts | 12 + .../sidebar/components/ProjectSwitcher.tsx | 2 +- .../components/SidebarNavSection.test.tsx | 8 +- .../sidebar/components/SidebarNavSection.tsx | 20 +- .../components/items/CommandCenterItem.tsx | 24 +- packages/ui/src/features/sidebar/constants.ts | 8 + packages/ui/src/primitives/CountBadge.tsx | 30 +- .../ui/src/primitives/ResizableSidebar.tsx | 7 +- packages/ui/src/router/routes/__root.tsx | 98 +++-- packages/ui/src/shell/GlobalEventHandlers.tsx | 13 +- packages/ui/src/shell/createSidebarStore.ts | 23 +- 40 files changed, 2076 insertions(+), 178 deletions(-) create mode 100644 packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx create mode 100644 packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelHotkeys.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelNav.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelSidebar.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelSwitcher.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx create mode 100644 packages/ui/src/features/canvas/components/NewTaskFab.tsx create mode 100644 packages/ui/src/features/canvas/components/channelGlyph.test.tsx create mode 100644 packages/ui/src/features/canvas/components/channelGlyph.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useStarredChannelSlots.ts create mode 100644 packages/ui/src/features/canvas/hooks/useTrackChannelsSpaceViewed.ts create mode 100644 packages/ui/src/features/command-center/useCommandCenterActiveCount.ts create mode 100644 packages/ui/src/features/command/keyboard-shortcuts.test.ts diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index e1af10334a..947423a6e9 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + countActiveTaskCells, getCellCount, getCellSessionId, getGridDimensions, @@ -97,3 +98,34 @@ describe("getCellSessionId", () => { expect(getCellSessionId(2)).toBe("cc-cell-2"); }); }); + +describe("countActiveTaskCells", () => { + const live = new Set(["task-1", "task-2"]); + + it("counts only cells whose task still exists", () => { + expect(countActiveTaskCells(["task-1", "task-2"], live)).toBe(2); + }); + + // Cells are persisted and only pruned on archive, so a deleted task's id + // lingers forever — counting the array's non-empty entries would never drop. + it("ignores a task that has since been deleted", () => { + expect(countActiveTaskCells(["task-1", "deleted-task"], live)).toBe(1); + }); + + it.each([ + { name: "empty cells", cells: [null, null] }, + { name: "the brainrot sentinel", cells: [BRAINROT_CELL] }, + { name: "terminal cells", cells: [makeTerminalCellValue("abc123")] }, + ])("does not count $name", ({ cells }) => { + expect(countActiveTaskCells(cells, live)).toBe(0); + }); + + it("counts a mixed grid correctly", () => { + expect( + countActiveTaskCells( + [null, BRAINROT_CELL, "task-1", "deleted", makeTerminalCellValue("t")], + live, + ), + ).toBe(1); + }); +}); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts index 7d2e5f8e16..36eaf5fa75 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -49,6 +49,21 @@ export function getTerminalCellCwd(value: string | null): string | null { return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1)); } +/** + * How many cells hold a task that still exists. + * + * Cells are persisted and only pruned when a task is archived — deleting one + * leaves its id behind forever — so a count has to be taken against the live + * task list rather than trusting the array's length. Excludes the brainrot and + * terminal sentinels, which are ambient chrome rather than parked work. + */ +export function countActiveTaskCells( + cells: readonly (string | null)[], + liveTaskIds: ReadonlySet, +): number { + return cells.filter((cell) => cell != null && liveTaskIds.has(cell)).length; +} + export function getGridDimensions(preset: LayoutPreset): GridDimensions { const [cols, rows] = preset.split("x").map(Number); return { cols, rows }; diff --git a/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx b/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx new file mode 100644 index 0000000000..4151943bb4 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx @@ -0,0 +1,30 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TabShortcutFallback } from "./TabShortcutFallback"; + +function pressCloseTab(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "w", + code: "KeyW", + metaKey: true, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + return event; +} + +describe("TabShortcutFallback", () => { + // Without a preventDefault here the key reaches Electron's Window ▸ Close + // role and takes the window — and everything unsaved in it — with it. + it("swallows Cmd+W so the host menu never sees it", () => { + render(); + expect(pressCloseTab().defaultPrevented).toBe(true); + }); + + // Disabled is how the BrowserTabStrip keeps ownership where it is mounted. + it("leaves the key alone when disabled", () => { + render(); + expect(pressCloseTab().defaultPrevented).toBe(false); + }); +}); diff --git a/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx b/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx new file mode 100644 index 0000000000..5ed7820766 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx @@ -0,0 +1,26 @@ +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Renders nothing — claims Cmd/Ctrl+W wherever BrowserTabStrip isn't mounted. + * + * The strip's own CLOSE_TAB handler preventDefaults unconditionally, because + * otherwise the key reaches Electron's Window ▸ Close role (`{ role: + * "windowMenu" }` in the host menu) and closes the window, losing everything in + * it. Any route that renders the app without the strip — the whole channels + * layout, and the settings shell either way — needs someone else to hold the key. + * + * The task view's editor panel keeps closing its own tab from + * usePanelKeyboardShortcuts; that handler runs too, and this one only swallows. + */ +export function TabShortcutFallback({ enabled }: { enabled: boolean }) { + useHotkeys( + SHORTCUTS.CLOSE_TAB, + (event) => { + event.preventDefault(); + }, + { enabled, enableOnFormTags: true, enableOnContentEditable: true }, + ); + + return null; +} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index 6644ac1ddb..763d156e32 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,10 +1,10 @@ -import { HashIcon } from "@phosphor-icons/react"; import { Button, Tooltip, TooltipContent, TooltipTrigger, } from "@posthog/quill"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; @@ -52,7 +52,10 @@ export function ChannelBreadcrumb({ const channelSegment = ( <> - + {channelGlyph(channelName, { + size: 12, + className: "mt-px shrink-0 text-muted-foreground/80", + })} = { - not_started: "Not started", - queued: "Queued", - in_progress: "In progress", - // "Ready", not "Completed": the agent has finished its work and the task is - // ready to look at, but the change itself isn't necessarily shipped/done. - completed: "Ready", - failed: "Failed", - cancelled: "Cancelled", -}; - // Once a PR exists its GitHub state is the truest top-line status — more // accurate than the run status, which routinely lingers on "in_progress" // (or a stale cloud status) after the agent opens the PR. Mirrors the PR @@ -102,18 +95,10 @@ const PR_STATE_LABELS: Record< }; function statusBadge(status: TaskRunStatus) { - const variant = - status === "completed" - ? "success" - : status === "failed" - ? "destructive" - : status === "in_progress" - ? "info" - : "default"; return ( - + {status === "in_progress" && } - {STATUS_LABELS[status]} + {RUN_STATUS_LABELS[status]} ); } diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index bc08328a46..7b954d61bb 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,57 @@ -import { HashIcon } from "@phosphor-icons/react"; +import { StarIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header: a clickable "# channel" that doubles as the Home -// item — it routes to the channel home (`/website/$channelId`, like the sidebar -// channel row) and highlights `bg-fill-selected` while you're there, the same -// pathname-driven active state the rest of the channel tab strip uses. Followed -// by that strip (Artifacts / Recents / CONTEXT.md), rendered into the -// header bar by every channel view so the tabs stay in view. +// The feed-side counterpart to the switcher's hover star. +function ChannelStarButton({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + +// The shared channel header. The new layout drops the section tab strip — the +// channel sidebar carries those entries — while flag off keeps it. export function ChannelHeader({ channelId }: { channelId: string }) { const navigate = useNavigate(); + const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); - const channelName = channels.find((c) => c.id === channelId)?.name; + const channel = channels.find((c) => c.id === channelId); + const channelName = channel?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; - // Every channel surface renders this header, so it is where "the viewer is - // in this channel" is known — and therefore where the channel is marked read. + // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); return ( @@ -33,12 +65,18 @@ export function ChannelHeader({ channelId }: { channelId: string }) { size="sm" className={cn("min-w-0", isHome ? "bg-fill-selected" : "")} > - + {channelGlyph(channelName, { + size: 20, + className: "shrink-0 text-muted-foreground/80", + })} {channelName ?? "Channel"} - + {channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && ( + + )} + {!channelsLayout && } ); } diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx new file mode 100644 index 0000000000..e4e7cfe547 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx @@ -0,0 +1,96 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + channelsLayout: true, + slots: [] as { id: string; name: string; path: string }[], + navigateToChannel: vi.fn(), +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useStarredChannelSlots", () => ({ + useStarredChannelSlots: () => ({ + slots: mocks.slots, + rest: [], + slotFor: () => undefined, + }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: (...args: unknown[]) => mocks.navigateToChannel(...args), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); + +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { ChannelHotkeys } from "./ChannelHotkeys"; + +function press(digit: string, modifiers: Partial = {}) { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: digit, + code: `Digit${digit}`, + bubbles: true, + cancelable: true, + ...modifiers, + }), + ); +} + +describe("ChannelHotkeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channelsLayout = true; + mocks.slots = [ + { id: "me-id", name: "me", path: "/me" }, + { id: "eng-id", name: "eng", path: "/eng" }, + ]; + useCurrentChannelStore.setState({ currentChannelId: null }); + }); + + // The regression: this component is rendered ALONE — no sidebar, no switcher. + // Binding the keys inside the switcher left them unowned exactly when the + // channel list hadn't resolved yet. + it("switches channels without the sidebar being mounted", () => { + render(); + + press("1", { metaKey: true }); + + expect(mocks.navigateToChannel).toHaveBeenCalledWith("me-id"); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + it("maps slot 2 to the first starred channel", () => { + render(); + press("2", { metaKey: true }); + expect(mocks.navigateToChannel).toHaveBeenCalledWith("eng-id"); + }); + + // mod+0 belongs to the host's "Actual Size" accelerator. + it("ignores mod+0", () => { + render(); + press("0", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + // ctrl+1-9 is the editor-panel tab switcher on every platform. + it("leaves pure ctrl presses to the panel tab switcher", () => { + render(); + press("1", { ctrlKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + it("does nothing for a slot with no channel behind it", () => { + mocks.slots = [{ id: "me-id", name: "me", path: "/me" }]; + render(); + press("5", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + it("stays out of the way when the layout is off", () => { + mocks.channelsLayout = false; + render(); + press("1", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx new file mode 100644 index 0000000000..16bfb4a0fa --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx @@ -0,0 +1,53 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Renders nothing — the unconditional owner of ⌘1-9 (switch channel) under the + * channels layout. + * + * Mounted from the root rather than from the switcher: the switcher only renders + * once a channel is already scoped, so binding there left the keys with no owner + * exactly when the user most needs them (channel list still loading, or failed). + * GlobalEventHandlers yields SWITCH_TASK to this whenever the layout is on, so + * there must always be someone listening. + */ +export function ChannelHotkeys() { + const channelsLayout = useChannelsLayout(); + const { slots } = useStarredChannelSlots(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + + useHotkeys( + SHORTCUTS.SWITCH_STARRED_CHANNEL, + (event, handler) => { + // Same ctrl guard as SWITCH_TASK: plain ctrl+N is the editor-panel tab + // switcher (SWITCH_TAB), so leave ctrl-only presses to it. + if (event.ctrlKey && !event.metaKey) return; + const slot = Number.parseInt(handler.keys?.[0] ?? "", 10); + if (Number.isNaN(slot)) return; + const channel = slots[slot - 1]; + if (!channel) return; + setCurrentChannel(channel.id); + navigateToChannel(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_channel", + surface: "sidebar", + channel_id: channel.id, + }); + }, + { + enabled: channelsLayout, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [slots, setCurrentChannel], + ); + + return null; +} diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx new file mode 100644 index 0000000000..c6f7181a72 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -0,0 +1,131 @@ +import { BellIcon, EnvelopeSimple, Lightning } from "@phosphor-icons/react"; +import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; +import { cn } from "@posthog/quill"; +import { + ANALYTICS_EVENTS, + type SidebarNavItem, +} from "@posthog/shared/analytics-events"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useCommandCenterActiveCount } from "@posthog/ui/features/command-center/useCommandCenterActiveCount"; +import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; +import { CountBadge } from "@posthog/ui/primitives/CountBadge"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { + navigateToActivity, + navigateToInbox, + navigateToWebsiteCommandCenter, +} from "@posthog/ui/router/navigationBridge"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { track } from "@posthog/ui/shell/analytics"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; + +const INBOX_REFETCH_INTERVAL_MS = 60_000; + +const ICON_BADGE_CLASS = + "-top-1 -right-1 absolute h-3.5 min-w-3.5 w-auto px-1 font-semibold text-[9px] ring-2 ring-chrome"; + +function NavIcon({ + icon, + label, + shortcut, + isActive, + onClick, + badge, +}: { + icon: ReactNode; + label: string; + shortcut?: string; + isActive: boolean; + onClick: () => void; + badge?: ReactNode; +}) { + return ( + + + + ); +} + +export function ChannelNav() { + const view = useAppView(); + + const { counts } = useInboxAllReports({ + ignoreFilters: true, + refetchIntervalMs: INBOX_REFETCH_INTERVAL_MS, + }); + const { items: activityItems } = useMentionActivity(); + const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); + const unseenActivity = useMemo( + () => countUnseenActivity(activityItems, lastSeenAt), + [activityItems, lastSeenAt], + ); + const commandCenterCount = useCommandCenterActiveCount(); + + const withTrack = (item: SidebarNavItem, action: () => void) => () => { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item, + in_more: false, + layout: "channels", + }); + action(); + }; + + const isActivity = view.type === "activity"; + const isCommandCenter = view.type === "command-center"; + + return ( +
+ } + label="Inbox" + shortcut={formatHotkey(SHORTCUTS.INBOX)} + isActive={view.type === "inbox"} + onClick={withTrack("inbox", navigateToInbox)} + badge={} + /> + } + label="Activity" + isActive={isActivity} + onClick={withTrack("activity", navigateToActivity)} + badge={ + + } + /> + + } + label="Command Center" + isActive={isCommandCenter} + onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} + badge={ + + } + /> +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx new file mode 100644 index 0000000000..bf8a323c9d --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -0,0 +1,382 @@ +import { + BookOpenTextIcon, + ChatsCircleIcon, + FunnelSimple as FunnelSimpleIcon, + MagnifyingGlass, + PackageIcon, + RepeatIcon, +} from "@phosphor-icons/react"; +import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; +import { filterChannelItems } from "@posthog/core/canvas/channelItems"; +import { RUN_STATUS_FILTER_OPTIONS } from "@posthog/core/canvas/runStatus"; +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Input, + MenuLabel, + Skeleton, +} from "@posthog/quill"; +import { LOOPS_FLAG } from "@posthog/shared"; +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; +import { NewTaskFab } from "@posthog/ui/features/canvas/components/NewTaskFab"; +import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { type ReactNode, useMemo, useState } from "react"; + +const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = + [ + { value: "anyone", label: "Anyone" }, + { value: "me", label: "Me" }, + { value: "others", label: "Other people" }, + ] as const; + +const HEADER_ICON_BUTTON_CLASS = + "flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; + +const cnHeaderButton = (active: boolean) => + cn(HEADER_ICON_BUTTON_CLASS, active && "bg-fill-selected text-foreground"); + +const RECENTS_CAP = 30; + +function RecentSectionHeader({ + searchOpen, + onToggleSearch, + query, + onQueryChange, + createdByFilter, + onCreatedByChange, + statusFilter, + onStatusChange, + filtersActive, +}: { + searchOpen: boolean; + onToggleSearch: () => void; + query: string; + onQueryChange: (value: string) => void; + createdByFilter: CreatedByFilter; + onCreatedByChange: (value: CreatedByFilter) => void; + statusFilter: TaskRunStatus | null; + onStatusChange: (value: TaskRunStatus | null) => void; + filtersActive: boolean; +}) { + return ( + <> +
+
+ Recent +
+ + + + + + } + /> + + Created by + + onCreatedByChange(value as CreatedByFilter) + } + > + {CREATED_BY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Status + + onStatusChange( + value === "any" ? null : (value as TaskRunStatus), + ) + } + > + {RUN_STATUS_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ {searchOpen && ( +
+ onQueryChange(event.target.value)} + placeholder="Search…" + aria-label="Search recent items" + className="h-6 text-[12px]" + /> +
+ )} + + ); +} + +// Varied widths so the loading state reads as the list it becomes. +const SKELETON_ROW_WIDTHS = [ + "w-3/5", + "w-4/5", + "w-2/5", + "w-3/4", + "w-1/2", + "w-2/3", +] as const; + +function ChannelItemsSkeleton() { + return ( +
+ + {SKELETON_ROW_WIDTHS.map((width) => ( +
+ + +
+ ))} +
+ ); +} + +/** + * The sidebar body while a channel is active: the switcher, the channel's + * sections, then its pinned and recent tasks & canvases. + */ +export function ChannelSidebar({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + + const { items, actions, me, isLoading, channelMissing } = + useChannelItems(channelId); + + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(""); + const [createdByFilter, setCreatedByFilter] = + useState("anyone"); + const [statusFilter, setStatusFilter] = useState(null); + const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; + + const base = `/website/${channelId}`; + // Activeness is a key comparison rather than a flag baked into each item, so + // navigating doesn't rebuild the list. + const activeKey = useMemo(() => { + const dashboard = pathname.match(/\/dashboards\/([^/]+)$/); + if (dashboard) return `canvas:${dashboard[1]}`; + const task = pathname.match(/\/tasks\/([^/]+)$/); + return task ? `task:${task[1]}` : null; + }, [pathname]); + + const pinnedItems = useMemo(() => items.filter((i) => i.pinned), [items]); + const recentItems = useMemo( + () => + filterChannelItems( + items.filter((i) => !i.pinned), + { query, createdBy: createdByFilter, status: statusFilter, me }, + ).slice(0, RECENTS_CAP), + [items, query, createdByFilter, statusFilter, me], + ); + + const sectionRow = ( + label: string, + icon: ReactNode, + to: string, + onClick: () => void, + ) => ( + + ); + + return ( +
+ + +
+ {sectionRow( + "Feed", + , + base, + () => + void navigate({ to: "/website/$channelId", params: { channelId } }), + )} + {sectionRow( + "Context", + , + `${base}/context`, + () => + void navigate({ + to: "/website/$channelId/context", + params: { channelId }, + }), + )} + {loopsEnabled && + sectionRow( + "Loops", + , + `${base}/loops`, + () => + void navigate({ + to: "/website/$channelId/loops", + params: { channelId }, + }), + )} + {sectionRow( + "Artifacts", + , + `${base}/artifacts`, + () => + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }), + )} +
+ + {/* Relative so the FAB can float over the list. */} +
+
+ {isLoading && items.length === 0 && } + + {channelMissing && ( + + + + + + Channel unavailable + + It may have been deleted, or belong to another project. + + + + )} + + {pinnedItems.length > 0 && ( + <> + Pinned +
+ {pinnedItems.map((item) => ( + + ))} +
+ + )} + + {(items.some((i) => !i.pinned) || filtersActive || searchOpen) && ( + <> + { + if (searchOpen) setQuery(""); + setSearchOpen(!searchOpen); + }} + query={query} + onQueryChange={setQuery} + createdByFilter={createdByFilter} + onCreatedByChange={setCreatedByFilter} + statusFilter={statusFilter} + onStatusChange={setStatusFilter} + filtersActive={filtersActive} + /> + {recentItems.length > 0 ? ( +
+ {recentItems.map((item) => ( + + ))} +
+ ) : ( + + + + + + No matches + + Try a different search or clear the filters. + + + + )} + + )} + + {!isLoading && !channelMissing && items.length === 0 && ( + + + + + + Nothing here yet + + Tasks and canvases you create in this channel show up here. + + + + )} +
+ +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx new file mode 100644 index 0000000000..8ee851a4be --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -0,0 +1,302 @@ +import { Popover as BasePopover } from "@base-ui/react/popover"; +import { CaretUpDownIcon, PlusIcon, StarIcon } from "@phosphor-icons/react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + cn, + Input, + Kbd, + Popover, + PopoverContent, + PopoverTrigger, + Separator, + Skeleton, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { useState } from "react"; + +// Clears the row's 8px `mx-2` inset plus an 8px gap, so the panel lands past +// the sidebar edge rather than over it. +const POPOVER_SIDE_OFFSET = 16; + +function SwitcherRow({ + channel, + active, + onPick, + hotkeySlot, +}: { + channel: Channel; + active: boolean; + onPick: () => void; + hotkeySlot?: number; +}) { + const isMe = channel.name === PERSONAL_CHANNEL_NAME; + const { isStarred, toggleStar } = useChannelStarToggle(channel); + + const trackedToggleStar = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: isStarred ? "unstar" : "star", + surface: "sidebar", + channel_id: channel.id, + }); + toggleStar(); + }; + + const row = ( +
+ + {isMe ? ( + // #me is permanently pinned, so its star is inert — but the slot + // still has to be filled to keep the rows aligned. + + + + ) : ( + + )} +
+ ); + + if (isMe) return row; + return ( + + + + + + {isStarred ? "Unstar channel" : "Star channel"} + + + + ); +} + +// An overlay rather than a sibling: a sibling would shrink the trigger, which +// the popover anchors to (see POPOVER_SIDE_OFFSET). +function TriggerStar({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + +/** + * The channel header as a switcher: clicking the "# channel" title opens a + * searchable popover — "#me" first, then starred channels, then the rest. + */ +export function ChannelSwitcher({ channelId }: { channelId: string }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const { channels, isLoading: isLoadingChannels } = useChannels(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const current = channels.find((c) => c.id === channelId); + // ChannelHotkeys owns the keys these slots describe; sharing the derivation + // keeps the advertised key and the key that fires in agreement. + const { slots, rest, slotFor } = useStarredChannelSlots(); + + const normalizedQuery = query.trim().toLowerCase(); + const matches = (c: Channel) => + !normalizedQuery || c.name.toLowerCase().includes(normalizedQuery); + const topFiltered = slots.filter(matches); + const restFiltered = rest.filter(matches); + + const pick = (channel: Channel) => { + setOpen(false); + setQuery(""); + setCurrentChannel(channel.id); + navigateToChannel(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_channel", + surface: "sidebar", + channel_id: channel.id, + }); + }; + + const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; + + return ( +
+ + {/* Portaled because ResizableSidebar's transform makes the sidebar a + containing block, which would clip a fixed overlay; z-40 keeps it + under the popup's z-50. */} + + + + + + {channelGlyph(current?.name, { + size: 14, + className: "text-muted-foreground", + })} + + + {current ? ( + current.name + ) : isLoadingChannels ? ( + // A placeholder word here would read as a real channel named + // "channel"; a skeleton says "still loading" honestly. + + ) : ( + "Unavailable" + )} + + + + + } + /> + +
+ setQuery(event.target.value)} + placeholder="Search channels…" + aria-label="Search channels" + className="h-7 text-[13px]" + /> +
+
+ {topFiltered.map((channel) => ( + pick(channel)} + hotkeySlot={slotFor(channel)} + /> + ))} + {topFiltered.length > 0 && restFiltered.length > 0 && ( + + )} + {restFiltered.map((channel) => ( + pick(channel)} + /> + ))} + {topFiltered.length === 0 && restFiltered.length === 0 && ( +

+ No channels match "{query.trim()}". +

+ )} +
+ + +
+
+ {showStar && current && } + {/* Inert star for #me, so the trigger reads the same on every channel. */} + {current && !showStar && ( + + + + )} + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 235760b9f8..67202fad8a 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -7,7 +7,6 @@ import { FileTextIcon, HashIcon, LinkIcon, - LockSimpleIcon, PencilSimpleIcon, PlusIcon, StarIcon, @@ -43,6 +42,7 @@ import { TooltipTrigger, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; @@ -61,6 +61,10 @@ import { useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { + resetCurrentChannel, + useCurrentChannelStore, +} from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { @@ -68,10 +72,11 @@ import { useOverflowTickerReveal, } from "@posthog/ui/primitives/OverflowTickerText"; import { toast } from "@posthog/ui/primitives/toast"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { hostClient } from "../hostClient"; // One actionable entry in a channel's menu, rendered the same whether it @@ -131,6 +136,12 @@ function useChannelActions(channel: Channel): { await deleteChannel(channel.id); removeStar(); + // Unscope immediately if this was the current channel — otherwise the + // sidebar renders a dead id (and new tasks file against it) until the + // channels list refetches. useCurrentChannel is the backstop. + if (useCurrentChannelStore.getState().currentChannelId === channel.id) { + resetCurrentChannel(); + } track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "delete", surface: "sidebar", @@ -352,18 +363,18 @@ function ChannelSection({ }); }} {...focusProps} - className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" + className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" > - + ), + })} @@ -568,7 +576,7 @@ function PersonalChannelRow() { surface: "sidebar", channel_id: channelId, }); - void navigate({ to: "/website/$channelId/new", params: { channelId } }); + openTaskInput({ channelId }); }; const newCanvas = async () => { @@ -591,18 +599,18 @@ function PersonalChannelRow() { data-selected={isActive || undefined} disabled={isCreating} onClick={() => void open()} - className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" + className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" > - + ), + })} {PERSONAL_CHANNEL_NAME} - {/* The lock and the hover "+" share the right edge, so fade the lock - out as the "+" comes in. */} -
@@ -765,19 +762,6 @@ export function ChannelsList() { const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); const others = channels.filter((c) => !starredRefToShortcutId.has(c.path)); - // Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load - // (so the counts are accurate). The sidebar stays mounted while navigating - // between channels, so this naturally fires once per entry into the space. - const viewedTrackedRef = useRef(false); - useEffect(() => { - if (isLoading || viewedTrackedRef.current) return; - viewedTrackedRef.current = true; - track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, { - channel_count: channels.length, - starred_count: starred.length, - }); - }, [isLoading, channels.length, starred.length]); - return ( // One shared provider groups every row tooltip so that once one shows, // moving to the next row reveals its tooltip instantly (no re-delay). diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx new file mode 100644 index 0000000000..f4f9c10634 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -0,0 +1,261 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + featureFlags: new Map(), + channelsLayout: false, + channelsEnabled: false, + channels: [] as { id: string; name: string; path: string }[], + channelsLoading: false, + archivedTaskIds: new Set(), + navigateToArchived: vi.fn(), + track: vi.fn(), + routeChannelId: undefined as string | undefined, +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: (...args: unknown[]) => mocks.track(...args), +})); + +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: (key: string) => mocks.featureFlags.get(key) ?? true, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ + channels: mocks.channels, + isLoading: mocks.channelsLoading, + }), +})); +vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ + useArchivedTaskIds: () => mocks.archivedTaskIds, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStars: () => ({ starredRefToShortcutId: new Map() }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToArchived: (...args: unknown[]) => mocks.navigateToArchived(...args), +})); + +// The sidebar's children each mount their own query stack; this suite is about +// the shell's own decisions, so they're stubbed out. +vi.mock("@posthog/ui/features/canvas/components/ChannelNav", () => ({ + ChannelNav: () => null, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelSidebar", () => ({ + ChannelSidebar: ({ channelId }: { channelId: string }) => ( +
{channelId}
+ ), +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelsList", () => ({ + ChannelsList: () =>
, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ + ChannelsFab: () => null, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarNavSection", () => ({ + SidebarNavSection: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarMenu", () => ({ + SidebarMenu: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/ProjectSwitcher", () => ({ + ProjectSwitcher: () => null, +})); +vi.mock("@posthog/ui/features/sidebar/components/UpdateBanner", () => ({ + UpdateBanner: () => null, +})); +vi.mock("@posthog/ui/features/loops/components/LoopsPromoCard", () => ({ + LoopsPromoCard: () => null, +})); +vi.mock("@posthog/ui/features/workspace/useWorkspace", () => ({ + useWorkspaces: () => ({ data: {}, isFetched: true }), +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ channelId: mocks.routeChannelId }), +})); + +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { ChannelsSidebar } from "./ChannelsSidebar"; + +function renderSidebar() { + return render( + + + , + ); +} + +const ME = { id: "me-id", name: "me", path: "/me" }; + +describe("ChannelsSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.featureFlags.clear(); + mocks.channelsLayout = false; + mocks.channels = []; + mocks.channelsLoading = false; + mocks.archivedTaskIds = new Set(); + mocks.track.mockClear(); + mocks.routeChannelId = undefined; + useCurrentChannelStore.setState({ currentChannelId: null }); + useSidebarStore.setState({ channelsEnabled: false, open: true }); + }); + + describe("the Archived row", () => { + beforeEach(() => { + mocks.archivedTaskIds = new Set(["archived-1"]); + }); + + // The layout puts an Archive action on every item row, so hiding the + // destination leaves archived tasks with nowhere to be seen — + // navigateToArchived has no other caller in the app. + it("is present under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(screen.getByText("Archived")).toBeTruthy(); + }); + + it("is present with neither channels world on", () => { + renderSidebar(); + expect(screen.getByText("Archived")).toBeTruthy(); + }); + + // The alpha replaced the task list with the channel tree, so the row went + // with it — that part is deliberate. + it("is absent in the channels alpha", () => { + useSidebarStore.setState({ channelsEnabled: true }); + mocks.featureFlags.set(PROJECT_BLUEBIRD_FLAG, true); + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + + it("stays hidden when nothing is archived", () => { + mocks.channelsLayout = true; + mocks.archivedTaskIds = new Set(); + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + }); + + describe("auto-scoping to #me", () => { + it("keeps a deep-linked channel instead of overwriting it with #me", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng-id", name: "eng", path: "/eng" }]; + mocks.routeChannelId = "eng-id"; + + renderSidebar(); + + expect(useCurrentChannelStore.getState().currentChannelId).toBe("eng-id"); + }); + + it("scopes to the personal channel once the list lands", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + // Both flags behind the layout re-evaluate on every flags payload, so a + // momentary false must not permanently strand the sidebar unscoped: the + // auto-scope latch has to reset when the layout turns off. + it("re-scopes after the layout flag flickers off and back on", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + const { rerender } = renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + + mocks.channelsLayout = false; + rerender( + + + , + ); + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + + mocks.channelsLayout = true; + rerender( + + + , + ); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + it("does not scope to a channel the project does not have", () => { + mocks.channelsLayout = true; + mocks.channels = [{ id: "eng", name: "eng", path: "/eng" }]; + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + expect(screen.queryByTestId("channel-sidebar")).toBeNull(); + }); + + // A stale id from a previous project must not be rendered as a channel. + it("clears a scoped channel missing from the loaded list", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + useCurrentChannelStore.setState({ currentChannelId: "from-old-project" }); + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).not.toBe( + "from-old-project", + ); + }); + }); + + it("renders the flag-off sidebar menu untouched", () => { + renderSidebar(); + expect(screen.getByTestId("sidebar-menu")).toBeTruthy(); + expect(screen.getByTestId("sidebar-nav-section")).toBeTruthy(); + }); + + describe("space-viewed tracking", () => { + // The event used to fire from ChannelsList, which the new layout barely + // renders — so space adoption would have read as zero once the flag landed. + it("fires from the shell under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng", name: "eng", path: "/eng" }]; + renderSidebar(); + expect(mocks.track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + { channel_count: 1, starred_count: 0, layout: "channels" }, + ); + }); + + it("does not fire outside the channels world", () => { + mocks.channels = [ME]; + renderSidebar(); + expect(mocks.track).not.toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + expect.anything(), + ); + }); + + it("fires again after leaving and re-entering the channels world", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + const { rerender } = renderSidebar(); + + mocks.channelsLayout = false; + rerender( + + + , + ); + mocks.channelsLayout = true; + rerender( + + + , + ); + + expect(mocks.track).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 92b7caca39..48031692e6 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -2,9 +2,16 @@ import { ArchiveIcon } from "@phosphor-icons/react"; import { Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; +import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSidebar"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; @@ -13,6 +20,7 @@ import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; import { TasksHeader } from "@posthog/ui/features/sidebar/components/TasksHeader"; import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { CHANNELS_SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { beginSidebarPeek, cancelSidebarPeek, @@ -21,11 +29,13 @@ import { } from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import { ErrorBoundary } from "@posthog/ui/primitives/ErrorBoundary"; import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; -import { useDeferredValue, useEffect } from "react"; +import { useParams } from "@tanstack/react-router"; +import { useDeferredValue, useEffect, useRef } from "react"; // The unified app sidebar (Code merged into the Bluebird chrome). Top to // bottom: the merged global nav, the Tasks/Channels body header, then the @@ -78,15 +88,59 @@ export function ChannelsSidebar() { ); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; - // The Switch in TasksHeader reads the live value and flips instantly. - // Swapping the sidebar body mounts a heavy tree (ChannelsList: the channels - // query + a provider-laden row per channel), so defer that decision: the - // urgent commit keeps the current body and paints the toggle, then the tree - // mounts in a follow-up non-blocking render. - const bodyChannelsEnabled = useDeferredValue(channelsEnabled); + const channelsLayout = useChannelsLayout(); + const channelsWorld = channelsLayout || channelsEnabled; + const bodyChannelsWorld = useDeferredValue(channelsWorld); + const showArchivedRow = channelsLayout || !bodyChannelsWorld; + useTrackChannelsSpaceViewed({ + enabled: channelsWorld, + layout: channelsLayout ? "channels" : "code", + }); + + const minWidth = channelsLayout ? CHANNELS_SIDEBAR_MIN_WIDTH : undefined; + useEffect(() => { + if (channelsLayout && width < CHANNELS_SIDEBAR_MIN_WIDTH) { + setWidth(CHANNELS_SIDEBAR_MIN_WIDTH); + } + }, [channelsLayout, width, setWidth]); const archivedTaskIds = useArchivedTaskIds(); + const params = useParams({ strict: false }); + const routeChannelId = params.channelId; + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const { currentChannelId, channels } = useCurrentChannel({ + enabled: channelsLayout, + }); + useEffect(() => { + if (!channelsLayout || !routeChannelId) return; + setCurrentChannel(routeChannelId); + }, [channelsLayout, routeChannelId, setCurrentChannel]); + const inChannel = channelsLayout && currentChannelId != null; + + const autoScopedRef = useRef(false); + useEffect(() => { + if (!channelsLayout) { + autoScopedRef.current = false; + return; + } + // A route-scoped channel wins over the default. Both effects run from the + // same render on a cold deep link, so without this guard the route effect + // writes its channel and this later effect immediately overwrites it with + // #me using the stale `currentChannelId` captured by that render. + if (routeChannelId || autoScopedRef.current || currentChannelId) return; + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + if (!me) return; + autoScopedRef.current = true; + setCurrentChannel(me.id); + }, [ + channelsLayout, + channels, + currentChannelId, + routeChannelId, + setCurrentChannel, + ]); + return ( - - + {!inChannel && ( + <> + + {!channelsLayout && } + + )} - {/* Body: the channel tree when channels are on, otherwise the task - list. Each owns its own scroll region. Gated on the deferred value so - the toggle paints before this heavy swap. */} - {bodyChannelsEnabled ? ( + {inChannel && currentChannelId ? ( + <> + + + } + resetKey={currentChannelId} + > + + + + + ) : bodyChannelsWorld ? ( <> - {/* The fab is a sibling of the scroll region, not a child, so it - stays pinned to the bottom-right instead of scrolling away. */} @@ -128,9 +195,7 @@ export function ChannelsSidebar() { - {/* Archived is a task-list affordance — hidden while channels are on, - since the body then shows the channel tree, not tasks. */} - {!channelsEnabled && archivedTaskIds.size > 0 && ( + {showArchivedRow && archivedTaskIds.size > 0 && ( + } + /> + + + { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item: "new_task", + in_more: false, + }); + openTaskInput({ channelId }); + }} + > + + New task + + { + trackAndCreateCanvas( + channelId, + undefined, + "sidebar", + () => void createAndOpenCanvas(), + ); + }} + > + + New canvas + + + + ); +} diff --git a/packages/ui/src/features/canvas/components/channelGlyph.test.tsx b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx new file mode 100644 index 0000000000..c5c9721736 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { isPrivateChannel } from "./channelGlyph"; + +describe("isPrivateChannel", () => { + it.each([ + ["me", true], + [" Me ", true], + ["ME", true], + ["code", false], + ["posthog-feedback", false], + // Not a prefix match: only the personal channel itself is private. + ["meeting-notes", false], + ["team-me", false], + [undefined, false], + ["", false], + ])("%s -> %s", (name, expected) => { + expect(isPrivateChannel(name)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/canvas/components/channelGlyph.tsx b/packages/ui/src/features/canvas/components/channelGlyph.tsx new file mode 100644 index 0000000000..f312c0d000 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelGlyph.tsx @@ -0,0 +1,41 @@ +import { + HashIcon, + type IconWeight, + LockSimpleIcon, +} from "@phosphor-icons/react"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import type { ReactNode } from "react"; + +/** + * Whether only its own members can see a channel. + * + * The personal "#me" channel is the only one today — it is per-user and can't + * be shared. Neither the folder `Channel` nor the backend `TaskChannel` carries + * a general privacy flag, so this is the one place that has to learn about it + * when one lands. + */ +export function isPrivateChannel(channelName: string | undefined): boolean { + if (!channelName) return false; + return normalizeChannelName(channelName) === PERSONAL_CHANNEL_NAME; +} + +/** + * A channel's leading glyph: a lock when it's private, otherwise a hash — the + * Slack convention, so privacy reads at a glance instead of having to be known. + */ +export function channelGlyph( + channelName: string | undefined, + opts?: { size?: number; className?: string; weight?: IconWeight }, +): ReactNode { + const Icon = isPrivateChannel(channelName) ? LockSimpleIcon : HashIcon; + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts index c376b91bb1..eaed603c31 100644 --- a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts +++ b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts @@ -1,8 +1,12 @@ +import { SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { createSidebarStore } from "@posthog/ui/shell/createSidebarStore"; export const useChannelsSidebarStore = createSidebarStore({ name: "channels-sidebar", - defaultWidth: 240, + defaultWidth: SIDEBAR_MIN_WIDTH, + // Also re-clamps the legacy Code width adopted by the migration below. + // The channels layout raises the floor further, at runtime (ChannelsSidebar). + minWidth: SIDEBAR_MIN_WIDTH, }); // One-time migration: the unified layout replaced the Code sidebar (whose diff --git a/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts b/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts index 6c06041574..904a6cb787 100644 --- a/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts +++ b/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts @@ -6,9 +6,9 @@ import { useFreeformChatStore } from "@posthog/ui/features/canvas/stores/freefor import { toast } from "@posthog/ui/primitives/toast"; import { navigateToChannelDashboard, - navigateToChannelNewTask, navigateToChannelTask, } from "@posthog/ui/router/navigationBridge"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { useMutation } from "@tanstack/react-query"; import { useCallback, useState } from "react"; @@ -28,7 +28,8 @@ export function useCanvasNavigation( navigateToChannelTask(channelId, intent.taskId); break; case "new-task": - navigateToChannelNewTask(channelId); + // Via openTaskInput so a stale prefill can't leak into the composer. + openTaskInput({ channelId }); break; case "canvas": navigateToChannelDashboard(channelId, intent.dashboardId); diff --git a/packages/ui/src/features/canvas/hooks/useStarredChannelSlots.ts b/packages/ui/src/features/canvas/hooks/useStarredChannelSlots.ts new file mode 100644 index 0000000000..7cff48f1bf --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useStarredChannelSlots.ts @@ -0,0 +1,44 @@ +import { useChannelStars } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useMemo } from "react"; + +export const STARRED_HOTKEY_SLOTS = 9; + +export function useStarredChannelSlots(): { + slots: Channel[]; + rest: Channel[]; + slotFor: (channel: Channel) => number | undefined; +} { + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + + return useMemo(() => { + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME) ?? null; + const byPath = new Map(channels.map((c) => [c.path, c])); + const starred: Channel[] = []; + for (const ref of starredRefToShortcutId.keys()) { + const channel = byPath.get(ref); + if (channel && channel.name !== PERSONAL_CHANNEL_NAME) { + starred.push(channel); + } + } + const slots = (me ? [me, ...starred] : starred).slice( + 0, + STARRED_HOTKEY_SLOTS, + ); + const slotted = new Set(slots.map((c) => c.id)); + const rest = channels + .filter((c) => !slotted.has(c.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + const slotIndex = new Map(slots.map((c, index) => [c.id, index + 1])); + return { + slots, + rest, + slotFor: (channel: Channel) => slotIndex.get(channel.id), + }; + }, [channels, starredRefToShortcutId]); +} diff --git a/packages/ui/src/features/canvas/hooks/useTrackChannelsSpaceViewed.ts b/packages/ui/src/features/canvas/hooks/useTrackChannelsSpaceViewed.ts new file mode 100644 index 0000000000..97af010709 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTrackChannelsSpaceViewed.ts @@ -0,0 +1,46 @@ +import { + ANALYTICS_EVENTS, + type SidebarLayout, +} from "@posthog/shared/analytics-events"; +import { useChannelStars } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { track } from "@posthog/ui/shell/analytics"; +import { useEffect, useRef } from "react"; + +export function useTrackChannelsSpaceViewed({ + enabled, + layout, +}: { + enabled: boolean; + layout: SidebarLayout; +}): void { + const { channels: allChannels, isLoading } = useChannels({ enabled }); + const { starredRefToShortcutId } = useChannelStars(); + + const shared = allChannels.filter( + (c: Channel) => c.name !== PERSONAL_CHANNEL_NAME, + ); + const channelCount = shared.length; + const starredCount = shared.filter((c: Channel) => + starredRefToShortcutId.has(c.path), + ).length; + + const trackedRef = useRef(false); + useEffect(() => { + if (!enabled) { + trackedRef.current = false; + return; + } + if (isLoading || trackedRef.current) return; + trackedRef.current = true; + track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, { + channel_count: channelCount, + starred_count: starredCount, + layout, + }); + }, [enabled, isLoading, channelCount, starredCount, layout]); +} diff --git a/packages/ui/src/features/command-center/useCommandCenterActiveCount.ts b/packages/ui/src/features/command-center/useCommandCenterActiveCount.ts new file mode 100644 index 0000000000..a11ee09d12 --- /dev/null +++ b/packages/ui/src/features/command-center/useCommandCenterActiveCount.ts @@ -0,0 +1,31 @@ +import { countActiveTaskCells } from "@posthog/core/command-center/grid"; +import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useMemo } from "react"; + +/** + * How many command-center cells hold a task that still exists. + * + * Shared by both sidebar shells so their badges can't disagree — and so neither + * counts a task that has since been deleted, whose id lingers in the persisted + * cell array. + */ +export function useCommandCenterActiveCount({ + enabled = true, +}: { + enabled?: boolean; +} = {}): number { + const showAllUsers = useSidebarStore((s) => s.showAllUsers); + const showInternal = useSidebarStore((s) => s.showInternal); + const { data: allTasks = [] } = useTasks( + { showAllUsers, showInternal }, + { enabled }, + ); + const cells = useCommandCenterStore((s) => s.cells); + + return useMemo( + () => countActiveTaskCells(cells, new Set(allTasks.map((t) => t.id))), + [cells, allTasks], + ); +} diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index bed635d6a8..532ea11c64 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -3,7 +3,6 @@ import { CaretRightIcon, ChartLine, EnvelopeSimple, - HashIcon, RepeatIcon, } from "@phosphor-icons/react"; import { workspaceIdSet } from "@posthog/core/command-center/eligibility"; @@ -32,6 +31,7 @@ import { } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useTaskChannelMap } from "@posthog/ui/features/canvas/hooks/useTaskChannelMap"; import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; @@ -529,7 +529,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { id: `channel-${channel.id}`, label: channel.name, keywords: "channel", - icon: , + icon: channelGlyph(channel.name, { + size: 12, + className: "text-muted-foreground", + }), action: "open-channel" as CommandMenuAction, channelId: channel.id, onRun: () => { diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx index fd919b5081..e95780c085 100644 --- a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -1,3 +1,4 @@ +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { CATEGORY_LABELS, formatHotkeyParts, @@ -109,7 +110,13 @@ function ShortcutsHeader() { } export function KeyboardShortcutsList() { - const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); + // Several keys change owner with the layout, so the sheet has to know which + // one is on rather than listing keys nothing handles. + const channelsLayout = useChannelsLayout(); + const shortcutsByCategory = useMemo( + () => getShortcutsByCategory({ channelsLayout }), + [channelsLayout], + ); const categoryOrder: ShortcutCategory[] = [ "general", diff --git a/packages/ui/src/features/command/keyboard-shortcuts.test.ts b/packages/ui/src/features/command/keyboard-shortcuts.test.ts new file mode 100644 index 0000000000..d164aecc3b --- /dev/null +++ b/packages/ui/src/features/command/keyboard-shortcuts.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { + getShortcutsByCategory, + KEYBOARD_SHORTCUTS, + SHORTCUTS, +} from "./keyboard-shortcuts"; + +function idsFor(channelsLayout: boolean): string[] { + return Object.values(getShortcutsByCategory({ channelsLayout })) + .flat() + .map((s) => s.id); +} + +describe("getShortcutsByCategory", () => { + it("advertises channel switching only in the channels layout", () => { + expect(idsFor(true)).toContain("switch-starred-channel"); + expect(idsFor(false)).not.toContain("switch-starred-channel"); + }); + + it.each(["switch-task", "new-tab"])( + "hides %s in the channels layout, where nothing owns it", + (id) => { + expect(idsFor(false)).toContain(id); + expect(idsFor(true)).not.toContain(id); + }, + ); + + it("keeps unmarked shortcuts in both layouts", () => { + const unmarked = KEYBOARD_SHORTCUTS.filter((s) => !s.availability).map( + (s) => s.id, + ); + const inLayout = new Set(idsFor(true)); + const outOfLayout = new Set(idsFor(false)); + for (const id of unmarked) { + expect(inLayout.has(id)).toBe(true); + expect(outOfLayout.has(id)).toBe(true); + } + }); + + it("defaults to the non-channels layout", () => { + expect( + Object.values(getShortcutsByCategory()) + .flat() + .map((s) => s.id), + ).toEqual(idsFor(false)); + }); +}); + +describe("SHORTCUTS", () => { + // The Electron View menu binds CmdOrCtrl+0 to "Actual Size" in the main + // process, which a renderer preventDefault can't reliably beat. + it("leaves mod+0 to the host's reset-zoom accelerator", () => { + expect(SHORTCUTS.SWITCH_STARRED_CHANNEL.split(",")).not.toContain("mod+0"); + expect(SHORTCUTS.RESET_ZOOM).toBe("mod+0"); + }); + + it("offers nine channel slots", () => { + expect(SHORTCUTS.SWITCH_STARRED_CHANNEL.split(",")).toHaveLength(9); + }); +}); diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 70dc6870a3..3a30e1e418 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -19,6 +19,11 @@ export const SHORTCUTS = { CLOSE_TAB: "mod+w", SWITCH_TAB: "ctrl+1,ctrl+2,ctrl+3,ctrl+4,ctrl+5,ctrl+6,ctrl+7,ctrl+8,ctrl+9", SWITCH_TASK: "mod+1,mod+2,mod+3,mod+4,mod+5,mod+6,mod+7,mod+8,mod+9", + // No mod+0: the Electron View menu owns CmdOrCtrl+0 for "Actual Size", and a + // renderer preventDefault can't reliably beat a main-process accelerator. #me + // takes slot 1 instead. + SWITCH_STARRED_CHANNEL: + "mod+1,mod+2,mod+3,mod+4,mod+5,mod+6,mod+7,mod+8,mod+9", OPEN_IN_EDITOR: "mod+o", COPY_PATH: "mod+shift+c", TOGGLE_FOCUS: "mod+r", @@ -41,6 +46,13 @@ export const SHORTCUTS = { export type ShortcutCategory = "general" | "navigation" | "panels" | "editor"; +/** + * Layouts a shortcut exists in. Several keys have different owners either side + * of the channels layout flag, and a sheet that lists a key nothing handles is + * worse than one that omits it. + */ +export type ShortcutAvailability = "channels-layout" | "no-channels-layout"; + export interface KeyboardShortcut { id: string; keys: string; @@ -48,6 +60,8 @@ export interface KeyboardShortcut { category: ShortcutCategory; context?: string; alternateKeys?: string; + /** Absent means the shortcut works in every layout. */ + availability?: ShortcutAvailability; } export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ @@ -63,6 +77,8 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "New tab", category: "navigation", context: "Channels", + // The tab strip that owns this doesn't exist in the channels layout. + availability: "no-channels-layout", }, { id: "command-menu", @@ -118,6 +134,16 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keys: "mod+1-9", description: "Switch to task 1-9", category: "navigation", + // Yielded to switch-starred-channel under the channels layout. + availability: "no-channels-layout", + }, + { + id: "switch-starred-channel", + keys: "mod+1-9", + description: "Switch to channel (⌘1 = #me, ⌘2-9 = starred)", + category: "navigation", + context: "Channels", + availability: "channels-layout", }, { id: "prev-task", @@ -285,17 +311,22 @@ export const CATEGORY_LABELS: Record = { editor: "Editor", }; -export function getShortcutsByCategory(): Record< - ShortcutCategory, - KeyboardShortcut[] -> { +export function getShortcutsByCategory({ + channelsLayout = false, +}: { + channelsLayout?: boolean; +} = {}): Record { const grouped: Record = { general: [], navigation: [], panels: [], editor: [], }; + const wanted: ShortcutAvailability = channelsLayout + ? "channels-layout" + : "no-channels-layout"; for (const shortcut of KEYBOARD_SHORTCUTS) { + if (shortcut.availability && shortcut.availability !== wanted) continue; grouped[shortcut.category].push(shortcut); } return grouped; diff --git a/packages/ui/src/features/settings/components/SettingsPanel.tsx b/packages/ui/src/features/settings/components/SettingsPanel.tsx index 931f5e92dc..575c6c40ff 100644 --- a/packages/ui/src/features/settings/components/SettingsPanel.tsx +++ b/packages/ui/src/features/settings/components/SettingsPanel.tsx @@ -30,6 +30,7 @@ import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SettingsPageContent } from "@posthog/ui/features/settings/components/SettingsPageContent"; import { closeSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; @@ -156,6 +157,8 @@ export function SettingsPanel({ const client = useOptionalAuthenticatedClient(); const { data: user } = useCurrentUser({ client }); const billingEnabled = useFeatureFlag(BILLING_FLAG); + // The channels layout's nav is fixed, so the Sidebar page can't do anything. + const channelsLayout = useChannelsLayout(); const { localWorkspaces } = useHostCapabilities(); const logoutMutation = useLogoutMutation(); @@ -164,6 +167,7 @@ export function SettingsPanel({ billingEnabled, spendAnalysisEnabled, localWorkspaces, + channelsLayout, }); const sidebarGroups = SIDEBAR_GROUPS.map((group) => ({ ...group, diff --git a/packages/ui/src/features/settings/settingsVisibility.test.ts b/packages/ui/src/features/settings/settingsVisibility.test.ts index 2bf877efd1..02af514218 100644 --- a/packages/ui/src/features/settings/settingsVisibility.test.ts +++ b/packages/ui/src/features/settings/settingsVisibility.test.ts @@ -37,6 +37,18 @@ describe("getHiddenSettingsCategories", () => { "updates", ], }, + { + // The channels layout uses a fixed nav, so the Sidebar page's + // reorder/hide controls would have nothing to act on. + name: "hides the sidebar page under the channels layout", + input: { + billingEnabled: true, + spendAnalysisEnabled: true, + localWorkspaces: true, + channelsLayout: true, + }, + expected: ["sidebar"], + }, ])("$name", ({ input, expected }) => { expect([...getHiddenSettingsCategories(input)]).toEqual(expected); }); diff --git a/packages/ui/src/features/settings/settingsVisibility.ts b/packages/ui/src/features/settings/settingsVisibility.ts index dcba2a38b1..ecd19e1935 100644 --- a/packages/ui/src/features/settings/settingsVisibility.ts +++ b/packages/ui/src/features/settings/settingsVisibility.ts @@ -16,12 +16,18 @@ interface SettingsVisibility { billingEnabled: boolean; spendAnalysisEnabled: boolean; localWorkspaces: boolean; + /** + * The channels layout replaces the customizable nav with a fixed one, so the + * Sidebar page's controls have nothing to act on there. + */ + channelsLayout?: boolean; } export function getHiddenSettingsCategories({ billingEnabled, spendAnalysisEnabled, localWorkspaces, + channelsLayout = false, }: SettingsVisibility): ReadonlySet { const hiddenCategories = new Set(); @@ -33,6 +39,12 @@ export function getHiddenSettingsCategories({ hiddenCategories.add(category); } } + // Better to hide the page than to leave one whose controls silently do + // nothing. SettingsPanel also redirects direct navigation to a hidden + // category, so a deep link can't reach it either. + if (channelsLayout) { + hiddenCategories.add("sidebar"); + } return hiddenCategories; } diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index b148d99107..45f3880177 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -58,7 +58,7 @@ import { Avatar, Box } from "@radix-ui/themes"; import { ChevronRightIcon } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; -// The two-line user/project card used at the bottom of the sidebar. +/** The account / project / org menu at the bottom of the sidebar. */ export function ProjectSwitcher() { const [popoverOpen, setPopoverOpen] = useState(false); diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx index 33d83fb141..47da00ef51 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx @@ -39,6 +39,11 @@ vi.mock("@posthog/ui/router/useAppView", () => ({ useAppView })); vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ useFeatureFlag: () => true, })); +// These tests pin the legacy layout (flag off), where the "Enable channels" +// toggle row is present. +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => false, +})); vi.mock("@posthog/ui/router/navigationBridge", () => ({ navigateToActivity, navigateToAgents, @@ -139,9 +144,10 @@ describe("SidebarNavSection", () => { await user.click(screen.getByRole("button", { name: /Inbox/ })); expect(navigateToInbox).toHaveBeenCalledTimes(1); + // `layout` separates these from ChannelNav's identically-named clicks. expect(track).toHaveBeenCalledWith( ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, - { item: "inbox", in_more: false }, + { item: "inbox", in_more: false, layout: "code" }, ); }); diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 097c1d3e97..8901c91a0a 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -3,7 +3,7 @@ import { ANALYTICS_EVENTS, type SidebarNavItem, } from "@posthog/shared/analytics-events"; -import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { useCommandCenterActiveCount } from "@posthog/ui/features/command-center/useCommandCenterActiveCount"; 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"; @@ -14,7 +14,6 @@ import { orderedNavItems, } from "@posthog/ui/features/sidebar/constants"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToActivity, navigateToCommandCenter, @@ -107,19 +106,9 @@ export function SidebarNavSection({ // count — keeps the standalone (Channels) render self-contained without // opening a redundant subscription when composed inside SidebarMenu. const needsOwnCount = providedActiveCount === undefined; - const showAllUsers = useSidebarStore((s) => s.showAllUsers); - const showInternal = useSidebarStore((s) => s.showInternal); - const { data: allTasks = [] } = useTasks( - { showAllUsers, showInternal }, - { enabled: needsOwnCount }, - ); - const commandCenterCells = useCommandCenterStore((s) => s.cells); - const ownActiveCount = (() => { - const taskIds = new Set(allTasks.map((t) => t.id)); - return commandCenterCells.filter( - (taskId) => taskId != null && taskIds.has(taskId), - ).length; - })(); + const ownActiveCount = useCommandCenterActiveCount({ + enabled: needsOwnCount, + }); const commandCenterActiveCount = providedActiveCount ?? ownActiveCount; const openCommandMenu = useCommandMenuStore((s) => s.open); @@ -131,6 +120,7 @@ export function SidebarNavSection({ track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { item, in_more: depth === 1, + layout: "code", }); action(); }; diff --git a/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx b/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx index 7852a5c741..c0cc8ebb12 100644 --- a/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx @@ -1,4 +1,5 @@ import { Lightning } from "@phosphor-icons/react"; +import { CountBadge } from "@posthog/ui/primitives/CountBadge"; import { SidebarItem } from "../SidebarItem"; interface CommandCenterItemProps { @@ -8,10 +9,10 @@ interface CommandCenterItemProps { depth?: number; } -function formatActiveCount(count: number): string { - if (count > 99) return "99+"; - return String(count); -} +// A trailing count in a sidebar row rather than a notification pill: no fill, +// so it sits on the row's own background. +const ROW_COUNT_CLASS = + "h-[16px] min-w-[16px] w-auto bg-transparent px-1 font-normal text-[11px] text-gray-11"; export function CommandCenterItem({ isActive, @@ -26,15 +27,14 @@ export function CommandCenterItem({ label="Command Center" isActive={isActive} onClick={onClick} + // CountBadge renders nothing at zero, so no guard is needed here. endContent={ - activeCount && activeCount > 0 ? ( - - {formatActiveCount(activeCount)} - - ) : undefined + } /> ); diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index 0ed3b5081f..ef08be90bd 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -2,6 +2,14 @@ import type { SidebarNavItem } from "@posthog/shared/analytics-events"; export const SIDEBAR_MIN_WIDTH = 240; +/** + * Wider floor for the channels layout only. The title bar's left strip is + * pinned to the sidebar width and none of its contents shrink; that layout + * adds a search button, taking the strip to 228px, so 240 would leave the two + * groups touching. Off, the strip needs 208px and keeps the usual floor. + */ +export const CHANNELS_SIDEBAR_MIN_WIDTH = 272; + export const CUSTOMIZABLE_NAV_ITEMS = [ { id: "inbox", label: "Inbox", analyticsId: "inbox", defaultVisible: true }, { diff --git a/packages/ui/src/primitives/CountBadge.tsx b/packages/ui/src/primitives/CountBadge.tsx index 68b145de0e..ffa24d0bdd 100644 --- a/packages/ui/src/primitives/CountBadge.tsx +++ b/packages/ui/src/primitives/CountBadge.tsx @@ -1,7 +1,11 @@ import { cn } from "@posthog/quill"; +/** Unread counts read red; ambient "how much is parked here" counts stay grey. */ +export type CountBadgeTone = "notification" | "neutral"; + interface CountBadgeProps { count: number; + tone?: CountBadgeTone; className?: string; title?: string; } @@ -17,14 +21,36 @@ function countBadgeSizeClass(label: string): string { : "h-[18px] w-[18px] shrink-0"; } -export function CountBadge({ count, className, title }: CountBadgeProps) { +const TONE_CLASS: Record = { + notification: "bg-(--red-9) text-(--gray-contrast)", + // Theme tokens, not the absolute gray scale: these sit on chrome whose + // lightness relationship to gray-N inverts between light and dark. + neutral: "bg-muted text-muted-foreground", +}; + +/** + * A count pill. The one place the 99+ cap and the hide-at-zero rule live — + * every badge that reimplemented them drifted on one or the other. + * + * Size and position come through `className` (tailwind-merge lets callers + * override the defaults), because the surfaces genuinely differ: a nav icon + * overlay is not the same shape as a sidebar row's trailing count. + */ +export function CountBadge({ + count, + tone = "notification", + className, + title, +}: CountBadgeProps) { + if (count <= 0) return null; const label = formatCount(count); return ( void; side: "left" | "right"; + // Floor for drag-resize. Defaults to SIDEBAR_MIN_WIDTH; callers whose chrome + // needs more room can raise it. + minWidth?: number; // Enables drag-to-close/reopen. Without it, dragging just clamps at min. setOpen?: (open: boolean) => void; // While closed, the panel can "peek" — slide out over the content as a @@ -45,6 +48,7 @@ export const ResizableSidebar: React.FC = ({ isResizing, setIsResizing, side, + minWidth = SIDEBAR_MIN_WIDTH, setOpen, peek = false, onPeekEnter, @@ -101,7 +105,7 @@ export const ResizableSidebar: React.FC = ({ const pointer = side === "left" ? e.clientX : window.innerWidth - e.clientX; const maxWidth = window.innerWidth * 0.5; - const clamped = Math.max(SIDEBAR_MIN_WIDTH, Math.min(maxWidth, pointer)); + const clamped = Math.max(minWidth, Math.min(maxWidth, pointer)); if (open) { if (pointer < DRAG_COLLAPSE_AT && setOpen) { @@ -166,6 +170,7 @@ export const ResizableSidebar: React.FC = ({ isResizing, setIsResizing, side, + minWidth, open, peek, width, diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 6ecb9a0032..7cc2b195db 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -2,6 +2,7 @@ import { ArrowSquareOut, CaretLeftIcon, CaretRightIcon, + MagnifyingGlass, } from "@phosphor-icons/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { Button, ButtonGroup } from "@posthog/quill"; @@ -21,7 +22,9 @@ import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal"; import { BlankTabView } from "@posthog/ui/features/browser-tabs/BlankTabView"; import { BrowserTabStrip } from "@posthog/ui/features/browser-tabs/BrowserTabStrip"; import { BrowserTabsDndProvider } from "@posthog/ui/features/browser-tabs/BrowserTabsDnd"; +import { TabShortcutFallback } from "@posthog/ui/features/browser-tabs/TabShortcutFallback"; import { useActiveTabIsBlank } from "@posthog/ui/features/browser-tabs/useBrowserTabs"; +import { ChannelHotkeys } from "@posthog/ui/features/canvas/components/ChannelHotkeys"; import { ChannelsSidebar } from "@posthog/ui/features/canvas/components/ChannelsSidebar"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; import { @@ -30,9 +33,14 @@ import { } from "@posthog/ui/features/canvas/components/FeedbackModal"; import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; import { useChannelDeepLink } from "@posthog/ui/features/canvas/hooks/useChannelDeepLink"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; import { ConnectivityBanner } from "@posthog/ui/features/connectivity/ConnectivityBanner"; import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; import { useOpenTargetDeepLink } from "@posthog/ui/features/deep-links/useOpenTargetDeepLink"; @@ -58,6 +66,7 @@ import { UpdateAvailableModal } from "@posthog/ui/features/updates/UpdateAvailab import { WhatsNewModal } from "@posthog/ui/features/updates/WhatsNewModal"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; import LogosLandscape from "@posthog/ui/primitives/Logo"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; @@ -191,6 +200,9 @@ function RootLayout() { ); const channelsToggleOn = useSidebarStore((s) => s.channelsEnabled); const channelsEnabled = channelsToggleOn && bluebirdEnabled; + // The new channels layout has exactly one gate: its feature flag (no + // sidebar toggle). When on it subsumes the channels alpha entirely. + const channelsLayout = useChannelsLayout(); // When the sidebar is collapsed (Cmd+B) the title bar's left block shrinks to // fit its own controls so the tab strip flushes left with the content pane. const sidebarOpen = useSidebarStore((s) => s.open); @@ -317,6 +329,9 @@ function RootLayout() { onToggleCommandMenu={toggleCommandMenu} onToggleShortcutsSheet={toggleShortcutsSheet} /> + {/* The settings shell has never mounted the tab strip, so nothing here + was stopping Cmd+W from closing the window. */} + {billingEnabled && } @@ -384,43 +399,68 @@ function RootLayout() { )} - {localWorkspaces && ( + {(localWorkspaces || channelsLayout) && ( - - - - + + + )} + {localWorkspaces && ( + + + + + )} )} - {/* Tabs work in both spaces: channel tabs under /website and plain - task tabs in the Code experience. The strip's route→tab effect - noops on param-less routes (inbox, agents, new-task), so it's safe - to mount everywhere. */} - + {/* The new layout has no global tab strip (tabs live inside the + task view); search/inbox/activity live in the sidebar nav. The + strip is also the only global owner of Cmd+W, so something has to + keep holding that key when it isn't mounted. */} + {channelsLayout ? ( + + ) : ( + + )} {/* Gated so an empty right-side group can't claim a no-drag rect in the title bar for nothing — every pixel without controls should drag the window. */} - {(billingEnabled || channelsEnabled) && ( + {(billingEnabled || channelsEnabled || channelsLayout) && ( - {channelsEnabled && ( + {(channelsEnabled || channelsLayout) && ( + ); +} + +/** + * The channel pane's header: the channel you're in, and the way back out of it. + * + * Clicking anywhere on the row slides the sidebar back to the channel list — + * the list is where switching happens, so this row only has to be the door to + * it. Leaving the channel scoped means the route (and the main pane) stay put. + */ +export function ChannelBackRow({ channelId }: { channelId: string }) { + const { channels, isLoading } = useChannels(); + const current = channels.find((c) => c.id === channelId); + const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; + + return ( +
+ + + + {showStar && current && } + {/* Inert star for #me, so the row reads the same on every channel. */} + {current && !showStar && ( + + + + )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 7b954d61bb..42ed864ceb 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -15,7 +15,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The feed-side counterpart to the switcher's hover star. +// The feed-side counterpart to the sidebar back row's hover star. function ChannelStarButton({ channel }: { channel: Channel }) { const { isStarred, toggleStar } = useChannelStarToggle(channel); return ( diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx index e4e7cfe547..696c78a398 100644 --- a/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx @@ -48,8 +48,8 @@ describe("ChannelHotkeys", () => { useCurrentChannelStore.setState({ currentChannelId: null }); }); - // The regression: this component is rendered ALONE — no sidebar, no switcher. - // Binding the keys inside the switcher left them unowned exactly when the + // The regression: this component is rendered ALONE — no sidebar at all. + // Binding the keys inside the sidebar left them unowned exactly when the // channel list hadn't resolved yet. it("switches channels without the sidebar being mounted", () => { render(); diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx index 16bfb4a0fa..59a839e041 100644 --- a/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx @@ -11,9 +11,10 @@ import { useHotkeys } from "react-hotkeys-hook"; * Renders nothing — the unconditional owner of ⌘1-9 (switch channel) under the * channels layout. * - * Mounted from the root rather than from the switcher: the switcher only renders - * once a channel is already scoped, so binding there left the keys with no owner - * exactly when the user most needs them (channel list still loading, or failed). + * Mounted from the root rather than from the sidebar: the sidebar only renders + * its channel pane once a channel is already scoped, so binding there left the + * keys with no owner exactly when the user most needs them (channel list still + * loading, or failed). * GlobalEventHandlers yields SWITCH_TASK to this whenever the layout is on, so * there must always be someone listening. */ diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index bf8a323c9d..010dbf025a 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -28,8 +28,8 @@ import { } from "@posthog/quill"; import { LOOPS_FLAG } from "@posthog/shared"; import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; -import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; import { NewTaskFab } from "@posthog/ui/features/canvas/components/NewTaskFab"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -182,8 +182,8 @@ function ChannelItemsSkeleton() { } /** - * The sidebar body while a channel is active: the switcher, the channel's - * sections, then its pinned and recent tasks & canvases. + * The channel pane of the sidebar slider: the way back to the channel list, + * the channel's sections, then its pinned and recent tasks & canvases. */ export function ChannelSidebar({ channelId }: { channelId: string }) { const navigate = useNavigate(); @@ -237,7 +237,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { return (
- +
{sectionRow( diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx deleted file mode 100644 index 8ee851a4be..0000000000 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { Popover as BasePopover } from "@base-ui/react/popover"; -import { CaretUpDownIcon, PlusIcon, StarIcon } from "@phosphor-icons/react"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, - cn, - Input, - Kbd, - Popover, - PopoverContent, - PopoverTrigger, - Separator, - Skeleton, -} from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; -import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; -import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; -import { - type Channel, - useChannels, -} from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; -import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; -import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; -import { track } from "@posthog/ui/shell/analytics"; -import { useState } from "react"; - -// Clears the row's 8px `mx-2` inset plus an 8px gap, so the panel lands past -// the sidebar edge rather than over it. -const POPOVER_SIDE_OFFSET = 16; - -function SwitcherRow({ - channel, - active, - onPick, - hotkeySlot, -}: { - channel: Channel; - active: boolean; - onPick: () => void; - hotkeySlot?: number; -}) { - const isMe = channel.name === PERSONAL_CHANNEL_NAME; - const { isStarred, toggleStar } = useChannelStarToggle(channel); - - const trackedToggleStar = () => { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: isStarred ? "unstar" : "star", - surface: "sidebar", - channel_id: channel.id, - }); - toggleStar(); - }; - - const row = ( -
- - {isMe ? ( - // #me is permanently pinned, so its star is inert — but the slot - // still has to be filled to keep the rows aligned. - - - - ) : ( - - )} -
- ); - - if (isMe) return row; - return ( - - - - - - {isStarred ? "Unstar channel" : "Star channel"} - - - - ); -} - -// An overlay rather than a sibling: a sibling would shrink the trigger, which -// the popover anchors to (see POPOVER_SIDE_OFFSET). -function TriggerStar({ channel }: { channel: Channel }) { - const { isStarred, toggleStar } = useChannelStarToggle(channel); - return ( - - ); -} - -/** - * The channel header as a switcher: clicking the "# channel" title opens a - * searchable popover — "#me" first, then starred channels, then the rest. - */ -export function ChannelSwitcher({ channelId }: { channelId: string }) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [createOpen, setCreateOpen] = useState(false); - const { channels, isLoading: isLoadingChannels } = useChannels(); - const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); - const current = channels.find((c) => c.id === channelId); - // ChannelHotkeys owns the keys these slots describe; sharing the derivation - // keeps the advertised key and the key that fires in agreement. - const { slots, rest, slotFor } = useStarredChannelSlots(); - - const normalizedQuery = query.trim().toLowerCase(); - const matches = (c: Channel) => - !normalizedQuery || c.name.toLowerCase().includes(normalizedQuery); - const topFiltered = slots.filter(matches); - const restFiltered = rest.filter(matches); - - const pick = (channel: Channel) => { - setOpen(false); - setQuery(""); - setCurrentChannel(channel.id); - navigateToChannel(channel.id); - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "open_channel", - surface: "sidebar", - channel_id: channel.id, - }); - }; - - const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; - - return ( -
- - {/* Portaled because ResizableSidebar's transform makes the sidebar a - containing block, which would clip a fixed overlay; z-40 keeps it - under the popup's z-50. */} - - - - - - {channelGlyph(current?.name, { - size: 14, - className: "text-muted-foreground", - })} - - - {current ? ( - current.name - ) : isLoadingChannels ? ( - // A placeholder word here would read as a real channel named - // "channel"; a skeleton says "still loading" honestly. - - ) : ( - "Unavailable" - )} - - - - - } - /> - -
- setQuery(event.target.value)} - placeholder="Search channels…" - aria-label="Search channels" - className="h-7 text-[13px]" - /> -
-
- {topFiltered.map((channel) => ( - pick(channel)} - hotkeySlot={slotFor(channel)} - /> - ))} - {topFiltered.length > 0 && restFiltered.length > 0 && ( - - )} - {restFiltered.map((channel) => ( - pick(channel)} - /> - ))} - {topFiltered.length === 0 && restFiltered.length === 0 && ( -

- No channels match "{query.trim()}". -

- )} -
- - -
-
- {showStar && current && } - {/* Inert star for #me, so the trigger reads the same on every channel. */} - {current && !showStar && ( - - - - )} - -
- ); -} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx new file mode 100644 index 0000000000..575655d549 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -0,0 +1,117 @@ +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 mocks = vi.hoisted(() => ({ + channels: [] as { id: string; name: string; path: string }[], + starredPaths: [] as string[], + navigate: vi.fn(), +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: mocks.channels, isLoading: false }), + useChannelMutations: () => ({ createChannel: vi.fn(), isDeleting: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStars: () => ({ + starredRefToShortcutId: new Map(mocks.starredPaths.map((p) => [p, p])), + }), + useChannelStarToggle: () => ({ + isStarred: false, + toggleStar: vi.fn(), + removeStar: vi.fn(), + }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useDashboards", () => ({ + useCreateAndOpenDashboard: () => vi.fn(), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useUnreadChannels", () => ({ + useIsChannelUnread: () => () => false, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", async () => { + const actual = await vi.importActual< + typeof import("@posthog/ui/features/canvas/hooks/useTaskChannels") + >("@posthog/ui/features/canvas/hooks/useTaskChannels"); + return { ...actual, useTaskChannels: () => ({ channels: [] }) }; +}); +vi.mock("@posthog/ui/features/canvas/components/RenameChannelModal", () => ({ + RenameChannelModal: () => null, +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mocks.navigate, + useRouterState: () => "/website", +})); + +import { ChannelsList } from "./ChannelsList"; + +const ME = { id: "me-id", name: "me", path: "/me" }; +const ENG = { id: "eng-id", name: "engineering", path: "/engineering" }; +const DESIGN = { id: "design-id", name: "design", path: "/design" }; + +function renderList() { + return render( + + + , + ); +} + +describe("ChannelsList", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channels = [ME, ENG, DESIGN]; + mocks.starredPaths = []; + }); + + it("pins #me above the channels, with its ⌘1 shortcut", () => { + renderList(); + const me = screen.getByText("me"); + const eng = screen.getByText("engineering"); + expect( + me.compareDocumentPosition(eng) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + // ChannelHotkeys binds ⌘1-9 to the same slots; the list is where they're + // advertised now that the switcher popover is gone. + expect(me.parentElement?.textContent).toMatch(/me(⌘|Ctrl)/); + }); + + describe("search", () => { + // The list is the only way to switch channels now, so with a few dozen + // channels it has to be filterable rather than only scrollable. + it("narrows the list to matching channels", async () => { + const user = userEvent.setup(); + renderList(); + + await user.type(screen.getByLabelText("Search channels"), "eng"); + + expect(screen.getByText("engineering")).toBeTruthy(); + expect(screen.queryByText("design")).toBeNull(); + expect(screen.queryByText("me")).toBeNull(); + }); + + // Grouping is for browsing; once you've named what you want, "Starred" and + // "Channels" headings only stand between you and the one row that matches. + it("drops the group headings while filtering", async () => { + const user = userEvent.setup(); + mocks.starredPaths = [ENG.path]; + renderList(); + expect(screen.getByText("Starred")).toBeTruthy(); + + await user.type(screen.getByLabelText("Search channels"), "eng"); + + expect(screen.queryByText("Starred")).toBeNull(); + expect(screen.getByText("engineering")).toBeTruthy(); + }); + + it("says so when nothing matches", async () => { + const user = userEvent.setup(); + renderList(); + + await user.type(screen.getByLabelText("Search channels"), "zzz"); + + expect(screen.getByText(/No channels match/)).toBeTruthy(); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 67202fad8a..c180d53d00 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -35,6 +35,8 @@ import { DropdownMenuTrigger, Empty, EmptyHeader, + Input, + Kbd, MenuLabel, Tooltip, TooltipContent, @@ -56,16 +58,19 @@ import { useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { showChannelPane } from "@posthog/ui/features/canvas/stores/channelPaneStore"; import { resetCurrentChannel, useCurrentChannelStore, } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { OverflowTickerText, @@ -310,13 +315,17 @@ function ChannelMenu({ function ChannelSection({ channel, isUnread, + hotkeySlot, }: { channel: Channel; /** Bolds the name: activity here the viewer hasn't seen. */ isUnread?: boolean; + /** ⌘1-9 slot, shown as a hint while the row isn't hovered. */ + hotkeySlot?: number; }) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const base = `/website/${channel.id}`; // Highlight the row whenever any of the channel's routes is open. const isActive = pathname === base || pathname.startsWith(`${base}/`); @@ -357,6 +366,10 @@ function ChannelSection({ surface: "sidebar", channel_id: channel.id, }); + // Slide before navigating: the route effect would get there + // too, but not until the navigation resolves. + showChannelPane(); + setCurrentChannel(channel.id); void navigate({ to: "/website/$channelId", params: { channelId: channel.id }, @@ -392,6 +405,11 @@ function ChannelSection({ > {channel.name} + {hotkeySlot != null && ( + + {formatHotkey(`mod+${hotkeySlot}`)} + + )} } /> @@ -529,9 +547,10 @@ function ChannelSection({ // The feed and task ownership live on the per-user backend personal channel; // the "me" folder is the bridge that keeps the folder-keyed surfaces // (CONTEXT.md, artifacts) routable, created lazily on first open. -function PersonalChannelRow() { +function PersonalChannelRow({ hotkeySlot }: { hotkeySlot?: number }) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const { channels } = useChannels(); const { createChannel, isCreating } = useChannelMutations(); // Listing backend channels lazily provisions the personal channel server-side. @@ -565,6 +584,8 @@ function PersonalChannelRow() { const open = async () => { const channelId = await ensureFolderId(); if (!channelId) return; + showChannelPane(); + setCurrentChannel(channelId); void navigate({ to: "/website/$channelId", params: { channelId } }); }; @@ -622,6 +643,11 @@ function PersonalChannelRow() { > {PERSONAL_CHANNEL_NAME} + {hotkeySlot != null && ( + + {formatHotkey(`mod+${hotkeySlot}`)} + + )}
@@ -746,7 +772,7 @@ function ChannelGroup({ ); } -// The channel list — the Channels space sidebar body. The private "#me" +// The channel list — the list pane of the sidebar slider. The private "#me" // channel is pinned at the top; starred channels surface in their own section // so the ones you use most stay in reach; the rest sit under a "Channels" // label. Creating anything goes through the floating ChannelsFab, mounted by @@ -754,49 +780,106 @@ function ChannelGroup({ export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); + // ChannelHotkeys owns the keys these slots describe; sharing the derivation + // keeps the advertised key and the key that fires in agreement. + const { slotFor } = useStarredChannelSlots(); const isUnread = useIsChannelUnread(); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLowerCase(); + const matches = (name: string) => + !normalizedQuery || name.toLowerCase().includes(normalizedQuery); + // The "me" folder renders as the pinned personal row, not a shared channel. + const me = allChannels.find((c) => c.name === PERSONAL_CHANNEL_NAME); const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); const others = channels.filter((c) => !starredRefToShortcutId.has(c.path)); + // Searching collapses the sections into one flat list: the group labels only + // stand between you and the row you already named, and an empty "Starred" + // heading reads as a result that isn't there. + const searchResults = channels.filter((c) => matches(c.name)); + const meMatches = matches(PERSONAL_CHANNEL_NAME); + const noMatches = + normalizedQuery !== "" && !meMatches && !searchResults.length; + return ( // One shared provider groups every row tooltip so that once one shows, // moving to the next row reveals its tooltip instantly (no re-delay). - {/* Bottom padding clears the floating create button (ChannelsFab), so the - last channel stays reachable at full scroll. */} - - + + + setQuery(event.target.value)} + placeholder="Search channels…" + aria-label="Search channels" + className="h-7 text-[13px]" + /> + + {/* Bottom padding clears the floating create button (ChannelsFab), so + the last channel stays reachable at full scroll. */} + + {normalizedQuery ? ( + <> + {meMatches && } + {searchResults.map((channel) => ( + + ))} + {noMatches && ( + + + No channels match “{query.trim()}”. + + + )} + + ) : ( + <> + - {starred.length > 0 && ( - - {starred.map((channel) => ( - - ))} - - )} + {starred.length > 0 && ( + + {starred.map((channel) => ( + + ))} + + )} - - {!isLoading && channels.length === 0 && ( - - No channels yet. - + + {!isLoading && channels.length === 0 && ( + + + No channels yet. + + + )} + {others.map((channel) => ( + + ))} + + )} - {others.map((channel) => ( - - ))} - + ); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx index f4f9c10634..e568e19179 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -1,5 +1,5 @@ import { Theme } from "@radix-ui/themes"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -80,6 +80,10 @@ vi.mock("@tanstack/react-router", () => ({ import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + showChannelList, + useChannelPaneStore, +} from "@posthog/ui/features/canvas/stores/channelPaneStore"; import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { ChannelsSidebar } from "./ChannelsSidebar"; @@ -105,9 +109,68 @@ describe("ChannelsSidebar", () => { mocks.track.mockClear(); mocks.routeChannelId = undefined; useCurrentChannelStore.setState({ currentChannelId: null }); + useChannelPaneStore.setState({ pane: "channel" }); useSidebarStore.setState({ channelsEnabled: false, open: true }); }); + // The sidebar is a two-pane slider: the channel list, and the channel you're + // in. Both stay mounted, so "which one is showing" is the offscreen pane + // being inert rather than unmounted. + describe("the channel-list slider", () => { + const ENG = { id: "eng-id", name: "eng", path: "/eng" }; + const listIsInteractive = () => + !screen.getByTestId("channels-list").parentElement?.hasAttribute("inert"); + + beforeEach(() => { + mocks.channelsLayout = true; + mocks.channels = [ME, ENG]; + }); + + it("rests on the channel you're in", () => { + mocks.routeChannelId = ENG.id; + renderSidebar(); + expect(screen.getByTestId("channel-sidebar").textContent).toBe(ENG.id); + expect(listIsInteractive()).toBe(false); + }); + + // Browsing the list is a sidebar move, not a navigation: the channel stays + // scoped, so the main pane keeps showing what it was showing. + it("shows the list on the way back, without leaving the channel", () => { + mocks.routeChannelId = ENG.id; + renderSidebar(); + + act(() => showChannelList()); + + expect(listIsInteractive()).toBe(true); + expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id); + }); + + // Opening a channel from anywhere — a deep link, a mention, ⌘1-9 — has to + // land on the channel even if the list was left open. + it("follows the route back into a channel", () => { + mocks.routeChannelId = ENG.id; + const { rerender } = renderSidebar(); + act(() => showChannelList()); + + mocks.routeChannelId = ME.id; + rerender( + + + , + ); + + expect(listIsInteractive()).toBe(false); + expect(screen.getByTestId("channel-sidebar").textContent).toBe(ME.id); + }); + + it("stays on the list while no channel resolves", () => { + mocks.channels = [ENG]; + renderSidebar(); + expect(listIsInteractive()).toBe(true); + expect(screen.queryByTestId("channel-sidebar")).toBeNull(); + }); + }); + describe("the Archived row", () => { beforeEach(() => { mocks.archivedTaskIds = new Set(["archived-1"]); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 48031692e6..75735fc5cd 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,5 +1,5 @@ import { ArchiveIcon } from "@phosphor-icons/react"; -import { Separator } from "@posthog/quill"; +import { cn, Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; @@ -11,6 +11,10 @@ import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannels import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed"; +import { + showChannelPane, + useChannelPaneStore, +} from "@posthog/ui/features/canvas/stores/channelPaneStore"; import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard"; @@ -37,9 +41,48 @@ import { Box, Flex } from "@radix-ui/themes"; import { useParams } from "@tanstack/react-router"; import { useDeferredValue, useEffect, useRef } from "react"; -// The unified app sidebar (Code merged into the Bluebird chrome). Top to -// bottom: the merged global nav, the Tasks/Channels body header, then the -// selected list and the workspace switcher pinned to the bottom. +/** + * The sidebar slider: the channel list and the channel you're in, laid out side + * by side in a track that translates between them. + * + * Both panes stay mounted so the slide has something to slide, and so coming + * back to the list doesn't rebuild every row's menus and dialogs. The offscreen + * one is `inert`, keeping it out of the tab order and off screen readers. + */ +function ChannelPanes({ + channelId, + showList, +}: { + channelId: string | null; + showList: boolean; +}) { + return ( + +
+
+ + +
+
+ {channelId && ( + } + resetKey={channelId} + > + + + )} +
+
+
+ ); +} export function ChannelsSidebar() { const width = useChannelsSidebarStore((state) => state.width); const setWidth = useChannelsSidebarStore((state) => state.setWidth); @@ -115,8 +158,16 @@ export function ChannelsSidebar() { useEffect(() => { if (!channelsLayout || !routeChannelId) return; setCurrentChannel(routeChannelId); + // Landing on a channel — a deep link, a mention, ⌘1-9 — is a request to see + // it, so the slider follows the route even if the list was being browsed. + showChannelPane(); }, [channelsLayout, routeChannelId, setCurrentChannel]); - const inChannel = channelsLayout && currentChannelId != null; + + // Browsing the list is view state, not navigation: you stay in the channel + // (route and main pane unchanged) while you look around. With no channel to + // slide to there's only the list. + const pane = useChannelPaneStore((s) => s.pane); + const showList = pane === "list" || currentChannelId == null; const autoScopedRef = useRef(false); useEffect(() => { @@ -157,33 +208,23 @@ export function ChannelsSidebar() { onPeekDismiss={cancelSidebarPeek} > - {!inChannel && ( + {!channelsLayout && ( <> - {!channelsLayout && } + )} - {inChannel && currentChannelId ? ( + {channelsLayout ? ( <> - - } - resetKey={currentChannelId} - > - - - + ) : bodyChannelsWorld ? ( <> - - - + diff --git a/packages/ui/src/features/canvas/stores/channelPaneStore.ts b/packages/ui/src/features/canvas/stores/channelPaneStore.ts new file mode 100644 index 0000000000..10257adf58 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/channelPaneStore.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; + +/** + * Which of the two sidebar panes the Channels layout is showing. + * + * The sidebar is a master/detail slider: the channel list, and the channel you + * are in. Which one shows is view state, not a route — going "back to channels" + * browses the list while you stay in the channel, so the scoped channel + * (`currentChannelStore`) and the visible pane have to be separate facts. + */ +type ChannelPane = "list" | "channel"; + +interface ChannelPaneState { + pane: ChannelPane; + setPane: (pane: ChannelPane) => void; +} + +export const useChannelPaneStore = create()((set) => ({ + // A scoped channel is the resting state, so a cold start lands on the channel + // rather than sliding away from it. + pane: "channel", + setPane: (pane) => set({ pane }), +})); + +/** Slide back to the channel list, keeping the scoped channel as it is. */ +export function showChannelList(): void { + useChannelPaneStore.getState().setPane("list"); +} + +/** Slide to the channel pane — every channel entry point goes through here. */ +export function showChannelPane(): void { + useChannelPaneStore.getState().setPane("channel"); +} From 9141dde0b242a8a5dcd79e51da7cd219e5a7c7de Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:00:56 +0200 Subject: [PATCH 3/8] fix(channels): settle the slider's chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the back row's border; it's a sidebar row, not a control on top of one. - One FAB on both panes: with a channel it creates a task or canvas inside it, and either way it can create a channel. - Tighten the gap between the global nav and the pane below it. - Move Archived out of the sidebar and into the account menu, beside Settings. - Gate the list's search and ⌘1-9 hints on the layout flag, so the alpha's channel tree is untouched. Generated-By: PostHog Code Task-Id: 1932f1b0-a072-4de2-a21d-6455e6c445ee --- packages/ui/src/features/canvas/AGENTS.md | 4 + .../canvas/components/ChannelBackRow.tsx | 7 +- .../features/canvas/components/ChannelNav.tsx | 2 +- .../canvas/components/ChannelSidebar.tsx | 4 +- .../canvas/components/ChannelsFab.tsx | 156 ++++++++++++++---- .../canvas/components/ChannelsList.test.tsx | 14 ++ .../canvas/components/ChannelsList.tsx | 36 ++-- .../components/ChannelsSidebar.test.tsx | 10 +- .../canvas/components/ChannelsSidebar.tsx | 4 +- .../features/canvas/components/NewTaskFab.tsx | 83 ---------- .../sidebar/components/ProjectSwitcher.tsx | 22 +++ 11 files changed, 199 insertions(+), 143 deletions(-) delete mode 100644 packages/ui/src/features/canvas/components/NewTaskFab.tsx diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index e90757ed5f..0b3455fb8a 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -45,6 +45,10 @@ The root `AGENTS.md` architecture rules still apply. channel you're in (`ChannelSidebar`, headed by `ChannelBackRow`). Both panes stay mounted — the offscreen one is `inert` — so the slide has something to slide and returning to the list doesn't rebuild every row. +- One `ChannelsFab` serves both panes: given a `channelId` it creates inside + that channel (task, canvas), and either way it can create a channel. Off the + layout it keeps its original two-item menu. Archived moves out of the sidebar + and into the account menu (`ProjectSwitcher`), beside Settings. - **Which pane shows is view state, not a route.** `channelPaneStore` holds it, separately from the scoped channel (`currentChannelStore`): "back to channels" browses the list while the route, the main pane and the scoped channel stay diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx index 7c01b8d780..da52b2e098 100644 --- a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx @@ -50,7 +50,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) { const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; return ( -
+
); diff --git a/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/packages/ui/src/features/canvas/components/ChannelsFab.tsx index 1afff9639e..14d0835b42 100644 --- a/packages/ui/src/features/canvas/components/ChannelsFab.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -1,67 +1,153 @@ -import { FileTextIcon, HashIcon, PlusIcon } from "@phosphor-icons/react"; +import { + ChartBarIcon, + FileTextIcon, + HashIcon, + PlusIcon, +} from "@phosphor-icons/react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, Tooltip, TooltipContent, TooltipTrigger, } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { isContentEmpty } from "@posthog/ui/features/message-editor/content"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { Tooltip as ShortcutTooltip } from "@posthog/ui/primitives/Tooltip"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; import { useRouterState } from "@tanstack/react-router"; import { useState } from "react"; -// The create affordance for the Channels space, floated over the bottom-right -// of the channel list. It owns the create-channel modal (the list itself has no -// other entry point) and opens its menu upward, since it sits at the bottom. -export function ChannelsFab() { +/** + * The create affordance for the Channels space, floated over the bottom-right + * of whichever sidebar pane is showing. + * + * The same button on both panes, so "create" is always the same corner: given a + * channel it creates inside it (task, canvas), and either way it can create a + * channel — the list has no other entry point for that. + */ +export function ChannelsFab({ channelId }: { channelId?: string }) { + const channelsLayout = useChannelsLayout(); const [modalOpen, setModalOpen] = useState(false); + const hasDraft = useDraftStore( + (s) => !isContentEmpty(s.drafts["task-input"]), + ); + const createAndOpenCanvas = useCreateAndOpenDashboard(channelId); // New task has no /website mirror yet, so it jumps back to Code unless we're // already in the Channels space — same rule as the nav's New task row. const inChannels = useRouterState({ select: (s) => s.location.pathname.startsWith("/website"), }); + const newTask = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "sidebar", + channel_id: channelId, + }); + // In a channel the task is filed there; from the list it's whatever the + // space defaults to. + if (channelId) { + openTaskInput({ channelId }); + return; + } + openTaskInput(inChannels ? { space: "website" } : undefined); + }; + + const newChannelItem = ( + setModalOpen(true)}> + + New channel + + ); + + const trigger = ( + + ); + return ( <> - - - - - } - /> - } - /> - - Create something new - - - - setModalOpen(true)}> - - New channel - - - openTaskInput(inChannels ? { space: "website" } : undefined) - } + {channelsLayout ? ( + // The draft dot needs saying out loud, and the button is where the + // create shortcut is worth advertising. + + + + ) : ( + + } /> + + Create something new + + + )} + + {/* Off the layout this is the list's only menu, and "New channel" + has led it since it shipped — leave that alone. */} + {!channelsLayout && newChannelItem} + New task + {channelId && ( + { + // Create + open a canvas with the default template directly; + // the canvas's own composer drives what gets built. + trackAndCreateCanvas( + channelId, + undefined, + "sidebar", + () => void createAndOpenCanvas(), + ); + }} + > + + New canvas + + )} + {channelsLayout && ( + <> + + {newChannelItem} + + )} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx index 575655d549..ca2940fc22 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -6,10 +6,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ channels: [] as { id: string; name: string; path: string }[], starredPaths: [] as string[], + channelsLayout: true, navigate: vi.fn(), })); vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ useChannels: () => ({ channels: mocks.channels, isLoading: false }), useChannelMutations: () => ({ createChannel: vi.fn(), isDeleting: false }), @@ -63,6 +67,7 @@ describe("ChannelsList", () => { vi.clearAllMocks(); mocks.channels = [ME, ENG, DESIGN]; mocks.starredPaths = []; + mocks.channelsLayout = true; }); it("pins #me above the channels, with its ⌘1 shortcut", () => { @@ -105,6 +110,15 @@ describe("ChannelsList", () => { expect(screen.getByText("engineering")).toBeTruthy(); }); + // The alpha renders this list as a plain tree with no slider around it, and + // ChannelHotkeys doesn't bind ⌘1-9 there either — so neither shows. + it("is absent off the channels layout, along with the shortcut hints", () => { + mocks.channelsLayout = false; + renderList(); + expect(screen.queryByLabelText("Search channels")).toBeNull(); + expect(screen.getByText("me").parentElement?.textContent).toBe("me"); + }); + it("says so when nothing matches", async () => { const user = userEvent.setup(); renderList(); diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index c180d53d00..a2b223aa10 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -57,6 +57,7 @@ import { useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { @@ -781,13 +782,18 @@ export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); // ChannelHotkeys owns the keys these slots describe; sharing the derivation - // keeps the advertised key and the key that fires in agreement. + // keeps the advertised key and the key that fires in agreement — including + // the fact that it only binds them under the layout, so off it the list + // advertises nothing. const { slotFor } = useStarredChannelSlots(); + // Search and the shortcut hints belong to the slider, where this list is a + // pane you switch channels from. The alpha still renders it as a plain tree. + const channelsLayout = useChannelsLayout(); const isUnread = useIsChannelUnread(); const [query, setQuery] = useState(""); - const normalizedQuery = query.trim().toLowerCase(); + const normalizedQuery = channelsLayout ? query.trim().toLowerCase() : ""; const matches = (name: string) => !normalizedQuery || name.toLowerCase().includes(normalizedQuery); @@ -810,15 +816,17 @@ export function ChannelsList() { // moving to the next row reveals its tooltip instantly (no re-delay). - - setQuery(event.target.value)} - placeholder="Search channels…" - aria-label="Search channels" - className="h-7 text-[13px]" - /> - + {channelsLayout && ( + + setQuery(event.target.value)} + placeholder="Search channels…" + aria-label="Search channels" + className="h-7 text-[13px]" + /> + + )} {/* Bottom padding clears the floating create button (ChannelsFab), so the last channel stays reachable at full scroll. */} ) : ( <> - + {starred.length > 0 && ( @@ -855,7 +865,7 @@ export function ChannelsList() { key={channel.id} channel={channel} isUnread={isUnread(channel.name)} - hotkeySlot={slotFor(channel)} + hotkeySlot={channelsLayout ? slotFor(channel) : undefined} /> ))} diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx index e568e19179..cf1aa4e30d 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -176,14 +176,14 @@ describe("ChannelsSidebar", () => { mocks.archivedTaskIds = new Set(["archived-1"]); }); - // The layout puts an Archive action on every item row, so hiding the - // destination leaves archived tasks with nowhere to be seen — - // navigateToArchived has no other caller in the app. - it("is present under the channels layout", () => { + // The layout puts an Archive action on every item row, so the destination + // still has to exist — it moved into the account menu (ProjectSwitcher), + // which is where navigateToArchived is called from now. + it("leaves the sidebar body under the channels layout", () => { mocks.channelsLayout = true; mocks.channels = [ME]; renderSidebar(); - expect(screen.getByText("Archived")).toBeTruthy(); + expect(screen.queryByText("Archived")).toBeNull(); }); it("is present with neither channels world on", () => { diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 75735fc5cd..f04af4146b 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -134,7 +134,9 @@ export function ChannelsSidebar() { const channelsLayout = useChannelsLayout(); const channelsWorld = channelsLayout || channelsEnabled; const bodyChannelsWorld = useDeferredValue(channelsWorld); - const showArchivedRow = channelsLayout || !bodyChannelsWorld; + // Under the layout the row moves into the account menu (ProjectSwitcher), + // beside Settings — the bottom of the sidebar belongs to the channel list. + const showArchivedRow = !channelsLayout && !bodyChannelsWorld; useTrackChannelsSpaceViewed({ enabled: channelsWorld, layout: channelsLayout ? "channels" : "code", diff --git a/packages/ui/src/features/canvas/components/NewTaskFab.tsx b/packages/ui/src/features/canvas/components/NewTaskFab.tsx deleted file mode 100644 index 36c74195f8..0000000000 --- a/packages/ui/src/features/canvas/components/NewTaskFab.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { ChartBarIcon, FileTextIcon, PlusIcon } from "@phosphor-icons/react"; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; -import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { - formatHotkey, - SHORTCUTS, -} from "@posthog/ui/features/command/keyboard-shortcuts"; -import { isContentEmpty } from "@posthog/ui/features/message-editor/content"; -import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; -import { Tooltip } from "@posthog/ui/primitives/Tooltip"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { track } from "@posthog/ui/shell/analytics"; - -export function NewTaskFab({ channelId }: { channelId: string }) { - const hasDraft = useDraftStore( - (s) => !isContentEmpty(s.drafts["task-input"]), - ); - const createAndOpenCanvas = useCreateAndOpenDashboard(channelId); - - return ( - - - - - {hasDraft && ( - - )} - - } - /> - - - { - track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { - item: "new_task", - in_more: false, - }); - openTaskInput({ channelId }); - }} - > - - New task - - { - trackAndCreateCanvas( - channelId, - undefined, - "sidebar", - () => void createAndOpenCanvas(), - ); - }} - > - - New canvas - - - - ); -} diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 45f3880177..59290aab08 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -1,5 +1,6 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"; import { + Archive, ArrowSquareOut, Buildings, Check, @@ -39,6 +40,7 @@ import { ItemTitle, } from "@posthog/quill"; import { EXTERNAL_LINKS } from "@posthog/shared"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { @@ -47,10 +49,12 @@ import { useSwitchOrgMutation, } from "@posthog/ui/features/auth/useAuthMutations"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek"; import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; +import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { isMac } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -77,6 +81,12 @@ export function ProjectSwitcher() { const switchOrgMutation = useSwitchOrgMutation(); const logoutMutation = useLogoutMutation(); const { groupedProjects, currentProject, currentProjectId } = useProjects(); + // The channels layout has no room for a standing Archived row, so archived + // tasks live here — a peer of Settings, not buried inside it. Still hidden + // when there is nothing archived, exactly as the row was. + const channelsLayout = useChannelsLayout(); + const archivedTaskIds = useArchivedTaskIds(); + const showArchived = channelsLayout && archivedTaskIds.size > 0; const currentOrgGroup = groupedProjects.find((group) => group.orgId === currentOrgId) ?? null; @@ -153,6 +163,11 @@ export function ProjectSwitcher() { setPopoverOpen(false); }; + const handleArchived = () => { + setPopoverOpen(false); + navigateToArchived(); + }; + const handleSettings = () => { setPopoverOpen(false); openSettings(); @@ -339,6 +354,13 @@ export function ProjectSwitcher() { + {showArchived && ( + + + Archived + + )} + Settings From b8e1d0e0ae136f916025619676a196270aad459f Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:00:58 +0200 Subject: [PATCH 4/8] fix(channels): flatten the list and make the panes swipeable (#3832) --- packages/ui/src/features/canvas/AGENTS.md | 7 +- .../canvas/components/ChannelsList.test.tsx | 20 +++++ .../canvas/components/ChannelsList.tsx | 39 +++++++--- .../components/ChannelsSidebar.test.tsx | 75 ++++++++++++++++++- .../canvas/components/ChannelsSidebar.tsx | 17 ++++- .../canvas/hooks/useChannelPaneSwipe.ts | 65 ++++++++++++++++ 6 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useChannelPaneSwipe.ts diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index 0b3455fb8a..c98cf05a50 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -44,7 +44,12 @@ The root `AGENTS.md` architecture rules still apply. (`ChannelPanes` in `ChannelsSidebar.tsx`): the searchable channel list, and the channel you're in (`ChannelSidebar`, headed by `ChannelBackRow`). Both panes stay mounted — the offscreen one is `inert` — so the slide has something to - slide and returning to the list doesn't rebuild every row. + slide and returning to the list doesn't rebuild every row. A two-finger + horizontal swipe moves between them (`useChannelPaneSwipe`, wheel `deltaX` + accumulated per gesture and locked until the wheel goes quiet). +- In the list, "Starred"/"Channels" are headings, not parents: under the layout + the rows sit at the heading's level (no indent) and the "#"/lock glyph belongs + to the rows. The alpha's indented tree is unchanged. - One `ChannelsFab` serves both panes: given a `channelId` it creates inside that channel (task, canvas), and either way it can create a channel. Off the layout it keeps its original two-item menu. Archived moves out of the sidebar diff --git a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx index ca2940fc22..cfc24bad33 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -82,6 +82,26 @@ describe("ChannelsList", () => { expect(me.parentElement?.textContent).toMatch(/me(⌘|Ctrl)/); }); + // "Starred" and "Channels" are headings over the rows, not parents of them — + // under the layout the rows sit at the heading's level and keep the "#" for + // themselves. The alpha's tree is unchanged. + describe("group headings", () => { + beforeEach(() => { + mocks.starredPaths = [ENG.path]; + }); + + it("does not indent rows under the layout", () => { + renderList(); + expect(screen.getByText("engineering").closest(".pl-5")).toBeNull(); + }); + + it("keeps the indented tree off the layout", () => { + mocks.channelsLayout = false; + renderList(); + expect(screen.getByText("engineering").closest(".pl-5")).toBeTruthy(); + }); + }); + describe("search", () => { // The list is the only way to switch channels now, so with a few dozen // channels it has to be filterable rather than only scrollable. diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index a2b223aa10..e11770d38b 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -407,7 +407,7 @@ function ChannelSection({ {channel.name} {hotkeySlot != null && ( - + {formatHotkey(`mod+${hotkeySlot}`)} )} @@ -645,7 +645,7 @@ function PersonalChannelRow({ hotkeySlot }: { hotkeySlot?: number }) { {PERSONAL_CHANNEL_NAME} {hotkeySlot != null && ( - + {formatHotkey(`mod+${hotkeySlot}`)} )} @@ -708,18 +708,23 @@ const CHANNELS_SECTION_ID = "channels:all"; // the label styling) and animates the panel height (which janked on a list this // long). Unstyled parts give a plain label row that snaps. // -// The whole header row is the trigger. It rests as a "#" and swaps to a chevron -// on hover or keyboard focus, so the row only advertises the disclosure when -// you're actually reaching for it. +// The whole header row is the trigger. Under the layout the icon well rests +// empty and fills with a chevron on hover or keyboard focus, so the row only +// advertises the disclosure when you're reaching for it — a "#" there read as a +// channel named "Starred", and the glyph belongs to the rows, not the label +// above them. function ChannelGroup({ sectionId, label, className, + flat, children, }: { sectionId: string; label: string; className?: string; + /** Layout-only: rows sit at the label's level instead of indented under it. */ + flat?: boolean; children: ReactNode; }) { const collapsedSections = useSidebarStore((s) => s.collapsedSections); @@ -744,10 +749,12 @@ function ChannelGroup({ render={} />} > - + {!flat && ( + + )} {isOpen ? ( -
{children}
+
{children}
); @@ -859,7 +866,11 @@ export function ChannelsList() { /> {starred.length > 0 && ( - + {starred.map((channel) => ( )} - + {!isLoading && channels.length === 0 && ( diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx index cf1aa4e30d..c0f4daa9d8 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -1,6 +1,6 @@ import { Theme } from "@radix-ui/themes"; import { act, render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ featureFlags: new Map(), @@ -169,6 +169,79 @@ describe("ChannelsSidebar", () => { expect(listIsInteractive()).toBe(true); expect(screen.queryByTestId("channel-sidebar")).toBeNull(); }); + + // A trackpad swipe reaches the panes as a horizontal wheel. Right (negative + // deltaX, the platform "back" direction) leaves the channel; left returns to + // the one still scoped. + describe("swiping", () => { + // Wheel deltas within one gesture arrive back to back; a pause between + // them is what ends it. Fake timers let a test say which it's sending. + const wheel = (deltaX: number, deltaY = 0) => + act(() => { + screen.getByTestId("channels-list").dispatchEvent( + new WheelEvent("wheel", { + deltaX, + deltaY, + bubbles: true, + cancelable: true, + }), + ); + }); + const pause = () => act(() => void vi.advanceTimersByTime(500)); + + beforeEach(() => { + vi.useFakeTimers(); + mocks.routeChannelId = ENG.id; + }); + afterEach(() => vi.useRealTimers()); + + it("goes back to the list and forward into the channel", () => { + renderSidebar(); + + wheel(-80); + expect(listIsInteractive()).toBe(true); + // The channel is browsed away from, not left. + expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id); + + pause(); + wheel(80); + expect(listIsInteractive()).toBe(false); + }); + + // One flick is dozens of small deltas, so the distance has to add up + // across them rather than be read off any one event. + it("adds a gesture's deltas up", () => { + renderSidebar(); + wheel(-20); + expect(listIsInteractive()).toBe(false); + wheel(-20); + wheel(-20); + expect(listIsInteractive()).toBe(true); + }); + + it("ignores a mostly-vertical wheel", () => { + renderSidebar(); + wheel(-80, -200); + expect(listIsInteractive()).toBe(false); + }); + + it("forgets a nudge once the gesture ends", () => { + renderSidebar(); + wheel(-30); + pause(); + wheel(-30); + expect(listIsInteractive()).toBe(false); + }); + + // The momentum tail of one flick keeps delivering deltas; read as fresh + // travel they'd swipe straight back to where the flick started. + it("does not let one flick's momentum swipe twice", () => { + renderSidebar(); + wheel(-80); + wheel(200); + expect(listIsInteractive()).toBe(true); + }); + }); }); describe("the Archived row", () => { diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index f04af4146b..d9f75f0947 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -7,11 +7,13 @@ import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSi import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { useChannelPaneSwipe } from "@posthog/ui/features/canvas/hooks/useChannelPaneSwipe"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed"; import { + showChannelList, showChannelPane, useChannelPaneStore, } from "@posthog/ui/features/canvas/stores/channelPaneStore"; @@ -48,6 +50,10 @@ import { useDeferredValue, useEffect, useRef } from "react"; * Both panes stay mounted so the slide has something to slide, and so coming * back to the list doesn't rebuild every row's menus and dialogs. The offscreen * one is `inert`, keeping it out of the tab order and off screen readers. + * + * A two-finger horizontal swipe moves between them, so the back row isn't the + * only way out of a channel — and swiping the other way returns to the channel + * that stayed scoped the whole time. */ function ChannelPanes({ channelId, @@ -56,8 +62,17 @@ function ChannelPanes({ channelId: string | null; showList: boolean; }) { + const panesRef = useRef(null); + useChannelPaneSwipe(panesRef, { + // With no channel to slide to, the list is all there is — leave the gesture + // to the platform rather than eat it for a slide that can't happen. + enabled: channelId != null, + onBack: showChannelList, + onForward: showChannelPane, + }); + return ( - +
, + { + enabled, + onBack, + onForward, + }: { enabled: boolean; onBack: () => void; onForward: () => void }, +): void { + useEffect(() => { + const element = ref.current; + if (!element || !enabled) return; + + let travelled = 0; + let lastEventAt = Number.NEGATIVE_INFINITY; + let locked = false; + + const onWheel = (event: WheelEvent) => { + // A mostly-vertical wheel is someone scrolling the list, not swiping. + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + // Claim it before anything upstream reads it as history navigation. + event.preventDefault(); + + if (event.timeStamp - lastEventAt > GESTURE_GAP_MS) { + travelled = 0; + locked = false; + } + lastEventAt = event.timeStamp; + if (locked) return; + + travelled += event.deltaX; + if (travelled <= -SWIPE_THRESHOLD_PX) { + locked = true; + onBack(); + } else if (travelled >= SWIPE_THRESHOLD_PX) { + locked = true; + onForward(); + } + }; + + element.addEventListener("wheel", onWheel, { passive: false }); + return () => element.removeEventListener("wheel", onWheel); + }, [ref, enabled, onBack, onForward]); +} From 208cc215c34d3e102673f1179a4224f5a090b89d Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:01:00 +0200 Subject: [PATCH 5/8] feat(channels): shimmer a sidebar item's icon while its run is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel sidebar's recent/pinned rows showed no sign that a task was still working — run status only appeared as a badge in the hover preview card, so a running task looked identical to a finished one while scanning the list. Sweep a highlight band across the row's icon instead of swapping in a spinner. Keeping the glyph means a running task still reads as a task and a running canvas as a canvas, so the list stays scannable by kind while it moves. The animation masks rather than recolours, so unlike the neighbouring ph-pulse it repaints nothing per frame. Reduced motion holds a dimmed glyph rather than dropping the signal, and the existing ph-window-blurred rule parks it when the window loses focus. Generated-By: PostHog Code Task-Id: 1ee0ef0c-17af-4e55-b0ae-b036cd38eafc --- packages/core/src/canvas/runStatus.ts | 11 +++ .../canvas/components/ChannelItemRow.test.tsx | 76 +++++++++++++++++++ .../canvas/components/ChannelItemRow.tsx | 23 +++++- packages/ui/src/styles/globals.css | 40 ++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx diff --git a/packages/core/src/canvas/runStatus.ts b/packages/core/src/canvas/runStatus.ts index f96bb33f98..90917af05d 100644 --- a/packages/core/src/canvas/runStatus.ts +++ b/packages/core/src/canvas/runStatus.ts @@ -37,6 +37,17 @@ export function runStatusVariant( return status ? RUN_STATUS_VARIANTS[status] : "default"; } +/** + * Whether the run is still in flight, and so worth animating in a list. Canvases + * and tasks that never started carry no run to wait on, and `not_started` is a + * queued-but-unclaimed task rather than one doing work. + */ +export function isRunStatusActive( + status: TaskRunStatus | null | undefined, +): boolean { + return status === "queued" || status === "in_progress"; +} + export const RUN_STATUS_FILTER_OPTIONS: readonly { value: TaskRunStatus | null; label: string; diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx new file mode 100644 index 0000000000..0f87c264d1 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -0,0 +1,76 @@ +import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ChannelItemRow } from "./ChannelItemRow"; + +const actions = { + open: () => {}, + togglePin: () => {}, + archive: () => {}, +}; + +function item(overrides: Partial = {}): ChannelItemModel { + return { + key: "task:task-1", + kind: "task", + id: "task-1", + title: "Investigate signup drop-off", + ts: Date.parse("2026-07-17T12:00:00.000Z"), + pinned: false, + rawStatus: null, + authorUser: null, + authorName: null, + templateId: null, + ...overrides, + }; +} + +function renderRow(model: ChannelItemModel) { + return render( + + + , + ); +} + +describe("ChannelItemRow", () => { + it.each([ + ["queued" as const, true], + ["in_progress" as const, true], + ["not_started" as const, false], + ["completed" as const, false], + ["failed" as const, false], + ["cancelled" as const, false], + ])("marks %s as running: %s", (rawStatus: TaskRunStatus, running) => { + renderRow(item({ rawStatus })); + + expect(!!screen.queryByRole("img", { name: "Running" })).toBe(running); + }); + + it("leaves a canvas, which has no run to wait on, static", () => { + renderRow( + item({ + key: "canvas:canvas-1", + kind: "canvas", + id: "canvas-1", + title: "Web analytics overview", + templateId: "web-analytics", + }), + ); + + expect(screen.queryByRole("img", { name: "Running" })).toBeNull(); + }); + + // The point of the shimmer over a spinner: a running task still looks like a + // task, so the list stays scannable by kind while work is in flight. + it("keeps the item's own glyph while running", () => { + renderRow(item({ rawStatus: "in_progress" })); + + const running = screen.getByRole("img", { name: "Running" }); + expect(running).toHaveClass("ph-shimmer"); + // The glyph is wrapped, not replaced — no spinner swapped in its place. + expect(running.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index b19beca7da..d9ba598093 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -2,6 +2,7 @@ import { PreviewCard } from "@base-ui/react/preview-card"; import { Archive, FileTextIcon, PushPin } from "@phosphor-icons/react"; import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { + isRunStatusActive, runStatusLabel, runStatusVariant, } from "@posthog/core/canvas/runStatus"; @@ -47,6 +48,19 @@ function itemIcon(item: ChannelItemModel): ReactNode { ); } +/** + * Marks a row whose run is still going. The glyph is kept and shimmered rather + * than swapped for a spinner, so the list stays scannable by kind while it + * moves — you can still tell a running task from a running canvas. + */ +function RunningIcon({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + function authorLabel(item: ChannelItemModel): string | null { if (item.authorUser) return userDisplayName(item.authorUser); return item.authorName; @@ -64,6 +78,13 @@ export function ChannelItemRow({ const icon = itemIcon(item); const statusLabel = runStatusLabel(item.rawStatus); const author = authorLabel(item); + // Only the row shimmers. The preview card spells the status out in a badge, + // so animating its copy of the icon would say the same thing twice. + const rowIcon = isRunStatusActive(item.rawStatus) ? ( + {icon} + ) : ( + icon + ); return ( @@ -74,7 +95,7 @@ export function ChannelItemRow({
{item.title}} isActive={isActive} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index b9e19787dc..316dea96b1 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -435,6 +435,46 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { animation: ph-pulse 1s ease-in-out infinite; } +/* Shimmer for an item whose run is still in flight. A highlight band sweeps + across the element via an animated mask, so the icon keeps its own glyph and + colour instead of being swapped for a spinner — a running task still reads as + a task. Masking works on inline SVG, so this needs no per-icon plumbing. + Unlike ph-pulse above it never touches `color`, so it repaints nothing. */ +/* The mask tiles, and one 100% of mask-position moves it by (size - 100%) of + the element — so at 300% the loop is only seamless if it travels a whole + tile, i.e. 300/(300 - 100) = 150%. Landing anywhere else snaps on repeat. */ +@keyframes ph-shimmer { + from { + mask-position: 0 0; + } + to { + mask-position: -150% 0; + } +} + +.ph-shimmer { + display: inline-flex; + mask-image: linear-gradient( + 75deg, + rgb(0 0 0 / 0.3) 35%, + #000 50%, + rgb(0 0 0 / 0.3) 65% + ); + mask-size: 300% 100%; + mask-repeat: repeat; + animation: ph-shimmer 2.4s linear infinite; +} + +/* Reduced motion still needs the state to be legible, so hold a dimmed glyph + rather than dropping the signal entirely. */ +@media (prefers-reduced-motion: reduce) { + .ph-shimmer { + mask-image: none; + opacity: 0.7; + animation: none; + } +} + /* Freeze CSS keyframe animations while the app window is unfocused or hidden. Perpetual indicators (spinners, pulses, floats) otherwise keep the compositor and GPU busy in the background for animations nobody is looking at — and some, From 3729ea9155bfd77452c13670a46b1621a7cb0747 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:01:02 +0200 Subject: [PATCH 6/8] feat(channels): rebrand the spaces layout Use Spaces terminology throughout the flag-gated layout while preserving the legacy Channels copy and all internal channel contracts. Generated-By: PostHog Code Task-Id: 346a07ca-892e-4431-90aa-16be9aa4cbdc --- .../features/browser-tabs/BrowserTabStrip.tsx | 5 ++- packages/ui/src/features/canvas/AGENTS.md | 12 +++---- .../canvas/components/ActivityView.tsx | 7 ++-- .../canvas/components/ChannelBackRow.test.tsx | 10 +++--- .../canvas/components/ChannelBackRow.tsx | 6 ++-- .../canvas/components/ChannelContextPanel.tsx | 5 ++- .../components/ChannelFeedView.stories.tsx | 14 ++++---- .../canvas/components/ChannelHeader.tsx | 4 +-- .../canvas/components/ChannelIntro.tsx | 16 +++++---- .../canvas/components/ChannelSidebar.tsx | 4 +-- .../canvas/components/ChannelsFab.tsx | 2 +- .../canvas/components/ChannelsList.test.tsx | 19 ++++++++--- .../canvas/components/ChannelsList.tsx | 33 +++++++++++-------- .../canvas/components/CreateChannelModal.tsx | 14 +++++--- .../canvas/components/FeedbackModal.tsx | 10 +++++- .../canvas/components/RenameChannelModal.tsx | 8 +++-- .../components/WebsiteChannelArtifacts.tsx | 5 ++- .../components/WebsiteChannelHistory.tsx | 5 ++- .../canvas/components/WebsiteChannelHome.tsx | 12 +++++-- .../components/WebsiteChannelsIndex.tsx | 9 +++-- .../canvas/components/WebsiteContext.tsx | 18 +++++++--- .../canvas/components/WebsiteLayout.tsx | 9 +++-- .../canvas/components/WebsiteNewTask.test.tsx | 6 +++- .../canvas/components/WebsiteNewTask.tsx | 6 ++-- .../ui/src/features/command/CommandMenu.tsx | 8 +++-- .../features/command/keyboard-shortcuts.ts | 4 +-- .../website/$channelId/tasks/$taskId.tsx | 4 ++- 27 files changed, 169 insertions(+), 86 deletions(-) diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index b3f9ebeb96..56b532d81f 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -29,6 +29,7 @@ import { useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useDashboard, useDashboards, @@ -145,6 +146,7 @@ function isAppView(value: string): value is AppView { } export function BrowserTabStrip() { + const spacesLayout = useChannelsLayout(); const logger = useService(ROOT_LOGGER); const snapshot = useTabsSnapshot(); const navigate = useNavigate(); @@ -509,7 +511,8 @@ export function BrowserTabStrip() { const meta = channelSectionFor(section); return { id: t.id, - label: meta?.label ?? channel ?? "Channel", + label: + meta?.label ?? channel ?? (spacesLayout ? "Space" : "Channel"), icon: , channelName: channel, // No section meta → the channel's index page. diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index c98cf05a50..5c576960b7 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -25,18 +25,18 @@ The root `AGENTS.md` architecture rules still apply. - **Suffix `…` on anything that opens another step.** A menu item or button whose click opens a follow-up surface — a dialog, a nested menu, a picker, a confirmation — gets a trailing ellipsis (`…`, the character, not three dots) to - signal it isn't the final action: `New…`, `Rename channel…`, `Delete channel…`, + signal it isn't the final action: `New…`, `Rename space…`, `Delete space…`, `Choose a template…`. A label that performs its action immediately or navigates straight to a destination gets **no** ellipsis (`Edit CONTEXT.md`, `Star - channel`). When in doubt: does clicking it ask for more input or confirmation + space`). When in doubt: does clicking it ask for more input or confirmation before anything happens? If yes, add the `…`. ## Spaces & chrome -- Channels is a **top-level space** reached through the app rail (`AppNav`), +- Spaces is a **top-level space** reached through the app rail (`AppNav`), gated behind `project-bluebird` and wired in `routes/__root.tsx`. The rail's - spaces are Code (`/code`), Inbox (`/inbox`), and Channels (`/website`). -- The Channels space has **its own chrome**: rail + a persistent channel-list + spaces are Code (`/code`), Inbox (`/inbox`), and Spaces (`/website`). +- The Spaces UI has **its own chrome**: rail + a persistent channel-list sidebar (`ChannelsList`, rendered in `__root`) + the `WebsiteLayout` outlet. It does NOT use the code `HeaderRow`/`MainSidebar`, so breadcrumbs render in `WebsiteLayout`'s own top bar (below). @@ -62,7 +62,7 @@ The root `AGENTS.md` architecture rules still apply. ## Breadcrumbs -- **`WebsiteLayout` renders its own top bar.** The Channels space has no code +- **`WebsiteLayout` renders its own top bar.** The Spaces UI has no code `HeaderRow`, so breadcrumbs (and the dashboard controls) are a local bar inside `WebsiteLayout`, not pushed through the header store. - **A page does not get its own crumb — its H1 is the title.** A view that diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index f209b08b02..569e73ab29 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -26,6 +26,7 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -222,6 +223,7 @@ function ActivityRow({ // in, or messaged in — newest activity first. Rows clear as they are opened, not // when the page is; merely landing here shouldn't dismiss what you haven't read. export function ActivityView() { + const spacesLayout = useChannelsLayout(); const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); const { @@ -285,7 +287,7 @@ export function ActivityView() { Activity - Tasks you're involved in across channels. + Tasks you're involved in across {spacesLayout ? "spaces" : "channels"}.
{unreadCount > 0 && ( @@ -314,8 +316,7 @@ export function ActivityView() { No activity yet - Tasks you create, get tagged in, or reply to across channels - land here. + Tasks you create, get tagged in, or reply to across {spacesLayout ? "spaces" : "channels"} land here. diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx index ddc2167209..933aa37cf0 100644 --- a/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx @@ -51,7 +51,7 @@ describe("ChannelBackRow", () => { const user = userEvent.setup(); renderRow(ENG.id); - await user.click(screen.getByRole("button", { name: "Back to channels" })); + await user.click(screen.getByRole("button", { name: "Back to spaces" })); expect(useChannelPaneStore.getState().pane).toBe("list"); }); @@ -60,12 +60,12 @@ describe("ChannelBackRow", () => { // change height (and everything below it shift) when you switch channels. it("offers a star on shared channels only", () => { renderRow(ENG.id); - expect(screen.getByRole("button", { name: "Star channel" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Star space" })).toBeTruthy(); renderRow(ME.id); - expect( - screen.getAllByRole("button", { name: "Star channel" }), - ).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Star space" })).toHaveLength( + 1, + ); }); // A channel the project doesn't have must not read as one that's still diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx index da52b2e098..99d24c87fd 100644 --- a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx @@ -19,7 +19,7 @@ function RowStar({ channel }: { channel: Channel }) { return ( {channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && ( diff --git a/packages/ui/src/features/canvas/components/ChannelIntro.tsx b/packages/ui/src/features/canvas/components/ChannelIntro.tsx index 75cd9583ba..efdd267c03 100644 --- a/packages/ui/src/features/canvas/components/ChannelIntro.tsx +++ b/packages/ui/src/features/canvas/components/ChannelIntro.tsx @@ -9,6 +9,7 @@ import { import { getLocalDayDiff } from "@posthog/shared"; import type { TaskChannel } from "@posthog/shared/domain-types"; import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Heading, Text } from "@radix-ui/themes"; import { FileCheckCorner, FilePlusCorner, Info } from "lucide-react"; @@ -44,6 +45,8 @@ export function ChannelIntro({ onCreateContextMd: () => void; }) { const creator = channel?.created_by; + const spacesLayout = useChannelsLayout(); + const noun = spacesLayout ? "space" : "channel"; return (
@@ -55,9 +58,9 @@ export function ChannelIntro({ @{userDisplayName(creator ?? null)} {" "} - created this channel {creationDatePhrase(channel.created_at)}. This + created this {noun} {creationDatePhrase(channel.created_at)}. This is the very beginning of the{" "} - {channelName} channel. + {channelName} {noun}. )}
@@ -70,7 +73,7 @@ export function ChannelIntro({ Created context.md - Used in all sessions within this channel + Used in all sessions within this {noun} @@ -125,10 +128,11 @@ export function ChannelIntro({ - Learn more about channels + + Learn more about {spacesLayout ? "spaces" : "channels"} + - A channel is a group of tasks that are related to a specific - topic. + A {noun} is a group of tasks that are related to a specific topic. diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 0b046bd5d6..cad0552652 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -294,7 +294,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { - Channel unavailable + Space unavailable It may have been deleted, or belong to another project. @@ -369,7 +369,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { Nothing here yet - Tasks and canvases you create in this channel show up here. + Tasks and canvases you create in this space show up here. diff --git a/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/packages/ui/src/features/canvas/components/ChannelsFab.tsx index 14d0835b42..a7e8be4fc0 100644 --- a/packages/ui/src/features/canvas/components/ChannelsFab.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -71,7 +71,7 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { const newChannelItem = ( setModalOpen(true)}> - New channel + {channelsLayout ? "New space" : "New channel"} ); diff --git a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx index cfc24bad33..0109a40b25 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -100,6 +100,15 @@ describe("ChannelsList", () => { renderList(); expect(screen.getByText("engineering").closest(".pl-5")).toBeTruthy(); }); + + it("rebrands only the spaces layout", () => { + renderList(); + expect(screen.getByText("Spaces")).toBeTruthy(); + + mocks.channelsLayout = false; + renderList(); + expect(screen.getByText("Channels")).toBeTruthy(); + }); }); describe("search", () => { @@ -109,7 +118,7 @@ describe("ChannelsList", () => { const user = userEvent.setup(); renderList(); - await user.type(screen.getByLabelText("Search channels"), "eng"); + await user.type(screen.getByLabelText("Search spaces"), "eng"); expect(screen.getByText("engineering")).toBeTruthy(); expect(screen.queryByText("design")).toBeNull(); @@ -124,7 +133,7 @@ describe("ChannelsList", () => { renderList(); expect(screen.getByText("Starred")).toBeTruthy(); - await user.type(screen.getByLabelText("Search channels"), "eng"); + await user.type(screen.getByLabelText("Search spaces"), "eng"); expect(screen.queryByText("Starred")).toBeNull(); expect(screen.getByText("engineering")).toBeTruthy(); @@ -135,7 +144,7 @@ describe("ChannelsList", () => { it("is absent off the channels layout, along with the shortcut hints", () => { mocks.channelsLayout = false; renderList(); - expect(screen.queryByLabelText("Search channels")).toBeNull(); + expect(screen.queryByLabelText("Search spaces")).toBeNull(); expect(screen.getByText("me").parentElement?.textContent).toBe("me"); }); @@ -143,9 +152,9 @@ describe("ChannelsList", () => { const user = userEvent.setup(); renderList(); - await user.type(screen.getByLabelText("Search channels"), "zzz"); + await user.type(screen.getByLabelText("Search spaces"), "zzz"); - expect(screen.getByText(/No channels match/)).toBeTruthy(); + expect(screen.getByText(/No spaces match/)).toBeTruthy(); }); }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index e11770d38b..5b7eac01e1 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -110,6 +110,8 @@ function useChannelActions(channel: Channel): { confirmDelete: () => Promise; isDeleting: boolean; } { + const spacesLayout = useChannelsLayout(); + const noun = spacesLayout ? "space" : "channel"; const [renameOpen, setRenameOpen] = useState(false); // "Delete channel" opens a confirmation dialog rather than deleting inline — // the action is destructive and irreversible. @@ -166,7 +168,7 @@ function useChannelActions(channel: Channel): { channel_id: channel.id, success: false, }); - toast.error("Couldn't delete channel", { + toast.error(`Couldn't delete ${noun}`, { description: error instanceof Error ? error.message : String(error), }); return false; @@ -176,7 +178,7 @@ function useChannelActions(channel: Channel): { const actions: ChannelActionItem[] = [ { key: "star", - label: isStarred ? "Unstar channel" : "Star channel", + label: isStarred ? `Unstar ${noun}` : `Star ${noun}`, icon: , onSelect: () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -195,14 +197,14 @@ function useChannelActions(channel: Channel): { }, { key: "rename", - label: "Rename channel…", + label: `Rename ${noun}…`, icon: , separatorBefore: true, onSelect: () => setRenameOpen(true), }, { key: "delete", - label: "Delete channel…", + label: `Delete ${noun}…`, icon: , variant: "destructive", onSelect: () => setConfirmDeleteOpen(true), @@ -324,6 +326,8 @@ function ChannelSection({ /** ⌘1-9 slot, shown as a hint while the row isn't hovered. */ hotkeySlot?: number; }) { + const spacesLayout = useChannelsLayout(); + const noun = spacesLayout ? "space" : "channel"; const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); @@ -506,17 +510,17 @@ function ChannelSection({ Delete {channel.name}? - This permanently deletes the channel and can’t be undone. + This permanently deletes the {noun} and can’t be undone.
  • - The channel and its{" "} + The {noun} and its{" "} CONTEXT.md are deleted.
  • - Every canvas saved in this channel is permanently deleted. + Every canvas saved in this {noun} is permanently deleted.
  • - Filed tasks are removed from the channel, but the tasks + Filed tasks are removed from the {noun}, but the tasks themselves are not deleted.
@@ -535,7 +539,7 @@ function ChannelSection({ }) } > - Delete channel + Delete {noun} @@ -828,8 +832,8 @@ export function ChannelsList() { setQuery(event.target.value)} - placeholder="Search channels…" - aria-label="Search channels" + placeholder="Search spaces…" + aria-label="Search spaces" className="h-7 text-[13px]" /> @@ -854,7 +858,8 @@ export function ChannelsList() { {noMatches && ( - No channels match “{query.trim()}”. + No {channelsLayout ? "spaces" : "channels"} match “ + {query.trim()}”. )} @@ -884,13 +889,13 @@ export function ChannelsList() { {!isLoading && channels.length === 0 && ( - No channels yet. + No {channelsLayout ? "spaces" : "channels"} yet. )} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index d12be62b5c..4154c267e1 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -16,6 +16,7 @@ import { } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerateContext"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; @@ -52,6 +53,7 @@ export function CreateChannelModal({ existingContext, }: CreateChannelModalProps) { const isDescribeMode = !!existingContext; + const spacesLayout = useChannelsLayout(); const { createChannel, isCreating } = useChannelMutations(); const { generate, isStarting } = useGenerateContext(); const navigate = useNavigate(); @@ -119,7 +121,7 @@ export function CreateChannelModal({ surface: "sidebar", success: false, }); - toast.error("Couldn't create channel", { + toast.error(`Couldn't create ${spacesLayout ? "space" : "channel"}`, { description: error instanceof Error ? error.message : String(error), }); return; @@ -189,7 +191,7 @@ export function CreateChannelModal({ label would just repeat it. */} {isDescribeMode && ( - What's this channel about? + What's this {spacesLayout ? "space" : "channel"} about? )}