Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
BrainIcon,
HashIcon,
PlugsConnectedIcon,
RobotIcon,
SquaresFourIcon,
Expand All @@ -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,
Expand Down Expand Up @@ -505,15 +505,18 @@ 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);
return {
id: t.id,
label:
meta?.label ?? channel ?? (spacesLayout ? "Space" : "Channel"),
icon: <HashIcon size={14} />,
icon: channelGlyph(channel ?? undefined, {
size: 14,
space: spacesLayout,
}),
channelName: channel,
// No section meta → the channel's index page.
isChannelHome: !meta,
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/features/canvas/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
}));
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelBackRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +79,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) {
<span className="flex w-4 shrink-0 items-center justify-center">
{channelGlyph(current?.name, {
size: 14,
space: spacesLayout,
className: "text-muted-foreground",
})}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ export function ChannelBreadcrumb({
onRename,
trailing,
}: ChannelBreadcrumbProps) {
const spacesLayout = useChannelsLayout();
const currentEditScope = editScopeKey ?? leafLabel;
const [editingScope, setEditingScope] = useState<string | null>(null);
const editing = editingScope === currentEditScope;
Expand All @@ -54,6 +56,7 @@ export function ChannelBreadcrumb({
<>
{channelGlyph(channelName, {
size: 12,
space: spacesLayout,
className: "mt-px shrink-0 text-muted-foreground/80",
})}
<Text
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
>
{channelGlyph(channelName, {
size: 20,
space: channelsLayout,
className: "shrink-0 text-muted-foreground/80",
})}
<Text className="min-w-0 truncate font-medium" title={channelName}>
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/features/canvas/components/ChannelsFab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ChartBarIcon,
CubeIcon,
FileTextIcon,
HashIcon,
PlusIcon,
Expand Down Expand Up @@ -70,7 +71,7 @@ export function ChannelsFab({ channelId }: { channelId?: string }) {

const newChannelItem = (
<DropdownMenuItem onClick={() => setModalOpen(true)}>
<HashIcon size={14} className="text-gray-9" />
{channelsLayout ? <CubeIcon size={14} /> : <HashIcon size={14} />}
{channelsLayout ? "New space" : "New channel"}
</DropdownMenuItem>
);
Expand Down
15 changes: 10 additions & 5 deletions packages/ui/src/features/canvas/components/ChannelsList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
28 changes: 16 additions & 12 deletions packages/ui/src/features/canvas/components/ChannelsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CaretDownIcon,
CaretRightIcon,
ChartBarIcon,
CubeFocusIcon,
DotsThreeIcon,
FileTextIcon,
HashIcon,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -812,30 +815,30 @@ 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
* option, so ↓ would walk onto spaces the user has folded away. Paying the
* rebuild on expand is better than highlighting a row nobody can see.
*/
keepMounted?: boolean;
icon: ReactNode;
children: ReactNode;
}) {
const collapsedSections = useSidebarStore((s) => s.collapsedSections);
Expand All @@ -860,12 +863,9 @@ function ChannelGroup({
render={<MenuLabel render={<button type="button" />} />}
>
<span className="relative flex size-3.5 shrink-0 items-center justify-center">
{!flat && (
<HashIcon
size={14}
className="group-hover/group-trigger:hidden group-focus-visible/group-trigger:hidden"
/>
)}
<span className="group-hover/group-trigger:hidden group-focus-visible/group-trigger:hidden">
{icon}
</span>
{isOpen ? (
<CaretDownIcon
size={14}
Expand Down Expand Up @@ -1006,6 +1006,7 @@ export function ChannelsList() {
label="Starred"
flat={channelsLayout}
keepMounted={!channelsLayout}
icon={<StarIcon size={14} />}
>
{starred.map((channel) => (
<ChannelSection
Expand All @@ -1023,6 +1024,9 @@ export function ChannelsList() {
label={channelsLayout ? "Spaces" : "Channels"}
flat={channelsLayout}
keepMounted={!channelsLayout}
icon={
channelsLayout ? <CubeFocusIcon size={14} /> : <HashIcon size={14} />
}
>
{!isLoading && channels.length === 0 && (
<Empty className="px-2 py-1 text-subtle-foreground text-xs">
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -114,7 +115,7 @@ export function RenameChannelModal({
}}
>
<TextField.Slot>
<HashIcon size={16} className="text-gray-10" />
{channelGlyph(channel.name, { size: 16, space: spacesLayout })}
</TextField.Slot>
<TextField.Slot side="right">
<Text className="text-gray-9 text-sm tabular-nums">
Expand Down
17 changes: 16 additions & 1 deletion packages/ui/src/features/canvas/components/channelGlyph.test.tsx
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -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);
});
});
18 changes: 14 additions & 4 deletions packages/ui/src/features/canvas/components/channelGlyph.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CubeIcon,
HashIcon,
type IconWeight,
LockSimpleIcon,
Expand All @@ -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 (
<Icon
size={opts?.size ?? 16}
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/features/command/CommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
keywords: "space channel",
icon: channelGlyph(channel.name, {
size: 12,
space: spacesLayout,
className: "text-muted-foreground",
}),
action: "open-channel" as CommandMenuAction,
Expand Down
Loading