diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx index a98265526035..d186d07284a8 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemsPane.tsx @@ -133,7 +133,6 @@ export function ChannelItemsPane({ cap, channelIdFor, emptyState, - overlay, searchLabel = "Search sessions", }: { items: readonly ChannelItemModel[]; @@ -147,7 +146,6 @@ export function ChannelItemsPane({ cap?: number; channelIdFor?: (item: ChannelItemModel) => string | undefined; emptyState: ReactNode; - overlay?: ReactNode; searchLabel?: string; }) { const [editingTaskId, setEditingTaskId] = useState(null); @@ -415,7 +413,6 @@ export function ChannelItemsPane({ ))} - {overlay} ({ vi.mock("@posthog/ui/features/canvas/components/ChannelBackRow", () => ({ ChannelBackRow: () => null, })); -vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ - ChannelsFab: () => null, -})); - // The row menu's spaces list reaches for a QueryClient the unit test has no // stack for. Stubbed at the module boundary, as ShellLayout.test.tsx does for // the same reason. diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 4e7e3e5514ee..5226fb83bca5 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -17,7 +17,6 @@ import { import { LOOPS_FLAG } from "@posthog/shared"; import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemsPane } from "@posthog/ui/features/canvas/components/ChannelItemsPane"; -import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { type ChannelPageKey, channelPageLabel, @@ -243,7 +242,6 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { cap={RECENTS_CAP} channelIdFor={() => channelId} emptyState={} - overlay={} searchLabel={ tab === "canvas" ? "Search canvases" : "Search sessions" } diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx index d14987da5242..222832b601b6 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -31,15 +31,22 @@ import { track } from "@posthog/ui/shell/analytics"; import { useState } from "react"; /** - * The create affordance for the Channels space, floated over the bottom-right - * of whichever sidebar pane is showing. + * The create button. Given a channel it creates inside it (task, canvas); from + * the list it creates a channel, which has no other entry point. * - * The same button on both panes, so "create" is always the same corner: given a - * channel it creates inside it (task, canvas); from the list it creates a - * channel, which has no other entry point. + * On the spaces layout it sits in the nav rail (`placement="rail"`), which + * every destination keeps on screen whether or not it draws a sidebar. Off the + * layout it floats over the bottom-right of the channel list. */ -export function ChannelsFab({ channelId }: { channelId?: string }) { +export function ChannelsFab({ + channelId, + placement = "floating", +}: { + channelId?: string; + placement?: "floating" | "rail"; +}) { const channelsLayout = useChannelsLayout(); + const inRail = placement === "rail"; const [modalOpen, setModalOpen] = useState(false); const hasDraft = useDraftStore((state) => Object.entries(state.drafts).some( @@ -51,7 +58,7 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { const newTask = () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "new_task_open", - surface: "sidebar", + surface: inRail ? "nav" : "sidebar", channel_id: channelId, }); // In a channel the task is filed there; from the list it's whatever the @@ -86,18 +93,22 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { const trigger = ( ); const tooltip = ( - + {channelsLayout ? ( <> {/* The draft dot needs saying out loud, and the button is where @@ -128,8 +139,8 @@ export function ChannelsFab({ channelId }: { channelId?: string }) { {tooltip} {/* Off the layout this is the list's only menu, and "New channel" diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx index bdda821fdd82..9a5d47db366c 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -34,6 +34,10 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/components/CreateChannelModal", () => ({ + CreateChannelModal: ({ open }: { open: boolean }) => + open ?
New space dialog
: null, +})); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ useChannelsLayout: () => mocks.channelsLayout, })); @@ -303,6 +307,19 @@ describe("ChannelsList", () => { expect(screen.queryByRole("heading", { name: "Spaces" })).toBeNull(); expect(screen.getByText("Channels")).toBeTruthy(); }); + + // The heading's "+" is the list's way to a new space now that nothing + // floats over it; off the layout the floating button still offers one. + it("starts a new space from the Spaces heading on the layout only", async () => { + const view = renderList(); + await userEvent.click(screen.getByRole("button", { name: "New space" })); + expect(screen.getByRole("dialog")).toHaveTextContent("New space dialog"); + + view.unmount(); + mocks.channelsLayout = false; + renderList(); + expect(screen.queryByRole("button", { name: "New space" })).toBeNull(); + }); }); describe("search", () => { @@ -344,6 +361,16 @@ describe("ChannelsList", () => { ); }); + it("offers a new space when nothing matches", async () => { + const user = userEvent.setup(); + renderList(); + + await user.type(screen.getByLabelText("Search spaces"), "zzz"); + await user.click(screen.getByRole("button", { name: "New space" })); + + expect(screen.getByRole("dialog")).toHaveTextContent("New space dialog"); + }); + it("says so when nothing matches", async () => { const user = userEvent.setup(); renderList(); diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx index 54b6c0b59ca9..f956e74be5fb 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -57,6 +57,7 @@ import { ChannelItemHoverCard, SpaceHoverCard, } from "@posthog/ui/features/canvas/components/ChannelItemHoverCard"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import type { ChannelActionItem } from "@posthog/ui/features/canvas/components/channelActions"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { PresenceAvatars } from "@posthog/ui/features/canvas/components/PresenceAvatars"; @@ -1689,6 +1690,50 @@ const CHANNELS_SECTION_ID = "channels:all"; /** A heading's identity in the flat list, kept clear of any channel's id. */ const sectionValue = (sectionId: string) => `section:${sectionId}`; +// Starts a new space. On the Spaces heading it is the same hover-revealed +// plus a space row shows; inline (the no-match state) it is a labelled button. +function NewSpaceButton({ + appearance = "heading", +}: { + appearance?: "heading" | "inline"; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + {appearance === "inline" ? ( + + ) : ( + + setOpen(true)} + > + + + } + /> + New space + + )} + + + ); +} + // A collapsible sidebar group ("Starred" / "Channels"). Base UI directly rather // than quill's Collapsible: quill styles its trigger as a button (which fought // the label styling) and animates the panel height (which janked on a list this @@ -1704,11 +1749,15 @@ function ChannelGroup({ flat, keepMounted = true, asOption = false, + trailing, children, }: { sectionId: string; label: string; className?: string; + /** A control on the heading's right. Beside the trigger, not in it: the + * heading is a button, and a button can't hold another. */ + trailing?: ReactNode; /** Layout-only: removes the legacy tree indent; rows apply their own inset. */ flat?: boolean; /** @@ -1740,41 +1789,48 @@ function ChannelGroup({ }} className={cn(className, "mb-2")} > - {/* MenuLabel carries the sidebar's label styling; `render` keeps it a +
+ {/* MenuLabel carries the sidebar's label styling; `render` keeps it a real button so the whole row is clickable. Wrapped in an option when the keyboard walks the list, so the heading is a stop on the way down rather than a gap the highlight jumps over. */} - span]:w-full [&>span]:items-center", - )} - render={ - asOption ? ( - } />} - /> - ) : ( - } /> - ) - } - > - {label} - {/* On the right, because the heading's name is the left edge every row + span]:w-full [&>span]:items-center", + )} + render={ + asOption ? ( + } />} + /> + ) : ( + } /> + ) + } + > + {label} + {/* On the right, because the heading's name is the left edge every row beneath it lines up to. Always drawn: which way the section is, is the one thing this row has to say. */} - {isOpen ? ( - - ) : ( - + {isOpen ? ( + + ) : ( + + )} + + {trailing && ( +
+ {trailing} +
)} -
+
{/* Stay mounted while collapsed. Every row builds a context menu, a dropdown, a tooltip and two dialogs up front, so unmounting on close makes each expand rebuild the lot (~940ms for 46 channels, vs ~80ms @@ -1789,8 +1845,8 @@ function ChannelGroup({ // The channel list is the list pane of the sidebar slider. The personal 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 -// the sidebar outside this scroll region. +// label. Creating a task goes through the create button in the nav rail (off +// the layout, the floating ChannelsFab the sidebar mounts outside this list). export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); // ChannelHotkeys owns the keys these slots describe; sharing the derivation @@ -2045,10 +2101,13 @@ export function ChannelsList() { /> ))} {noMatches && ( - + No {channelsLayout ? "spaces" : "channels"} match “{query.trim()}”. + {/* Filtering hides the Spaces heading and its "+", so the space you + searched for and didn't find can still be made from here. */} + )} @@ -2090,6 +2149,8 @@ export function ChannelsList() { flat={channelsLayout} keepMounted={!channelsLayout} asOption={channelsLayout} + // Off the layout the floating create button already offers a channel. + trailing={channelsLayout ? : undefined} > {!isLoading && channels.length === 0 && ( @@ -2125,7 +2186,9 @@ export function ChannelsList() { // own padding has to win: `!` is what outranks an unlayered rule. const listClass = cn( "sidebar-autocomplete-tree flex flex-col gap-px", - "!max-h-none !px-2 !pt-2 !pb-16 scroll-py-8", + // The layout keeps the create button in the rail, so nothing floats over + // the list's end and it needs no clearance there. + "!max-h-none !px-2 !pt-2 !pb-2 scroll-py-8", scrollClass, ); diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index aa9adfa611df..df97ae4a9301 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -129,7 +129,6 @@ function ChannelPanes({ >
-
{channelId && ( diff --git a/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx index 42aadf823552..2a5e960a81c4 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/NavRail.test.tsx @@ -95,6 +95,22 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); vi.mock("@posthog/ui/features/canvas/components/ActivityHoverCard", () => ({ ActivityHoverCard: () =>
Recent activity card
, })); +vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ + ChannelsFab: ({ + channelId, + placement, + }: { + channelId?: string; + placement?: string; + }) => ( +