Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 30a35d6

Browse files
authored
feat(ui): three ways out of the pinned list — drag out, context menu, hover star
Unpinning a starred space was buried in the All spaces directory. Now: dragging a pinned row and releasing it outside the list unpins it (in-place drops stay safe — the row itself is still the drop target), every space row gets a right-click menu (Open space / New task / Unpin space), and hover reveals a filled star between the plus and the gear that unpins directly. #me stays fixed and offers no unpin. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986
1 parent 7c457dd commit 30a35d6

2 files changed

Lines changed: 168 additions & 76 deletions

File tree

‎packages/ui/src/features/canvas/components/SpaceSection.tsx‎

Lines changed: 141 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1-
import { CaretRightIcon, GearSixIcon, PlusIcon } from "@phosphor-icons/react";
1+
import {
2+
CaretRightIcon,
3+
GearSixIcon,
4+
PlusIcon,
5+
StarIcon,
6+
} from "@phosphor-icons/react";
27
import { isOwnedBy } from "@posthog/core/canvas/channelItems";
3-
import { Button, cn, Skeleton } from "@posthog/quill";
8+
import {
9+
Button,
10+
ContextMenu,
11+
ContextMenuContent,
12+
ContextMenuItem,
13+
ContextMenuSeparator,
14+
ContextMenuTrigger,
15+
cn,
16+
Skeleton,
17+
} from "@posthog/quill";
418
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
519
import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow";
620
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
721
import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems";
22+
import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars";
823
import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels";
24+
import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
925
import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels";
1026
import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore";
1127
import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore";
@@ -23,10 +39,13 @@ const INITIAL_ROWS = 5;
2339
/**
2440
* One pinned space in the static sidebar. The whole row is one click target:
2541
* it folds the space's task list open and closed (caret included — no separate
26-
* hover zones). Hovering reveals two controls on the right: a plus that files
27-
* a new task into this space, and the gear that opens the space itself in the
28-
* main view, where the header tabs (Feed/Context/Loops/Artifacts) live. #me
29-
* wears its lock in the same right-hand well, stepping aside on hover.
42+
* hover zones). Hovering reveals the controls on the right: a plus that files
43+
* a new task into this space, a filled star that unpins it, and the gear that
44+
* opens the space itself in the main view, where the header tabs
45+
* (Feed/Context/Loops/Artifacts) live. Right-click carries the same actions
46+
* as a menu, and dragging the row out of the pinned list also unpins. #me
47+
* wears its lock in the same right-hand well, stepping aside on hover, and
48+
* can't be unpinned.
3049
*
3150
* An opened space leads with its first five items and a "Show more" that
3251
* unfolds the rest inline — the pinned-spaces region around these sections is
@@ -61,6 +80,8 @@ export function SpaceSection({
6180
const base = `/website/${channel.id}`;
6281
const isActive = pathname === base || pathname.startsWith(`${base}/`);
6382
const isUnread = useIsChannelUnread()(channel.name);
83+
const isPersonal = channel.name === PERSONAL_CHANNEL_NAME;
84+
const { toggleStar } = useChannelStarToggle(channel);
6485
// Only #me has a glyph under the layout (its lock) — same rule as
6586
// ChannelBackRow; it sits in the trailing well, not in front of the name.
6687
const glyph = channelGlyph(channel.name, {
@@ -135,83 +156,130 @@ export function SpaceSection({
135156
});
136157
};
137158

159+
const newTask = () => {
160+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
161+
action_type: "new_task_open",
162+
surface: "sidebar",
163+
channel_id: channel.id,
164+
});
165+
openTaskInput({ channelId: channel.id });
166+
};
167+
168+
// Unpinning removes the space from this list; it stays reachable (and
169+
// re-pinnable) in the All spaces directory below.
170+
const unpin = () => {
171+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
172+
action_type: "unstar",
173+
surface: "sidebar",
174+
channel_id: channel.id,
175+
});
176+
toggleStar();
177+
};
178+
138179
// While searching, a space is its matches — none means the whole section
139180
// steps aside rather than listing an empty shell.
140181
if (searching && sectionItems.length === 0) return null;
141182

142183
return (
143184
<div>
144-
<div className="group/space relative">
145-
<Button
146-
ref={dragHandleRef}
147-
variant="default"
148-
left
149-
aria-expanded={open}
150-
data-selected={isActive || undefined}
151-
className="w-full gap-1.5 text-left data-selected:bg-fill-selected"
152-
onClick={() => toggle(channel.id)}
153-
>
154-
<CaretRightIcon
155-
size={12}
156-
className={cn(
157-
"shrink-0 text-muted-foreground transition-transform",
158-
open && "rotate-90",
159-
)}
160-
/>
161-
<span
162-
className={cn(
163-
"min-w-0 flex-1 truncate text-[13px]",
164-
// Bold is unread's alone; full contrast is shared with the space
165-
// you're in — the same vocabulary as the channel list rows.
166-
isUnread ? "font-bold" : "font-medium",
167-
isUnread || isActive
168-
? "text-foreground"
169-
: "text-muted-foreground group-hover/button:text-foreground",
170-
)}
185+
{/* Right-click carries the row's management — the discoverable path to
186+
everything the hover controls do. */}
187+
<ContextMenu>
188+
<ContextMenuTrigger render={<div className="group/space relative" />}>
189+
<Button
190+
ref={dragHandleRef}
191+
variant="default"
192+
left
193+
aria-expanded={open}
194+
data-selected={isActive || undefined}
195+
className="w-full gap-1.5 text-left data-selected:bg-fill-selected"
196+
onClick={() => toggle(channel.id)}
171197
>
172-
{channel.name}
173-
</span>
174-
{/* Trailing well, reserved so the name truncates clear of the lock
175-
and the two hover controls rather than shifting when they appear. */}
176-
<span aria-hidden className="h-6 w-12 shrink-0" />
177-
</Button>
178-
{/* Overlays, not children — the row is a button already. The lock
198+
<CaretRightIcon
199+
size={12}
200+
className={cn(
201+
"shrink-0 text-muted-foreground transition-transform",
202+
open && "rotate-90",
203+
)}
204+
/>
205+
<span
206+
className={cn(
207+
"min-w-0 flex-1 truncate text-[13px]",
208+
// Bold is unread's alone; full contrast is shared with the space
209+
// you're in — the same vocabulary as the channel list rows.
210+
isUnread ? "font-bold" : "font-medium",
211+
isUnread || isActive
212+
? "text-foreground"
213+
: "text-muted-foreground group-hover/button:text-foreground",
214+
)}
215+
>
216+
{channel.name}
217+
</span>
218+
{/* Trailing well, reserved so the name truncates clear of the lock
219+
and the hover controls rather than shifting when they appear.
220+
#me has two controls (new task, open); shared spaces add the
221+
unpin star between them. */}
222+
<span
223+
aria-hidden
224+
className={cn("h-6 shrink-0", isPersonal ? "w-12" : "w-[72px]")}
225+
/>
226+
</Button>
227+
{/* Overlays, not children — the row is a button already. The lock
179228
yields its spot to the controls on hover so the well never doubles
180229
up. */}
181-
{glyph && (
182-
<span
183-
aria-hidden
184-
className="-translate-y-1/2 pointer-events-none absolute top-1/2 right-[11px] flex items-center transition-opacity group-hover/space:opacity-0"
230+
{glyph && (
231+
<span
232+
aria-hidden
233+
className="-translate-y-1/2 pointer-events-none absolute top-1/2 right-[11px] flex items-center transition-opacity group-hover/space:opacity-0"
234+
>
235+
{glyph}
236+
</span>
237+
)}
238+
<Button
239+
variant="default"
240+
size="icon-sm"
241+
aria-label={`New task in ${channel.name}`}
242+
onClick={newTask}
243+
className={cn(
244+
"-translate-y-1/2 absolute top-1/2 text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/space:opacity-100",
245+
isPersonal ? "right-[30px]" : "right-[54px]",
246+
)}
185247
>
186-
{glyph}
187-
</span>
188-
)}
189-
<Button
190-
variant="default"
191-
size="icon-sm"
192-
aria-label={`New task in ${channel.name}`}
193-
onClick={() => {
194-
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
195-
action_type: "new_task_open",
196-
surface: "sidebar",
197-
channel_id: channel.id,
198-
});
199-
openTaskInput({ channelId: channel.id });
200-
}}
201-
className="-translate-y-1/2 absolute top-1/2 right-[30px] text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/space:opacity-100"
202-
>
203-
<PlusIcon size={14} />
204-
</Button>
205-
<Button
206-
variant="default"
207-
size="icon-sm"
208-
aria-label={`Open ${channel.name}`}
209-
onClick={openSpace}
210-
className="-translate-y-1/2 absolute top-1/2 right-[6px] text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/space:opacity-100"
211-
>
212-
<GearSixIcon size={14} />
213-
</Button>
214-
</div>
248+
<PlusIcon size={14} />
249+
</Button>
250+
{/* Filled: this row is pinned, and the star is the way out. */}
251+
{!isPersonal && (
252+
<Button
253+
variant="default"
254+
size="icon-sm"
255+
aria-label={`Unpin ${channel.name}`}
256+
onClick={unpin}
257+
className="-translate-y-1/2 absolute top-1/2 right-[30px] text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/space:opacity-100"
258+
>
259+
<StarIcon size={14} weight="fill" />
260+
</Button>
261+
)}
262+
<Button
263+
variant="default"
264+
size="icon-sm"
265+
aria-label={`Open ${channel.name}`}
266+
onClick={openSpace}
267+
className="-translate-y-1/2 absolute top-1/2 right-[6px] text-muted-foreground opacity-0 transition-opacity focus-visible:opacity-100 group-hover/space:opacity-100"
268+
>
269+
<GearSixIcon size={14} />
270+
</Button>
271+
</ContextMenuTrigger>
272+
<ContextMenuContent className="w-48">
273+
<ContextMenuItem onClick={openSpace}>Open space</ContextMenuItem>
274+
<ContextMenuItem onClick={newTask}>New task</ContextMenuItem>
275+
{!isPersonal && (
276+
<>
277+
<ContextMenuSeparator />
278+
<ContextMenuItem onClick={unpin}>Unpin space</ContextMenuItem>
279+
</>
280+
)}
281+
</ContextMenuContent>
282+
</ContextMenu>
215283

