From 677d5b225b452ef06e4afd3820a65444acf61980 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 31 Jul 2026 21:53:25 +0200 Subject: [PATCH 01/26] feat(ui): prototype static spaces sidebar under code-spaces-layout Generated-By: PostHog Code Task-Id: 7698cad8-55a1-4e67-9657-91ce3e8f0a52 --- .../components/ChannelsSidebar.test.tsx | 280 +++-------------- .../canvas/components/ChannelsSidebar.tsx | 124 +------- .../canvas/components/SpaceSection.tsx | 257 +++++++++++++++ .../canvas/components/SpacesSidebarNav.tsx | 293 ++++++++++++++++++ .../canvas/stores/spacesSidebarStore.ts | 38 +++ 5 files changed, 637 insertions(+), 355 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/SpaceSection.tsx create mode 100644 packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx create mode 100644 packages/ui/src/features/canvas/stores/spacesSidebarStore.ts diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx index 6db5d123b4..fd6f024382 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -1,6 +1,6 @@ import { Theme } from "@radix-ui/themes"; -import { act, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ featureFlags: new Map(), @@ -11,7 +11,6 @@ const mocks = vi.hoisted(() => ({ archivedTaskIds: new Set(), navigateToArchived: vi.fn(), track: vi.fn(), - routeChannelId: undefined as string | undefined, })); vi.mock("@posthog/ui/shell/analytics", () => ({ @@ -42,13 +41,8 @@ vi.mock("@posthog/ui/router/navigationBridge", () => ({ // The sidebar's children each mount their own query stack; this suite is about // the shell's own decisions, so they're stubbed out. -vi.mock("@posthog/ui/features/canvas/components/ChannelNav", () => ({ - ChannelNav: () => null, -})); -vi.mock("@posthog/ui/features/canvas/components/ChannelSidebar", () => ({ - ChannelSidebar: ({ channelId }: { channelId: string }) => ( -
{channelId}
- ), +vi.mock("@posthog/ui/features/canvas/components/SpacesSidebarNav", () => ({ + SpacesSidebarNav: () =>
, })); vi.mock("@posthog/ui/features/canvas/components/ChannelsList", () => ({ ChannelsList: () =>
, @@ -77,17 +71,9 @@ vi.mock("@posthog/ui/features/loops/components/LoopsPromoCard", () => ({ vi.mock("@posthog/ui/features/workspace/useWorkspace", () => ({ useWorkspaces: () => ({ data: {}, isFetched: true }), })); -vi.mock("@tanstack/react-router", () => ({ - useParams: () => ({ channelId: mocks.routeChannelId }), -})); import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { - showChannelList, - useChannelPaneStore, -} from "@posthog/ui/features/canvas/stores/channelPaneStore"; -import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { ChannelsSidebar } from "./ChannelsSidebar"; @@ -110,140 +96,53 @@ describe("ChannelsSidebar", () => { mocks.channelsLoading = false; mocks.archivedTaskIds = new Set(); mocks.track.mockClear(); - mocks.routeChannelId = undefined; - useCurrentChannelStore.setState({ currentChannelId: null }); - useChannelPaneStore.setState({ pane: "channel" }); useSidebarStore.setState({ channelsEnabled: false, open: true }); }); - // The sidebar is a two-pane slider: the channel list, and the channel you're - // in. Both stay mounted, so "which one is showing" is the offscreen pane - // being inert rather than unmounted. - describe("the channel-list slider", () => { - const ENG = { id: "eng-id", name: "eng", path: "/eng" }; - const listIsInteractive = () => - !screen.getByTestId("channels-list").parentElement?.hasAttribute("inert"); - - beforeEach(() => { + describe("the static spaces nav", () => { + // Under `code-spaces-layout` the slider is gone: the shell renders the + // static nav where every space expands with its tasks beneath it. + it("renders the static spaces nav instead of the panes", () => { mocks.channelsLayout = true; - mocks.channels = [ME, ENG]; - }); - - it("rests on the channel you're in", () => { - mocks.routeChannelId = ENG.id; + mocks.channels = [ME]; renderSidebar(); - expect(screen.getByTestId("channel-sidebar").textContent).toBe(ENG.id); - expect(listIsInteractive()).toBe(false); + expect(screen.getByTestId("spaces-sidebar-nav")).toBeTruthy(); + expect(screen.queryByTestId("channels-list")).toBeNull(); }); - // Browsing the list is a sidebar move, not a navigation: the channel stays - // scoped, so the main pane keeps showing what it was showing. - it("shows the list on the way back, without leaving the channel", () => { - mocks.routeChannelId = ENG.id; + it("fires space-viewed tracking from the shell", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng-id", name: "eng", path: "/eng" }]; renderSidebar(); - - act(() => showChannelList()); - - expect(listIsInteractive()).toBe(true); - expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id); + expect(mocks.track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + { channel_count: 1, starred_count: 0, layout: "channels" }, + ); }); - // Opening a channel from anywhere — a deep link, a mention, ⌘1-9 — has to - // land on the channel even if the list was left open. - it("follows the route back into a channel", () => { - mocks.routeChannelId = ENG.id; + it("fires again after leaving and re-entering the channels world", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; const { rerender } = renderSidebar(); - act(() => showChannelList()); - mocks.routeChannelId = ME.id; + mocks.channelsLayout = false; + rerender( + + + , + ); + mocks.channelsLayout = true; rerender( , ); - expect(listIsInteractive()).toBe(false); - expect(screen.getByTestId("channel-sidebar").textContent).toBe(ME.id); - }); - - it("stays on the list while no channel resolves", () => { - mocks.channels = [ENG]; - renderSidebar(); - expect(listIsInteractive()).toBe(true); - expect(screen.queryByTestId("channel-sidebar")).toBeNull(); - }); - - // A trackpad swipe reaches the panes as a horizontal wheel. Right (negative - // deltaX, the platform "back" direction) leaves the channel; left returns to - // the one still scoped. - describe("swiping", () => { - // Wheel deltas within one gesture arrive back to back; a pause between - // them is what ends it. Fake timers let a test say which it's sending. - const wheel = (deltaX: number, deltaY = 0) => - act(() => { - screen.getByTestId("channels-list").dispatchEvent( - new WheelEvent("wheel", { - deltaX, - deltaY, - bubbles: true, - cancelable: true, - }), - ); - }); - const pause = () => act(() => void vi.advanceTimersByTime(500)); - - beforeEach(() => { - vi.useFakeTimers(); - mocks.routeChannelId = ENG.id; - }); - afterEach(() => vi.useRealTimers()); - - it("goes back to the list and forward into the channel", () => { - renderSidebar(); - - wheel(-80); - expect(listIsInteractive()).toBe(true); - // The channel is browsed away from, not left. - expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id); - - pause(); - wheel(80); - expect(listIsInteractive()).toBe(false); - }); - - // One flick is dozens of small deltas, so the distance has to add up - // across them rather than be read off any one event. - it("adds a gesture's deltas up", () => { - renderSidebar(); - wheel(-20); - expect(listIsInteractive()).toBe(false); - wheel(-20); - wheel(-20); - expect(listIsInteractive()).toBe(true); - }); - - it("ignores a mostly-vertical wheel", () => { - renderSidebar(); - wheel(-80, -200); - expect(listIsInteractive()).toBe(false); - }); - - it("forgets a nudge once the gesture ends", () => { - renderSidebar(); - wheel(-30); - pause(); - wheel(-30); - expect(listIsInteractive()).toBe(false); - }); - - // The momentum tail of one flick keeps delivering deltas; read as fresh - // travel they'd swipe straight back to where the flick started. - it("does not let one flick's momentum swipe twice", () => { - renderSidebar(); - wheel(-80); - wheel(200); - expect(listIsInteractive()).toBe(true); - }); + expect( + mocks.track.mock.calls.filter( + ([event]) => event === ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + ), + ).toHaveLength(2); }); }); @@ -284,117 +183,18 @@ describe("ChannelsSidebar", () => { }); }); - describe("auto-scoping to #me", () => { - it("keeps a deep-linked channel instead of overwriting it with #me", () => { - mocks.channelsLayout = true; - mocks.channels = [ME, { id: "eng-id", name: "eng", path: "/eng" }]; - mocks.routeChannelId = "eng-id"; - - renderSidebar(); - - expect(useCurrentChannelStore.getState().currentChannelId).toBe("eng-id"); - }); - - it("scopes to the personal channel once the list lands", () => { - mocks.channelsLayout = true; - mocks.channels = [ME]; - renderSidebar(); - expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); - }); - - // Both flags behind the layout re-evaluate on every flags payload, so a - // momentary false must not permanently strand the sidebar unscoped: the - // auto-scope latch has to reset when the layout turns off. - it("re-scopes after the layout flag flickers off and back on", () => { - mocks.channelsLayout = true; - mocks.channels = [ME]; - const { rerender } = renderSidebar(); - expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); - - mocks.channelsLayout = false; - rerender( - - - , - ); - expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); - - mocks.channelsLayout = true; - rerender( - - - , - ); - expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); - }); - - it("does not scope to a channel the project does not have", () => { - mocks.channelsLayout = true; - mocks.channels = [{ id: "eng", name: "eng", path: "/eng" }]; - renderSidebar(); - expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); - expect(screen.queryByTestId("channel-sidebar")).toBeNull(); - }); - - // A stale id from a previous project must not be rendered as a channel. - it("clears a scoped channel missing from the loaded list", () => { - mocks.channelsLayout = true; - mocks.channels = [ME]; - useCurrentChannelStore.setState({ currentChannelId: "from-old-project" }); - renderSidebar(); - expect(useCurrentChannelStore.getState().currentChannelId).not.toBe( - "from-old-project", - ); - }); - }); - it("renders the flag-off sidebar menu untouched", () => { renderSidebar(); expect(screen.getByTestId("sidebar-menu")).toBeTruthy(); expect(screen.getByTestId("sidebar-nav-section")).toBeTruthy(); }); - describe("space-viewed tracking", () => { - // The event used to fire from ChannelsList, which the new layout barely - // renders — so space adoption would have read as zero once the flag landed. - it("fires from the shell under the channels layout", () => { - mocks.channelsLayout = true; - mocks.channels = [ME, { id: "eng", name: "eng", path: "/eng" }]; - renderSidebar(); - expect(mocks.track).toHaveBeenCalledWith( - ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, - { channel_count: 1, starred_count: 0, layout: "channels" }, - ); - }); - - it("does not fire outside the channels world", () => { - mocks.channels = [ME]; - renderSidebar(); - expect(mocks.track).not.toHaveBeenCalledWith( - ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, - expect.anything(), - ); - }); - - it("fires again after leaving and re-entering the channels world", () => { - mocks.channelsLayout = true; - mocks.channels = [ME]; - const { rerender } = renderSidebar(); - - mocks.channelsLayout = false; - rerender( - - - , - ); - mocks.channelsLayout = true; - rerender( - - - , - ); - - expect(mocks.track).toHaveBeenCalledTimes(2); - }); + it("does not fire tracking outside the channels world", () => { + mocks.channels = [ME]; + renderSidebar(); + expect(mocks.track).not.toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + expect.anything(), + ); }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 05f0d80851..7b5dd730cf 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,23 +1,13 @@ import { ArchiveIcon } from "@phosphor-icons/react"; -import { cn, Separator } from "@posthog/quill"; +import { Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; -import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; -import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSidebar"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; -import { useChannelPaneSwipe } from "@posthog/ui/features/canvas/hooks/useChannelPaneSwipe"; +import { SpacesSidebarNav } from "@posthog/ui/features/canvas/components/SpacesSidebarNav"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; -import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed"; -import { - showChannelList, - showChannelPane, - useChannelPaneStore, -} from "@posthog/ui/features/canvas/stores/channelPaneStore"; -import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; @@ -35,69 +25,12 @@ import { } from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; -import { ErrorBoundary } from "@posthog/ui/primitives/ErrorBoundary"; import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; -import { useParams } from "@tanstack/react-router"; -import { useDeferredValue, useEffect, useRef } from "react"; - -/** - * The sidebar slider: the channel list and the channel you're in, laid out side - * by side in a track that translates between them. - * - * Both panes stay mounted so the slide has something to slide, and so coming - * back to the list doesn't rebuild every row's menus and dialogs. The offscreen - * one is `inert`, keeping it out of the tab order and off screen readers. - * - * A two-finger horizontal swipe moves between them, so the back row isn't the - * only way out of a channel — and swiping the other way returns to the channel - * that stayed scoped the whole time. - */ -function ChannelPanes({ - channelId, - showList, -}: { - channelId: string | null; - showList: boolean; -}) { - const panesRef = useRef(null); - useChannelPaneSwipe(panesRef, { - // With no channel to slide to, the list is all there is — leave the gesture - // to the platform rather than eat it for a slide that can't happen. - enabled: channelId != null, - onBack: showChannelList, - onForward: showChannelPane, - }); +import { useDeferredValue, useEffect } from "react"; - return ( - -
-
- - -
-
- {channelId && ( - } - resetKey={channelId} - > - - - )} -
-
-
- ); -} export function ChannelsSidebar() { const width = useChannelsSidebarStore((state) => state.width); const setWidth = useChannelsSidebarStore((state) => state.setWidth); @@ -166,49 +99,6 @@ export function ChannelsSidebar() { const archivedTaskIds = useArchivedTaskIds(); - const params = useParams({ strict: false }); - const routeChannelId = params.channelId; - const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); - const { currentChannelId, channels } = useCurrentChannel({ - enabled: channelsLayout, - }); - useEffect(() => { - if (!channelsLayout || !routeChannelId) return; - setCurrentChannel(routeChannelId); - // Landing on a channel — a deep link, a mention, ⌘1-9 — is a request to see - // it, so the slider follows the route even if the list was being browsed. - showChannelPane(); - }, [channelsLayout, routeChannelId, setCurrentChannel]); - - // Browsing the list is view state, not navigation: you stay in the channel - // (route and main pane unchanged) while you look around. With no channel to - // slide to there's only the list. - const pane = useChannelPaneStore((s) => s.pane); - const showList = pane === "list" || currentChannelId == null; - - const autoScopedRef = useRef(false); - useEffect(() => { - if (!channelsLayout) { - autoScopedRef.current = false; - return; - } - // A route-scoped channel wins over the default. Both effects run from the - // same render on a cold deep link, so without this guard the route effect - // writes its channel and this later effect immediately overwrites it with - // #me using the stale `currentChannelId` captured by that render. - if (routeChannelId || autoScopedRef.current || currentChannelId) return; - const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - if (!me) return; - autoScopedRef.current = true; - setCurrentChannel(me.id); - }, [ - channelsLayout, - channels, - currentChannelId, - routeChannelId, - setCurrentChannel, - ]); - return ( - - + {/* The static spaces sidebar (prototype): one nav where every space + expands inline with its tasks beneath it. Replaces the panes + slider under this flag. */} + + + ) : bodyChannelsWorld ? ( <> diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx new file mode 100644 index 0000000000..0ec1a40fec --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -0,0 +1,257 @@ +import { + CaretRightIcon, + CubeIcon, + LinkIcon, + PencilSimpleIcon, + StarIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; +import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; +import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; +import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; +import { useRouterState } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; + +const RECENTS_CAP = 10; + +/** + * One pinned space in the static sidebar: an expandable header row, and beneath + * it the space's tasks (the same `ChannelItemRow`s the channel pane renders). + * Expand state is local view state in `spacesSidebarStore`; the space's + * presence in the sidebar is the user's star. + */ +export function SpaceSection({ channel }: { channel: Channel }) { + const open = useSpacesSidebarStore((s) => !!s.openSections[channel.id]); + const toggle = useSpacesSidebarStore((s) => s.toggle); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const base = `/website/${channel.id}`; + const isActive = pathname === base || pathname.startsWith(`${base}/`); + + const { items, actions, isLoading } = useChannelItems(channel.id); + const { toggleStar } = useChannelStarToggle(channel); + const [menuOpen, setMenuOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); + const [editingTaskId, setEditingTaskId] = useState(null); + const { renameTask } = useRenameTask(); + + const { data: allTasks = [] } = useTasks({ showAllUsers: true }); + const allTaskIds = useMemo( + () => new Set(allTasks.map((t) => t.id)), + [allTasks], + ); + const commandCenterCells = useCommandCenterStore((state) => state.cells); + const assignTaskToCommandCenter = useCommandCenterStore( + (state) => state.assignTask, + ); + + const activeKey = useMemo(() => { + const dashboard = pathname.match(/\/dashboards\/([^/]+)$/); + if (dashboard) return `canvas:${dashboard[1]}`; + const task = pathname.match(/\/tasks\/([^/]+)$/); + return task ? `task:${task[1]}` : null; + }, [pathname]); + + const sectionItems = useMemo( + () => + [ + ...items.filter((i) => i.pinned), + ...items.filter((i) => !i.pinned), + ].slice(0, RECENTS_CAP), + [items], + ); + + const commandCenterAssigner = (taskId: string) => { + const cellIndex = commandCenterCells.findIndex( + (cellTaskId) => cellTaskId == null || !allTaskIds.has(cellTaskId), + ); + if (cellIndex === -1) return undefined; + return () => assignTaskToCommandCenter(cellIndex, taskId); + }; + + const glyph = channelGlyph(channel.name, { size: 14, space: true }) ?? ( + + ); + + return ( +
+ {/* Header: chevron toggles the task list; the row itself opens the space. */} +
+ + + + + + } + > + ··· + + + { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "unstar", + surface: "sidebar", + channel_id: channel.id, + }); + toggleStar(); + }} + > + + Remove from sidebar + + void copyChannelLink(channel.id, "sidebar")} + > + + Copy link + + setRenameOpen(true)}> + + Rename + + + + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "delete", + surface: "sidebar", + channel_id: channel.id, + }) + } + > + + Delete space + + + + +
+ + {/* Tasks under the space */} + {open && ( +
+ {isLoading && sectionItems.length === 0 ? ( +
+ Loading… +
+ ) : sectionItems.length === 0 ? ( +
+ No tasks here yet. +
+ ) : ( + sectionItems.map((item) => ( + setEditingTaskId(item.id) + : undefined + } + onAddToCommandCenter={ + item.kind === "task" && !commandCenterCells.includes(item.id) + ? commandCenterAssigner(item.id) + : undefined + } + onEditSubmit={ + item.kind === "task" + ? async (newTitle) => { + setEditingTaskId(null); + try { + await renameTask({ + taskId: item.id, + currentTitle: item.title, + newTitle, + }); + } catch (error) { + toast.error("Couldn't rename task", { + description: + error instanceof Error + ? error.message + : String(error), + }); + } + } + : undefined + } + onEditCancel={() => setEditingTaskId(null)} + /> + )) + )} +
+ )} + + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx new file mode 100644 index 0000000000..f5d6bddafc --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -0,0 +1,293 @@ +import { + CaretRightIcon, + CubeIcon, + HouseIcon, + ListChecks, + PlusIcon, + Robot, + Tray, +} from "@phosphor-icons/react"; +import { + Autocomplete, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + Button, + cn, + Popover, + PopoverContent, + PopoverTrigger, + Separator, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection"; +import { useChannelStarMutations } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; +import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; +import { CountBadge } from "@posthog/ui/primitives/CountBadge"; +import { toast } from "@posthog/ui/primitives/toast"; +import { + navigateToCode, + navigateToInbox, +} from "@posthog/ui/router/navigationBridge"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; +import { useRouterState } from "@tanstack/react-router"; +import { useState } from "react"; + +/** + * The static spaces nav: Home / Tasks / Inbox, then every starred space with + * its tasks expanded inline beneath it, then "Add space" (star an existing + * space or create a new one) and the pinned-agents section. Replaces the + * panes slider under `code-spaces-layout`. + */ +export function SpacesSidebarNav() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isHome = + pathname === "/code" || pathname === "/code/" || pathname === "/"; + const isTasks = + pathname.startsWith("/code/tasks") || pathname.startsWith("/website"); + const isInbox = + pathname.startsWith("/code/inbox") || pathname.startsWith("/inbox"); + + const { slots: pinnedSpaces } = useStarredChannelSlots(); + const { star } = useChannelStarMutations(); + const { channels } = useChannels(); + const { counts } = useInboxAllReports({ + ignoreFilters: true, + refetchIntervalMs: 60_000, + }); + + const openAgents = useSpacesSidebarStore((s) => s.openAgents); + const toggleAgents = useSpacesSidebarStore((s) => s.toggleAgents); + + const [addOpen, setAddOpen] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + + // The list is every channel not already pinned; the personal channel isn't a + // thing you pin to this list. + const addable = channels.filter( + (c) => + c.name !== PERSONAL_CHANNEL_NAME && + !pinnedSpaces.some((p) => p.id === c.id), + ); + + const addExisting = (channelId: string | null) => { + if (!channelId) return; + const channel = channels.find((c) => c.id === channelId); + if (!channel) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "star", + surface: "sidebar", + channel_id: channel.id, + }); + star(channel).catch((error: unknown) => + toast.error("Couldn't add space", { + description: error instanceof Error ? error.message : String(error), + }), + ); + useSpacesSidebarStore.getState().setOpen(channel.id, true); + setAddOpen(false); + }; + + return ( + <> + {/* Nav: Home / Tasks / Inbox */} +
+ + + + {/* Inbox, with "+" and chevron affordances on hover */} +
+ + + + + +
+
+ + {/* Pinned spaces */} +
+ {pinnedSpaces.map((space) => ( + + ))} + + {/* Add space */} + + + } + > + + + + Add space + + + c.id)} + onValueChange={(value) => addExisting(value ?? null)} + > +
+ +
+ + {addable.length === 0 && ( +
+ No more spaces to add. +
+ )} + {addable.map((c) => ( + + + + + {c.name} + + ))} +
+
+ +
+ +
+
+
+
+ + {/* Pinned agents (prototype placeholder) */} +
+
+ + + + +
+ {openAgents && ( +
+ Agents you pin will show here. +
+ )} +
+ + + + ); +} diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts new file mode 100644 index 0000000000..58c38ba5b5 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -0,0 +1,38 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +/** + * View state for the static spaces sidebar: which space sections are expanded. + * Unexpanded is the default (mock shows collapse chevron-down for the first + * space only), so the map stores explicit expansions. `openAgents` toggles the + * pinned-agents placeholder section. + */ +interface SpacesSidebarState { + openSections: Record; + openAgents: boolean; + setOpen: (channelId: string, open: boolean) => void; + toggle: (channelId: string) => void; + toggleAgents: () => void; +} + +export const useSpacesSidebarStore = create()( + persist( + (set) => ({ + openSections: {}, + openAgents: false, + setOpen: (channelId, open) => + set((state) => ({ + openSections: { ...state.openSections, [channelId]: open }, + })), + toggle: (channelId) => + set((state) => ({ + openSections: { + ...state.openSections, + [channelId]: !state.openSections[channelId], + }, + })), + toggleAgents: () => set((state) => ({ openAgents: !state.openAgents })), + }), + { name: "spaces-sidebar" }, + ), +); From 60c7159a84e0ef1a5a0993555dd15d91729b1ba5 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 31 Jul 2026 22:05:13 +0200 Subject: [PATCH 02/26] refactor(ui): align spaces sidebar with existing rows and full add-space toggle Generated-By: PostHog Code Task-Id: 7698cad8-55a1-4e67-9657-91ce3e8f0a52 --- .../canvas/components/SpaceSection.tsx | 9 - .../canvas/components/SpacesSidebarNav.tsx | 420 +++++++++--------- .../stores/spacesSidebarSelectionStore.ts | 19 + .../canvas/stores/spacesSidebarStore.ts | 5 + 4 files changed, 224 insertions(+), 229 deletions(-) create mode 100644 packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index 0ec1a40fec..5592557f3b 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -1,6 +1,5 @@ import { CaretRightIcon, - CubeIcon, LinkIcon, PencilSimpleIcon, StarIcon, @@ -17,7 +16,6 @@ import { } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; -import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; @@ -88,10 +86,6 @@ export function SpaceSection({ channel }: { channel: Channel }) { return () => assignTaskToCommandCenter(cellIndex, taskId); }; - const glyph = channelGlyph(channel.name, { size: 14, space: true }) ?? ( - - ); - return (
{/* Header: chevron toggles the task list; the row itself opens the space. */} @@ -116,9 +110,6 @@ export function SpaceSection({ channel }: { channel: Channel }) { className="min-w-0 flex-1 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" onClick={() => toggle(channel.id)} > - - {glyph} - s.location.pathname }); const isHome = pathname === "/code" || pathname === "/code/" || pathname === "/"; - const isTasks = - pathname.startsWith("/code/tasks") || pathname.startsWith("/website"); + const isTasks = pathname.startsWith("/code/tasks"); const isInbox = pathname.startsWith("/code/inbox") || pathname.startsWith("/inbox"); const { slots: pinnedSpaces } = useStarredChannelSlots(); - const { star } = useChannelStarMutations(); + const { star, unstar } = useChannelStarMutations(); + const { starredRefToShortcutId } = useChannelStars(); const { channels } = useChannels(); const { counts } = useInboxAllReports({ ignoreFilters: true, refetchIntervalMs: 60_000, }); - const openAgents = useSpacesSidebarStore((s) => s.openAgents); - const toggleAgents = useSpacesSidebarStore((s) => s.toggleAgents); - - const [addOpen, setAddOpen] = useState(false); - const [createOpen, setCreateOpen] = useState(false); + const openAddSpace = useSpacesSidebarStore((s) => s.openAddSpace); + const toggleAddSpace = useSpacesSidebarStore((s) => s.toggleAddSpace); + const showPreview = useSpacesSidebarSelectionStore((s) => s.showPreview); + const togglePreview = useSpacesSidebarSelectionStore((s) => s.togglePreview); - // The list is every channel not already pinned; the personal channel isn't a - // thing you pin to this list. - const addable = channels.filter( - (c) => - c.name !== PERSONAL_CHANNEL_NAME && - !pinnedSpaces.some((p) => p.id === c.id), + const allSpaces = useMemo( + () => channels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME), + [channels], ); - const addExisting = (channelId: string | null) => { - if (!channelId) return; + const togglePin = (channelId: string) => { const channel = channels.find((c) => c.id === channelId); if (!channel) return; + const shortcutId = starredRefToShortcutId.get(channel.path); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "star", + action_type: shortcutId ? "unstar" : "star", surface: "sidebar", channel_id: channel.id, }); - star(channel).catch((error: unknown) => - toast.error("Couldn't add space", { + 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), }), ); - useSpacesSidebarStore.getState().setOpen(channel.id, true); - setAddOpen(false); }; return ( <> - {/* Nav: Home / Tasks / Inbox */} -
- - - - {/* Inbox, with "+" and chevron affordances on hover */} -
- + + + } onClick={() => navigateToInbox()} - > - - - - Inbox - - - - - - + />
- {/* Pinned spaces */} -
+ {/* Pinned (starred) spaces */} +
{pinnedSpaces.map((space) => ( ))} +
- {/* Add space */} - - - } - > - - - - Add space - - - c.id)} - onValueChange={(value) => addExisting(value ?? null)} - > -
- +
+ + -
- - {addable.length === 0 && ( -
- No more spaces to add. -
- )} - {addable.map((c) => ( - - - - - {c.name} - - ))} -
- - -
- -
- - -
+ + + } + onClick={toggleAddSpace} + aria-expanded={openAddSpace} + /> +
+ {openAddSpace && + allSpaces.map((channel) => { + const shortcutId = starredRefToShortcutId.get(channel.path); + return ( +
+ + {shortcutId ? ( + + ) : null} + {channel.name} + + } + onClick={() => togglePin(channel.id)} + endContent={ + + } + /> +
+ ); + })} - {/* Pinned agents (prototype placeholder) */} -
-
- - - - + {/* Preview toggle */} +
+ + + Preview + + } + endContent={ + + } + onClick={togglePreview} + aria-expanded={showPreview} + />
- {openAgents && ( -
- Agents you pin will show here. -
- )}
- - ); } diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts new file mode 100644 index 0000000000..675c3e41ac --- /dev/null +++ b/packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface SpacesSidebarSelectionState { + showPreview: boolean; + togglePreview: () => void; +} + +export const useSpacesSidebarSelectionStore = + create()( + persist( + (set) => ({ + showPreview: false, + togglePreview: () => + set((state) => ({ showPreview: !state.showPreview })), + }), + { name: "spaces-sidebar-selection" }, + ), + ); diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index 58c38ba5b5..b1cfce4110 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -10,9 +10,11 @@ import { persist } from "zustand/middleware"; interface SpacesSidebarState { openSections: Record; openAgents: boolean; + openAddSpace: boolean; setOpen: (channelId: string, open: boolean) => void; toggle: (channelId: string) => void; toggleAgents: () => void; + toggleAddSpace: () => void; } export const useSpacesSidebarStore = create()( @@ -20,6 +22,7 @@ export const useSpacesSidebarStore = create()( (set) => ({ openSections: {}, openAgents: false, + openAddSpace: false, setOpen: (channelId, open) => set((state) => ({ openSections: { ...state.openSections, [channelId]: open }, @@ -32,6 +35,8 @@ export const useSpacesSidebarStore = create()( }, })), toggleAgents: () => set((state) => ({ openAgents: !state.openAgents })), + toggleAddSpace: () => + set((state) => ({ openAddSpace: !state.openAddSpace })), }), { name: "spaces-sidebar" }, ), From d5fd7fcf69dae4b25548ebacbdd94870c707834c Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 31 Jul 2026 22:10:44 +0200 Subject: [PATCH 03/26] chore(ui): drop Preview section from spaces sidebar prototype Generated-By: PostHog Code Task-Id: 7698cad8-55a1-4e67-9657-91ce3e8f0a52 --- .../canvas/components/SpacesSidebarNav.tsx | 36 +------------------ .../stores/spacesSidebarSelectionStore.ts | 19 ---------- 2 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 7c0589f386..c582f21d7f 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -18,7 +18,6 @@ import { import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useSpacesSidebarSelectionStore } from "@posthog/ui/features/canvas/stores/spacesSidebarSelectionStore"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; @@ -37,7 +36,7 @@ import { useMemo } from "react"; * The static spaces nav: the shell keeps ChannelNav (the highlighted icon row). * Below it, Home / Tasks / Inbox as full-width items, then every starred space * with its tasks expanded inline, then an "Add space" toggle that renders all - * project spaces as plain rows with a hover pin/star action, and Preview. + * project spaces as plain rows with a hover pin/star action. */ export function SpacesSidebarNav() { const pathname = useRouterState({ select: (s) => s.location.pathname }); @@ -58,8 +57,6 @@ export function SpacesSidebarNav() { const openAddSpace = useSpacesSidebarStore((s) => s.openAddSpace); const toggleAddSpace = useSpacesSidebarStore((s) => s.toggleAddSpace); - const showPreview = useSpacesSidebarSelectionStore((s) => s.showPreview); - const togglePreview = useSpacesSidebarSelectionStore((s) => s.togglePreview); const allSpaces = useMemo( () => channels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME), @@ -236,37 +233,6 @@ export function SpacesSidebarNav() {
); })} - - {/* Preview toggle */} -
- - - Preview - - } - endContent={ - - } - onClick={togglePreview} - aria-expanded={showPreview} - /> -
); diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts deleted file mode 100644 index 675c3e41ac..0000000000 --- a/packages/ui/src/features/canvas/stores/spacesSidebarSelectionStore.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -interface SpacesSidebarSelectionState { - showPreview: boolean; - togglePreview: () => void; -} - -export const useSpacesSidebarSelectionStore = - create()( - persist( - (set) => ({ - showPreview: false, - togglePreview: () => - set((state) => ({ showPreview: !state.showPreview })), - }), - { name: "spaces-sidebar-selection" }, - ), - ); From 50066f03ff47a14b615cd61f6475bfe7212cba9d Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 31 Jul 2026 22:25:41 +0200 Subject: [PATCH 04/26] feat(ui): polish space rows, add settings tabs, and full All spaces list Generated-By: PostHog Code Task-Id: 7698cad8-55a1-4e67-9657-91ce3e8f0a52 --- .../canvas/components/AllSpacesSection.tsx | 124 +++++++++ .../canvas/components/SpaceSection.tsx | 249 ++++++++++-------- .../canvas/components/SpacesSidebarNav.tsx | 142 +--------- .../canvas/stores/spacesSidebarStore.ts | 11 +- 4 files changed, 263 insertions(+), 263 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/AllSpacesSection.tsx diff --git a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx new file mode 100644 index 0000000000..d5333c6c84 --- /dev/null +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx @@ -0,0 +1,124 @@ +import { + CaretRightIcon, + CheckIcon, + CubeIcon, + StarIcon, +} from "@phosphor-icons/react"; +import { cn } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + useChannelStarMutations, + useChannelStars, +} from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +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 { useMemo } from "react"; + +/** + * The "All spaces" toggle: every space in the project as a row with a hover + * pin/star. Under the new sidebar the starred spaces are the pinned ones, so + * this list is the project's directory — clicking a row pins it (stars), and + * the row carries the star back out again. Personal channel is listed by + * name only (it can't be pinned). + */ +export function AllSpacesSection() { + const open = useSpacesSidebarStore((s) => s.openAddSpace); + const toggle = useSpacesSidebarStore((s) => s.toggleAddSpace); + + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const { star, unstar } = useChannelStarMutations(); + + const allSpaces = useMemo(() => channels, [channels]); + + const togglePin = (channelId: string) => { + const channel = channels.find((c) => c.id === channelId); + if (!channel || channel.name === PERSONAL_CHANNEL_NAME) return; + 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 ( +
+
+ } + label="All spaces" + badge={ + + } + onClick={toggle} + aria-expanded={open} + /> +
+ {open && + allSpaces.map((channel) => { + const shortcutId = starredRefToShortcutId.get(channel.path); + const isPersonal = channel.name === PERSONAL_CHANNEL_NAME; + return ( +
+ + {shortcutId ? ( + + ) : null} + {channel.name} + + } + onClick={() => togglePin(channel.id)} + endContent={ + isPersonal ? undefined : ( + + ) + } + /> +
+ ); + })} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index 5592557f3b..2f1596fa84 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -1,54 +1,68 @@ -import { - CaretRightIcon, - LinkIcon, - PencilSimpleIcon, - StarIcon, - TrashIcon, -} from "@phosphor-icons/react"; +import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react"; import { Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; -import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; +import { channelPageLabel } from "@posthog/ui/features/canvas/components/channelPages"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; -import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { useRouterState } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useMemo, useState } from "react"; const RECENTS_CAP = 10; +function RowStar({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + /** - * One pinned space in the static sidebar: an expandable header row, and beneath - * it the space's tasks (the same `ChannelItemRow`s the channel pane renders). - * Expand state is local view state in `spacesSidebarStore`; the space's - * presence in the sidebar is the user's star. + * One pinned space in the static sidebar: the channel's header row (a + * full-width row like ChannelBackRow, expanding on click), and beneath it the + * space's tasks rendered as ChannelItemRows. Expand state is local view state + * in `spacesSidebarStore`; the space's presence in the sidebar is its star. */ export function SpaceSection({ channel }: { channel: Channel }) { const open = useSpacesSidebarStore((s) => !!s.openSections[channel.id]); const toggle = useSpacesSidebarStore((s) => s.toggle); + const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const base = `/website/${channel.id}`; const isActive = pathname === base || pathname.startsWith(`${base}/`); + const spacesLayout = useChannelsLayout(); const { items, actions, isLoading } = useChannelItems(channel.id); - const { toggleStar } = useChannelStarToggle(channel); - const [menuOpen, setMenuOpen] = useState(false); - const [renameOpen, setRenameOpen] = useState(false); const [editingTaskId, setEditingTaskId] = useState(null); const { renameTask } = useRenameTask(); @@ -87,98 +101,105 @@ export function SpaceSection({ channel }: { channel: Channel }) { }; return ( -
- {/* Header: chevron toggles the task list; the row itself opens the space. */} -
- - - - - - } +
+ + toggle(channel.id)} > - ··· - - - + + {channel.name} + + {/* Star well, reserved so the row doesn't shift when a space is pinned */} + + + } + /> + {channel.name} + + + + {/* Tabs + tasks under the space. The tab row mirrors the space pages + (Feed/Context/Loops/Artifacts/Settings), each navigating to the + space's own route; Settings opens the global settings as in the nav + row, since a per-space settings sheet doesn't exist yet. */} + {open && ( +
+ {section.label} + + ); + })} + + + )} {/* Tasks under the space */} {open && ( @@ -237,12 +258,6 @@ export function SpaceSection({ channel }: { channel: Channel }) { )}
)} - -
); } diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index c582f21d7f..37b246c66f 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -1,28 +1,17 @@ import { - CaretRightIcon, - CheckIcon, HouseIcon, ListChecksIcon, PlusIcon, - StarIcon, TrayIcon, } from "@phosphor-icons/react"; -import { cn } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpacesSection"; import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection"; -import { - useChannelStarMutations, - useChannelStars, -} from "@posthog/ui/features/canvas/hooks/useChannelStars"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { CountBadge } from "@posthog/ui/primitives/CountBadge"; -import { toast } from "@posthog/ui/primitives/toast"; import { navigateToCode, navigateToInbox, @@ -30,13 +19,11 @@ import { import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { useRouterState } from "@tanstack/react-router"; -import { useMemo } from "react"; /** * The static spaces nav: the shell keeps ChannelNav (the highlighted icon row). * Below it, Home / Tasks / Inbox as full-width items, then every starred space - * with its tasks expanded inline, then an "Add space" toggle that renders all - * project spaces as plain rows with a hover pin/star action. + * with its tasks expanded inline, and the All spaces section. */ export function SpacesSidebarNav() { const pathname = useRouterState({ select: (s) => s.location.pathname }); @@ -47,39 +34,11 @@ export function SpacesSidebarNav() { pathname.startsWith("/code/inbox") || pathname.startsWith("/inbox"); const { slots: pinnedSpaces } = useStarredChannelSlots(); - const { star, unstar } = useChannelStarMutations(); - const { starredRefToShortcutId } = useChannelStars(); - const { channels } = useChannels(); const { counts } = useInboxAllReports({ ignoreFilters: true, refetchIntervalMs: 60_000, }); - const openAddSpace = useSpacesSidebarStore((s) => s.openAddSpace); - const toggleAddSpace = useSpacesSidebarStore((s) => s.toggleAddSpace); - - const allSpaces = useMemo( - () => channels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME), - [channels], - ); - - const togglePin = (channelId: string) => { - const channel = channels.find((c) => c.id === channelId); - if (!channel) return; - 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 ( <> {/* Icon row (kept as-is from the previous layout) */} @@ -125,17 +84,6 @@ export function SpacesSidebarNav() { > - } onClick={() => navigateToInbox()} @@ -150,90 +98,8 @@ export function SpacesSidebarNav() { ))}
- {/* Add space: a full toggle like the others, rendering every space in the - project so it can be pinned/unpinned; clicking a row pins it. */} -
-
- - - Add space - - } - endContent={ - - } - onClick={toggleAddSpace} - aria-expanded={openAddSpace} - /> -
- {openAddSpace && - allSpaces.map((channel) => { - const shortcutId = starredRefToShortcutId.get(channel.path); - return ( -
- - {shortcutId ? ( - - ) : null} - {channel.name} - - } - onClick={() => togglePin(channel.id)} - endContent={ - - } - /> -
- ); - })} -
+ {/* All spaces in the project */} + ); } diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index b1cfce4110..f940f8723f 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -2,18 +2,15 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; /** - * View state for the static spaces sidebar: which space sections are expanded. - * Unexpanded is the default (mock shows collapse chevron-down for the first - * space only), so the map stores explicit expansions. `openAgents` toggles the - * pinned-agents placeholder section. + * View state for the static spaces sidebar: per-space expands, and the + * "All spaces" list toggle. `openSections` stores explicit space expansion + * (default collapsed); `openAddSpace` stores the all-spaces list toggle. */ interface SpacesSidebarState { openSections: Record; - openAgents: boolean; openAddSpace: boolean; setOpen: (channelId: string, open: boolean) => void; toggle: (channelId: string) => void; - toggleAgents: () => void; toggleAddSpace: () => void; } @@ -21,7 +18,6 @@ export const useSpacesSidebarStore = create()( persist( (set) => ({ openSections: {}, - openAgents: false, openAddSpace: false, setOpen: (channelId, open) => set((state) => ({ @@ -34,7 +30,6 @@ export const useSpacesSidebarStore = create()( [channelId]: !state.openSections[channelId], }, })), - toggleAgents: () => set((state) => ({ openAgents: !state.openAgents })), toggleAddSpace: () => set((state) => ({ openAddSpace: !state.openAddSpace })), }), From e9de196ac7b6394d317584475740a99141eb3316 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 31 Jul 2026 22:41:43 +0200 Subject: [PATCH 05/26] refactor(ui): rework static spaces sidebar to match the sidebar's row language Drops the Home/Tasks/Inbox rows (the ChannelNav icon row already covers them), replaces the per-space tab strip with the channel header's own tabs, and makes space rows behave like the rest of the sidebar: the row opens the space, the disclosure caret folds it, the star (hover-revealed) pins. The All spaces list becomes a section-labelled directory whose rows open the space instead of silently pinning it, with a New space entry and the ChannelsFab restored so the static layout keeps a create affordance. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../components/AllSpacesSection.test.tsx | 116 +++++++ .../canvas/components/AllSpacesSection.tsx | 202 ++++++----- .../canvas/components/ChannelsSidebar.tsx | 11 +- .../canvas/components/SpaceSection.tsx | 323 +++++++++--------- .../canvas/components/SpacesSidebarNav.tsx | 91 +---- .../canvas/stores/spacesSidebarStore.ts | 4 +- 6 files changed, 422 insertions(+), 325 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx diff --git a/packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx b/packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx new file mode 100644 index 0000000000..ea2968a2fb --- /dev/null +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx @@ -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(), + 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 ?
: 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( + + + , + ); +} + +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(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx index d5333c6c84..ac2b0dc022 100644 --- a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx @@ -1,43 +1,72 @@ import { + CaretDownIcon, CaretRightIcon, - CheckIcon, - CubeIcon, + CubeFocusIcon, + PlusIcon, StarIcon, } from "@phosphor-icons/react"; -import { cn } from "@posthog/quill"; +import { Button, cn, MenuLabel } 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 { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +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 { useMemo } from "react"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; /** - * The "All spaces" toggle: every space in the project as a row with a hover - * pin/star. Under the new sidebar the starred spaces are the pinned ones, so - * this list is the project's directory — clicking a row pins it (stars), and - * the row carries the star back out again. Personal channel is listed by - * name only (it can't be pinned). + * The project's space directory. A section label (like Starred/Spaces in the + * channel list) that folds open to every shared space, alphabetically. + * 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 allSpaces = useMemo(() => channels, [channels]); + const spaces = useMemo( + () => + channels + .filter((c) => c.name !== PERSONAL_CHANNEL_NAME) + .sort((a, b) => a.name.localeCompare(b.name)), + [channels], + ); - const togglePin = (channelId: string) => { - const channel = channels.find((c) => c.id === channelId); - if (!channel || channel.name === PERSONAL_CHANNEL_NAME) return; + 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", @@ -53,72 +82,89 @@ export function AllSpacesSection() { }; return ( -
-
- } - label="All spaces" - badge={ +
+ {/* Same header shape as the channel list's groups: MenuLabel carries the + sidebar's label styling, and the section glyph swaps to a disclosure + caret on hover or keyboard focus. */} + + } + > + + + + + {open ? ( + + ) : ( - } - onClick={toggle} - aria-expanded={open} - /> -
- {open && - allSpaces.map((channel) => { - const shortcutId = starredRefToShortcutId.get(channel.path); - const isPersonal = channel.name === PERSONAL_CHANNEL_NAME; - return ( -
- - {shortcutId ? ( - - ) : null} - {channel.name} - - } - onClick={() => togglePin(channel.id)} - endContent={ - isPersonal ? undefined : ( - - ) - } - /> -
- ); - })} + )} + + All spaces + + + {open && ( + <> + {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. +
+ openSpace(channel)} + // Star well, so the name truncates clear of the hover star. + endContent={} + /> + +
+ ); + })} + {/* The directory is also where a space that doesn't exist yet would + be — so creating one starts here. */} + } + label={New space} + onClick={() => setCreateOpen(true)} + /> + + + )}
); } diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 7b5dd730cf..e6481f71be 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -133,9 +133,14 @@ export function ChannelsSidebar() { {/* The static spaces sidebar (prototype): one nav where every space expands inline with its tasks beneath it. Replaces the panes - slider under this flag. */} - - + slider under this flag. The Fab keeps create (task, canvas, + space) in the same corner as the panes layout; bottom padding + keeps the last row reachable under it. */} + + + + + ) : bodyChannelsWorld ? ( diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index 2f1596fa84..46adb93aaf 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -1,21 +1,16 @@ -import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react"; -import { - Button, - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@posthog/quill"; +import { CaretRightIcon, StarIcon } from "@phosphor-icons/react"; +import { Button, cn, Skeleton } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; -import { channelPageLabel } from "@posthog/ui/features/canvas/components/channelPages"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; -import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; @@ -25,6 +20,10 @@ import { useMemo, useState } from "react"; const RECENTS_CAP = 10; +// An overlay rather than a sibling: the header button fills the row, and +// nesting the star inside it would be a button within a button (see +// ChannelBackRow). Hidden at rest — every pinned row wearing a star reads as a +// column of noise; the row being here at all already says "pinned". function RowStar({ channel }: { channel: Channel }) { const { isStarred, toggleStar } = useChannelStarToggle(channel); return ( @@ -40,7 +39,10 @@ function RowStar({ channel }: { channel: Channel }) { }); toggleStar(); }} - className="-translate-y-1/2 absolute top-1/2 right-[6px] text-muted-foreground" + className={cn( + "-translate-y-1/2 absolute top-1/2 right-[6px] text-muted-foreground transition-opacity", + "opacity-0 focus-visible:opacity-100 group-hover/space:opacity-100", + )} > @@ -48,19 +50,33 @@ function RowStar({ channel }: { channel: Channel }) { } /** - * One pinned space in the static sidebar: the channel's header row (a - * full-width row like ChannelBackRow, expanding on click), and beneath it the - * space's tasks rendered as ChannelItemRows. Expand state is local view state - * in `spacesSidebarStore`; the space's presence in the sidebar is its star. + * One pinned space in the static sidebar. The row itself opens the space (its + * feed) and expands it; the disclosure caret toggles the expansion alone, so + * you can fold a space away without leaving where you are. Beneath it, the + * space's tasks as ChannelItemRows — the space's pages (Feed/Recents/…) live + * in the channel header's tabs, not here. + * + * Expand state is local view state in `spacesSidebarStore`; the space's + * presence in the sidebar is its star. */ export function SpaceSection({ channel }: { channel: Channel }) { const open = useSpacesSidebarStore((s) => !!s.openSections[channel.id]); const toggle = useSpacesSidebarStore((s) => s.toggle); + const setOpen = useSpacesSidebarStore((s) => s.setOpen); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const base = `/website/${channel.id}`; const isActive = pathname === base || pathname.startsWith(`${base}/`); - const spacesLayout = useChannelsLayout(); + const isUnread = useIsChannelUnread()(channel.name); + const isPersonal = channel.name === PERSONAL_CHANNEL_NAME; + // Only #me carries a glyph under the layout (its lock); a cube in front of + // every space said nothing the name didn't — same rule as ChannelBackRow. + const glyph = channelGlyph(channel.name, { + size: 14, + space: true, + className: "text-muted-foreground", + }); const { items, actions, isLoading } = useChannelItems(channel.id); const [editingTaskId, setEditingTaskId] = useState(null); @@ -91,6 +107,7 @@ export function SpaceSection({ channel }: { channel: Channel }) { ].slice(0, RECENTS_CAP), [items], ); + const overflowCount = items.length - sectionItems.length; const commandCenterAssigner = (taskId: string) => { const cellIndex = commandCenterCells.findIndex( @@ -100,161 +117,145 @@ export function SpaceSection({ channel }: { channel: Channel }) { return () => assignTaskToCommandCenter(cellIndex, taskId); }; - return ( -
- - toggle(channel.id)} - > - - - {channel.name} - - {/* Star well, reserved so the row doesn't shift when a space is pinned */} - - - } - /> - {channel.name} - - + const openSpace = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "nav_click", + surface: "sidebar", + channel_id: channel.id, + }); + setCurrentChannel(channel.id); + setOpen(channel.id, true); + void navigate({ + to: "/website/$channelId", + params: { channelId: channel.id }, + }); + }; - {/* Tabs + tasks under the space. The tab row mirrors the space pages - (Feed/Context/Loops/Artifacts/Settings), each navigating to the - space's own route; Settings opens the global settings as in the nav - row, since a per-space settings sheet doesn't exist yet. */} - {open && ( - - )} + {channel.name} + + {/* Star well, reserved so the name truncates before running under + the hover star rather than shifting when it appears. */} + {!isPersonal && } + + {/* Overlays, not children — the row is a button already. */} + + {!isPersonal && } +
- {/* Tasks under the space */} + {/* Tasks under the space, pinned first. Same inset as the channel + groups' trees (pl-5). */} {open && ( -
+
{isLoading && sectionItems.length === 0 ? ( -
- Loading… +
+ +
) : sectionItems.length === 0 ? (
- No tasks here yet. + No tasks yet
) : ( - sectionItems.map((item) => ( - setEditingTaskId(item.id) - : undefined - } - onAddToCommandCenter={ - item.kind === "task" && !commandCenterCells.includes(item.id) - ? commandCenterAssigner(item.id) - : undefined - } - onEditSubmit={ - item.kind === "task" - ? async (newTitle) => { - setEditingTaskId(null); - try { - await renameTask({ - taskId: item.id, - currentTitle: item.title, - newTitle, - }); - } catch (error) { - toast.error("Couldn't rename task", { - description: - error instanceof Error - ? error.message - : String(error), - }); + <> + {sectionItems.map((item) => ( + setEditingTaskId(item.id) + : undefined + } + onAddToCommandCenter={ + item.kind === "task" && + !commandCenterCells.includes(item.id) + ? commandCenterAssigner(item.id) + : undefined + } + onEditSubmit={ + item.kind === "task" + ? async (newTitle) => { + setEditingTaskId(null); + try { + await renameTask({ + taskId: item.id, + currentTitle: item.title, + newTitle, + }); + } catch (error) { + toast.error("Couldn't rename task", { + description: + error instanceof Error + ? error.message + : String(error), + }); + } } - } - : undefined - } - onEditCancel={() => setEditingTaskId(null)} - /> - )) + : undefined + } + onEditCancel={() => setEditingTaskId(null)} + /> + ))} + {/* The list is a cap, not the whole story — the rest live on the + space's Recents page. */} + {overflowCount > 0 && ( + + )} + )}
)} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 37b246c66f..8c9733e4fc 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -1,104 +1,31 @@ -import { - HouseIcon, - ListChecksIcon, - PlusIcon, - TrayIcon, -} from "@phosphor-icons/react"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpacesSection"; import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; -import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; -import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; -import { CountBadge } from "@posthog/ui/primitives/CountBadge"; -import { - navigateToCode, - navigateToInbox, -} from "@posthog/ui/router/navigationBridge"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { track } from "@posthog/ui/shell/analytics"; -import { useRouterState } from "@tanstack/react-router"; /** - * The static spaces nav: the shell keeps ChannelNav (the highlighted icon row). - * Below it, Home / Tasks / Inbox as full-width items, then every starred space - * with its tasks expanded inline, and the All spaces section. + * The static spaces nav: the shell keeps ChannelNav (the highlighted icon + * row — Inbox, Activity, Command Center and Settings already live there), then + * every pinned space with its tasks expandable inline, then the All spaces + * directory. No Home/Tasks/Inbox rows: the icon row covers them, and repeating + * them as full-width rows pushed the spaces — the point of this sidebar — + * below the fold. */ export function SpacesSidebarNav() { - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const isHome = - pathname === "/code" || pathname === "/code/" || pathname === "/"; - const isTasks = pathname.startsWith("/code/tasks"); - const isInbox = - pathname.startsWith("/code/inbox") || pathname.startsWith("/inbox"); - const { slots: pinnedSpaces } = useStarredChannelSlots(); - const { counts } = useInboxAllReports({ - ignoreFilters: true, - refetchIntervalMs: 60_000, - }); return ( <> - {/* Icon row (kept as-is from the previous layout) */} - {/* Home / Tasks / Inbox */} -
- } - label="Home" - isActive={isHome} - onClick={() => navigateToCode()} - /> - } - label="Tasks" - isActive={isTasks} - onClick={() => navigateToCode()} - /> -
- } - label="Inbox" - isActive={isInbox} - badge={} - endContent={ - - - - } - onClick={() => navigateToInbox()} - /> -
-
- - {/* Pinned (starred) spaces */} -
+ {/* Pinned (starred) spaces; #me first. */} +
{pinnedSpaces.map((space) => ( ))}
- {/* All spaces in the project */} + {/* Every space in the project */} ); diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index f940f8723f..78732f2c72 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -18,7 +18,9 @@ export const useSpacesSidebarStore = create()( persist( (set) => ({ openSections: {}, - openAddSpace: false, + // Open by default: with nothing pinned yet, a collapsed directory left + // the sidebar looking empty. Returning users keep whatever they chose. + openAddSpace: true, setOpen: (channelId, open) => set((state) => ({ openSections: { ...state.openSections, [channelId]: open }, From c81861de8dd21055d472a29f75fedeb2cc80860e Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 31 Jul 2026 23:18:40 +0200 Subject: [PATCH 06/26] 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 --- packages/core/src/canvas/channelItems.ts | 4 +- .../canvas/components/AllSpacesSection.tsx | 184 +++++++++--------- .../canvas/components/ChannelHeader.tsx | 10 +- .../canvas/components/ChannelTabs.tsx | 42 +++- .../canvas/components/ChannelsSidebar.tsx | 11 +- .../canvas/components/SpaceSection.tsx | 122 +++++------- .../canvas/components/SpacesSidebarNav.tsx | 40 ++-- .../canvas/stores/spacesSidebarStore.ts | 11 +- 8 files changed, 233 insertions(+), 191 deletions(-) diff --git a/packages/core/src/canvas/channelItems.ts b/packages/core/src/canvas/channelItems.ts index 9bd9214fce..552d1d2913 100644 --- a/packages/core/src/canvas/channelItems.ts +++ b/packages/core/src/canvas/channelItems.ts @@ -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, owner: ChannelItemOwner, ): boolean { diff --git a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx index ac2b0dc022..1184fc9eb9 100644 --- a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx @@ -1,11 +1,5 @@ -import { - CaretDownIcon, - CaretRightIcon, - CubeFocusIcon, - PlusIcon, - StarIcon, -} from "@phosphor-icons/react"; -import { Button, cn, MenuLabel } from "@posthog/quill"; +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 { @@ -26,11 +20,12 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useMemo, useState } from "react"; /** - * The project's space directory. A section label (like Starred/Spaces in the - * channel list) that folds open to every shared space, alphabetically. - * 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 + * 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() { @@ -82,89 +77,94 @@ export function AllSpacesSection() { }; return ( -
- {/* Same header shape as the channel list's groups: MenuLabel carries the - sidebar's label styling, and the section glyph swaps to a disclosure - caret on hover or keyboard focus. */} - - } +
+
- - - + {/* Same shape as a pinned space row, so the carets line up. */} + +
+ ); + })} +
+ {/* Below the scroll region so it's reachable without scrolling + the whole directory. */} + } + label={New space} + onClick={() => setCreateOpen(true)} /> - ) : ( - - )} - - All spaces -
- - {open && ( - <> - {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. -
- openSpace(channel)} - // Star well, so the name truncates clear of the hover star. - endContent={} - /> - -
- ); - })} - {/* The directory is also where a space that doesn't exist yet would - be — so creating one starts here. */} - } - label={New space} - onClick={() => setCreateOpen(true)} - /> - - - )} + + )} +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 40f65fb29e..e8c038527f 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -15,9 +15,9 @@ 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 +// name doesn't change size between the space home and its sub-pages. Both +// layouts carry the section tab strip (the new one trails it after the +// breadcrumb, Feed included). Starring lives on the sidebar back row and the // channel list, not here. export function ChannelHeader({ channelId, @@ -48,6 +48,10 @@ export function ChannelHeader({ channelId={channelId} leafIcon={page ? channelPageIcon(page, { size: 12 }) : undefined} leafLabel={page ? channelPageLabel(page) : undefined} + // The static sidebar's rows only fold their task lists, so the space's + // pages need a switcher in the main view — the same strip the legacy + // header carries, with Feed leading back to the root. + trailing={} /> ); } diff --git a/packages/ui/src/features/canvas/components/ChannelTabs.tsx b/packages/ui/src/features/canvas/components/ChannelTabs.tsx index 6bd815d842..a18365700c 100644 --- a/packages/ui/src/features/canvas/components/ChannelTabs.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTabs.tsx @@ -2,6 +2,7 @@ import { Button, cn } from "@posthog/quill"; import { LOOPS_FLAG } from "@posthog/shared"; import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections"; import { ChannelPinnedMenu } from "@posthog/ui/features/canvas/components/ChannelPinnedMenu"; +import { channelPageLabel } from "@posthog/ui/features/canvas/components/channelPages"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { Link, useRouterState } from "@tanstack/react-router"; @@ -11,16 +12,53 @@ const TABS = CHANNEL_SECTIONS.map((s) => ({ to: `/website/$channelId/${s.key}` as const, })); +// The space-page variant: Feed leads (it's the space itself, and the way back +// once you've tabbed away), Context follows it, and the labels come from the +// channelPages table ("Context", not the legacy "CONTEXT.md"). +const SPACE_TABS = (["context", "loops", "artifacts", "history"] as const).map( + (key) => ({ + key, + label: channelPageLabel(key), + to: `/website/$channelId/${key}` as const, + }), +); + // Home / History / Artifacts tab switcher shown in the channel header bar, with // a Pinned quick-access menu alongside. Pathname-driven active state (the // codebase's convention) rather than Link's activeProps. -export function ChannelTabs({ channelId }: { channelId: string }) { +export function ChannelTabs({ + channelId, + includeHome, +}: { + channelId: string; + /** + * Adds a leading Feed tab for the space root. On: the header is the only tab + * strip (channels layout). Off: legacy header, where the channel pill beside + * this nav already links home. + */ + includeHome?: boolean; +}) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); - const tabs = loopsEnabled ? TABS : TABS.filter((tab) => tab.key !== "loops"); + const sectionTabs = includeHome ? SPACE_TABS : TABS; + const tabs = loopsEnabled + ? sectionTabs + : sectionTabs.filter((tab) => tab.key !== "loops"); + const home = `/website/${channelId}`; return (
{/* Tasks under the space, pinned first. Same inset as the channel @@ -191,7 +173,7 @@ export function SpaceSection({ channel }: { channel: Channel }) {
) : sectionItems.length === 0 ? (
- No tasks yet + {onlyMine ? "None of your tasks here" : "No tasks yet"}
) : ( <> @@ -252,7 +234,7 @@ export function SpaceSection({ channel }: { channel: Channel }) { }) } > - View all ({items.length}) + View all ({visibleItems.length}) )} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 8c9733e4fc..c7dd9f9912 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -1,32 +1,48 @@ +import { Switch } from "@posthog/quill"; import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpacesSection"; import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; /** * The static spaces nav: the shell keeps ChannelNav (the highlighted icon - * row — Inbox, Activity, Command Center and Settings already live there), then - * every pinned space with its tasks expandable inline, then the All spaces - * directory. No Home/Tasks/Inbox rows: the icon row covers them, and repeating - * them as full-width rows pushed the spaces — the point of this sidebar — - * below the fold. + * row — Inbox, Activity, Command Center and Settings already live there), + * then the sidebar-wide "My tasks" filter, then every pinned space with its + * tasks foldable inline. The All spaces directory is docked at the bottom and + * opens upward; the pinned list is what scrolls in between. */ export function SpacesSidebarNav() { const { slots: pinnedSpaces } = useStarredChannelSlots(); + const onlyMyTasks = useSpacesSidebarStore((s) => s.onlyMyTasks); + const toggleOnlyMyTasks = useSpacesSidebarStore((s) => s.toggleOnlyMyTasks); return ( - <> +
+ {/* One filter over every space's task list below. */} +
+ My tasks + +
+ {/* Pinned (starred) spaces; #me first. */} -
- {pinnedSpaces.map((space) => ( - - ))} +
+
+ {pinnedSpaces.map((space) => ( + + ))} +
- {/* Every space in the project */} + {/* Every space in the project, docked at the bottom. */} - +
); } diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index 78732f2c72..69b626a8a3 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -9,9 +9,11 @@ import { persist } from "zustand/middleware"; interface SpacesSidebarState { openSections: Record; openAddSpace: boolean; - setOpen: (channelId: string, open: boolean) => void; + /** Narrow every space's task list to items the viewer created. */ + onlyMyTasks: boolean; toggle: (channelId: string) => void; toggleAddSpace: () => void; + toggleOnlyMyTasks: () => void; } export const useSpacesSidebarStore = create()( @@ -21,10 +23,7 @@ export const useSpacesSidebarStore = create()( // Open by default: with nothing pinned yet, a collapsed directory left // the sidebar looking empty. Returning users keep whatever they chose. openAddSpace: true, - setOpen: (channelId, open) => - set((state) => ({ - openSections: { ...state.openSections, [channelId]: open }, - })), + onlyMyTasks: false, toggle: (channelId) => set((state) => ({ openSections: { @@ -34,6 +33,8 @@ export const useSpacesSidebarStore = create()( })), toggleAddSpace: () => set((state) => ({ openAddSpace: !state.openAddSpace })), + toggleOnlyMyTasks: () => + set((state) => ({ onlyMyTasks: !state.onlyMyTasks })), }), { name: "spaces-sidebar" }, ), From ab7f3027dff4e4d15b8a460cc850c882ac1fffb2 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 07:54:01 +0200 Subject: [PATCH 07/26] feat(ui): line-tab page switcher, draggable spaces and tasks, per-space task windows Sidebar feedback round three. The space header swaps its trailing button strip for quill line tabs (Feed/Context/Loops/Artifacts/Recents) on their own row under the space name, matching Inbox/Activity; the header bar grows via min-height so single-row breadcrumbs are unchanged. Each space's task list leads with a "New session" row, shows a five-row scroll window with View all pinned below, and its task rows are draggable into the Command Center with the same text/x-task-id payload as the code sidebar. Pinned spaces reorder by dragging their header row (dnd-kit sortable, #me fixed first, order persisted in the sidebar store). The filter row is labeled "Spaces" and its switch explains itself in a tooltip. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/ChannelHeader.tsx | 38 +++--- .../canvas/components/ChannelItemRow.tsx | 11 ++ .../canvas/components/ChannelTabs.tsx | 123 ++++++++++------- .../canvas/components/SpaceSection.tsx | 127 +++++++++++------- .../canvas/components/SpacesSidebarNav.tsx | 123 ++++++++++++++--- .../canvas/components/WebsiteLayout.tsx | 7 +- .../canvas/stores/spacesSidebarStore.ts | 10 ++ 7 files changed, 316 insertions(+), 123 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index e8c038527f..af47830de7 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -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, @@ -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. Both -// layouts carry the section tab strip (the new one trails it after the -// breadcrumb, Feed included). 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, @@ -42,17 +45,22 @@ export function ChannelHeader({ // layout flag graduates. if (!channelsLayout) return ; + // 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 ( - } - /> +
+
+ +
+ +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 195e40f385..d57207f951 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -271,6 +271,17 @@ export function ChannelItemRow({ label={{item.title}} isActive={isActive} onClick={() => actions.open(item)} + // Tasks drag into the Command Center grid, which accepts this + // payload type — same contract as the code sidebar's TaskItem. + draggable={item.kind === "task"} + onDragStart={ + item.kind === "task" + ? (e) => { + e.dataTransfer.setData("text/x-task-id", item.id); + e.dataTransfer.effectAllowed = "copy"; + } + : undefined + } endContent={ {/* Badges take the timestamp's slot on a task row: the row's diff --git a/packages/ui/src/features/canvas/components/ChannelTabs.tsx b/packages/ui/src/features/canvas/components/ChannelTabs.tsx index a18365700c..52e90a4949 100644 --- a/packages/ui/src/features/canvas/components/ChannelTabs.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTabs.tsx @@ -1,10 +1,13 @@ -import { Button, cn } from "@posthog/quill"; +import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import { LOOPS_FLAG } from "@posthog/shared"; import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections"; import { ChannelPinnedMenu } from "@posthog/ui/features/canvas/components/ChannelPinnedMenu"; -import { channelPageLabel } from "@posthog/ui/features/canvas/components/channelPages"; +import { + type ChannelPageKey, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { Link, useRouterState } from "@tanstack/react-router"; +import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; const TABS = CHANNEL_SECTIONS.map((s) => ({ key: s.key, @@ -12,53 +15,16 @@ const TABS = CHANNEL_SECTIONS.map((s) => ({ to: `/website/$channelId/${s.key}` as const, })); -// The space-page variant: Feed leads (it's the space itself, and the way back -// once you've tabbed away), Context follows it, and the labels come from the -// channelPages table ("Context", not the legacy "CONTEXT.md"). -const SPACE_TABS = (["context", "loops", "artifacts", "history"] as const).map( - (key) => ({ - key, - label: channelPageLabel(key), - to: `/website/$channelId/${key}` as const, - }), -); - -// Home / History / Artifacts tab switcher shown in the channel header bar, with -// a Pinned quick-access menu alongside. Pathname-driven active state (the -// codebase's convention) rather than Link's activeProps. -export function ChannelTabs({ - channelId, - includeHome, -}: { - channelId: string; - /** - * Adds a leading Feed tab for the space root. On: the header is the only tab - * strip (channels layout). Off: legacy header, where the channel pill beside - * this nav already links home. - */ - includeHome?: boolean; -}) { +// Home / History / Artifacts tab switcher shown in the legacy channel header +// bar, with a Pinned quick-access menu alongside. Pathname-driven active state +// (the codebase's convention) rather than Link's activeProps. +export function ChannelTabs({ channelId }: { channelId: string }) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); - const sectionTabs = includeHome ? SPACE_TABS : TABS; - const tabs = loopsEnabled - ? sectionTabs - : sectionTabs.filter((tab) => tab.key !== "loops"); - const home = `/website/${channelId}`; + const tabs = loopsEnabled ? TABS : TABS.filter((tab) => tab.key !== "loops"); return ( ); } + +// The space pages in the header's tab row: Feed leads (it's the space itself, +// and the way back once you've tabbed away), and labels come from the +// channelPages table ("Context", not the legacy "CONTEXT.md"). +const PAGE_TAB_KEYS = [ + "home", + "context", + "loops", + "artifacts", + "history", +] as const satisfies readonly ChannelPageKey[]; + +/** + * The channels-layout page switcher: quill line tabs under the space name, + * matching the tab strips elsewhere (Inbox, Activity). Every space scene tells + * the header which page it is, so the active tab is the `page` prop rather + * than a pathname match. The Pinned quick-access menu keeps the row's right + * edge. + */ +export function ChannelPageTabs({ + channelId, + page, +}: { + channelId: string; + page?: ChannelPageKey; +}) { + const navigate = useNavigate(); + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + const tabs = PAGE_TAB_KEYS.filter((key) => loopsEnabled || key !== "loops"); + + return ( +
+ { + if (value === "home") { + void navigate({ + to: "/website/$channelId", + params: { channelId }, + }); + return; + } + void navigate({ + to: `/website/$channelId/${value as Exclude<(typeof PAGE_TAB_KEYS)[number], "home">}`, + params: { channelId }, + }); + }} + > + + {tabs.map((key) => ( + + + {channelPageLabel(key)} + + + ))} + + + + + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index c0c8377b2e..25a3c62f74 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -1,4 +1,4 @@ -import { CaretRightIcon, GearSixIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, GearSixIcon, PlusIcon } from "@phosphor-icons/react"; import { isOwnedBy } from "@posthog/core/canvas/channelItems"; import { Button, cn, Skeleton } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; @@ -10,12 +10,14 @@ import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadC import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { type Ref, useMemo, useState } from "react"; const RECENTS_CAP = 10; @@ -30,7 +32,18 @@ const RECENTS_CAP = 10; * Expand state is local view state in `spacesSidebarStore`; the space's * presence in the sidebar is its star, managed from the All spaces directory. */ -export function SpaceSection({ channel }: { channel: Channel }) { +export function SpaceSection({ + channel, + dragHandleRef, +}: { + channel: Channel; + /** + * From the sortable wrapper (SpacesSidebarNav): binding the header row as + * the drag handle keeps the task rows free for their own native drag (into + * the Command Center) without dnd-kit swallowing it. + */ + dragHandleRef?: Ref; +}) { const open = useSpacesSidebarStore((s) => !!s.openSections[channel.id]); const toggle = useSpacesSidebarStore((s) => s.toggle); const onlyMine = useSpacesSidebarStore((s) => s.onlyMyTasks); @@ -110,6 +123,7 @@ export function SpaceSection({ channel }: { channel: Channel }) {
+ + {open && ( +
+ {rows.length === 0 ? ( +
+ No tasks yet +
+ ) : ( + rows.map(({ task, spaceName, folderId }) => ( + {task.title || "Untitled task"}} + isActive={pathname.endsWith(`/tasks/${task.id}`)} + onClick={ + folderId + ? () => + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId: folderId, taskId: task.id }, + }) + : undefined + } + endContent={ + spaceName ? ( + + {spaceName} + + ) : undefined + } + /> + )) + )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index 25a3c62f74..058c8ec5d4 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -10,7 +10,6 @@ import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadC import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; -import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; @@ -19,15 +18,15 @@ import { track } from "@posthog/ui/shell/analytics"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type Ref, useMemo, useState } from "react"; -const RECENTS_CAP = 10; +const RECENTS_CAP = 5; /** * One pinned space in the static sidebar. The whole row is one click target: * it folds the space's task list open and closed (caret included — no separate - * hover zones). The hover gear on the right is what opens the space itself in - * the main view, where the header tabs (Feed/Context/Loops/Artifacts) live. - * #me wears its lock in the same right-hand well, stepping aside for the gear - * on hover. + * hover zones). Hovering reveals two controls on the right: a plus that files + * a new task into this space, and the gear that opens the space itself in the + * main view, where the header tabs (Feed/Context/Loops/Artifacts) live. #me + * wears its lock in the same right-hand well, stepping aside on hover. * * Expand state is local view state in `spacesSidebarStore`; the space's * presence in the sidebar is its star, managed from the All spaces directory. @@ -152,11 +151,12 @@ export function SpaceSection({ {channel.name} {/* Trailing well, reserved so the name truncates clear of the lock - and hover gear rather than shifting when they appear. */} - + and the two hover controls rather than shifting when they appear. */} + {/* Overlays, not children — the row is a button already. The lock - yields its spot to the gear on hover so the well never doubles up. */} + yields its spot to the controls on hover so the well never doubles + up. */} {glyph && ( )} + +
+ + {/* The viewer's tasks across every space. */} +
+ +
+ {/* The section label, with one filter over every space's task list. */}
Spaces diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index c5161e64ca..5b5f45f0fa 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -1,3 +1,11 @@ +import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; @@ -7,7 +15,10 @@ import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; -import { useBackendChannel } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { + PERSONAL_CHANNEL_NAME, + useBackendChannel, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -109,36 +120,89 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { [channelId, fileTask, navigate, queryClient], ); + // The selectable spaces, personal first then alphabetical. Switching + // navigates to that space's own new-task route; the composer's draft lives + // in the shared "task-input" draft store, so text typed before switching + // survives the navigation. + const spaceOptions = useMemo( + () => + [...channels].sort((a, b) => { + if (a.name === PERSONAL_CHANNEL_NAME) return -1; + if (b.name === PERSONAL_CHANNEL_NAME) return 1; + return a.name.localeCompare(b.name); + }), + [channels], + ); + return ( -
- - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "new_task_suggestion", - surface: "new_task", - channel_id: channelId, - suggestion_label: label, - }) - } - onContextChipClick={ - channelContext ? handleContextChipClick : undefined - } - /> +
+ {/* Which space the task will file into — the sidebar's New session + button lands here with a default, and this is where to retarget. */} +
+ Space + + + {channelName ?? "Choose a space"} + + + } + /> + + {spaceOptions.map((space) => ( + { + if (space.id === channelId) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "new_task", + channel_id: space.id, + }); + void navigate({ + to: "/website/$channelId/new", + params: { channelId: space.id }, + }); + }} + > + {space.name} + {space.id === channelId && } + + ))} + + +
+
+ + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_suggestion", + surface: "new_task", + channel_id: channelId, + suggestion_label: label, + }) + } + onContextChipClick={ + channelContext ? handleContextChipClick : undefined + } + /> +
void; toggleAddSpace: () => void; toggleOnlyMyTasks: () => void; + toggleMyTasks: () => void; setSpaceOrder: (ids: string[]) => void; } @@ -33,6 +36,7 @@ export const useSpacesSidebarStore = create()( openAddSpace: true, onlyMyTasks: false, spaceOrder: [], + openMyTasks: true, toggle: (channelId) => set((state) => ({ openSections: { @@ -44,6 +48,8 @@ export const useSpacesSidebarStore = create()( set((state) => ({ openAddSpace: !state.openAddSpace })), toggleOnlyMyTasks: () => set((state) => ({ onlyMyTasks: !state.onlyMyTasks })), + toggleMyTasks: () => + set((state) => ({ openMyTasks: !state.openMyTasks })), setSpaceOrder: (ids) => set({ spaceOrder: ids }), }), { name: "spaces-sidebar" }, From b57e1b3fa6abcc375219a8abe41ec59a809c25b4 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:05:42 +0200 Subject: [PATCH 09/26] refactor(ui): move the space selector into the composer's chip row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's space picker becomes a chip beside the Cloud/Local workspace-mode select — same trigger shape, icon + name + caret — via a new spaceSelector slot on TaskInput (channels new-task only; /code is untouched). A space's own "+" still pre-fills that space; the sidebar's global New session now always lands on #me, with the chip as the way to retarget. Draft text survives the switch as before. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/SpaceSelect.tsx | 86 +++++++++++ .../canvas/components/SpacesSidebarNav.tsx | 15 +- .../canvas/components/WebsiteNewTask.tsx | 143 +++++++----------- .../task-detail/components/TaskInput.tsx | 17 ++- 4 files changed, 159 insertions(+), 102 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/SpaceSelect.tsx diff --git a/packages/ui/src/features/canvas/components/SpaceSelect.tsx b/packages/ui/src/features/canvas/components/SpaceSelect.tsx new file mode 100644 index 0000000000..3d7e88c76f --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpaceSelect.tsx @@ -0,0 +1,86 @@ +import { + CaretDown, + CheckIcon, + CubeFocusIcon, + LockSimpleIcon, +} from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useMemo } from "react"; + +function spaceIcon(name: string | undefined) { + return name === PERSONAL_CHANNEL_NAME ? ( + + ) : ( + + ); +} + +/** + * Which space a new task files into — a chip for the composer's selector row, + * drawn exactly like WorkspaceModeSelect ("Cloud"/"Local") beside it. + * Personal space first, the rest alphabetical. + */ +export function SpaceSelect({ + value, + onChange, +}: { + value: string; + onChange: (channelId: string) => void; +}) { + const { channels } = useChannels(); + const current = channels.find((c) => c.id === value); + + const options = useMemo( + () => + [...channels].sort((a, b) => { + if (a.name === PERSONAL_CHANNEL_NAME) return -1; + if (b.name === PERSONAL_CHANNEL_NAME) return 1; + return a.name.localeCompare(b.name); + }), + [channels], + ); + + return ( + + + + {spaceIcon(current?.name)} + + {current?.name ?? "Space"} + + + } + /> + + {options.map((space) => ( + { + if (space.id !== value) onChange(space.id); + }} + > + + {spaceIcon(space.name)} + + {space.name} + {space.id === value && } + + ))} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index a4fdf020bf..f024f1ee16 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -17,7 +17,6 @@ import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSectio import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; 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 { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; @@ -62,7 +61,6 @@ export function SpacesSidebarNav() { const toggleOnlyMyTasks = useSpacesSidebarStore((s) => s.toggleOnlyMyTasks); const spaceOrder = useSpacesSidebarStore((s) => s.spaceOrder); const setSpaceOrder = useSpacesSidebarStore((s) => s.setSpaceOrder); - const currentChannelId = useCurrentChannelStore((s) => s.currentChannelId); const me = pinnedSpaces.find((c) => c.name === PERSONAL_CHANNEL_NAME); // The user's drag order over the starred set; spaces they've never dragged @@ -96,9 +94,9 @@ export function SpacesSidebarNav() {
- {/* The create entry point, now that the floating button is gone. Files - into the space you're in; the composer's space selector can retarget - it. */} + {/* The create entry point, now that the floating button is gone. The + global button defaults to #me — the composer's space chip is where + to retarget; a space's own "+" pre-fills that space instead. */}
- } - /> - - {spaceOptions.map((space) => ( - { - if (space.id === channelId) return; - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "new_task_open", - surface: "new_task", - channel_id: space.id, - }); - void navigate({ - to: "/website/$channelId/new", - params: { channelId: space.id }, - }); - }} - > - {space.name} - {space.id === channelId && } - - ))} - - -
-
- - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "new_task_suggestion", - surface: "new_task", - channel_id: channelId, - suggestion_label: label, - }) - } - onContextChipClick={ - channelContext ? handleContextChipClick : undefined - } - /> -
+
+ + } + onTaskCreated={onTaskCreated} + channelContext={channelContext} + channelName={channelName} + channelId={backendChannel?.id} + channelContextId={channelId} + allowNoRepo + // So a prompt handed to openTaskInput survives routing into a channel. + initialPrompt={view.initialPrompt} + initialPromptKey={view.taskInputRequestId} + initialCloudRepository={view.initialCloudRepository} + initialModel={view.initialModel} + initialMode={view.initialMode} + reportAssociation={view.reportAssociation} + suggestions={CHANNEL_TASK_SUGGESTIONS} + onSuggestionSelect={(label) => + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_suggestion", + surface: "new_task", + channel_id: channelId, + suggestion_label: label, + }) + } + onContextChipClick={ + channelContext ? handleContextChipClick : undefined + } + />
void; + /** + * A space picker chip rendered first in the selector row above the composer + * (beside the workspace-mode chip). Channels new-task screen only — /code + * has no spaces to pick. + */ + spaceSelector?: ReactNode; } export function TaskInput({ @@ -170,6 +183,7 @@ export function TaskInput({ suggestions, onSuggestionSelect, onContextChipClick, + spaceSelector, }: TaskInputProps = {}) { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const trpc = useHostTRPC(); @@ -1204,6 +1218,7 @@ export function TaskInput({ align="center" className="absolute bottom-full left-0 mb-2 min-w-0" > + {spaceSelector} {piHarnessEnabled && ( Date: Sat, 1 Aug 2026 09:08:48 +0200 Subject: [PATCH 10/26] refactor(ui): My tasks renders real space rows under a section-label header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-space My tasks list now builds ChannelItemModels through the same core builder the space lists use and renders them as ChannelItemRows — status dot, badge stacks, hover preview card, pin/archive menu — with open routing each row into its own space. The space name rides in a new optional contextLabel slot in the row's trailing stack, muted. The section header drops the space-row shape for the same 12px section-label type as the Spaces header beneath it, still folding on click. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/ChannelItemRow.tsx | 11 ++ .../canvas/components/MyTasksSection.tsx | 169 ++++++++++-------- 2 files changed, 109 insertions(+), 71 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index d57207f951..52560d625b 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -177,6 +177,7 @@ export function ChannelItemRow({ onAddToCommandCenter, onEditSubmit, onEditCancel, + contextLabel, }: { item: ChannelItemModel; /** The space this row is listed under, ticked in the menu's "File to…". */ @@ -184,6 +185,11 @@ export function ChannelItemRow({ isActive: boolean; actions: ChannelItemActions; isEditing?: boolean; + /** + * A muted marker for where the row lives — the cross-space "My tasks" list + * names each row's space with it. Lists scoped to one space omit it. + */ + contextLabel?: string; /** Puts the row into inline-rename mode. Absent for canvases. */ onRename?: () => void; /** Absent when the command centre has no free cell, which disables the item. */ @@ -284,6 +290,11 @@ export function ChannelItemRow({ } endContent={ + {contextLabel && ( + + {contextLabel} + + )} {/* Badges take the timestamp's slot on a task row: the row's identity (pin, source, cloud, PR) is what you scan a task list for, and the relative age is still in the preview diff --git a/packages/ui/src/features/canvas/components/MyTasksSection.tsx b/packages/ui/src/features/canvas/components/MyTasksSection.tsx index 748aa67695..deac5725ed 100644 --- a/packages/ui/src/features/canvas/components/MyTasksSection.tsx +++ b/packages/ui/src/features/canvas/components/MyTasksSection.tsx @@ -1,5 +1,13 @@ import { CaretRightIcon } from "@phosphor-icons/react"; -import { Button, cn } from "@posthog/quill"; +import { + buildChannelItems, + type ChannelItemModel, +} from "@posthog/core/canvas/channelItems"; +import { cn } from "@posthog/quill"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; +import type { ChannelItemActions } from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { normalizeChannelName, @@ -7,18 +15,20 @@ import { useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; -import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { toast } from "@posthog/ui/primitives/toast"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useMemo } from "react"; const MY_TASKS_CAP = 10; /** - * The viewer's tasks across every space, newest first, each wearing its - * space's name in muted text on the right. Rows open the task inside its - * space; tasks that predate spaces (no backend channel) fall back to the - * personal space. Same folding header shape as the space rows below it. + * The viewer's tasks across every space, newest first — the same rows a + * space's own list renders (status dot, badges, hover card), each wearing its + * space's name in muted text. Rows open the task inside its space; tasks that + * predate spaces (no backend channel) fall back to the personal space. The + * header is a section label like "Spaces" below it, folding on click. */ export function MyTasksSection() { const open = useSpacesSidebarStore((s) => s.openMyTasks); @@ -30,91 +40,108 @@ export function MyTasksSection() { const { data: myTasks = [] } = useTasks(); const { channels: backendChannels } = useTaskChannels({ enabled: open }); const { channels: folderChannels } = useChannels(); + const archivedTaskIds = useArchivedTaskIds(); + const { pinnedTaskIds, togglePin } = usePinnedTasks(); + const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); + + // Same item shape the space lists use, so the rows render identically. + const items = useMemo( + () => + buildChannelItems({ + dashboards: [], + feedTasks: myTasks, + archivedTaskIds, + pinnedTaskIds, + ownedBy: null, + }).slice(0, MY_TASKS_CAP), + [myTasks, archivedTaskIds, pinnedTaskIds], + ); - const rows = useMemo(() => { + // Each task's space: backend channel → display name → folder channel (which + // the routes need). Unmapped tasks open under #me and carry no label. + const spaceFor = useMemo(() => { const backendById = new Map(backendChannels.map((c) => [c.id, c])); const folderByName = new Map( folderChannels.map((c) => [normalizeChannelName(c.name), c]), ); const me = folderChannels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - return [...myTasks] - .sort( - (a, b) => - (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0), - ) - .slice(0, MY_TASKS_CAP) - .map((task) => { - const backend = task.channel - ? backendById.get(task.channel) - : undefined; - const spaceName = backend - ? backend.channel_type === "personal" - ? PERSONAL_CHANNEL_NAME - : backend.name - : null; - const folder = spaceName - ? folderByName.get(normalizeChannelName(spaceName)) - : undefined; - return { - task, - spaceName, - // The route needs the folder channel; unmapped tasks open under #me. - folderId: (folder ?? me)?.id, - }; - }); - }, [myTasks, backendChannels, folderChannels]); + const byTaskId = new Map< + string, + { spaceName: string | null; folderId: string | undefined } + >(); + for (const item of items) { + const backend = item.task?.channel + ? backendById.get(item.task.channel) + : undefined; + const spaceName = backend + ? backend.channel_type === "personal" + ? PERSONAL_CHANNEL_NAME + : backend.name + : null; + const folder = spaceName + ? folderByName.get(normalizeChannelName(spaceName)) + : undefined; + byTaskId.set(item.id, { spaceName, folderId: (folder ?? me)?.id }); + } + return byTaskId; + }, [items, backendChannels, folderChannels]); + + const actions = useMemo( + () => ({ + open: (item) => { + const folderId = spaceFor.get(item.id)?.folderId; + if (!folderId) return; + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId: folderId, taskId: item.id }, + }); + }, + togglePin: (item) => { + togglePin(item.id).catch(() => { + toast.error("Couldn't update pin"); + }); + }, + archive: (item) => { + void archiveTask({ taskId: item.id }); + }, + // Tasks only here — canvases (the deletable kind) never appear. + remove: () => {}, + }), + [spaceFor, navigate, togglePin, archiveTask], + ); return (
- {/* Same shape as a space row, so the carets line up. */} - + My tasks + {open && ( -
- {rows.length === 0 ? ( +
+ {items.length === 0 ? (
No tasks yet
) : ( - rows.map(({ task, spaceName, folderId }) => ( - {task.title || "Untitled task"}} - isActive={pathname.endsWith(`/tasks/${task.id}`)} - onClick={ - folderId - ? () => - void navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId: folderId, taskId: task.id }, - }) - : undefined - } - endContent={ - spaceName ? ( - - {spaceName} - - ) : undefined - } + items.map((item) => ( + )) )} From 2827a9a68db51f82748e4a92997e78ebd7ee9249 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:12:11 +0200 Subject: [PATCH 11/26] style(ui): use the canonical quill line-tabs recipe for the space page tabs Drops the custom indicator-transition overrides so the header's page tabs are the untouched quill TabsList variant="line" pattern, identical to the Loops and Skills tab strips. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- packages/ui/src/features/canvas/components/ChannelTabs.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelTabs.tsx b/packages/ui/src/features/canvas/components/ChannelTabs.tsx index 33eca9ee1e..f09844409f 100644 --- a/packages/ui/src/features/canvas/components/ChannelTabs.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTabs.tsx @@ -94,10 +94,9 @@ export function ChannelPageTabs({ }); }} > - + {/* The canonical line-tabs recipe (LoopsListView, SkillsView) verbatim + — quill draws the strip, nothing custom on top. */} + {tabs.map((key) => ( {channelPageIcon(key, { size: 14 })} From 490719c709d53c1e2300c15d0d2482b1070c7e96 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:13:23 +0200 Subject: [PATCH 12/26] style(ui): make the sidebar's New session button quill primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left-aligned outline treatment read oddly in the sidebar; the create CTA is now quill's primary variant — elevated, centered, still sm-sized to match the rows around it. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../src/features/canvas/components/SpacesSidebarNav.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index f024f1ee16..2459bbc56b 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -98,10 +98,12 @@ export function SpacesSidebarNav() { global button defaults to #me — the composer's space chip is where to retarget; a space's own "+" pre-fills that space instead. */}
+ {/* quill's primary treatment — the sidebar's one elevated CTA, sized + like the sm rows around it. */}
From dfce7948b1368c45519f353c7b1d60b9daa4e82c Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:20:48 +0200 Subject: [PATCH 13/26] =?UTF-8?q?style(ui):=20space=20selector=20menu=20?= =?UTF-8?q?=E2=80=94=20starred=20first,=20divider,=20flyout=20item=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's space menu now leads with the starred spaces in their sidebar order (#me first), a separator, then the rest alphabetically. Items use the switchers' flyout vocabulary — leading check well, the space's own glyph (only #me carries one), left-aligned 13px name — and the content gets a proper min width. The blanket cube/lock icons are gone in favor of the channelGlyph rule. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/SpaceSelect.tsx | 121 +++++++++++------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSelect.tsx b/packages/ui/src/features/canvas/components/SpaceSelect.tsx index 3d7e88c76f..38e5377dfa 100644 --- a/packages/ui/src/features/canvas/components/SpaceSelect.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSelect.tsx @@ -1,32 +1,29 @@ -import { - CaretDown, - CheckIcon, - CubeFocusIcon, - LockSimpleIcon, -} from "@phosphor-icons/react"; +import { CaretDown, Check } from "@phosphor-icons/react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@posthog/quill"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { 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 { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useMemo } from "react"; -function spaceIcon(name: string | undefined) { - return name === PERSONAL_CHANNEL_NAME ? ( - - ) : ( - - ); -} - /** * Which space a new task files into — a chip for the composer's selector row, - * drawn exactly like WorkspaceModeSelect ("Cloud"/"Local") beside it. - * Personal space first, the rest alphabetical. + * drawn exactly like WorkspaceModeSelect ("Cloud"/"Local") beside it. The menu + * leads with the starred spaces in their sidebar order (#me first), a + * separator, then everything else alphabetically — items in the flyout + * vocabulary the switchers use: a leading check well, then the space's own + * glyph (only #me carries one), then the name. */ export function SpaceSelect({ value, @@ -36,26 +33,68 @@ export function SpaceSelect({ onChange: (channelId: string) => void; }) { const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const spaceOrder = useSpacesSidebarStore((s) => s.spaceOrder); const current = channels.find((c) => c.id === value); - const options = useMemo( - () => - [...channels].sort((a, b) => { - if (a.name === PERSONAL_CHANNEL_NAME) return -1; - if (b.name === PERSONAL_CHANNEL_NAME) return 1; - return a.name.localeCompare(b.name); - }), - [channels], - ); + const { starred, rest } = useMemo(() => { + const me = channels.filter((c) => c.name === PERSONAL_CHANNEL_NAME); + const rank = new Map(spaceOrder.map((id, index) => [id, index])); + const starredList = channels + .filter( + (c) => + c.name !== PERSONAL_CHANNEL_NAME && + starredRefToShortcutId.has(c.path), + ) + .sort( + (a, b) => + (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); + const starredIds = new Set(starredList.map((c) => c.id)); + return { + starred: [...me, ...starredList], + rest: channels + .filter( + (c) => c.name !== PERSONAL_CHANNEL_NAME && !starredIds.has(c.id), + ) + .sort((a, b) => a.name.localeCompare(b.name)), + }; + }, [channels, starredRefToShortcutId, spaceOrder]); + + const triggerGlyph = channelGlyph(current?.name, { size: 14, space: true }); + + const renderItem = (space: Channel) => { + const glyph = channelGlyph(space.name, { size: 14, space: true }); + return ( + { + if (space.id !== value) onChange(space.id); + }} + > + + {space.id === value && } + + {glyph && ( + + {glyph} + + )} + {space.name} + + ); + }; return ( - - {spaceIcon(current?.name)} - + {triggerGlyph && ( + {triggerGlyph} + )} {current?.name ?? "Space"} } /> - - {options.map((space) => ( - { - if (space.id !== value) onChange(space.id); - }} - > - - {spaceIcon(space.name)} - - {space.name} - {space.id === value && } - - ))} + + {starred.map(renderItem)} + {starred.length > 0 && rest.length > 0 && } + {rest.map(renderItem)} ); From 4914224111b3409da17620ab1dc6b2d418d9d8b0 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:28:05 +0200 Subject: [PATCH 14/26] feat(ui): replace My tasks with a drag-in Watch list, MenuLabel section headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-space My tasks section becomes a Watch list: drag any task row onto it (they all carry the text/x-task-id payload) to keep a local reference, newest first, rendered with the same ChannelItemRows and muted space names; a hover × forgets the reference. Watching is a local-only pointer for now, persisted in the sidebar store. Both it and the Spaces header now use the MenuLabel section style (the code sidebar's "Sessions" look), with a Separator between the two sections. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/MyTasksSection.tsx | 152 ------------- .../canvas/components/SpacesSidebarNav.tsx | 16 +- .../canvas/components/WatchListSection.tsx | 204 ++++++++++++++++++ .../canvas/stores/spacesSidebarStore.ts | 28 ++- 4 files changed, 236 insertions(+), 164 deletions(-) delete mode 100644 packages/ui/src/features/canvas/components/MyTasksSection.tsx create mode 100644 packages/ui/src/features/canvas/components/WatchListSection.tsx diff --git a/packages/ui/src/features/canvas/components/MyTasksSection.tsx b/packages/ui/src/features/canvas/components/MyTasksSection.tsx deleted file mode 100644 index deac5725ed..0000000000 --- a/packages/ui/src/features/canvas/components/MyTasksSection.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; -import { - buildChannelItems, - type ChannelItemModel, -} from "@posthog/core/canvas/channelItems"; -import { cn } from "@posthog/quill"; -import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; -import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; -import type { ChannelItemActions } from "@posthog/ui/features/canvas/components/ChannelItemRow"; -import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { - normalizeChannelName, - PERSONAL_CHANNEL_NAME, - useTaskChannels, -} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; -import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { toast } from "@posthog/ui/primitives/toast"; -import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { useMemo } from "react"; - -const MY_TASKS_CAP = 10; - -/** - * The viewer's tasks across every space, newest first — the same rows a - * space's own list renders (status dot, badges, hover card), each wearing its - * space's name in muted text. Rows open the task inside its space; tasks that - * predate spaces (no backend channel) fall back to the personal space. The - * header is a section label like "Spaces" below it, folding on click. - */ -export function MyTasksSection() { - const open = useSpacesSidebarStore((s) => s.openMyTasks); - const toggleMyTasks = useSpacesSidebarStore((s) => s.toggleMyTasks); - const navigate = useNavigate(); - const pathname = useRouterState({ select: (s) => s.location.pathname }); - - // useTasks() without showAllUsers is already scoped to the viewer. - const { data: myTasks = [] } = useTasks(); - const { channels: backendChannels } = useTaskChannels({ enabled: open }); - const { channels: folderChannels } = useChannels(); - const archivedTaskIds = useArchivedTaskIds(); - const { pinnedTaskIds, togglePin } = usePinnedTasks(); - const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); - - // Same item shape the space lists use, so the rows render identically. - const items = useMemo( - () => - buildChannelItems({ - dashboards: [], - feedTasks: myTasks, - archivedTaskIds, - pinnedTaskIds, - ownedBy: null, - }).slice(0, MY_TASKS_CAP), - [myTasks, archivedTaskIds, pinnedTaskIds], - ); - - // Each task's space: backend channel → display name → folder channel (which - // the routes need). Unmapped tasks open under #me and carry no label. - const spaceFor = useMemo(() => { - const backendById = new Map(backendChannels.map((c) => [c.id, c])); - const folderByName = new Map( - folderChannels.map((c) => [normalizeChannelName(c.name), c]), - ); - const me = folderChannels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - const byTaskId = new Map< - string, - { spaceName: string | null; folderId: string | undefined } - >(); - for (const item of items) { - const backend = item.task?.channel - ? backendById.get(item.task.channel) - : undefined; - const spaceName = backend - ? backend.channel_type === "personal" - ? PERSONAL_CHANNEL_NAME - : backend.name - : null; - const folder = spaceName - ? folderByName.get(normalizeChannelName(spaceName)) - : undefined; - byTaskId.set(item.id, { spaceName, folderId: (folder ?? me)?.id }); - } - return byTaskId; - }, [items, backendChannels, folderChannels]); - - const actions = useMemo( - () => ({ - open: (item) => { - const folderId = spaceFor.get(item.id)?.folderId; - if (!folderId) return; - void navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId: folderId, taskId: item.id }, - }); - }, - togglePin: (item) => { - togglePin(item.id).catch(() => { - toast.error("Couldn't update pin"); - }); - }, - archive: (item) => { - void archiveTask({ taskId: item.id }); - }, - // Tasks only here — canvases (the deletable kind) never appear. - remove: () => {}, - }), - [spaceFor, navigate, togglePin, archiveTask], - ); - - return ( -
- {/* Same section-label shape and type as the "Spaces" header below it, - with a fold caret. */} - - - {open && ( -
- {items.length === 0 ? ( -
- No tasks yet -
- ) : ( - items.map((item) => ( - - )) - )} -
- )} -
- ); -} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 2459bbc56b..0fcce87c40 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -4,6 +4,8 @@ import { useSortable } from "@dnd-kit/react/sortable"; import { PlusIcon } from "@phosphor-icons/react"; import { Button, + MenuLabel, + Separator, Switch, Tooltip, TooltipContent, @@ -12,8 +14,8 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpacesSection"; import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; -import { MyTasksSection } from "@posthog/ui/features/canvas/components/MyTasksSection"; import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection"; +import { WatchListSection } from "@posthog/ui/features/canvas/components/WatchListSection"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -118,14 +120,16 @@ export function SpacesSidebarNav() {
- {/* The viewer's tasks across every space. */} -
- + {/* Dragged-in task references, kept locally. */} +
+
+ + {/* The section label, with one filter over every space's task list. */} -
- Spaces +
+ Spaces s.openWatchList); + const toggleWatchList = useSpacesSidebarStore((s) => s.toggleWatchList); + const watchList = useSpacesSidebarStore((s) => s.watchList); + const addToWatchList = useSpacesSidebarStore((s) => s.addToWatchList); + const removeFromWatchList = useSpacesSidebarStore( + (s) => s.removeFromWatchList, + ); + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const [isDropTarget, setIsDropTarget] = useState(false); + + // Anyone's task can be watched, so resolve against the full list. + const { data: allTasks = [] } = useTasks({ showAllUsers: true }); + const { channels: backendChannels } = useTaskChannels({ enabled: open }); + const { channels: folderChannels } = useChannels(); + const archivedTaskIds = useArchivedTaskIds(); + const { pinnedTaskIds, togglePin } = usePinnedTasks(); + const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); + + // Same item shape the space lists use, held in watch-list order (newest + // watched first) rather than the builder's recency sort. + const items = useMemo(() => { + const watched = new Set(watchList); + const built = buildChannelItems({ + dashboards: [], + feedTasks: allTasks.filter((t) => watched.has(t.id)), + archivedTaskIds, + pinnedTaskIds, + ownedBy: null, + }); + const byId = new Map(built.map((item) => [item.id, item])); + return watchList.flatMap((id) => byId.get(id) ?? []); + }, [watchList, allTasks, archivedTaskIds, pinnedTaskIds]); + + // Each task's space: backend channel → display name → folder channel (which + // the routes need). Unmapped tasks open under #me and carry no label. + const spaceFor = useMemo(() => { + const backendById = new Map(backendChannels.map((c) => [c.id, c])); + const folderByName = new Map( + folderChannels.map((c) => [normalizeChannelName(c.name), c]), + ); + const me = folderChannels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + const byTaskId = new Map< + string, + { spaceName: string | null; folderId: string | undefined } + >(); + for (const item of items) { + const backend = item.task?.channel + ? backendById.get(item.task.channel) + : undefined; + const spaceName = backend + ? backend.channel_type === "personal" + ? PERSONAL_CHANNEL_NAME + : backend.name + : null; + const folder = spaceName + ? folderByName.get(normalizeChannelName(spaceName)) + : undefined; + byTaskId.set(item.id, { spaceName, folderId: (folder ?? me)?.id }); + } + return byTaskId; + }, [items, backendChannels, folderChannels]); + + const actions = useMemo( + () => ({ + open: (item) => { + const folderId = spaceFor.get(item.id)?.folderId; + if (!folderId) return; + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId: folderId, taskId: item.id }, + }); + }, + togglePin: (item) => { + togglePin(item.id).catch(() => { + toast.error("Couldn't update pin"); + }); + }, + archive: (item) => { + void archiveTask({ taskId: item.id }); + }, + // Tasks only here — canvases (the deletable kind) never appear. + remove: () => {}, + }), + [spaceFor, navigate, togglePin, archiveTask], + ); + + const handleDragOver = (e: DragEvent) => { + if (!e.dataTransfer.types.includes("text/x-task-id")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDropTarget(true); + }; + const handleDragLeave = (e: DragEvent) => { + // dragleave fires when crossing into children; only clear on a real exit. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setIsDropTarget(false); + }; + const handleDrop = (e: DragEvent) => { + setIsDropTarget(false); + const taskId = e.dataTransfer.getData("text/x-task-id"); + if (!taskId) return; + e.preventDefault(); + addToWatchList(taskId); + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: drop target for task drags; every row inside stays keyboard-reachable +
+ {/* Same section-label shape as the code sidebar's "Sessions" header, + folding on click. */} + + } + > + Watch list + + + + {open && + (items.length === 0 ? ( +
+ Drag a session here to keep an eye on it +
+ ) : ( +
+ {items.map((item) => ( + // Overlay, not endContent: the row is a button already. +
+ + +
+ ))} +
+ ))} +
+ ); +} diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index 0fb9ba0af7..c2039d0c55 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -18,13 +18,20 @@ interface SpacesSidebarState { * and never ranked. */ spaceOrder: string[]; - /** The cross-space "My tasks" section's fold. */ - openMyTasks: boolean; + /** The watch list section's fold. */ + openWatchList: boolean; + /** + * Task ids the user dragged into the watch list, newest first. Local-only + * references for now — watching doesn't touch the task or its space. + */ + watchList: string[]; toggle: (channelId: string) => void; toggleAddSpace: () => void; toggleOnlyMyTasks: () => void; - toggleMyTasks: () => void; + toggleWatchList: () => void; setSpaceOrder: (ids: string[]) => void; + addToWatchList: (taskId: string) => void; + removeFromWatchList: (taskId: string) => void; } export const useSpacesSidebarStore = create()( @@ -36,7 +43,8 @@ export const useSpacesSidebarStore = create()( openAddSpace: true, onlyMyTasks: false, spaceOrder: [], - openMyTasks: true, + openWatchList: true, + watchList: [], toggle: (channelId) => set((state) => ({ openSections: { @@ -48,9 +56,17 @@ export const useSpacesSidebarStore = create()( set((state) => ({ openAddSpace: !state.openAddSpace })), toggleOnlyMyTasks: () => set((state) => ({ onlyMyTasks: !state.onlyMyTasks })), - toggleMyTasks: () => - set((state) => ({ openMyTasks: !state.openMyTasks })), + toggleWatchList: () => + set((state) => ({ openWatchList: !state.openWatchList })), setSpaceOrder: (ids) => set({ spaceOrder: ids }), + addToWatchList: (taskId) => + set((state) => ({ + watchList: [taskId, ...state.watchList.filter((id) => id !== taskId)], + })), + removeFromWatchList: (taskId) => + set((state) => ({ + watchList: state.watchList.filter((id) => id !== taskId), + })), }), { name: "spaces-sidebar" }, ), From 9462babd63dacfdece1a0d18c5da2aedc1dcb628 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:33:04 +0200 Subject: [PATCH 15/26] fix(ui): make watch-list drops land visibly A dropped task only rendered if it happened to be in the viewer's loaded task list, so drops of anything else stored a reference and showed nothing. Watch entries now capture the title at drop time (rows put it on the drag payload alongside the id), the list synthesizes a row for any watched task the query doesn't hold, and a toast confirms each add. Legacy bare-id entries are normalized on read. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/ChannelItemRow.tsx | 7 ++- .../canvas/components/WatchListSection.tsx | 51 ++++++++++++++++--- .../canvas/stores/spacesSidebarStore.ts | 26 +++++++--- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 52560d625b..9279ae044d 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -277,13 +277,16 @@ export function ChannelItemRow({ label={{item.title}} isActive={isActive} onClick={() => actions.open(item)} - // Tasks drag into the Command Center grid, which accepts this - // payload type — same contract as the code sidebar's TaskItem. + // Tasks drag into the Command Center grid and the sidebar's + // watch list, which accept this payload type — same contract as + // the code sidebar's TaskItem. The title rides along so a drop + // target can keep a legible reference without refetching. draggable={item.kind === "task"} onDragStart={ item.kind === "task" ? (e) => { e.dataTransfer.setData("text/x-task-id", item.id); + e.dataTransfer.setData("text/x-task-title", item.title); e.dataTransfer.effectAllowed = "copy"; } : undefined diff --git a/packages/ui/src/features/canvas/components/WatchListSection.tsx b/packages/ui/src/features/canvas/components/WatchListSection.tsx index 02c59c93dd..e9d40b83d0 100644 --- a/packages/ui/src/features/canvas/components/WatchListSection.tsx +++ b/packages/ui/src/features/canvas/components/WatchListSection.tsx @@ -14,7 +14,10 @@ import { PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; +import { + useSpacesSidebarStore, + type WatchedTaskRef, +} from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; @@ -49,10 +52,23 @@ export function WatchListSection() { const { pinnedTaskIds, togglePin } = usePinnedTasks(); const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); + // Older builds persisted bare id strings; treat them as minimal refs. + const watchedRefs = useMemo( + () => + (watchList as unknown as (WatchedTaskRef | string)[]).map((entry) => + typeof entry === "string" + ? { id: entry, title: "Untitled task", addedAt: 0 } + : entry, + ), + [watchList], + ); + // Same item shape the space lists use, held in watch-list order (newest - // watched first) rather than the builder's recency sort. + // watched first) rather than the builder's recency sort. A watched task the + // viewer's task list doesn't hold (someone else's, or beyond the page) still + // renders, from the reference captured at drop time. const items = useMemo(() => { - const watched = new Set(watchList); + const watched = new Set(watchedRefs.map((entry) => entry.id)); const built = buildChannelItems({ dashboards: [], feedTasks: allTasks.filter((t) => watched.has(t.id)), @@ -61,8 +77,24 @@ export function WatchListSection() { ownedBy: null, }); const byId = new Map(built.map((item) => [item.id, item])); - return watchList.flatMap((id) => byId.get(id) ?? []); - }, [watchList, allTasks, archivedTaskIds, pinnedTaskIds]); + return watchedRefs.map( + (entry) => + byId.get(entry.id) ?? { + key: `task:${entry.id}`, + kind: "task" as const, + id: entry.id, + title: entry.title, + ts: entry.addedAt, + pinned: pinnedTaskIds.has(entry.id), + rawStatus: null, + authorUser: null, + authorName: null, + authorUuid: null, + templateId: null, + task: null, + }, + ); + }, [watchedRefs, allTasks, archivedTaskIds, pinnedTaskIds]); // Each task's space: backend channel → display name → folder channel (which // the routes need). Unmapped tasks open under #me and carry no label. @@ -133,7 +165,14 @@ export function WatchListSection() { const taskId = e.dataTransfer.getData("text/x-task-id"); if (!taskId) return; e.preventDefault(); - addToWatchList(taskId); + // Title from the drag payload where the source provides it (channel + // rows); the loaded task list covers drags from the code sidebar. + const title = + e.dataTransfer.getData("text/x-task-title") || + allTasks.find((t) => t.id === taskId)?.title || + "Untitled task"; + addToWatchList({ id: taskId, title, addedAt: Date.now() }); + toast.success("Added to watch list", { description: title }); }; return ( diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index c2039d0c55..94f36d7fe4 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -1,6 +1,17 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +/** + * A watched task, self-sufficient for rendering: the title and added-at are + * captured at drop time so a reference stays legible even when the task isn't + * in the viewer's loaded task list (someone else's, or beyond the page). + */ +export interface WatchedTaskRef { + id: string; + title: string; + addedAt: number; +} + /** * View state for the static spaces sidebar: per-space expands, and the * "All spaces" list toggle. `openSections` stores explicit space expansion @@ -21,16 +32,16 @@ interface SpacesSidebarState { /** The watch list section's fold. */ openWatchList: boolean; /** - * Task ids the user dragged into the watch list, newest first. Local-only + * Tasks the user dragged into the watch list, newest first. Local-only * references for now — watching doesn't touch the task or its space. */ - watchList: string[]; + watchList: WatchedTaskRef[]; toggle: (channelId: string) => void; toggleAddSpace: () => void; toggleOnlyMyTasks: () => void; toggleWatchList: () => void; setSpaceOrder: (ids: string[]) => void; - addToWatchList: (taskId: string) => void; + addToWatchList: (ref: WatchedTaskRef) => void; removeFromWatchList: (taskId: string) => void; } @@ -59,13 +70,16 @@ export const useSpacesSidebarStore = create()( toggleWatchList: () => set((state) => ({ openWatchList: !state.openWatchList })), setSpaceOrder: (ids) => set({ spaceOrder: ids }), - addToWatchList: (taskId) => + addToWatchList: (ref) => set((state) => ({ - watchList: [taskId, ...state.watchList.filter((id) => id !== taskId)], + watchList: [ + ref, + ...state.watchList.filter((entry) => entry.id !== ref.id), + ], })), removeFromWatchList: (taskId) => set((state) => ({ - watchList: state.watchList.filter((id) => id !== taskId), + watchList: state.watchList.filter((entry) => entry.id !== taskId), })), }), { name: "spaces-sidebar" }, From 0ae6d88f5b609822a14a5a2f9f8f520a0b1185e7 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:38:13 +0200 Subject: [PATCH 16/26] =?UTF-8?q?fix(ui):=20watch=20list=20=E2=80=94=20no?= =?UTF-8?q?=20timestamps,=20removable=20legacy=20entries,=20clearer=20remo?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watch rows drop the relative-age text (they're references, not activity), and removal now works everywhere: v0 persisted bare-id entries are lifted to refs by a store migration so the × matches them, the × itself gets a solid chrome backing and bolder glyph so it reads over the badges it covers, and "Remove from watch list" joins the row's hover-card and right-click menus for a discoverable path. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/ChannelItemRow.tsx | 22 ++++++++++--- .../canvas/components/TaskRowMenu.tsx | 5 +++ .../canvas/components/WatchListSection.tsx | 32 +++++++------------ .../canvas/stores/spacesSidebarStore.ts | 19 ++++++++++- 4 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 9279ae044d..6cfdc0693a 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -178,6 +178,8 @@ export function ChannelItemRow({ onEditSubmit, onEditCancel, contextLabel, + hideTimestamp = false, + onRemoveFromWatchList, }: { item: ChannelItemModel; /** The space this row is listed under, ticked in the menu's "File to…". */ @@ -186,10 +188,17 @@ export function ChannelItemRow({ actions: ChannelItemActions; isEditing?: boolean; /** - * A muted marker for where the row lives — the cross-space "My tasks" list - * names each row's space with it. Lists scoped to one space omit it. + * A muted marker for where the row lives — the watch list names each row's + * space with it. Lists scoped to one space omit it. */ contextLabel?: string; + /** Drops the relative-age text from the trailing slot (watch-list rows). */ + hideTimestamp?: boolean; + /** + * Watch-list rows only: forgets the local reference, surfaced in the row's + * menus alongside the hover ×. + */ + onRemoveFromWatchList?: () => void; /** Puts the row into inline-rename mode. Absent for canvases. */ onRename?: () => void; /** Absent when the command centre has no free cell, which disables the item. */ @@ -242,6 +251,7 @@ export function ChannelItemRow({ onAddToCommandCenter, onRename, onTogglePin: () => actions.togglePin(item), + onRemoveFromWatchList, onArchive: () => actions.archive(item), }; @@ -319,9 +329,11 @@ export function ChannelItemRow({ )} - - {formatRelativeTimeShort(item.ts)} - + {!hideTimestamp && ( + + {formatRelativeTimeShort(item.ts)} + + )} )} diff --git a/packages/ui/src/features/canvas/components/TaskRowMenu.tsx b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx index f3e3e4310e..ea45c0628e 100644 --- a/packages/ui/src/features/canvas/components/TaskRowMenu.tsx +++ b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx @@ -43,6 +43,8 @@ export interface TaskRowMenuProps { /** Absent where there's no inline rename to open — canvases, for now. */ onRename?: () => void; onTogglePin: () => void; + /** Present only on watch-list rows — forgets the local reference. */ + onRemoveFromWatchList?: () => void; /** Tasks are archived; canvases are deleted (with an undo window). */ onArchive?: () => void; onDelete?: () => void; @@ -124,6 +126,9 @@ function TaskRowMenuItems({ )} + {menu.onRemoveFromWatchList && ( + Remove from watch list + )} {menu.onArchive && Archive} {/* The ellipsis is the promise that a confirm follows — deleting a canvas takes it away from everyone in the space. */} diff --git a/packages/ui/src/features/canvas/components/WatchListSection.tsx b/packages/ui/src/features/canvas/components/WatchListSection.tsx index e9d40b83d0..a76f001ef0 100644 --- a/packages/ui/src/features/canvas/components/WatchListSection.tsx +++ b/packages/ui/src/features/canvas/components/WatchListSection.tsx @@ -14,10 +14,7 @@ import { PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { - useSpacesSidebarStore, - type WatchedTaskRef, -} from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; @@ -52,23 +49,12 @@ export function WatchListSection() { const { pinnedTaskIds, togglePin } = usePinnedTasks(); const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); - // Older builds persisted bare id strings; treat them as minimal refs. - const watchedRefs = useMemo( - () => - (watchList as unknown as (WatchedTaskRef | string)[]).map((entry) => - typeof entry === "string" - ? { id: entry, title: "Untitled task", addedAt: 0 } - : entry, - ), - [watchList], - ); - // Same item shape the space lists use, held in watch-list order (newest // watched first) rather than the builder's recency sort. A watched task the // viewer's task list doesn't hold (someone else's, or beyond the page) still // renders, from the reference captured at drop time. const items = useMemo(() => { - const watched = new Set(watchedRefs.map((entry) => entry.id)); + const watched = new Set(watchList.map((entry) => entry.id)); const built = buildChannelItems({ dashboards: [], feedTasks: allTasks.filter((t) => watched.has(t.id)), @@ -77,7 +63,7 @@ export function WatchListSection() { ownedBy: null, }); const byId = new Map(built.map((item) => [item.id, item])); - return watchedRefs.map( + return watchList.map( (entry) => byId.get(entry.id) ?? { key: `task:${entry.id}`, @@ -94,7 +80,7 @@ export function WatchListSection() { task: null, }, ); - }, [watchedRefs, allTasks, archivedTaskIds, pinnedTaskIds]); + }, [watchList, allTasks, archivedTaskIds, pinnedTaskIds]); // Each task's space: backend channel → display name → folder channel (which // the routes need). Unmapped tasks open under #me and carry no label. @@ -224,15 +210,21 @@ export function WatchListSection() { isActive={pathname.endsWith(`/tasks/${item.id}`)} actions={actions} contextLabel={spaceFor.get(item.id)?.spaceName ?? undefined} + // Reference rows, not activity rows — the age adds noise. + hideTimestamp + onRemoveFromWatchList={() => removeFromWatchList(item.id)} /> + {/* Solid chrome backing so the × reads over the badges it + covers; the row menu carries the same action for keyboard + and right-click. */}
))} diff --git a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts index 94f36d7fe4..baec0e92b3 100644 --- a/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -82,6 +82,23 @@ export const useSpacesSidebarStore = create()( watchList: state.watchList.filter((entry) => entry.id !== taskId), })), }), - { name: "spaces-sidebar" }, + { + name: "spaces-sidebar", + version: 1, + // v0 persisted watch entries as bare task-id strings; those couldn't be + // matched (or removed) once entries became refs. Lift them. + migrate: (persisted) => { + const state = persisted as { watchList?: unknown } | undefined; + if (state && Array.isArray(state.watchList)) { + state.watchList = state.watchList.map( + (entry: WatchedTaskRef | string): WatchedTaskRef => + typeof entry === "string" + ? { id: entry, title: "Untitled task", addedAt: 0 } + : entry, + ); + } + return state; + }, + }, ), ); From 191a02a738d87e7cde2d818c2c4d8623cda32492 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:41:06 +0200 Subject: [PATCH 17/26] style(ui): label the Spaces my-tasks switch "Mine" Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/SpacesSidebarNav.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 0fcce87c40..ba45158787 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -130,23 +130,26 @@ export function SpacesSidebarNav() { {/* The section label, with one filter over every space's task list. */}
Spaces - - - } - /> - - {onlyMyTasks - ? "Showing only your tasks — switch off to see everyone's" - : "Only show tasks you created, in every space"} - - +
+ Mine + + + } + /> + + {onlyMyTasks + ? "Showing only your tasks — switch off to see everyone's" + : "Only show tasks you created, in every space"} + + +
{/* Pinned (starred) spaces; #me first and fixed, the rest reorderable. */} From b7f63dd7e6964d8d59ce20c884916f8ecc4a69f5 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:47:47 +0200 Subject: [PATCH 18/26] feat(ui): search across all spaces, full inline task lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search toggle joins the Spaces header (same icon-button + input recipe as the channel sidebar's Sessions header): typing filters every space's task list by title, spaces without a match step aside, and matching spaces open on their matches regardless of fold state. Per-space lists drop the five-row cap and View all — an unfolded space shows its whole list, with the pinned-spaces region as the sidebar's single scroll container so nothing nests scrollbars. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/SpaceSection.tsx | 154 ++++++++---------- .../canvas/components/SpacesSidebarNav.tsx | 68 +++++++- 2 files changed, 131 insertions(+), 91 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index 058c8ec5d4..a63d7f0a43 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -18,8 +18,6 @@ import { track } from "@posthog/ui/shell/analytics"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type Ref, useMemo, useState } from "react"; -const RECENTS_CAP = 5; - /** * One pinned space in the static sidebar. The whole row is one click target: * it folds the space's task list open and closed (caret included — no separate @@ -28,12 +26,18 @@ const RECENTS_CAP = 5; * main view, where the header tabs (Feed/Context/Loops/Artifacts) live. #me * wears its lock in the same right-hand well, stepping aside on hover. * + * The open list is the full list — the pinned-spaces region around these + * sections is the one scroll container, so no per-space scrollbars or + * "View all" hops. A search query (from the Spaces header) overrides the + * fold: matching spaces open on their matches, spaces without any disappear. + * * Expand state is local view state in `spacesSidebarStore`; the space's * presence in the sidebar is its star, managed from the All spaces directory. */ export function SpaceSection({ channel, dragHandleRef, + query, }: { channel: Channel; /** @@ -42,6 +46,8 @@ export function SpaceSection({ * the Command Center) without dnd-kit swallowing it. */ dragHandleRef?: Ref; + /** Lowercased search from the Spaces header, applied across every space. */ + query?: string; }) { const open = useSpacesSidebarStore((s) => !!s.openSections[channel.id]); const toggle = useSpacesSidebarStore((s) => s.toggle); @@ -81,21 +87,23 @@ export function SpaceSection({ return task ? `task:${task[1]}` : null; }, [pathname]); - // The sidebar-wide "My tasks" toggle narrows every space's list to items the - // viewer created — the same fail-closed ownership rule #me itself uses. - const visibleItems = useMemo( - () => (onlyMine ? items.filter((i) => isOwnedBy(i, me)) : items), - [items, onlyMine, me], - ); + // The sidebar-wide "Mine" toggle narrows every space's list to items the + // viewer created — the same fail-closed ownership rule #me itself uses — + // and the header search narrows it further by title. + const searching = !!query; + const visibleItems = useMemo(() => { + const mine = onlyMine ? items.filter((i) => isOwnedBy(i, me)) : items; + return query + ? mine.filter((i) => i.title.toLowerCase().includes(query)) + : mine; + }, [items, onlyMine, me, query]); const sectionItems = useMemo( - () => - [ - ...visibleItems.filter((i) => i.pinned), - ...visibleItems.filter((i) => !i.pinned), - ].slice(0, RECENTS_CAP), + () => [ + ...visibleItems.filter((i) => i.pinned), + ...visibleItems.filter((i) => !i.pinned), + ], [visibleItems], ); - const overflowCount = visibleItems.length - sectionItems.length; const commandCenterAssigner = (taskId: string) => { const cellIndex = commandCenterCells.findIndex( @@ -118,6 +126,10 @@ export function SpaceSection({ }); }; + // While searching, a space is its matches — none means the whole section + // steps aside rather than listing an empty shell. + if (searching && sectionItems.length === 0) return null; + return (
@@ -193,8 +205,8 @@ export function SpaceSection({
{/* Tasks under the space, pinned first. Same inset as the channel - groups' trees (pl-5). */} - {open && ( + groups' trees (pl-5). A search opens every matching space. */} + {(open || searching) && (
{isLoading && sectionItems.length === 0 ? (
@@ -206,74 +218,48 @@ export function SpaceSection({ {onlyMine ? "None of your tasks here" : "No tasks yet"}
) : ( - <> - {/* Five recents, no inner scroll — the full list is one click - away on the space's Recents page via View all. */} -
- {sectionItems.map((item) => ( - setEditingTaskId(item.id) - : undefined - } - onAddToCommandCenter={ - item.kind === "task" && - !commandCenterCells.includes(item.id) - ? commandCenterAssigner(item.id) - : undefined - } - onEditSubmit={ - item.kind === "task" - ? async (newTitle) => { - setEditingTaskId(null); - try { - await renameTask({ - taskId: item.id, - currentTitle: item.title, - newTitle, - }); - } catch (error) { - toast.error("Couldn't rename task", { - description: - error instanceof Error - ? error.message - : String(error), - }); - } - } - : undefined - } - onEditCancel={() => setEditingTaskId(null)} - /> - ))} -
- {/* The list is a cap, not the whole story — the rest live on the - space's Recents page. */} - {overflowCount > 0 && ( - - )} - + sectionItems.map((item) => ( + setEditingTaskId(item.id) + : undefined + } + onAddToCommandCenter={ + item.kind === "task" && !commandCenterCells.includes(item.id) + ? commandCenterAssigner(item.id) + : undefined + } + onEditSubmit={ + item.kind === "task" + ? async (newTitle) => { + setEditingTaskId(null); + try { + await renameTask({ + taskId: item.id, + currentTitle: item.title, + newTitle, + }); + } catch (error) { + toast.error("Couldn't rename task", { + description: + error instanceof Error + ? error.message + : String(error), + }); + } + } + : undefined + } + onEditCancel={() => setEditingTaskId(null)} + /> + )) )}
)} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index ba45158787..403a58858e 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -1,9 +1,11 @@ import { PointerSensor } from "@dnd-kit/dom"; import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; import { useSortable } from "@dnd-kit/react/sortable"; -import { PlusIcon } from "@phosphor-icons/react"; +import { MagnifyingGlass, PlusIcon } from "@phosphor-icons/react"; import { Button, + cn, + Input, MenuLabel, Separator, Switch, @@ -23,7 +25,7 @@ import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spaces import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import type { RefCallback } from "react"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; /** * One sortable pinned space. The header row is the drag handle (via @@ -31,7 +33,15 @@ import { useMemo } from "react"; * into the Command Center. #me renders outside this wrapper — it's always * first and doesn't reorder. */ -function SortableSpace({ space, index }: { space: Channel; index: number }) { +function SortableSpace({ + space, + index, + query, +}: { + space: Channel; + index: number; + query?: string; +}) { const { ref, handleRef, isDragging } = useSortable({ id: space.id, index, @@ -44,6 +54,7 @@ function SortableSpace({ space, index }: { space: Channel; index: number }) { } + query={query} />
); @@ -63,6 +74,12 @@ export function SpacesSidebarNav() { const toggleOnlyMyTasks = useSpacesSidebarStore((s) => s.toggleOnlyMyTasks); const spaceOrder = useSpacesSidebarStore((s) => s.spaceOrder); const setSpaceOrder = useSpacesSidebarStore((s) => s.setSpaceOrder); + // One search over every space's task list; transient view state. + const [searchOpen, setSearchOpen] = useState(false); + const [searchText, setSearchText] = useState(""); + const query = searchOpen + ? searchText.trim().toLowerCase() || undefined + : undefined; const me = pinnedSpaces.find((c) => c.name === PERSONAL_CHANNEL_NAME); // The user's drag order over the starred set; spaces they've never dragged @@ -127,10 +144,27 @@ export function SpacesSidebarNav() { - {/* The section label, with one filter over every space's task list. */} + {/* The section label, with search and one filter over every space's + task list. */}
Spaces
+ Mine
- {/* Pinned (starred) spaces; #me first and fixed, the rest reorderable. */} + {searchOpen && ( +
+ setSearchText(event.target.value)} + placeholder="Search all spaces…" + aria-label="Search tasks in all spaces" + className="h-6 text-[12px]" + /> +
+ )} + + {/* Pinned (starred) spaces; #me first and fixed, the rest reorderable. + This region is the sidebar's one scroll container — spaces unfold + their full lists inside it, so there are no nested scrollbars. */}
- {me && } + {me && } {/* The handle doubles as the fold toggle, so a small pickup distance keeps plain clicks from starting a drag. */} {orderedSpaces.map((space, index) => ( - + ))}
From 7007eac9c3036ed6500e3dc49563c670cff82fc8 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:52:11 +0200 Subject: [PATCH 19/26] feat(ui): drag spaces from the directory to pin; live watch-list status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All-spaces rows now carry a text/x-space-id drag payload and the pinned region accepts them — dropping stars the space (one gesture instead of the hover star) and lands it at the bottom of the user's order, ready to reorder. The watch list stops going stale: each watched task gets its own polled detail query, merged over the list copies, so a task the task-list query doesn't return still renders with its real status dot and badges instead of the synthesized quiet row. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/AllSpacesSection.tsx | 7 ++ .../canvas/components/SpacesSidebarNav.tsx | 68 +++++++++++++++++-- .../canvas/components/WatchListSection.tsx | 28 +++++++- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx index 35a9499685..3aac8f018a 100644 --- a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx @@ -116,6 +116,13 @@ export function AllSpacesSection() { 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={ diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 403a58858e..bdf4815fcc 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -18,13 +18,21 @@ import { AllSpacesSection } from "@posthog/ui/features/canvas/components/AllSpac import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; import { SpaceSection } from "@posthog/ui/features/canvas/components/SpaceSection"; import { WatchListSection } from "@posthog/ui/features/canvas/components/WatchListSection"; -import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { + useChannelStarMutations, + useChannelStars, +} from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; +import { toast } from "@posthog/ui/primitives/toast"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; -import type { RefCallback } from "react"; +import type { DragEvent, RefCallback } from "react"; import { useMemo, useState } from "react"; /** @@ -81,6 +89,13 @@ export function SpacesSidebarNav() { ? searchText.trim().toLowerCase() || undefined : undefined; + // Dropping an All-spaces row into this region pins it (stars it), same as + // the directory's star but as one gesture. + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const { star } = useChannelStarMutations(); + const [isSpaceDropTarget, setIsSpaceDropTarget] = useState(false); + const me = pinnedSpaces.find((c) => c.name === PERSONAL_CHANNEL_NAME); // The user's drag order over the starred set; spaces they've never dragged // keep their backend order after the ranked ones (sort is stable). @@ -96,6 +111,41 @@ export function SpacesSidebarNav() { ); }, [pinnedSpaces, spaceOrder]); + const handleSpaceDragOver = (e: DragEvent) => { + if (!e.dataTransfer.types.includes("text/x-space-id")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsSpaceDropTarget(true); + }; + const handleSpaceDragLeave = (e: DragEvent) => { + // dragleave fires when crossing into children; only clear on a real exit. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setIsSpaceDropTarget(false); + }; + const handleSpaceDrop = (e: DragEvent) => { + setIsSpaceDropTarget(false); + const spaceId = e.dataTransfer.getData("text/x-space-id"); + if (!spaceId) return; + e.preventDefault(); + const channel = channels.find((c) => c.id === spaceId); + if (!channel || starredRefToShortcutId.has(channel.path)) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "star", + surface: "sidebar", + channel_id: channel.id, + }); + star(channel).catch((error: unknown) => + toast.error("Couldn't pin space", { + description: error instanceof Error ? error.message : String(error), + }), + ); + // Pins land at the bottom of the user's order, ready to drag into place. + setSpaceOrder([ + ...orderedSpaces.map((c) => c.id).filter((id) => id !== spaceId), + spaceId, + ]); + }; + const handleDragEnd: DragDropEvents["dragend"] = (event) => { if (event.canceled) return; const sourceId = event.operation.source?.id; @@ -201,8 +251,18 @@ export function SpacesSidebarNav() { {/* Pinned (starred) spaces; #me first and fixed, the rest reorderable. This region is the sidebar's one scroll container — spaces unfold - their full lists inside it, so there are no nested scrollbars. */} -
+ their full lists inside it, so there are no nested scrollbars. It + also accepts All-spaces rows dragged up from the directory below. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: drop target for space drags; the directory's star is the keyboard path */} +
{me && } {/* The handle doubles as the fold toggle, so a small pickup diff --git a/packages/ui/src/features/canvas/components/WatchListSection.tsx b/packages/ui/src/features/canvas/components/WatchListSection.tsx index a76f001ef0..6220700a08 100644 --- a/packages/ui/src/features/canvas/components/WatchListSection.tsx +++ b/packages/ui/src/features/canvas/components/WatchListSection.tsx @@ -16,11 +16,15 @@ import { } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; +import { useQueries } from "@tanstack/react-query"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type DragEvent, useMemo, useState } from "react"; +const WATCHED_TASK_POLL_INTERVAL_MS = 30_000; + /** * A personal watch list: drag any task row onto the section (they all carry * the text/x-task-id payload) to keep a reference to it here, newest first. @@ -43,6 +47,16 @@ export function WatchListSection() { // Anyone's task can be watched, so resolve against the full list. const { data: allTasks = [] } = useTasks({ showAllUsers: true }); + // The list query misses tasks (its own filters and page), and a synthesized + // fallback row carries no live status — so each watched task also gets its + // own polled detail query, the same authoritative record the task views + // read. The status dot then agrees with the space lists. + const watchedTaskQueries = useQueries({ + queries: watchList.map((entry) => ({ + ...taskDetailQuery(entry.id), + refetchInterval: WATCHED_TASK_POLL_INTERVAL_MS, + })), + }); const { channels: backendChannels } = useTaskChannels({ enabled: open }); const { channels: folderChannels } = useChannels(); const archivedTaskIds = useArchivedTaskIds(); @@ -55,9 +69,17 @@ export function WatchListSection() { // renders, from the reference captured at drop time. const items = useMemo(() => { const watched = new Set(watchList.map((entry) => entry.id)); + // Detail records win over list copies: fresher, and they cover watched + // tasks the list query doesn't return at all. + const taskById = new Map( + allTasks.filter((t) => watched.has(t.id)).map((t) => [t.id, t]), + ); + for (const query of watchedTaskQueries) { + if (query.data) taskById.set(query.data.id, query.data); + } const built = buildChannelItems({ dashboards: [], - feedTasks: allTasks.filter((t) => watched.has(t.id)), + feedTasks: [...taskById.values()], archivedTaskIds, pinnedTaskIds, ownedBy: null, @@ -80,7 +102,7 @@ export function WatchListSection() { task: null, }, ); - }, [watchList, allTasks, archivedTaskIds, pinnedTaskIds]); + }, [watchList, allTasks, watchedTaskQueries, archivedTaskIds, pinnedTaskIds]); // Each task's space: backend channel → display name → folder channel (which // the routes need). Unmapped tasks open under #me and carry no label. @@ -222,7 +244,7 @@ export function WatchListSection() { size="icon-sm" aria-label="Remove from watch list" onClick={() => removeFromWatchList(item.id)} - className="-translate-y-1/2 absolute top-1/2 right-0.5 bg-chrome text-muted-foreground opacity-0 shadow-sm transition-opacity focus-visible:opacity-100 group-hover/watch:opacity-100 hover:text-foreground" + className="-translate-y-1/2 absolute top-1/2 right-0.5 bg-chrome text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/watch:opacity-100" > From c217ff2078feab9348adcf35114d0793d21c4bc8 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:54:09 +0200 Subject: [PATCH 20/26] feat(ui): space lists open on five rows with an inline Show more An opened space leads with its first five items; "Show more (n)" unfolds the rest in place and flips to "Show less". Search still shows every match uncapped, and the pinned-spaces region stays the single scroll container. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/SpaceSection.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index a63d7f0a43..a273029eb9 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -18,6 +18,8 @@ import { track } from "@posthog/ui/shell/analytics"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type Ref, useMemo, useState } from "react"; +const INITIAL_ROWS = 5; + /** * One pinned space in the static sidebar. The whole row is one click target: * it folds the space's task list open and closed (caret included — no separate @@ -26,10 +28,11 @@ import { type Ref, useMemo, useState } from "react"; * main view, where the header tabs (Feed/Context/Loops/Artifacts) live. #me * wears its lock in the same right-hand well, stepping aside on hover. * - * The open list is the full list — the pinned-spaces region around these - * sections is the one scroll container, so no per-space scrollbars or - * "View all" hops. A search query (from the Spaces header) overrides the - * fold: matching spaces open on their matches, spaces without any disappear. + * An opened space leads with its first five items and a "Show more" that + * unfolds the rest inline — the pinned-spaces region around these sections is + * the one scroll container, so no per-space scrollbars or page hops. A search + * query (from the Spaces header) overrides both the fold and the cap: + * matching spaces open on all their matches, spaces without any disappear. * * Expand state is local view state in `spacesSidebarStore`; the space's * presence in the sidebar is its star, managed from the All spaces directory. @@ -104,6 +107,12 @@ export function SpaceSection({ ], [visibleItems], ); + // "Show more" unfolds the rest of the list inline; a search always shows + // every match. Transient view state — a fresh mount starts compact. + const [showAll, setShowAll] = useState(false); + const displayItems = + searching || showAll ? sectionItems : sectionItems.slice(0, INITIAL_ROWS); + const hiddenCount = sectionItems.length - displayItems.length; const commandCenterAssigner = (taskId: string) => { const cellIndex = commandCenterCells.findIndex( @@ -218,7 +227,7 @@ export function SpaceSection({ {onlyMine ? "None of your tasks here" : "No tasks yet"}
) : ( - sectionItems.map((item) => ( + displayItems.map((item) => ( )) )} + {/* The rest unfold in place; a search already shows every match. */} + {!searching && (hiddenCount > 0 || showAll) && ( + + )}
)}
From 75c853e8ed7148d7963e7c484514348dcc9b0e7c Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 09:56:49 +0200 Subject: [PATCH 21/26] style(ui): inline the all-spaces search into the section header row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search input no longer drops in as a second row under SPACES — opening search swaps the section label for the input on the same line (the Mine switch steps aside for the width and returns on close), with Escape closing it. The header row holds a stable height either way. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/SpacesSidebarNav.tsx | 78 +++++++++++-------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index bdf4815fcc..1c7f07fa04 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -195,10 +195,29 @@ export function SpacesSidebarNav() { {/* The section label, with search and one filter over every space's - task list. */} -
- Spaces -
+ task list. Searching swaps the label for the input on the same row — + the header holds its line either way; the Mine switch steps aside + for the input's width and returns on close. */} +
+ {searchOpen ? ( + setSearchText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setSearchOpen(false); + setSearchText(""); + } + }} + placeholder="Search all spaces…" + aria-label="Search tasks in all spaces" + className="h-6 min-w-0 flex-1 text-[12px]" + /> + ) : ( + Spaces + )} +
- Mine - - + Mine + + + } /> - } - /> - - {onlyMyTasks - ? "Showing only your tasks — switch off to see everyone's" - : "Only show tasks you created, in every space"} - - + + {onlyMyTasks + ? "Showing only your tasks — switch off to see everyone's" + : "Only show tasks you created, in every space"} + + + + )}
- {searchOpen && ( -
- setSearchText(event.target.value)} - placeholder="Search all spaces…" - aria-label="Search tasks in all spaces" - className="h-6 text-[12px]" - /> -
- )} - {/* Pinned (starred) spaces; #me first and fixed, the rest reorderable. This region is the sidebar's one scroll container — spaces unfold their full lists inside it, so there are no nested scrollbars. It From 7c457dd4f3c9f7a4b363293463be4e37f3d8cc9b Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 10:00:41 +0200 Subject: [PATCH 22/26] fix(ui): stop the space header remounting on every tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each space page pushed its own ChannelHeader through the header store on mount and nulled it on unmount, so switching tabs tore down and rebuilt the whole header — breadcrumb, tab strip, indicator — which read as a flicker. WebsiteLayout now renders one persistent ChannelHeader for the five page routes (page derived from the pathname), so a tab switch just updates the page prop and the indicator slides. Task detail, new task, canvases, and legacy-layout scenes keep the store path unchanged. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../canvas/components/WebsiteLayout.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 3e51ddb990..750956bc7e 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -24,6 +24,7 @@ import { } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; +import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { channelPageIcon, @@ -63,7 +64,7 @@ import { useParams, useRouterState, } from "@tanstack/react-router"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useMemo, useState } from "react"; function threadIdFor(dashboardId: string): string { return `dashboard:${dashboardId}`; @@ -429,6 +430,26 @@ export function WebsiteLayout() { ? "Space" : "Channel"; + // The five space pages render one persistent header here rather than each + // scene pushing its own copy through the header store: a header that + // remounts on every tab switch flashes, while a stable instance lets the + // tab indicator slide. Scenes still push (harmlessly) for the legacy + // layout; everything else (task detail, new task, mirrored pages) keeps + // the store path. + const channelPage = useMemo(() => { + if (!spacesLayout || !channelId) return null; + if (pathname === base) return "home" as const; + if (pathname === `${base}/context`) return "context" as const; + if (pathname === `${base}/loops`) return "loops" as const; + if (pathname === `${base}/artifacts`) return "artifacts" as const; + if (pathname === `${base}/history`) return "history" as const; + return null; + }, [spacesLayout, channelId, base, pathname]); + const spaceHeader = + channelPage && channelId ? ( + + ) : null; + const isDashboardDetail = Boolean(channelId && dashboardId); // The canvases grid (its own sub-route now that the channel index is the // static homepage, which carries its own header content). @@ -446,7 +467,7 @@ export function WebsiteLayout() { new task, CONTEXT.md) pushes its "# channel / leaf" breadcrumb into the header store, as do channel-less mirrored pages (Home, Skills, …). Hidden when the canvas toolbar is showing (grid / a single canvas). */} - {!showToolbar && headerContent && ( + {!showToolbar && (spaceHeader ?? headerContent) && ( - {headerContent} + {spaceHeader ?? headerContent} {channelTask && } From 30a35d670c030fe4df85763bbdf1840b8073087d Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 10:04:26 +0200 Subject: [PATCH 23/26] =?UTF-8?q?feat(ui):=20three=20ways=20out=20of=20the?= =?UTF-8?q?=20pinned=20list=20=E2=80=94=20drag=20out,=20context=20menu,=20?= =?UTF-8?q?hover=20star?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../canvas/components/SpaceSection.tsx | 214 ++++++++++++------ .../canvas/components/SpacesSidebarNav.tsx | 30 ++- 2 files changed, 168 insertions(+), 76 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSection.tsx b/packages/ui/src/features/canvas/components/SpaceSection.tsx index a273029eb9..58c11e6260 100644 --- a/packages/ui/src/features/canvas/components/SpaceSection.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -1,11 +1,27 @@ -import { CaretRightIcon, GearSixIcon, PlusIcon } from "@phosphor-icons/react"; +import { + CaretRightIcon, + GearSixIcon, + PlusIcon, + StarIcon, +} from "@phosphor-icons/react"; import { isOwnedBy } from "@posthog/core/canvas/channelItems"; -import { Button, cn, Skeleton } from "@posthog/quill"; +import { + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + cn, + Skeleton, +} from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; @@ -23,10 +39,13 @@ const INITIAL_ROWS = 5; /** * One pinned space in the static sidebar. The whole row is one click target: * it folds the space's task list open and closed (caret included — no separate - * hover zones). Hovering reveals two controls on the right: a plus that files - * a new task into this space, and the gear that opens the space itself in the - * main view, where the header tabs (Feed/Context/Loops/Artifacts) live. #me - * wears its lock in the same right-hand well, stepping aside on hover. + * hover zones). Hovering reveals the controls on the right: a plus that files + * a new task into this space, a filled star that unpins it, and the gear that + * opens the space itself in the main view, where the header tabs + * (Feed/Context/Loops/Artifacts) live. Right-click carries the same actions + * as a menu, and dragging the row out of the pinned list also unpins. #me + * wears its lock in the same right-hand well, stepping aside on hover, and + * can't be unpinned. * * An opened space leads with its first five items and a "Show more" that * unfolds the rest inline — the pinned-spaces region around these sections is @@ -61,6 +80,8 @@ export function SpaceSection({ const base = `/website/${channel.id}`; const isActive = pathname === base || pathname.startsWith(`${base}/`); const isUnread = useIsChannelUnread()(channel.name); + const isPersonal = channel.name === PERSONAL_CHANNEL_NAME; + const { toggleStar } = useChannelStarToggle(channel); // Only #me has a glyph under the layout (its lock) — same rule as // ChannelBackRow; it sits in the trailing well, not in front of the name. const glyph = channelGlyph(channel.name, { @@ -135,83 +156,130 @@ export function SpaceSection({ }); }; + const newTask = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "sidebar", + channel_id: channel.id, + }); + openTaskInput({ channelId: channel.id }); + }; + + // Unpinning removes the space from this list; it stays reachable (and + // re-pinnable) in the All spaces directory below. + const unpin = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "unstar", + surface: "sidebar", + channel_id: channel.id, + }); + toggleStar(); + }; + // While searching, a space is its matches — none means the whole section // steps aside rather than listing an empty shell. if (searching && sectionItems.length === 0) return null; return (
-
- - {/* Overlays, not children — the row is a button already. The lock + + + {channel.name} + + {/* Trailing well, reserved so the name truncates clear of the lock + and the hover controls rather than shifting when they appear. + #me has two controls (new task, open); shared spaces add the + unpin star between them. */} + + + {/* Overlays, not children — the row is a button already. The lock yields its spot to the controls on hover so the well never doubles up. */} - {glyph && ( - + {glyph} + + )} + - -
+ + + {/* Filled: this row is pinned, and the star is the way out. */} + {!isPersonal && ( + + )} + + + + Open space + New task + {!isPersonal && ( + <> + + Unpin space + + )} + + {/* Tasks under the space, pinned first. Same inset as the channel groups' trees (pl-5). A search opens every matching space. */} diff --git a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx index 1c7f07fa04..8446add0b1 100644 --- a/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -90,10 +90,11 @@ export function SpacesSidebarNav() { : undefined; // Dropping an All-spaces row into this region pins it (stars it), same as - // the directory's star but as one gesture. + // the directory's star but as one gesture; dragging a pinned row out again + // unpins it. const { channels } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); - const { star } = useChannelStarMutations(); + const { star, unstar } = useChannelStarMutations(); const [isSpaceDropTarget, setIsSpaceDropTarget] = useState(false); const me = pinnedSpaces.find((c) => c.name === PERSONAL_CHANNEL_NAME); @@ -150,7 +151,30 @@ export function SpacesSidebarNav() { if (event.canceled) return; const sourceId = event.operation.source?.id; const targetId = event.operation.target?.id; - if (!sourceId || !targetId || sourceId === targetId) return; + if (!sourceId) return; + // Released over no pinned row — the drag left the list, which unpins. + // In-place drops are safe: the row under the pointer (itself included) + // is still the target. + if (!targetId) { + const channel = channels.find((c) => c.id === String(sourceId)); + const shortcutId = channel && starredRefToShortcutId.get(channel.path); + if (!channel || !shortcutId) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "unstar", + surface: "sidebar", + channel_id: channel.id, + }); + unstar(shortcutId).catch((error: unknown) => + toast.error("Couldn't unpin space", { + description: error instanceof Error ? error.message : String(error), + }), + ); + setSpaceOrder( + orderedSpaces.map((c) => c.id).filter((id) => id !== channel.id), + ); + return; + } + if (sourceId === targetId) return; const ids = orderedSpaces.map((c) => c.id); const from = ids.indexOf(String(sourceId)); const to = ids.indexOf(String(targetId)); From 704b76eb2ee98be456440ee80943857decae1af2 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 1 Aug 2026 10:06:36 +0200 Subject: [PATCH 24/26] style(ui): drop the expanded fill on the All spaces header quill's default button paints --fill-expanded while aria-expanded, which read as a permanent highlight on a section that's open most of the time. The rotated caret already says open; hover feedback stays. Generated-By: PostHog Code Task-Id: 0331ac58-0a1c-4b4e-b884-2ff7d8e71986 --- .../ui/src/features/canvas/components/AllSpacesSection.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx index 3aac8f018a..9522af7f15 100644 --- a/packages/ui/src/features/canvas/components/AllSpacesSection.tsx +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx @@ -79,13 +79,16 @@ export function AllSpacesSection() { return (
- {/* Same shape as a pinned space row, so the carets line up. */} + {/* 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. */}