Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
677d5b2
feat(ui): prototype static spaces sidebar under code-spaces-layout
puemos Jul 31, 2026
60c7159
refactor(ui): align spaces sidebar with existing rows and full add-sp…
puemos Jul 31, 2026
d5fd7fc
chore(ui): drop Preview section from spaces sidebar prototype
puemos Jul 31, 2026
50066f0
feat(ui): polish space rows, add settings tabs, and full All spaces list
puemos Jul 31, 2026
e9de196
refactor(ui): rework static spaces sidebar to match the sidebar's row…
puemos Jul 31, 2026
c81861d
feat(ui): fold-only space rows, My-tasks filter, and bottom-docked di…
puemos Jul 31, 2026
ab7f302
feat(ui): line-tab page switcher, draggable spaces and tasks, per-spa…
puemos Aug 1, 2026
306a922
feat(ui): top New session button, My tasks section, space selector, t…
puemos Aug 1, 2026
b57e1b3
refactor(ui): move the space selector into the composer's chip row
puemos Aug 1, 2026
a7e1ed3
refactor(ui): My tasks renders real space rows under a section-label …
puemos Aug 1, 2026
2827a9a
style(ui): use the canonical quill line-tabs recipe for the space pag…
puemos Aug 1, 2026
490719c
style(ui): make the sidebar's New session button quill primary
puemos Aug 1, 2026
dfce794
style(ui): space selector menu — starred first, divider, flyout item …
puemos Aug 1, 2026
4914224
feat(ui): replace My tasks with a drag-in Watch list, MenuLabel secti…
puemos Aug 1, 2026
9462bab
fix(ui): make watch-list drops land visibly
puemos Aug 1, 2026
0ae6d88
fix(ui): watch list — no timestamps, removable legacy entries, cleare…
puemos Aug 1, 2026
191a02a
style(ui): label the Spaces my-tasks switch "Mine"
puemos Aug 1, 2026
b7f63dd
feat(ui): search across all spaces, full inline task lists
puemos Aug 1, 2026
7007eac
feat(ui): drag spaces from the directory to pin; live watch-list status
puemos Aug 1, 2026
c217ff2
feat(ui): space lists open on five rows with an inline Show more
puemos Aug 1, 2026
75c853e
style(ui): inline the all-spaces search into the section header row
puemos Aug 1, 2026
7c457dd
fix(ui): stop the space header remounting on every tab switch
puemos Aug 1, 2026
30a35d6
feat(ui): three ways out of the pinned list — drag out, context menu,…
puemos Aug 1, 2026
704b76e
style(ui): drop the expanded fill on the All spaces header
puemos Aug 1, 2026
2bcc510
style(ui): New session button steps down from primary to outline
puemos Aug 1, 2026
9f12ff6
style(ui): raise the New session button to an elevated chip
puemos Aug 1, 2026
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
4 changes: 3 additions & 1 deletion packages/core/src/canvas/channelItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export interface ChannelItemOwner {
// A display name is NOT an identity — two users can share one — so it must never
// gate the private `#me` space. Items without a creator uuid fail closed
// (excluded from #me); `authorName`/`authorUser` are display-only.
function isOwnedBy(
// Exported for the sidebar's "my tasks" filter, which applies the same
// fail-closed ownership rule to any channel's list.
export function isOwnedBy(
item: Pick<ChannelItemModel, "authorUuid">,
owner: ChannelItemOwner,
): boolean {
Expand Down
116 changes: 116 additions & 0 deletions packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Theme } from "@radix-ui/themes";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
channels: [] as { id: string; name: string; path: string }[],
starredRefToShortcutId: new Map<string, string>(),
star: vi.fn(() => Promise.resolve()),
unstar: vi.fn(() => Promise.resolve()),
navigate: vi.fn(),
setCurrentChannel: vi.fn(),
track: vi.fn(),
pathname: "/code",
}));

vi.mock("@posthog/ui/shell/analytics", () => ({
track: (...args: unknown[]) => mocks.track(...args),
}));
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => mocks.navigate,
useRouterState: ({
select,
}: {
select: (s: { location: { pathname: string } }) => string;
}) => select({ location: { pathname: mocks.pathname } }),
}));
vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({
useChannels: () => ({ channels: mocks.channels, isLoading: false }),
}));
vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({
useChannelStars: () => ({
starredRefToShortcutId: mocks.starredRefToShortcutId,
}),
useChannelStarMutations: () => ({ star: mocks.star, unstar: mocks.unstar }),
}));
vi.mock("@posthog/ui/features/canvas/stores/currentChannelStore", () => ({
useCurrentChannelStore: (
selector: (s: { setCurrentChannel: (id: string) => void }) => unknown,
) => selector({ setCurrentChannel: mocks.setCurrentChannel }),
}));
vi.mock("@posthog/ui/features/canvas/components/CreateChannelModal", () => ({
CreateChannelModal: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-space-modal" /> : null,
}));

