diff --git a/products/desktop/packages/core/src/canvas/channelItems.ts b/products/desktop/packages/core/src/canvas/channelItems.ts index 2f30acda8a88..9d2239898623 100644 --- a/products/desktop/packages/core/src/canvas/channelItems.ts +++ b/products/desktop/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/products/desktop/packages/shared/src/flags.ts b/products/desktop/packages/shared/src/flags.ts index 1ae04e045979..b9b42f88c735 100644 --- a/products/desktop/packages/shared/src/flags.ts +++ b/products/desktop/packages/shared/src/flags.ts @@ -20,6 +20,11 @@ export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; * project-bluebird. The key predates the rename, matching the live flag. */ export const CHANNELS_LAYOUT_FLAG = "code-spaces-layout"; +/** + * Switches the spaces layout from the original master/detail sidebar to the + * static, expandable spaces navigation. Requires code-spaces-layout. + */ +export const STATIC_SPACES_SIDEBAR_FLAG = "posthog-code-static-spaces-sidebar"; // Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab. export const LOOPS_FLAG = "loops"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; diff --git a/products/desktop/packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx new file mode 100644 index 000000000000..37aaa50c66bd --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/AllSpacesSection.test.tsx @@ -0,0 +1,136 @@ +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; + channelType: "public" | "personal"; + starred: boolean; + }[], + starredChannelIds: new Set(), + 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: () => ({ + starredChannelIds: mocks.starredChannelIds, + }), + 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", + channelType: "personal" as const, + starred: true, +}; +const ENG = { + id: "eng-id", + name: "eng", + channelType: "public" as const, + starred: false, +}; +const ADS = { + id: "ads-id", + name: "ads", + channelType: "public" as const, + starred: false, +}; + +function renderSection() { + return render( + + + , + ); +} + +describe("AllSpacesSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channels = [ME, ENG, ADS]; + mocks.starredChannelIds = new Set(); + 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.id); + expect(mocks.navigate).not.toHaveBeenCalled(); + }); + + it("unpins an already-pinned space", () => { + mocks.starredChannelIds = new Set([ENG.id]); + renderSection(); + fireEvent.click(screen.getByLabelText("Unpin space")); + expect(mocks.unstar).toHaveBeenCalledWith(ENG.id); + }); + + 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/products/desktop/packages/ui/src/features/canvas/components/AllSpacesSection.tsx b/products/desktop/packages/ui/src/features/canvas/components/AllSpacesSection.tsx new file mode 100644 index 000000000000..0205a041181e --- /dev/null +++ b/products/desktop/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 { starredChannelIds } = 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 isStarred = starredChannelIds.has(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: isStarred ? "unstar" : "star", + surface: "sidebar", + channel_id: channel.id, + }); + const run = isStarred ? unstar(channel.id) : star(channel.id); + run.catch((error: unknown) => + toast.error(isStarred ? "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 isStarred = starredChannelIds.has(channel.id); + 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/products/desktop/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHeader.tsx index ee7386b3005c..afbc8a15c670 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/products/desktop/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/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx index 696c78a39813..e4c62ef5b385 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx @@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({ channelsLayout: true, slots: [] as { id: string; name: string; path: string }[], navigateToChannel: vi.fn(), + view: { type: "task-input" } as { type: string; taskId?: string }, + tasks: [] as { id: string; title: string }[], })); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ @@ -20,9 +22,19 @@ vi.mock("@posthog/ui/features/canvas/hooks/useStarredChannelSlots", () => ({ vi.mock("@posthog/ui/router/navigationBridge", () => ({ navigateToChannel: (...args: unknown[]) => mocks.navigateToChannel(...args), })); +vi.mock("@posthog/ui/router/useAppView", () => ({ + useAppView: () => mocks.view, +})); +vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ + useTasks: () => ({ data: mocks.tasks }), +})); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { success: vi.fn() }, +})); vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { ChannelHotkeys } from "./ChannelHotkeys"; function press(digit: string, modifiers: Partial = {}) { @@ -45,7 +57,10 @@ describe("ChannelHotkeys", () => { { id: "me-id", name: "me", path: "/me" }, { id: "eng-id", name: "eng", path: "/eng" }, ]; + mocks.view = { type: "task-input" }; + mocks.tasks = []; useCurrentChannelStore.setState({ currentChannelId: null }); + useSpacesSidebarStore.setState({ watchList: [] }); }); // The regression: this component is rendered ALONE — no sidebar at all. @@ -93,4 +108,27 @@ describe("ChannelHotkeys", () => { press("1", { metaKey: true }); expect(mocks.navigateToChannel).not.toHaveBeenCalled(); }); + + // ⌘⇧W watches the task you're looking at — the keyboard twin of the row menu. + it("adds the current task to the watch list on mod+shift+w", () => { + mocks.view = { type: "task-detail", taskId: "task-1" }; + mocks.tasks = [{ id: "task-1", title: "Fix the thing" }]; + render(); + + press("w", { metaKey: true, shiftKey: true }); + + const watchList = useSpacesSidebarStore.getState().watchList; + expect(watchList.map((e) => e.id)).toEqual(["task-1"]); + expect(watchList[0].title).toBe("Fix the thing"); + }); + + // Nowhere obvious to watch → no-op, rather than watching a phantom. + it("does nothing on mod+shift+w outside a task detail", () => { + mocks.view = { type: "task-input" }; + render(); + + press("w", { metaKey: true, shiftKey: true }); + + expect(useSpacesSidebarStore.getState().watchList).toEqual([]); + }); }); diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx index 59a839e04185..3f2d8d99493f 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx @@ -2,8 +2,12 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { toast } from "@posthog/ui/primitives/toast"; import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { useHotkeys } from "react-hotkeys-hook"; @@ -22,6 +26,31 @@ export function ChannelHotkeys() { const channelsLayout = useChannelsLayout(); const { slots } = useStarredChannelSlots(); const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const view = useAppView(); + const { data: allTasks = [] } = useTasks({ showAllUsers: true }); + const addToWatchList = useSpacesSidebarStore((s) => s.addToWatchList); + + // ⌘⇧W watches whatever task you're looking at — the keyboard twin of the + // row's "Add to watch list". It's a no-op anywhere but a task detail, since + // that's the only place there's a single obvious task to watch. + useHotkeys( + SHORTCUTS.ADD_TO_WATCH_LIST, + () => { + const taskId = view.type === "task-detail" ? view.taskId : undefined; + if (!taskId) return; + const 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 }); + }, + { + enabled: channelsLayout, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [view, allTasks, addToWatchList], + ); useHotkeys( SHORTCUTS.SWITCH_STARRED_CHANNEL, diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index e2362f90afaa..6d46c4ad4d1e 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/products/desktop/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. */ @@ -209,9 +224,10 @@ export function ChannelItemRow({ if (item.kind !== "task") return; event.dataTransfer.setData("text/x-task-id", item.id); + event.dataTransfer.setData("text/x-task-title", item.title); event.dataTransfer.effectAllowed = "copy"; }, - [item.id, item.kind], + [item.id, item.kind, item.title], ); // The row's leading mark is always the task-list state vocabulary. Canvases // have no live run, so they use the quiet dot and move their glyph to the @@ -245,6 +261,7 @@ export function ChannelItemRow({ onAddToCommandCenter, onRename, onTogglePin: () => actions.togglePin(item), + onRemoveFromWatchList, onArchive: () => actions.archive(item), }; @@ -284,6 +301,11 @@ export function ChannelItemRow({ onClick={() => actions.open(item)} 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 @@ -305,9 +327,11 @@ export function ChannelItemRow({ )} - - {formatRelativeTimeShort(item.ts)} - + {!hideTimestamp && ( + + {formatRelativeTimeShort(item.ts)} + + )} )} diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelTabs.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelTabs.tsx index 6bd815d842d1..f09844409f95 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelTabs.tsx +++ b/products/desktop/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/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx index c546e2988ff8..c5d5f7fb7fd5 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx +++ b/products/desktop/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(), @@ -16,12 +16,14 @@ const mocks = vi.hoisted(() => ({ archivedTaskIds: new Set(), navigateToArchived: vi.fn(), track: vi.fn(), - routeChannelId: undefined as string | undefined, })); vi.mock("@posthog/ui/shell/analytics", () => ({ track: (...args: unknown[]) => mocks.track(...args), })); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({}), +})); vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ useFeatureFlag: (key: string) => mocks.featureFlags.get(key) ?? true, @@ -38,20 +40,29 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ useArchivedTaskIds: () => mocks.archivedTaskIds, })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStars: () => ({ starredChannelIds: new Set() }), +})); vi.mock("@posthog/ui/router/navigationBridge", () => ({ navigateToArchived: (...args: unknown[]) => mocks.navigateToArchived(...args), })); // 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/SpacesSidebarNav", () => ({ + SpacesSidebarNav: () =>
, +})); vi.mock("@posthog/ui/features/canvas/components/ChannelNav", () => ({ - ChannelNav: () => null, + ChannelNav: () =>
, })); vi.mock("@posthog/ui/features/canvas/components/ChannelSidebar", () => ({ ChannelSidebar: ({ channelId }: { channelId: string }) => (
{channelId}
), })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelPaneSwipe", () => ({ + useChannelPaneSwipe: () => undefined, +})); vi.mock("@posthog/ui/features/canvas/components/ChannelsList", () => ({ ChannelsList: () =>
, })); @@ -79,17 +90,12 @@ 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"; + PROJECT_BLUEBIRD_FLAG, + STATIC_SPACES_SIDEBAR_FLAG, +} from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { ChannelsSidebar } from "./ChannelsSidebar"; @@ -105,13 +111,7 @@ const ME = { id: "me-id", name: "me", channelType: "personal" as const, - starred: false, -}; -const ENG = { - id: "eng-id", - name: "eng", - channelType: "public" as const, - starred: false, + starred: true, }; describe("ChannelsSidebar", () => { @@ -123,139 +123,70 @@ 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 listIsInteractive = () => - !screen.getByTestId("channels-list").parentElement?.hasAttribute("inert"); - - beforeEach(() => { + describe("the static spaces nav", () => { + it("renders the static nav when its evaluation flag is on", () => { mocks.channelsLayout = true; - mocks.channels = [ME, ENG]; + mocks.featureFlags.set(STATIC_SPACES_SIDEBAR_FLAG, true); + mocks.channels = [ME]; + renderSidebar(); + expect(screen.getByTestId("spaces-sidebar-nav")).toBeTruthy(); + expect(screen.queryByTestId("channels-list")).toBeNull(); }); - it("rests on the channel you're in", () => { - mocks.routeChannelId = ENG.id; + it("keeps the original spaces sidebar when its evaluation flag is off", () => { + mocks.channelsLayout = true; + mocks.featureFlags.set(STATIC_SPACES_SIDEBAR_FLAG, false); + mocks.channels = [ME]; renderSidebar(); - expect(screen.getByTestId("channel-sidebar").textContent).toBe(ENG.id); - expect(listIsInteractive()).toBe(false); + expect(screen.getByTestId("channel-nav")).toBeTruthy(); + expect(screen.getByTestId("channels-list")).toBeTruthy(); + expect(screen.queryByTestId("spaces-sidebar-nav")).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", + channelType: "public", + starred: false, + }, + ]; 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); }); }); @@ -296,117 +227,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, 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 = [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, 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/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 3686c28c8ee1..343562ff1dc2 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,12 +1,16 @@ import { ArchiveIcon } from "@phosphor-icons/react"; import { cn, Separator } from "@posthog/quill"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { + PROJECT_BLUEBIRD_FLAG, + STATIC_SPACES_SIDEBAR_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 { SpacesSidebarNav } from "@posthog/ui/features/canvas/components/SpacesSidebarNav"; import { useChannelPaneSwipe } from "@posthog/ui/features/canvas/hooks/useChannelPaneSwipe"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; @@ -42,18 +46,6 @@ 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, @@ -63,8 +55,6 @@ function ChannelPanes({ }) { 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, @@ -97,6 +87,7 @@ function ChannelPanes({ ); } + export function ChannelsSidebar() { const width = useChannelsSidebarStore((state) => state.width); const setWidth = useChannelsSidebarStore((state) => state.setWidth); @@ -146,6 +137,8 @@ export function ChannelsSidebar() { const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; const channelsLayout = useChannelsLayout(); + const staticSpacesSidebarEnabled = useFeatureFlag(STATIC_SPACES_SIDEBAR_FLAG); + const staticSpacesSidebar = channelsLayout && staticSpacesSidebarEnabled; const channelsWorld = channelsLayout || channelsEnabled; const bodyChannelsWorld = useDeferredValue(channelsWorld); // Under the layout the row moves into the account menu (ProjectSwitcher), @@ -165,43 +158,37 @@ export function ChannelsSidebar() { const archivedTaskIds = useArchivedTaskIds(); + // Keep the original spaces sidebar alive while the static navigation is + // evaluated. The second flag switches only the sidebar treatment; the wider + // spaces layout remains controlled by code-spaces-layout. const params = useParams({ strict: false }); const routeChannelId = params.channelId; const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const { currentChannelId, channels } = useCurrentChannel({ - enabled: channelsLayout, + enabled: channelsLayout && !staticSpacesSidebar, }); useEffect(() => { - if (!channelsLayout || !routeChannelId) return; + if (!channelsLayout || staticSpacesSidebar || !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]); + }, [channelsLayout, staticSpacesSidebar, 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) { + if (!channelsLayout || staticSpacesSidebar) { 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.channelType === "personal"); + const me = channels.find((channel) => channel.channelType === "personal"); if (!me) return; autoScopedRef.current = true; setCurrentChannel(me.id); }, [ channelsLayout, + staticSpacesSidebar, channels, currentChannelId, routeChannelId, @@ -240,8 +227,19 @@ export function ChannelsSidebar() { - - + {staticSpacesSidebar ? ( + + + + ) : ( + <> + + + + )} ) : bodyChannelsWorld ? ( <> diff --git a/products/desktop/packages/ui/src/features/canvas/components/SpaceSection.tsx b/products/desktop/packages/ui/src/features/canvas/components/SpaceSection.tsx new file mode 100644 index 000000000000..ed69dbeb54ea --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/SpaceSection.tsx @@ -0,0 +1,367 @@ +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). The pt-1 keeps the first task's selected fill + from butting straight up against the space row's own when both are + active. 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/products/desktop/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx b/products/desktop/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx new file mode 100644 index 000000000000..b5b97c97b250 --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/SpacesSidebarNav.tsx @@ -0,0 +1,329 @@ +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 { starredChannelIds } = 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 || starredChannelIds.has(channel.id)) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "star", + surface: "sidebar", + channel_id: channel.id, + }); + star(channel.id).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)); + if (!channel || !starredChannelIds.has(channel.id)) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "unstar", + surface: "sidebar", + channel_id: channel.id, + }); + unstar(channel.id).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/products/desktop/packages/ui/src/features/canvas/components/TaskRowMenu.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskRowMenu.tsx index f3e3e4310e41..f1232e297a36 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/TaskRowMenu.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskRowMenu.tsx @@ -4,22 +4,26 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, + ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuTrigger, DropdownMenu, DropdownMenuTrigger, + Separator, } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useFileTaskToChannel } from "@posthog/ui/features/canvas/hooks/useFileTaskToChannel"; +import { useSpacesSidebarStore } from "@posthog/ui/features/canvas/stores/spacesSidebarStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { type MenuFlyoutItem, MenuSubFlyout, SearchableMenuFlyout, } from "@posthog/ui/primitives/SearchableMenuFlyout"; -import { type ComponentType, type ReactNode, useMemo } from "react"; +import { toast } from "@posthog/ui/primitives/toast"; +import { type ComponentType, Fragment, type ReactNode, useMemo } from "react"; /** * What a row's menu can do. The row owns the handlers because they're the same @@ -43,6 +47,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; @@ -61,17 +67,23 @@ interface MenuParts { }>; Sub: ComponentType<{ children: ReactNode }>; SubTrigger: ComponentType<{ children: ReactNode }>; + Separator: ComponentType; } const CONTEXT_PARTS: MenuParts = { Item: ContextMenuItem, Sub: ContextMenuSub, SubTrigger: ContextMenuSubTrigger, + Separator: ContextMenuSeparator, }; /** - * The row's actions, in the order the native menu used: the edits, then the - * places a task can be sent, then the destructive one last. + * The row's actions in three groups, separated by a rule: + * 1. mark it — pin, watch, rename (things you do to the item in place) + * 2. send it — Command Center, File to… (put it somewhere else) + * 3. remove it — archive, delete (get rid of it) + * A group with nothing in it (a canvas has no "send" actions) drops out along + * with the separator that would have led it, so the rule never floats alone. */ function TaskRowMenuItems({ parts, @@ -80,7 +92,7 @@ function TaskRowMenuItems({ parts: MenuParts; menu: TaskRowMenuProps; }) { - const { Item, Sub, SubTrigger } = parts; + const { Item, Sub, SubTrigger, Separator } = parts; // "File to…" is a Project Bluebird feature; gate the channel fetch behind the // flag so neither the submenu nor its request reaches ungated users. const bluebirdEnabled = useFeatureFlag( @@ -91,47 +103,116 @@ function TaskRowMenuItems({ const { channels } = useChannels({ enabled: bluebirdEnabled && isTask }); const fileToChannel = useFileTaskToChannel(); + // The watch list is a global view store, so any task row can toggle it — no + // need to thread a handler through every list. Watch-list rows still pass an + // explicit remover (they know their own context), which wins. + const watchList = useSpacesSidebarStore((s) => s.watchList); + const addToWatchList = useSpacesSidebarStore((s) => s.addToWatchList); + const removeFromWatchList = useSpacesSidebarStore( + (s) => s.removeFromWatchList, + ); + const isWatched = isTask && watchList.some((entry) => entry.id === menu.id); + const channelItems: MenuFlyoutItem[] = channels.map((channel) => ({ id: channel.id, label: channel.name, current: channel.id === menu.channelId, })); + // Sits with Pin — both are "keep an eye on this", not a place to send it to. + const removeWatch = + menu.onRemoveFromWatchList ?? (() => removeFromWatchList(menu.id)); + const watchItem = !isTask ? null : isWatched || menu.onRemoveFromWatchList ? ( + + Remove from watch list + + ) : ( + { + addToWatchList({ id: menu.id, title: menu.title, addedAt: Date.now() }); + toast.success("Added to watch list", { description: menu.title }); + }} + > + Add to watch list + + ); + + const groups: { key: string; items: ReactNode[] }[] = [ + { + key: "mark", + items: [ + + {menu.isPinned ? "Unpin" : "Pin"} + , + watchItem, + menu.onRename ? ( + + Rename + + ) : null, + ], + }, + { + key: "send", + items: [ + isTask ? ( + + Add to Command Center + + ) : null, + isTask && channelItems.length > 0 ? ( + + File to… + + + fileToChannel(channelId, menu.id, menu.title) + } + /> + + + ) : null, + ], + }, + { + key: "remove", + items: [ + menu.onArchive ? ( + + Archive + + ) : null, + // The ellipsis is the promise that a confirm follows — deleting a + // canvas takes it away from everyone in the space. + menu.onDelete ? ( + + Delete… + + ) : null, + ], + }, + ]; + + const visible = groups + .map((group) => ({ key: group.key, items: group.items.filter(Boolean) })) + .filter((group) => group.items.length > 0); + return ( <> - {menu.isPinned ? "Unpin" : "Pin"} - {menu.onRename && Rename} - {isTask && ( - - Add to Command Center - - )} - {isTask && channelItems.length > 0 && ( - - File to… - - - fileToChannel(channelId, menu.id, menu.title) - } - /> - - - )} - {menu.onArchive && Archive} - {/* The ellipsis is the promise that a confirm follows — deleting a canvas - takes it away from everyone in the space. */} - {menu.onDelete && ( - - Delete… - - )} + {visible.map((group, index) => ( + + {index > 0 && } + {group.items} + + ))} ); } @@ -194,6 +275,7 @@ export function TaskRowMenuList({ } /> ), + Separator: () => , }), [onAction, onSubmenuOpenChange], ); diff --git a/products/desktop/packages/ui/src/features/canvas/components/WatchListSection.tsx b/products/desktop/packages/ui/src/features/canvas/components/WatchListSection.tsx new file mode 100644 index 000000000000..ac5263c8a9bf --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/WatchListSection.tsx @@ -0,0 +1,248 @@ +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 { PERSONAL_CHANNEL_NAME } 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 } = useChannels({ enabled: open }); + 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 carries the same channel UUID used by the spaces routes. + // Unmapped tasks open under #me and carry no label. + const spaceFor = useMemo(() => { + const channelById = new Map( + channels.map((channel) => [channel.id, channel]), + ); + const me = channels.find((channel) => channel.channelType === "personal"); + const byTaskId = new Map< + string, + { spaceName: string | null; folderId: string | undefined } + >(); + for (const item of items) { + const channel = item.task?.channel + ? channelById.get(item.task.channel) + : undefined; + const spaceName = channel + ? channel.channelType === "personal" + ? PERSONAL_CHANNEL_NAME + : channel.name + : null; + byTaskId.set(item.id, { spaceName, folderId: (channel ?? me)?.id }); + } + return byTaskId; + }, [items, channels]); + + 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/products/desktop/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 2a0045508ae6..86898dcd5a0b 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -16,6 +16,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 { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost"; @@ -41,7 +42,7 @@ import { useHeaderStore } from "@posthog/ui/shell/headerStore"; import { Box, Flex } from "@radix-ui/themes"; import { useIsMutating, useQueryClient } from "@tanstack/react-query"; import { Outlet, useParams, useRouterState } from "@tanstack/react-router"; -import type { ReactNode } from "react"; +import { type ReactNode, useMemo } from "react"; // Edit toggle + autosave status for a canvas. Source is server-versioned now — // version browsing and revert live in the canvas view's own toolbar — so the @@ -249,6 +250,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). @@ -266,18 +287,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/products/desktop/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts b/products/desktop/packages/ui/src/features/canvas/stores/spacesSidebarStore.ts new file mode 100644 index 000000000000..baec0e92b3b7 --- /dev/null +++ b/products/desktop/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/products/desktop/packages/ui/src/features/command/keyboard-shortcuts.ts b/products/desktop/packages/ui/src/features/command/keyboard-shortcuts.ts index 6f1da7138522..6754d93638c0 100644 --- a/products/desktop/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/products/desktop/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -26,6 +26,7 @@ export const SHORTCUTS = { "mod+1,mod+2,mod+3,mod+4,mod+5,mod+6,mod+7,mod+8,mod+9", TOGGLE_FOCUS: "mod+r", PASTE_AS_FILE: "mod+shift+v", + ADD_TO_WATCH_LIST: "mod+shift+w", INBOX: "mod+i", SPACE_UP: "mod+up", SPACE_DOWN: "mod+down", @@ -143,6 +144,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ context: "Spaces", availability: "channels-layout", }, + { + id: "add-to-watch-list", + keys: SHORTCUTS.ADD_TO_WATCH_LIST, + description: "Add current task to watch list", + category: "navigation", + context: "Spaces", + availability: "channels-layout", + }, { id: "prev-task", keys: "mod+shift+[",