1- import { CaretRightIcon , GearSixIcon , PlusIcon } from "@phosphor-icons/react" ;
1+ import {
2+ CaretRightIcon ,
3+ GearSixIcon ,
4+ PlusIcon ,
5+ StarIcon ,
6+ } from "@phosphor-icons/react" ;
27import { 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" ;
418import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events" ;
519import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow" ;
620import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph" ;
721import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems" ;
22+ import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars" ;
823import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels" ;
24+ import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels" ;
925import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels" ;
1026import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore" ;
1127import { 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. */ }
0 commit comments