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.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 new file mode 100644 index 0000000000..9522af7f15 --- /dev/null +++ b/packages/ui/src/features/canvas/components/AllSpacesSection.tsx @@ -0,0 +1,172 @@ +import { CaretRightIcon, PlusIcon, StarIcon } from "@phosphor-icons/react"; +import { Button, cn } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { + useChannelStarMutations, + useChannelStars, +} from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/shell/analytics"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; + +/** + * The project's space directory, docked at the bottom of the sidebar. The + * header is shaped exactly like a pinned space row (same caret, same inset) and + * folding it open grows the section upward to a capped height, then the list + * scrolls. Clicking a row opens the space; the hover star is what pins it into + * the sidebar above — pinned rows wear their star filled so the directory + * shows what's already up there. The personal space isn't listed: it can't be + * pinned or shared, and it's always first in the pinned list anyway. + */ +export function AllSpacesSection() { + const open = useSpacesSidebarStore((s) => s.openAddSpace); + const toggle = useSpacesSidebarStore((s) => s.toggleAddSpace); + const [createOpen, setCreateOpen] = useState(false); + + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const { star, unstar } = useChannelStarMutations(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + const spaces = useMemo( + () => + channels + .filter((c) => c.name !== PERSONAL_CHANNEL_NAME) + .sort((a, b) => a.name.localeCompare(b.name)), + [channels], + ); + + const openSpace = (channel: Channel) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "nav_click", + surface: "sidebar", + channel_id: channel.id, + }); + setCurrentChannel(channel.id); + void navigate({ + to: "/website/$channelId", + params: { channelId: channel.id }, + }); + }; + + const togglePin = (channel: Channel) => { + const 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 ( +
+
+ {/* 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. */} + + + {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)} + // 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={ + + } + /> + +
+ ); + })} +
+ {/* Below the scroll region so it's reachable without scrolling + the whole directory. */} + } + 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..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. The new -// layout drops the section tab strip (the channel sidebar carries those -// entries); flag off keeps it. Starring lives on the sidebar back row and the -// channel list, not here. +// name doesn't change size between the space home and its sub-pages. Under the +// channels layout the page switcher is a row of line tabs beneath the space +// name (matching Inbox/Activity); the legacy header keeps its button strip. +// Starring lives on the sidebar back row and the channel list, not here. export function ChannelHeader({ channelId, page, @@ -42,13 +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..6cfdc0693a 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -177,6 +177,9 @@ export function ChannelItemRow({ onAddToCommandCenter, onEditSubmit, onEditCancel, + contextLabel, + hideTimestamp = false, + onRemoveFromWatchList, }: { item: ChannelItemModel; /** The space this row is listed under, ticked in the menu's "File to…". */ @@ -184,6 +187,18 @@ export function ChannelItemRow({ isActive: boolean; actions: ChannelItemActions; isEditing?: boolean; + /** + * 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. */ @@ -236,6 +251,7 @@ export function ChannelItemRow({ onAddToCommandCenter, onRename, onTogglePin: () => actions.togglePin(item), + onRemoveFromWatchList, onArchive: () => actions.archive(item), }; @@ -271,8 +287,27 @@ export function ChannelItemRow({ label={{item.title}} isActive={isActive} onClick={() => actions.open(item)} + // 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 + } 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 @@ -294,9 +329,11 @@ export function ChannelItemRow({ )} - - {formatRelativeTimeShort(item.ts)} - + {!hideTimestamp && ( + + {formatRelativeTimeShort(item.ts)} + + )} )} diff --git a/packages/ui/src/features/canvas/components/ChannelTabs.tsx b/packages/ui/src/features/canvas/components/ChannelTabs.tsx index 6bd815d842..f09844409f 100644 --- a/packages/ui/src/features/canvas/components/ChannelTabs.tsx +++ b/packages/ui/src/features/canvas/components/ChannelTabs.tsx @@ -1,9 +1,14 @@ -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 { + type ChannelPageKey, + channelPageIcon, + 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, @@ -11,9 +16,9 @@ const TABS = CHANNEL_SECTIONS.map((s) => ({ to: `/website/$channelId/${s.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. +// 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); @@ -41,3 +46,70 @@ export function ChannelTabs({ channelId }: { channelId: string }) { ); } + +// 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 }, + }); + }} + > + {/* The canonical line-tabs recipe (LoopsListView, SkillsView) verbatim + — quill draws the strip, nothing custom on top. */} + + {tabs.map((key) => ( + + {channelPageIcon(key, { size: 14 })} + + {channelPageLabel(key)} + + + ))} + + + + + +
+ ); +} 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..5901890637 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. The nav owns its own scroll regions + (pinned spaces scroll; All spaces docks at the bottom) and its + own create entry points (New session at the top, New space in + the directory), so no Fab here. */} + + + ) : 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..58c11e6260 --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -0,0 +1,356 @@ +import { + CaretRightIcon, + GearSixIcon, + PlusIcon, + StarIcon, +} from "@phosphor-icons/react"; +import { isOwnedBy } from "@posthog/core/canvas/channelItems"; +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"; +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 { openTaskInput } from "@posthog/ui/router/useOpenTask"; +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 + * 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 + * 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. + */ +export function SpaceSection({ + channel, + dragHandleRef, + query, +}: { + 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; + /** 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); + const onlyMine = useSpacesSidebarStore((s) => s.onlyMyTasks); + 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 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, { + size: 14, + space: true, + className: "text-muted-foreground", + }); + + const { items, actions, isLoading, me } = useChannelItems(channel.id); + 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]); + + // 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), + ], + [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( + (cellTaskId) => cellTaskId == null || !allTaskIds.has(cellTaskId), + ); + if (cellIndex === -1) return undefined; + return () => assignTaskToCommandCenter(cellIndex, taskId); + }; + + const openSpace = () => { + 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 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 ( +
+ {/* Right-click carries the row's management — the discoverable path to + everything the hover controls do. */} + + }> + + {/* 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. */} + {(open || searching) && ( +
+ {isLoading && sectionItems.length === 0 ? ( +
+ + +
+ ) : sectionItems.length === 0 ? ( +
+ {onlyMine ? "None of your tasks here" : "No tasks yet"} +
+ ) : ( + displayItems.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 rest unfold in place; a search already shows every match. */} + {!searching && (hiddenCount > 0 || showAll) && ( + + )} +
+ )} +
+ ); +} 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..38e5377dfa --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpaceSelect.tsx @@ -0,0 +1,119 @@ +import { CaretDown, Check } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@posthog/quill"; +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"; + +/** + * Which space a new task files into — a chip for the composer's selector row, + * 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, + onChange, +}: { + value: string; + 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 { 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 ( + + + {triggerGlyph && ( + {triggerGlyph} + )} + {current?.name ?? "Space"} + + + } + /> + + {starred.map(renderItem)} + {starred.length > 0 && rest.length > 0 && } + {rest.map(renderItem)} + + + ); +} 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..b800156159 --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -0,0 +1,330 @@ +import { PointerSensor } from "@dnd-kit/dom"; +import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; +import { useSortable } from "@dnd-kit/react/sortable"; +import { MagnifyingGlass, PlusIcon } from "@phosphor-icons/react"; +import { + Button, + cn, + Input, + MenuLabel, + Separator, + Switch, + Tooltip, + TooltipContent, + TooltipTrigger, +} 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 { WatchListSection } from "@posthog/ui/features/canvas/components/WatchListSection"; +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 { DragEvent, RefCallback } from "react"; +import { useMemo, useState } from "react"; + +/** + * One sortable pinned space. The header row is the drag handle (via + * SpaceSection's dragHandleRef), so the task rows keep their own native drag + * into the Command Center. #me renders outside this wrapper — it's always + * first and doesn't reorder. + */ +function SortableSpace({ + space, + index, + query, +}: { + space: Channel; + index: number; + query?: string; +}) { + const { ref, handleRef, isDragging } = useSortable({ + id: space.id, + index, + group: "pinned-spaces", + transition: { duration: 200, easing: "ease" }, + }); + + return ( +
+ } + query={query} + /> +
+ ); +} + +/** + * The static spaces nav: the shell keeps ChannelNav (the highlighted icon + * row — Inbox, Activity, Command Center and Settings already live there), + * then the Spaces section header with its my-tasks filter, then every pinned + * space with its tasks foldable inline — drag a space's row to reorder. 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); + 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; + + // Dropping an All-spaces row into this region pins it (stars it), same as + // the directory's star but as one gesture; dragging a pinned row out again + // unpins it. + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const { star, unstar } = 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). + const orderedSpaces = useMemo(() => { + const starred = pinnedSpaces.filter( + (c) => c.name !== PERSONAL_CHANNEL_NAME, + ); + const rank = new Map(spaceOrder.map((id, index) => [id, index])); + return [...starred].sort( + (a, b) => + (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER), + ); + }, [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; + const targetId = event.operation.target?.id; + 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)); + if (from < 0 || to < 0) return; + ids.splice(to, 0, ...ids.splice(from, 1)); + setSpaceOrder(ids); + }; + + return ( +
+ + + {/* 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. */} +
+ {/* Between flat and primary: quill's outline with a raised fill and a + small shadow, so it reads as an elevated chip on the chrome + without primary's shout. Centered, sized like the sm rows. */} + +
+ + {/* Dragged-in task references, kept locally. */} +
+ +
+ + + + {/* The section label, with search and one filter over every space's + 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 + )} +
+ + {!searchOpen && ( + <> + 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. + This region is the sidebar's one scroll container — spaces unfold + 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 + distance keeps plain clicks from starting a drag. */} + + {orderedSpaces.map((space, index) => ( + + ))} + +
+
+ + {/* Every space in the project, docked at the bottom. */} + +
+ ); +} 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 new file mode 100644 index 0000000000..6220700a08 --- /dev/null +++ b/packages/ui/src/features/canvas/components/WatchListSection.tsx @@ -0,0 +1,257 @@ +import { CaretRightIcon, XIcon } from "@phosphor-icons/react"; +import { + buildChannelItems, + type ChannelItemModel, +} from "@posthog/core/canvas/channelItems"; +import { Button, cn, MenuLabel } 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 { 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. + * Watching is a local-only pointer — the task stays in its space, and rows + * render exactly like a space's own list, each wearing its space's name. The + * hover × forgets the reference; the header is a MenuLabel section like the + * code sidebar's "Sessions". + */ +export function WatchListSection() { + const open = useSpacesSidebarStore((s) => 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 }); + // 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(); + 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. 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.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: [...taskById.values()], + archivedTaskIds, + pinnedTaskIds, + ownedBy: null, + }); + const byId = new Map(built.map((item) => [item.id, item])); + return watchList.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, + }, + ); + }, [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. + 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(); + // 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 ( + // 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. +
+ 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/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index a98cd7256d..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,18 +467,21 @@ 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 && } diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index c5161e64ca..26ab3c5e2d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -3,6 +3,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelContextPanel } from "@posthog/ui/features/canvas/components/ChannelContextPanel"; +import { SpaceSelect } from "@posthog/ui/features/canvas/components/SpaceSelect"; 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"; @@ -109,10 +110,34 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { [channelId, fileTask, navigate, queryClient], ); + // Retargeting 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 handleSpaceChange = useCallback( + (nextChannelId: string) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "new_task", + channel_id: nextChannelId, + }); + void navigate({ + to: "/website/$channelId/new", + params: { channelId: nextChannelId }, + }); + }, + [navigate], + ); + return (
+ } onTaskCreated={onTaskCreated} channelContext={channelContext} channelName={channelName} 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..baec0e92b3 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts @@ -0,0 +1,104 @@ +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 + * (default collapsed); `openAddSpace` stores the all-spaces list toggle. + */ +interface SpacesSidebarState { + openSections: Record; + openAddSpace: boolean; + /** Narrow every space's task list to items the viewer created. */ + onlyMyTasks: boolean; + /** + * The user's drag-reorder of the pinned spaces (channel ids, first = top). + * A local view preference layered over the starred set: spaces not listed + * here keep their backend order after the ranked ones; #me is always first + * and never ranked. + */ + spaceOrder: string[]; + /** The watch list section's fold. */ + openWatchList: boolean; + /** + * 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: WatchedTaskRef[]; + toggle: (channelId: string) => void; + toggleAddSpace: () => void; + toggleOnlyMyTasks: () => void; + toggleWatchList: () => void; + setSpaceOrder: (ids: string[]) => void; + addToWatchList: (ref: WatchedTaskRef) => void; + removeFromWatchList: (taskId: string) => void; +} + +export const useSpacesSidebarStore = create()( + persist( + (set) => ({ + openSections: {}, + // Open by default: with nothing pinned yet, a collapsed directory left + // the sidebar looking empty. Returning users keep whatever they chose. + openAddSpace: true, + onlyMyTasks: false, + spaceOrder: [], + openWatchList: true, + watchList: [], + toggle: (channelId) => + set((state) => ({ + openSections: { + ...state.openSections, + [channelId]: !state.openSections[channelId], + }, + })), + toggleAddSpace: () => + set((state) => ({ openAddSpace: !state.openAddSpace })), + toggleOnlyMyTasks: () => + set((state) => ({ onlyMyTasks: !state.onlyMyTasks })), + toggleWatchList: () => + set((state) => ({ openWatchList: !state.openWatchList })), + setSpaceOrder: (ids) => set({ spaceOrder: ids }), + addToWatchList: (ref) => + set((state) => ({ + watchList: [ + ref, + ...state.watchList.filter((entry) => entry.id !== ref.id), + ], + })), + removeFromWatchList: (taskId) => + set((state) => ({ + watchList: state.watchList.filter((entry) => entry.id !== taskId), + })), + }), + { + 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; + }, + }, + ), +); diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 2c0a9a8dc8..51457c105a 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -24,7 +24,14 @@ import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useConnectivity } from "../../../hooks/useConnectivity"; import { DotPatternBackground } from "../../../primitives/DotPatternBackground"; import { toast } from "../../../primitives/toast"; @@ -151,6 +158,12 @@ interface TaskInputProps { * the chip is non-interactive (only dismissable). */ onContextChipClick?: () => 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 && (