import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore";
import { AllSpacesSection } from "./AllSpacesSection";

const ME = { id: "me-id", name: "me", path: "/me" };
const ENG = { id: "eng-id", name: "eng", path: "/eng" };
const ADS = { id: "ads-id", name: "ads", path: "/ads" };

function renderSection() {
return render(
<Theme>
<AllSpacesSection />
</Theme>,
);
}

describe("AllSpacesSection", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [ME, ENG, ADS];
mocks.starredRefToShortcutId = new Map();
mocks.pathname = "/code";
useSpacesSidebarStore.setState({ openAddSpace: true });
});

it("lists shared spaces alphabetically and leaves the personal space out", () => {
renderSection();
const rows = screen
.getAllByRole("button")
.map((b) => b.textContent)
.filter((t) => t === "ads" || t === "eng" || t === "me");
expect(rows).toEqual(["ads", "eng"]);
});

it("opens the space on row click rather than pinning it", () => {
renderSection();
fireEvent.click(screen.getByText("eng"));
expect(mocks.navigate).toHaveBeenCalledWith({
to: "/website/$channelId",
params: { channelId: ENG.id },
});
expect(mocks.setCurrentChannel).toHaveBeenCalledWith(ENG.id);
expect(mocks.star).not.toHaveBeenCalled();
});

it("pins on the star without opening the space", () => {
renderSection();
fireEvent.click(screen.getAllByLabelText("Pin space")[0]);
expect(mocks.star).toHaveBeenCalledWith(ADS);
expect(mocks.navigate).not.toHaveBeenCalled();
});

it("unpins an already-pinned space through its shortcut", () => {
mocks.starredRefToShortcutId = new Map([[ENG.path, "shortcut-1"]]);
renderSection();
fireEvent.click(screen.getByLabelText("Unpin space"));
expect(mocks.unstar).toHaveBeenCalledWith("shortcut-1");
});

it("collapses to just the section label", () => {
useSpacesSidebarStore.setState({ openAddSpace: false });
renderSection();
expect(screen.getByText("All spaces")).toBeTruthy();
expect(screen.queryByText("eng")).toBeNull();
});

it("opens the create-space modal from the New space row", () => {
renderSection();
fireEvent.click(screen.getByText("New space"));
expect(screen.getByTestId("create-space-modal")).toBeTruthy();
});
});
172 changes: 172 additions & 0 deletions packages/ui/src/features/canvas/components/AllSpacesSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { CaretRightIcon, PlusIcon, StarIcon } from "@phosphor-icons/react";
import { Button, cn } from "@posthog/quill";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
import {
useChannelStarMutations,
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 { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore";
import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore";
import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem";
import { toast } from "@posthog/ui/primitives/toast";
import { track } from "@posthog/ui/shell/analytics";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { useMemo, useState } from "react";

/**
* The project's space directory, docked at the bottom of the sidebar. The
* header is shaped exactly like a pinned space row (same caret, same inset) and
* folding it open grows the section upward to a capped height, then the list
* scrolls. Clicking a row opens the space; the hover star is what pins it into
* the sidebar above — pinned rows wear their star filled so the directory
* shows what's already up there. The personal space isn't listed: it can't be
* pinned or shared, and it's always first in the pinned list anyway.
*/
export function AllSpacesSection() {
const open = useSpacesSidebarStore((s) => s.openAddSpace);
const toggle = useSpacesSidebarStore((s) => s.toggleAddSpace);
const [createOpen, setCreateOpen] = useState(false);

const { channels } = useChannels();
const { starredRefToShortcutId } = useChannelStars();
const { star, unstar } = useChannelStarMutations();
const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel);
const navigate = useNavigate();
const pathname = useRouterState({ select: (s) => s.location.pathname });

const spaces = useMemo(
() =>
channels
.filter((c) => c.name !== PERSONAL_CHANNEL_NAME)
.sort((a, b) => a.name.localeCompare(b.name)),
[channels],
);

const openSpace = (channel: Channel) => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "nav_click",
surface: "sidebar",
channel_id: channel.id,
});
setCurrentChannel(channel.id);
void navigate({
to: "/website/$channelId",
params: { channelId: channel.id },
});
};

const togglePin = (channel: Channel) => {
const shortcutId = starredRefToShortcutId.get(channel.path);
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: shortcutId ? "unstar" : "star",
surface: "sidebar",
channel_id: channel.id,
});
const run = shortcutId ? unstar(shortcutId) : star(channel);
run.catch((error: unknown) =>
toast.error(shortcutId ? "Couldn't unpin space" : "Couldn't pin space", {
description: error instanceof Error ? error.message : String(error),
}),
);
};

