diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index 5c36b5284d..c8a72cc020 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,6 +1,5 @@ import { BrainIcon, - HashIcon, PlugsConnectedIcon, RobotIcon, SquaresFourIcon, @@ -24,6 +23,7 @@ import { } from "@posthog/shared"; import { channelSectionFor } from "@posthog/ui/features/canvas/channelSections"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; import { useChannelMutations, @@ -505,7 +505,7 @@ export function BrowserTabStrip() { }; } // A channel tab: a sub-section (Artifacts/Recents/…) or the channel home. - // The section drives the label; the channel name carries the `#` hover + // The section drives the label; the channel name carries the space // context. Home has no section, so it labels by the channel name. if (channelId) { const meta = channelSectionFor(section); @@ -513,7 +513,10 @@ export function BrowserTabStrip() { id: t.id, label: meta?.label ?? channel ?? (spacesLayout ? "Space" : "Channel"), - icon: , + icon: channelGlyph(channel ?? undefined, { + size: 14, + space: spacesLayout, + }), channelName: channel, // No section meta → the channel's index page. isChannelHome: !meta, diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index 5c576960b7..897be5e307 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -47,9 +47,9 @@ The root `AGENTS.md` architecture rules still apply. 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. +- In the list, "Starred"/"Spaces" are headings above lightly indented shared + rows; the pinned private "me" row aligns with the headings. The alpha's more + deeply indented Channels tree and hash glyphs are 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/ChannelBackRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx index 933aa37cf0..ad1298628d 100644 --- a/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx @@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => true, +})); vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ useChannels: () => ({ channels: mocks.channels, isLoading: mocks.isLoading }), })); diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx index 99d24c87fd..e9a0712b07 100644 --- a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx @@ -7,6 +7,7 @@ import { type Channel, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { showChannelList } from "@posthog/ui/features/canvas/stores/channelPaneStore"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; @@ -45,6 +46,7 @@ function RowStar({ channel }: { channel: Channel }) { * it. Leaving the channel scoped means the route (and the main pane) stay put. */ export function ChannelBackRow({ channelId }: { channelId: string }) { + const spacesLayout = useChannelsLayout(); const { channels, isLoading } = useChannels(); const current = channels.find((c) => c.id === channelId); const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; @@ -77,6 +79,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) { {channelGlyph(current?.name, { size: 14, + space: spacesLayout, className: "text-muted-foreground", })} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx index 69067a0ba2..f7124726ee 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx @@ -5,6 +5,9 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn(), })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => true, +})); import { ChannelBreadcrumb } from "./ChannelBreadcrumb"; diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index 763d156e32..e47614aa05 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -5,6 +5,7 @@ import { TooltipTrigger, } from "@posthog/quill"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; @@ -45,6 +46,7 @@ export function ChannelBreadcrumb({ onRename, trailing, }: ChannelBreadcrumbProps) { + const spacesLayout = useChannelsLayout(); const currentEditScope = editScopeKey ?? leafLabel; const [editingScope, setEditingScope] = useState(null); const editing = editingScope === currentEditScope; @@ -54,6 +56,7 @@ export function ChannelBreadcrumb({ <> {channelGlyph(channelName, { size: 12, + space: spacesLayout, className: "mt-px shrink-0 text-muted-foreground/80", })} {channelGlyph(channelName, { size: 20, + space: channelsLayout, className: "shrink-0 text-muted-foreground/80", })} diff --git a/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/packages/ui/src/features/canvas/components/ChannelsFab.tsx index a7e8be4fc0..0596061a2d 100644 --- a/packages/ui/src/features/canvas/components/ChannelsFab.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -1,5 +1,6 @@ import { ChartBarIcon, + CubeIcon, FileTextIcon, HashIcon, PlusIcon, @@ -70,7 +71,7 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { const newChannelItem = ( setModalOpen(true)}> - + {channelsLayout ? : } {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 56e00378ee..dfb85c1f23 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -93,23 +93,28 @@ 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. + // "Starred" and "Spaces" are headings over the rows. Spaces receive a small + // Slack-style inset; the alpha keeps its deeper tree indentation. describe("group headings", () => { beforeEach(() => { mocks.starredPaths = [ENG.path]; }); - it("does not indent rows under the layout", () => { + it("slightly indents rows under the layout", () => { renderList(); - expect(screen.getByText("engineering").closest(".pl-5")).toBeNull(); + expect(screen.getByText("engineering").closest("button")).toHaveClass( + "pl-4", + ); + expect(screen.getByText("me").closest("button")).not.toHaveClass("pl-4"); }); it("keeps the indented tree off the layout", () => { mocks.channelsLayout = false; renderList(); expect(screen.getByText("engineering").closest(".pl-5")).toBeTruthy(); + expect(screen.getByText("engineering").closest("button")).not.toHaveClass( + "pl-4", + ); }); it("rebrands only the spaces layout", () => { diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index abd850d870..b04c2be60f 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -3,6 +3,7 @@ import { CaretDownIcon, CaretRightIcon, ChartBarIcon, + CubeFocusIcon, DotsThreeIcon, FileTextIcon, HashIcon, @@ -439,9 +440,11 @@ function ChannelSection({ data-selected={isActive || undefined} onClick={() => openChannel(channel)} {...focusProps} + className={spacesLayout ? "pl-4" : undefined} > {channelGlyph(channel.name, { size: 14, + space: spacesLayout, weight: isUnread ? "bold" : undefined, className: cn( "shrink-0", @@ -812,23 +815,22 @@ 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. 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. +// The whole header row is the trigger. Its section glyph swaps to a down/right +// disclosure caret on hover or keyboard focus while keeping Starred and Spaces +// distinct at rest. function ChannelGroup({ sectionId, label, className, flat, keepMounted = true, + icon, children, }: { sectionId: string; label: string; className?: string; - /** Layout-only: rows sit at the label's level instead of indented under it. */ + /** Layout-only: removes the legacy tree indent; rows apply their own inset. */ flat?: boolean; /** * Off under the layout: a kept-mounted collapsed row is still an Autocomplete @@ -836,6 +838,7 @@ function ChannelGroup({ * rebuild on expand is better than highlighting a row nobody can see. */ keepMounted?: boolean; + icon: ReactNode; children: ReactNode; }) { const collapsedSections = useSidebarStore((s) => s.collapsedSections); @@ -860,12 +863,9 @@ function ChannelGroup({ render={} />} > - {!flat && ( - - )} + + {icon} + {isOpen ? ( } > {starred.map((channel) => ( : + } > {!isLoading && channels.length === 0 && ( diff --git a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx index 79b7632730..40cec25a1c 100644 --- a/packages/ui/src/features/canvas/components/RenameChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/RenameChannelModal.tsx @@ -1,7 +1,8 @@ -import { HashIcon, XIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import { validateChannelName } from "@posthog/core/canvas/channelName"; import { Button } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; @@ -114,7 +115,7 @@ export function RenameChannelModal({ }} > - + {channelGlyph(channel.name, { size: 16, space: spacesLayout })} diff --git a/packages/ui/src/features/canvas/components/channelGlyph.test.tsx b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx index c5c9721736..27401260b1 100644 --- a/packages/ui/src/features/canvas/components/channelGlyph.test.tsx +++ b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx @@ -1,5 +1,7 @@ +import { CubeIcon, HashIcon, LockSimpleIcon } from "@phosphor-icons/react"; +import type { ReactElement } from "react"; import { describe, expect, it } from "vitest"; -import { isPrivateChannel } from "./channelGlyph"; +import { channelGlyph, isPrivateChannel } from "./channelGlyph"; describe("isPrivateChannel", () => { it.each([ @@ -17,3 +19,16 @@ describe("isPrivateChannel", () => { expect(isPrivateChannel(name)).toBe(expected); }); }); + +describe("channelGlyph", () => { + it.each([ + ["channel", false, HashIcon], + ["space", true, CubeIcon], + ["private space", true, LockSimpleIcon], + ])("renders the %s glyph", (_, space, expectedIcon) => { + const name = expectedIcon === LockSimpleIcon ? "me" : "engineering"; + const glyph = channelGlyph(name, { space }) as ReactElement; + + expect(glyph.type).toBe(expectedIcon); + }); +}); diff --git a/packages/ui/src/features/canvas/components/channelGlyph.tsx b/packages/ui/src/features/canvas/components/channelGlyph.tsx index f312c0d000..8e790e3548 100644 --- a/packages/ui/src/features/canvas/components/channelGlyph.tsx +++ b/packages/ui/src/features/canvas/components/channelGlyph.tsx @@ -1,4 +1,5 @@ import { + CubeIcon, HashIcon, type IconWeight, LockSimpleIcon, @@ -23,14 +24,23 @@ export function isPrivateChannel(channelName: string | undefined): boolean { } /** - * 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. + * A channel's leading glyph: a lock when it's private, otherwise a cube for the + * Spaces layout or a hash for legacy Channels. */ export function channelGlyph( channelName: string | undefined, - opts?: { size?: number; className?: string; weight?: IconWeight }, + opts?: { + size?: number; + className?: string; + weight?: IconWeight; + space?: boolean; + }, ): ReactNode { - const Icon = isPrivateChannel(channelName) ? LockSimpleIcon : HashIcon; + const Icon = isPrivateChannel(channelName) + ? LockSimpleIcon + : opts?.space + ? CubeIcon + : HashIcon; return (