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 (