return (
<div className="shrink-0 border-border border-t">
<div className="flex flex-col gap-px px-2 pt-1 pb-2">
{/* Same shape as a pinned space row, so the carets line up. The
directory stays open most of the time, so quill's expanded fill
would read as a permanent highlight — the rotated caret already
says "open"; hover feedback stays. */}
<Button
variant="default"
left
aria-expanded={open}
onClick={toggle}
className="w-full gap-1.5 text-left aria-expanded:bg-transparent! aria-expanded:hover:bg-fill-hover!"
>
<CaretRightIcon
size={12}
className={cn(
"shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium text-[13px] text-muted-foreground group-hover/button:text-foreground">
All spaces
</span>
</Button>

{open && (
<>
<div className="flex max-h-64 flex-col gap-px overflow-y-auto">
{spaces.map((channel) => {
const shortcutId = starredRefToShortcutId.get(channel.path);
const base = `/website/${channel.id}`;
const isActive =
pathname === base || pathname.startsWith(`${base}/`);
return (
// Overlay, not endContent: the row is a button already, and
// a star nested inside it would be a button within a button.
<div key={channel.id} className="group/space relative">
<SidebarItem
depth={1}
label={channel.name}
isActive={isActive}
onClick={() => openSpace(channel)}
// Dragging a directory row up into the pinned area pins
// it — same result as the star, one gesture instead.
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/x-space-id", channel.id);
e.dataTransfer.effectAllowed = "copy";
}}
// Star well, so the name truncates clear of the star.
endContent={
<span aria-hidden className="size-5 shrink-0" />
}
/>
<Button
variant="default"
size="icon-sm"
aria-label={shortcutId ? "Unpin space" : "Pin space"}
onClick={() => togglePin(channel)}
className={cn(
"-translate-y-1/2 absolute top-1/2 right-[2px] text-muted-foreground transition-opacity",
shortcutId
? "opacity-100"
: "opacity-0 focus-visible:opacity-100 group-hover/space:opacity-100",
)}
>
<StarIcon
size={13}
weight={shortcutId ? "fill" : "regular"}
/>
</Button>
</div>
);
})}
</div>
{/* Below the scroll region so it's reachable without scrolling
the whole directory. */}
<SidebarItem
depth={1}
icon={<PlusIcon size={14} className="text-muted-foreground" />}
label={<span className="text-muted-foreground">New space</span>}
onClick={() => setCreateOpen(true)}
/>
<CreateChannelModal
open={createOpen}
onOpenChange={setCreateOpen}
/>
</>
)}
</div>
</div>
);
}
34 changes: 23 additions & 11 deletions packages/ui/src/features/canvas/components/ChannelHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Button, cn } from "@posthog/quill";
import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb";
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
import {
ChannelPageTabs,
ChannelTabs,
} from "@posthog/ui/features/canvas/components/ChannelTabs";
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
import {
type ChannelPageKey,
Expand All @@ -15,10 +18,10 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";

// The shared channel header. Every space scene renders the same breadcrumb —
// the root segment is identical whether or not there's a leaf, so the space
// name doesn't change size between the space home and its sub-pages. The new
// layout drops the section tab strip (the channel sidebar carries those
// entries); flag off keeps it. Starring lives on the sidebar back row and the
// channel list, not here.
// name doesn't change size between the space home and its sub-pages. Under the
// channels layout the page switcher is a row of line tabs beneath the space
// name (matching Inbox/Activity); the legacy header keeps its button strip.
// Starring lives on the sidebar back row and the channel list, not here.
export function ChannelHeader({
channelId,
page,
Expand All @@ -42,13 +45,22 @@ export function ChannelHeader({
// layout flag graduates.
if (!channelsLayout) return <LegacyChannelHeader channelId={channelId} />;

// Two rows: the breadcrumb keeps the header bar's usual height, and the
// page tabs sit beneath it, left-aligned with the space name. The static
// sidebar's rows only fold their task lists, so this switcher is the one
// way between a space's pages.
return (
<ChannelBreadcrumb
channelName={channelName ?? "Space"}
channelId={channelId}
leafIcon={page ? channelPageIcon(page, { size: 12 }) : undefined}
leafLabel={page ? channelPageLabel(page) : undefined}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex h-10 min-w-0 items-center">
<ChannelBreadcrumb
channelName={channelName ?? "Space"}
channelId={channelId}
leafIcon={page ? channelPageIcon(page, { size: 12 }) : undefined}
leafLabel={page ? channelPageLabel(page) : undefined}
/>
</div>
<ChannelPageTabs channelId={channelId} page={page} />
</div>
);
}

Expand Down
Loading
Loading