Skip to content

Commit c81861d

Browse files
authored
feat(ui): fold-only space rows, My-tasks filter, and bottom-docked directory
Sidebar feedback round: a space row is now a single click target that folds its task list (no separate caret hover zone); the hover gear on the right opens the space in the main view, and #me's lock moves into that same trailing well. The header regains a tab strip under the channels layout (Feed, Context, Loops, Artifacts, Recents) as the breadcrumb's trailing slot, since the sidebar no longer carries page links. A My-tasks switch above the pinned list narrows every space's tasks to the viewer's own via the exported core ownership rule. All spaces docks at the bottom, sheds its cube for a caret aligned with the space rows, and opens upward to a capped, scrollable height. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986
1 parent e9de196 commit c81861d

8 files changed

Lines changed: 233 additions & 191 deletions

File tree

packages/core/src/canvas/channelItems.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export interface ChannelItemOwner {
3737
// A display name is NOT an identity — two users can share one — so it must never
3838
// gate the private `#me` space. Items without a creator uuid fail closed
3939
// (excluded from #me); `authorName`/`authorUser` are display-only.
40-
function isOwnedBy(
40+
// Exported for the sidebar's "my tasks" filter, which applies the same
41+
// fail-closed ownership rule to any channel's list.
42+
export function isOwnedBy(
4143
item: Pick<ChannelItemModel, "authorUuid">,
4244
owner: ChannelItemOwner,
4345
): boolean {
Lines changed: 92 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
1-
import {
2-
CaretDownIcon,
3-
CaretRightIcon,
4-
CubeFocusIcon,
5-
PlusIcon,
6-
StarIcon,
7-
} from "@phosphor-icons/react";
8-
import { Button, cn, MenuLabel } from "@posthog/quill";
1+
import { CaretRightIcon, PlusIcon, StarIcon } from "@phosphor-icons/react";
2+
import { Button, cn } from "@posthog/quill";
93
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
104
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
115
import {
@@ -26,11 +20,12 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
2620
import { useMemo, useState } from "react";
2721

2822
/**
29-
* The project's space directory. A section label (like Starred/Spaces in the
30-
* channel list) that folds open to every shared space, alphabetically.
31-
* Clicking a row opens the space; the hover star is what pins it into the
32-
* sidebar above — pinned rows wear their star filled so the directory shows
33-
* what's already up there. The personal space isn't listed: it can't be
23+
* The project's space directory, docked at the bottom of the sidebar. The
24+
* header is shaped exactly like a pinned space row (same caret, same inset) and
25+
* folding it open grows the section upward to a capped height, then the list
26+
* scrolls. Clicking a row opens the space; the hover star is what pins it into
27+
* the sidebar above — pinned rows wear their star filled so the directory
28+
* shows what's already up there. The personal space isn't listed: it can't be
3429
* pinned or shared, and it's always first in the pinned list anyway.
3530
*/
3631
export function AllSpacesSection() {
@@ -82,89 +77,94 @@ export function AllSpacesSection() {
8277
};
8378

8479
return (
85-
<div className="flex flex-col gap-px px-2 pb-2">
86-
{/* Same header shape as the channel list's groups: MenuLabel carries the
87-
sidebar's label styling, and the section glyph swaps to a disclosure
88-
caret on hover or keyboard focus. */}
89-
<MenuLabel
90-
render={
91-
<button
92-
type="button"
93-
onClick={toggle}
94-
aria-expanded={open}
95-
className="group/all-spaces flex w-full items-center gap-2"
96-
/>
97-
}
80+
<div className="shrink-0 border-border border-t">
81+
<div
82+
className={cn(
83+
"flex flex-col gap-px px-2 pt-1",
84+
// Open, the section ends in rows with hover controls at their right
85+
// edge — clear the floating create button. The bare header can share
86+
// its row with it.
87+
open ? "pb-12" : "pb-2",
88+
)}
9889
>
99-
<span className="relative flex size-3.5 shrink-0 items-center justify-center">
100-
<span className="group-hover/all-spaces:hidden group-focus-visible/all-spaces:hidden">
101-
<CubeFocusIcon size={14} />
90+
{/* Same shape as a pinned space row, so the carets line up. */}
91+
<Button
92+
variant="default"
93+
left
94+
aria-expanded={open}
95+
onClick={toggle}
96+
className="w-full gap-1.5 text-left"
97+
>
98+
<CaretRightIcon
99+
size={12}
100+
className={cn(
101+
"shrink-0 text-muted-foreground transition-transform",
102+
open && "rotate-90",
103+
)}
104+
/>
105+
<span className="min-w-0 flex-1 truncate font-medium text-[13px] text-muted-foreground group-hover/button:text-foreground">
106+
All spaces
102107
</span>
103-
{open ? (
104-
<CaretDownIcon
105-
size={14}
106-
className="hidden group-hover/all-spaces:block group-focus-visible/all-spaces:block"
108+
</Button>
109+
110+
{open && (
111+
<>
112+
<div className="flex max-h-64 flex-col gap-px overflow-y-auto">
113+
{spaces.map((channel) => {
114+
const shortcutId = starredRefToShortcutId.get(channel.path);
115+
const base = `/website/${channel.id}`;
116+
const isActive =
117+
pathname === base || pathname.startsWith(`${base}/`);
118+
return (
119+
// Overlay, not endContent: the row is a button already, and
120+
// a star nested inside it would be a button within a button.
121+
<div key={channel.id} className="group/space relative">
122+
<SidebarItem
123+
depth={1}
124+
label={channel.name}
125+
isActive={isActive}
126+
onClick={() => openSpace(channel)}
127+
// Star well, so the name truncates clear of the star.
128+
endContent={
129+
<span aria-hidden className="size-5 shrink-0" />
130+
}
131+
/>
132+
<Button
133+
variant="default"
134+
size="icon-sm"
135+
aria-label={shortcutId ? "Unpin space" : "Pin space"}
136+
onClick={() => togglePin(channel)}
137+
className={cn(
138+
"-translate-y-1/2 absolute top-1/2 right-[2px] text-muted-foreground transition-opacity",
139+
shortcutId
140+
? "opacity-100"
141+
: "opacity-0 focus-visible:opacity-100 group-hover/space:opacity-100",
142+
)}
143+
>
144+
<StarIcon
145+
size={13}
146+
weight={shortcutId ? "fill" : "regular"}
147+
/>
148+
</Button>
149+
</div>
150+
);
151+
})}
152+
</div>
153+
{/* Below the scroll region so it's reachable without scrolling
154+
the whole directory. */}
155+
<SidebarItem
156+
depth={1}
157+
icon={<PlusIcon size={14} className="text-muted-foreground" />}
158+
label={<span className="text-muted-foreground">New space</span>}
159+
onClick={() => setCreateOpen(true)}
107160
/>
108-
) : (
109-
<CaretRightIcon
110-
size={14}
111-
className="hidden group-hover/all-spaces:block group-focus-visible/all-spaces:block"
161+
<CreateChannelModal
162+
open={createOpen}
163+
onOpenChange={setCreateOpen}
112164
/>
113-
)}
114-
</span>
115-
All spaces
116-
</MenuLabel>
117-
118-
{open && (
119-
<>
120-
{spaces.map((channel) => {
121-
const shortcutId = starredRefToShortcutId.get(channel.path);
122-
const base = `/website/${channel.id}`;
123-
const isActive =
124-
pathname === base || pathname.startsWith(`${base}/`);
125-
return (
126-
// Overlay, not endContent: the row is a button already, and a
127-
// star nested inside it would be a button within a button.
128-
<div key={channel.id} className="group/space relative">
129-
<SidebarItem
130-
depth={1}
131-
label={channel.name}
132-
isActive={isActive}
133-
onClick={() => openSpace(channel)}
134-
// Star well, so the name truncates clear of the hover star.
135-
endContent={<span aria-hidden className="size-5 shrink-0" />}
136-
/>
137-
<Button
138-
variant="default"
139-
size="icon-sm"
140-
aria-label={shortcutId ? "Unpin space" : "Pin space"}
141-
onClick={() => togglePin(channel)}
142-
className={cn(
143-
"-translate-y-1/2 absolute top-1/2 right-[2px] text-muted-foreground transition-opacity",
144-
shortcutId
145-
? "opacity-100"
146-
: "opacity-0 focus-visible:opacity-100 group-hover/space:opacity-100",
147-
)}
148-
>
149-
<StarIcon
150-
size={13}
151-
weight={shortcutId ? "fill" : "regular"}
152-
/>
153-
</Button>
154-
</div>
155-
);
156-
})}
157-
{/* The directory is also where a space that doesn't exist yet would
158-
be — so creating one starts here. */}
159-
<SidebarItem
160-
depth={1}
161-
icon={<PlusIcon size={14} className="text-muted-foreground" />}
162-
label={<span className="text-muted-foreground">New space</span>}
163-
onClick={() => setCreateOpen(true)}
164-
/>
165-
<CreateChannelModal open={createOpen} onOpenChange={setCreateOpen} />
166-
</>
167-
)}
165+
</>
166+
)}
167+
</div>
168168
</div>
169169
);
170170
}

packages/ui/src/features/canvas/components/ChannelHeader.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import { useNavigate, useRouterState } from "@tanstack/react-router";
1515

1616
// The shared channel header. Every space scene renders the same breadcrumb —
1717
// the root segment is identical whether or not there's a leaf, so the space
18-
// name doesn't change size between the space home and its sub-pages. The new
19-
// layout drops the section tab strip (the channel sidebar carries those
20-
// entries); flag off keeps it. Starring lives on the sidebar back row and the
18+
// name doesn't change size between the space home and its sub-pages. Both
19+
// layouts carry the section tab strip (the new one trails it after the
20+
// breadcrumb, Feed included). Starring lives on the sidebar back row and the
2121
// channel list, not here.
2222
export function ChannelHeader({
2323
channelId,
@@ -48,6 +48,10 @@ export function ChannelHeader({
4848
channelId={channelId}
4949
leafIcon={page ? channelPageIcon(page, { size: 12 }) : undefined}
5050
leafLabel={page ? channelPageLabel(page) : undefined}
51+
// The static sidebar's rows only fold their task lists, so the space's
52+
// pages need a switcher in the main view — the same strip the legacy
53+
// header carries, with Feed leading back to the root.
54+
trailing={<ChannelTabs channelId={channelId} includeHome />}
5155
/>
5256
);
5357
}

packages/ui/src/features/canvas/components/ChannelTabs.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Button, cn } from "@posthog/quill";
22
import { LOOPS_FLAG } from "@posthog/shared";
33
import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections";
44
import { ChannelPinnedMenu } from "@posthog/ui/features/canvas/components/ChannelPinnedMenu";
5+
import { channelPageLabel } from "@posthog/ui/features/canvas/components/channelPages";
56
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
67
import { Link, useRouterState } from "@tanstack/react-router";
78

@@ -11,16 +12,53 @@ const TABS = CHANNEL_SECTIONS.map((s) => ({
1112
to: `/website/$channelId/${s.key}` as const,
1213
}));
1314

15+
// The space-page variant: Feed leads (it's the space itself, and the way back
16+
// once you've tabbed away), Context follows it, and the labels come from the
17+
// channelPages table ("Context", not the legacy "CONTEXT.md").
18+
const SPACE_TABS = (["context", "loops", "artifacts", "history"] as const).map(
19+
(key) => ({
20+
key,
21+
label: channelPageLabel(key),
22+
to: `/website/$channelId/${key}` as const,
23+
}),
24+
);
25+
1426
// Home / History / Artifacts tab switcher shown in the channel header bar, with
1527
// a Pinned quick-access menu alongside. Pathname-driven active state (the
1628
// codebase's convention) rather than Link's activeProps.
17-
export function ChannelTabs({ channelId }: { channelId: string }) {
29+
export function ChannelTabs({
30+
channelId,
31+
includeHome,
32+
}: {
33+
channelId: string;
34+
/**
35+
* Adds a leading Feed tab for the space root. On: the header is the only tab
36+
* strip (channels layout). Off: legacy header, where the channel pill beside
37+
* this nav already links home.
38+
*/
39+
includeHome?: boolean;
40+
}) {
1841
const pathname = useRouterState({ select: (s) => s.location.pathname });
1942
const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV);
20-
const tabs = loopsEnabled ? TABS : TABS.filter((tab) => tab.key !== "loops");
43+
const sectionTabs = includeHome ? SPACE_TABS : TABS;
44+
const tabs = loopsEnabled
45+
? sectionTabs
46+
: sectionTabs.filter((tab) => tab.key !== "loops");
47+
const home = `/website/${channelId}`;
2148

2249
return (
2350
<nav className="flex items-center gap-px">
51+
{includeHome && (
52+
<Button
53+
variant="default"
54+
size="sm"
55+
data-selected={pathname === home || undefined}
56+
className={cn(pathname === home && "bg-fill-selected")}
57+
render={<Link to="/website/$channelId" params={{ channelId }} />}
58+
>
59+
{channelPageLabel("home")}
60+
</Button>
61+
)}
2462
{tabs.map((tab) => {
2563
const href = tab.to.replace("$channelId", channelId);
2664
const active = pathname === href;

packages/ui/src/features/canvas/components/ChannelsSidebar.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,12 @@ export function ChannelsSidebar() {
133133
</Box>
134134
{/* The static spaces sidebar (prototype): one nav where every space
135135
expands inline with its tasks beneath it. Replaces the panes
136-
slider under this flag. The Fab keeps create (task, canvas,
137-
space) in the same corner as the panes layout; bottom padding
138-
keeps the last row reachable under it. */}
136+
slider under this flag. The nav owns its own scroll regions
137+
(pinned spaces scroll; All spaces docks at the bottom); the Fab
138+
keeps create (task, canvas, space) in the same corner as the
139+
panes layout. */}
139140
<Box className="relative min-h-0 flex-1">
140-
<Box className="h-full overflow-y-auto pb-16">
141-
<SpacesSidebarNav />
142-
</Box>
141+
<SpacesSidebarNav />
143142
<ChannelsFab />
144143
</Box>
145144
</>

0 commit comments

Comments
 (0)