216284
{/* Tasks under the space, pinned first. Same inset as the channel
217285
groups' trees (pl-5). A search opens every matching space. */}

‎packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,11 @@ export function SpacesSidebarNav() {
9090
: undefined;
9191

9292
// Dropping an All-spaces row into this region pins it (stars it), same as
93-
// the directory's star but as one gesture.
93+
// the directory's star but as one gesture; dragging a pinned row out again
94+
// unpins it.
9495
const { channels } = useChannels();
9596
const { starredRefToShortcutId } = useChannelStars();
96-
const { star } = useChannelStarMutations();
97+
const { star, unstar } = useChannelStarMutations();
9798
const [isSpaceDropTarget, setIsSpaceDropTarget] = useState(false);
9899

99100
const me = pinnedSpaces.find((c) => c.name === PERSONAL_CHANNEL_NAME);
@@ -150,7 +151,30 @@ export function SpacesSidebarNav() {
150151
if (event.canceled) return;
151152
const sourceId = event.operation.source?.id;
152153
const targetId = event.operation.target?.id;
153-
if (!sourceId || !targetId || sourceId === targetId) return;
154+
if (!sourceId) return;
155+
// Released over no pinned row — the drag left the list, which unpins.
156+
// In-place drops are safe: the row under the pointer (itself included)
157+
// is still the target.
158+
if (!targetId) {
159+
const channel = channels.find((c) => c.id === String(sourceId));
160+
const shortcutId = channel && starredRefToShortcutId.get(channel.path);
161+
if (!channel || !shortcutId) return;
162+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
163+
action_type: "unstar",
164+
surface: "sidebar",
165+
channel_id: channel.id,
166+
});
167+
unstar(shortcutId).catch((error: unknown) =>
168+
toast.error("Couldn't unpin space", {
169+
description: error instanceof Error ? error.message : String(error),
170+
}),
171+
);
172+
setSpaceOrder(
173+
orderedSpaces.map((c) => c.id).filter((id) => id !== channel.id),
174+
);
175+
return;
176+
}
177+
if (sourceId === targetId) return;
154178
const ids = orderedSpaces.map((c) => c.id);
155179
const from = ids.indexOf(String(sourceId));
156180
const to = ids.indexOf(String(targetId));

0 commit comments

Comments
 (0)