Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
23b977a
feat(ui): prototype static spaces sidebar under code-spaces-layout
puemos Aug 4, 2026
31431a8
refactor(ui): align spaces sidebar with existing rows and full add-sp…
puemos Aug 4, 2026
4ab0f13
chore(ui): drop Preview section from spaces sidebar prototype
puemos Aug 4, 2026
f283b4e
feat(ui): polish space rows, add settings tabs, and full All spaces list
puemos Aug 4, 2026
97b411f
refactor(ui): rework static spaces sidebar to match the sidebar's row…
puemos Aug 4, 2026
0d6ac79
feat(ui): fold-only space rows, My-tasks filter, and bottom-docked di…
puemos Aug 4, 2026
0adee2e
feat(ui): line-tab page switcher, draggable spaces and tasks, per-spa…
puemos Aug 4, 2026
58d156d
feat(ui): top New session button, My tasks section, space selector, t…
puemos Aug 4, 2026
3aaf0b3
refactor(ui): move the space selector into the composer's chip row
puemos Aug 4, 2026
9a89a18
refactor(ui): My tasks renders real space rows under a section-label …
puemos Aug 4, 2026
2555df6
style(ui): use the canonical quill line-tabs recipe for the space pag…
puemos Aug 4, 2026
edab451
style(ui): make the sidebar's New session button quill primary
puemos Aug 4, 2026
3282dcf
style(ui): space selector menu — starred first, divider, flyout item …
puemos Aug 4, 2026
4d8fad3
feat(ui): replace My tasks with a drag-in Watch list, MenuLabel secti…
puemos Aug 4, 2026
a67ec2a
fix(ui): make watch-list drops land visibly
puemos Aug 4, 2026
39b1676
fix(ui): watch list — no timestamps, removable legacy entries, cleare…
puemos Aug 4, 2026
16b5a82
style(ui): label the Spaces my-tasks switch "Mine"
puemos Aug 4, 2026
fb2f464
feat(ui): search across all spaces, full inline task lists
puemos Aug 4, 2026
5fc62af
feat(ui): drag spaces from the directory to pin; live watch-list status
puemos Aug 4, 2026
3c93cfd
feat(ui): space lists open on five rows with an inline Show more
puemos Aug 4, 2026
fa06ba0
style(ui): inline the all-spaces search into the section header row
puemos Aug 4, 2026
1bff1d2
fix(ui): stop the space header remounting on every tab switch
puemos Aug 4, 2026
931967e
feat(ui): three ways out of the pinned list — drag out, context menu,…
puemos Aug 4, 2026
26c6445
style(ui): drop the expanded fill on the All spaces header
puemos Aug 4, 2026
acc6a43
style(ui): New session button steps down from primary to outline
puemos Aug 4, 2026
e79c2ac
style(ui): raise the New session button to an elevated chip
puemos Aug 4, 2026
7f08127
feat(ui): watch-list shortcut, unread space dot, and roomier task rows
puemos Aug 4, 2026
5ffc3ae
feat(ui): group the task row menu into mark / send / remove
puemos Aug 4, 2026
7a6e0e4
feat(ui): gate static spaces sidebar
puemos Aug 4, 2026
854b795
fix(ui): update spaces prototype for current channel APIs
puemos Aug 4, 2026
589a397
chore: update to master
adamleithp Aug 5, 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 products/desktop/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
5 changes: 5 additions & 0 deletions products/desktop/packages/shared/src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
* project-bluebird. The key predates the rename, matching the live flag.
*/
export const CHANNELS_LAYOUT_FLAG = "code-spaces-layout";
/**
* Switches the spaces layout from the original master/detail sidebar to the
* static, expandable spaces navigation. Requires code-spaces-layout.
*/
export const STATIC_SPACES_SIDEBAR_FLAG = "posthog-code-static-spaces-sidebar";
// Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab.
export const LOOPS_FLAG = "loops";
export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
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;
channelType: "public" | "personal";
starred: boolean;
}[],
starredChannelIds: new Set<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: () => ({
starredChannelIds: mocks.starredChannelIds,
}),
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",
channelType: "personal" as const,
starred: true,
};
const ENG = {
id: "eng-id",
name: "eng",
channelType: "public" as const,
starred: false,
};
const ADS = {
id: "ads-id",
name: "ads",
channelType: "public" as const,
starred: false,
};

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

describe("AllSpacesSection", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [ME, ENG, ADS];
mocks.starredChannelIds = new Set();
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.id);
expect(mocks.navigate).not.toHaveBeenCalled();
});

it("unpins an already-pinned space", () => {
mocks.starredChannelIds = new Set([ENG.id]);
renderSection();
fireEvent.click(screen.getByLabelText("Unpin space"));
expect(mocks.unstar).toHaveBeenCalledWith(ENG.id);
});

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();
});
});
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 { starredChannelIds } = 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 isStarred = starredChannelIds.has(channel.id);
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: isStarred ? "unstar" : "star",
surface: "sidebar",
channel_id: channel.id,
});
const run = isStarred ? unstar(channel.id) : star(channel.id);
run.catch((error: unknown) =>
toast.error(isStarred ? "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 isStarred = starredChannelIds.has(channel.id);
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={isStarred ? "Unpin space" : "Pin space"}
onClick={() => togglePin(channel)}
className={cn(
"-translate-y-1/2 absolute top-1/2 right-[2px] text-muted-foreground transition-opacity",
isStarred
? "opacity-100"
: "opacity-0 focus-visible:opacity-100 group-hover/space:opacity-100",
)}
>
<StarIcon
size={13}
weight={isStarred ? "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>
);
}
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