diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index bd52aef08e..f803282b2e 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,5 +1,6 @@ import { BrainIcon, + HashIcon, HouseIcon, PlugsConnectedIcon, RobotIcon, @@ -24,8 +25,8 @@ import { } from "@posthog/shared"; import { channelSectionFor } from "@posthog/ui/features/canvas/channelSections"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; import { - type Channel, useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; @@ -33,7 +34,6 @@ import { useDashboard, useDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; @@ -50,7 +50,6 @@ import { useRouter, useRouterState, } from "@tanstack/react-router"; -import { SquircleDashed } from "lucide-react"; import { type ReactNode, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { @@ -87,13 +86,6 @@ declare module "@tanstack/history" { const canvasInfo = new Map(); const taskInfo = new Map(); -// Dedupe concurrent #me provisioning. The folder-creation endpoint isn't -// server-side idempotent, and the new-tab path is on Cmd+T (trivially -// double-fired/held) — so two landings racing before the first create's cache -// update lands could each create a "me" folder. One in-flight create is shared -// across callers until it settles. -let personalChannelInFlight: Promise | null = null; - /** Bounded insert (most-recent kept) so the caches don't grow unbounded over a * long session. */ const MAX_CACHE_ENTRIES = 200; @@ -525,8 +517,8 @@ export function BrowserTabStrip() { const meta = channelSectionFor(section); return { id: t.id, - label: meta?.label ?? channel ?? "Context", - icon: , + label: meta?.label ?? channel ?? "Channel", + icon: , channelName: channel, // No section meta → the channel's index page. isChannelHome: !meta, @@ -739,16 +731,7 @@ export function BrowserTabStrip() { // row uses); fall back to the new-task screen if it can't be created. void (async () => { try { - const existing = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - if (!existing && !personalChannelInFlight) { - personalChannelInFlight = createChannel( - PERSONAL_CHANNEL_NAME, - ).finally(() => { - personalChannelInFlight = null; - }); - } - const folder = existing ?? (await personalChannelInFlight); - if (!folder) return; + const folder = await ensurePersonalChannel(channels, createChannel); navigate({ to: "/website/$channelId", params: { channelId: folder.id }, diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index e3323ae71c..4261f1959a 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -176,7 +176,7 @@ export function ActivityView() { Activity - Mentions of you across contexts. + Mentions of you across channels.
{isLoading && items.length === 0 ? ( diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index 86bd34fbfa..9bd0adaa1f 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,3 +1,4 @@ +import { HashIcon } from "@phosphor-icons/react"; import { Button, Tooltip, @@ -7,7 +8,6 @@ import { import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; -import { SquircleDashed } from "lucide-react"; import { type ReactNode, useState } from "react"; interface ChannelBreadcrumbProps { @@ -48,10 +48,7 @@ export function ChannelBreadcrumb({ const channelSegment = ( <> - + { const user = userEvent.setup(); const onClose = renderPanel(); await user.click( - screen.getByRole("button", { name: "Close context panel" }), + screen.getByRole("button", { name: "Close CONTEXT.md panel" }), ); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx b/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx index b5ad42ac86..2d4d21d754 100644 --- a/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx +++ b/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx @@ -35,7 +35,7 @@ export function ChannelContextPanel({ diff --git a/packages/ui/src/features/canvas/components/ChannelIntro.tsx b/packages/ui/src/features/canvas/components/ChannelIntro.tsx index 33919338f7..c39d3d4e1e 100644 --- a/packages/ui/src/features/canvas/components/ChannelIntro.tsx +++ b/packages/ui/src/features/canvas/components/ChannelIntro.tsx @@ -55,9 +55,9 @@ export function ChannelIntro({ @{userDisplayName(creator ?? null)} {" "} - created this context {creationDatePhrase(channel.created_at)}. This + created this channel {creationDatePhrase(channel.created_at)}. This is the very beginning of the{" "} - {channelName} context. + {channelName} channel. )}
@@ -70,7 +70,7 @@ export function ChannelIntro({ Created context.md - Used in all sessions within this context + Used in all sessions within this channel @@ -125,9 +125,10 @@ export function ChannelIntro({ - Learn more about contexts + Learn more about channels - Context is a group of tasks that are related to a specific topic. + A channel is a group of tasks that are related to a specific + topic. diff --git a/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/packages/ui/src/features/canvas/components/ChannelsFab.tsx new file mode 100644 index 0000000000..1afff9639e --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -0,0 +1,71 @@ +import { FileTextIcon, HashIcon, PlusIcon } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { useRouterState } from "@tanstack/react-router"; +import { useState } from "react"; + +// The create affordance for the Channels space, floated over the bottom-right +// of the channel list. It owns the create-channel modal (the list itself has no +// other entry point) and opens its menu upward, since it sits at the bottom. +export function ChannelsFab() { + const [modalOpen, setModalOpen] = useState(false); + // New task has no /website mirror yet, so it jumps back to Code unless we're + // already in the Channels space — same rule as the nav's New task row. + const inChannels = useRouterState({ + select: (s) => s.location.pathname.startsWith("/website"), + }); + + return ( + <> + + + + + + } + /> + } + /> + + Create something new + + + + setModalOpen(true)}> + + New channel + + + openTaskInput(inChannels ? { space: "website" } : undefined) + } + > + + New task + + + + + + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index a228e5dca1..ca4c269794 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -1,7 +1,11 @@ +import { Collapsible } from "@base-ui/react/collapsible"; import { + CaretDownIcon, + CaretRightIcon, ChartBarIcon, DotsThreeIcon, FileTextIcon, + HashIcon, LinkIcon, LockSimpleIcon, PencilSimpleIcon, @@ -30,6 +34,8 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + Empty, + EmptyHeader, MenuLabel, Tooltip, TooltipContent, @@ -37,9 +43,9 @@ import { TooltipTrigger, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; +import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; import { useChannelStars, useChannelStarToggle, @@ -55,11 +61,11 @@ import { useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Box, Flex, Text } from "@radix-ui/themes"; +import { Box, Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { SquircleDashed } from "lucide-react"; import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; import { hostClient } from "../hostClient"; @@ -138,7 +144,7 @@ function useChannelActions(channel: Channel): { channel_id: channel.id, success: false, }); - toast.error("Couldn't delete context", { + toast.error("Couldn't delete channel", { description: error instanceof Error ? error.message : String(error), }); return false; @@ -148,7 +154,7 @@ function useChannelActions(channel: Channel): { const actions: ChannelActionItem[] = [ { key: "star", - label: isStarred ? "Unstar context" : "Star context", + label: isStarred ? "Unstar channel" : "Star channel", icon: , onSelect: () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -167,14 +173,14 @@ function useChannelActions(channel: Channel): { }, { key: "rename", - label: "Rename context…", + label: "Rename channel…", icon: , separatorBefore: true, onSelect: () => setRenameOpen(true), }, { key: "delete", - label: "Delete context…", + label: "Delete channel…", icon: , variant: "destructive", onSelect: () => setConfirmDeleteOpen(true), @@ -334,7 +340,7 @@ function ChannelSection({ channel }: { channel: Channel }) { }} className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" > - + Delete {channel.name}? - This permanently deletes the context and can’t be undone. + This permanently deletes the channel and can’t be undone.
  • - The context and its{" "} + The channel and its{" "} CONTEXT.md are deleted.
  • - Every canvas saved in this context is permanently deleted. + Every canvas saved in this channel is permanently deleted.
  • - Filed tasks are removed from the context, but the tasks + Filed tasks are removed from the channel, but the tasks themselves are not deleted.
@@ -470,7 +476,7 @@ function ChannelSection({ channel }: { channel: Channel }) { }) } > - Delete context + Delete channel @@ -490,54 +496,212 @@ function PersonalChannelRow() { const { createChannel, isCreating } = useChannelMutations(); // Listing backend channels lazily provisions the personal channel server-side. useTaskChannels(); + // The "+" dropdown (New task / New canvas), mirroring a shared channel row. + const [newMenuOpen, setNewMenuOpen] = useState(false); const meFolder = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + const createAndOpenCanvas = useCreateAndOpenDashboard(meFolder?.id); const isActive = !!meFolder && (pathname === `/website/${meFolder.id}` || pathname.startsWith(`/website/${meFolder.id}/`)); - const open = async () => { + // The "me" folder is created on first use, so every action resolves the id + // rather than closing over it — the row is actionable before it exists. The + // create is shared (ensurePersonalChannel) so a row click racing its "+" menu + // can't provision two. + const ensureFolderId = async (): Promise => { try { - const folder = meFolder ?? (await createChannel(PERSONAL_CHANNEL_NAME)); - void navigate({ - to: "/website/$channelId", - params: { channelId: folder.id }, - }); + return (await ensurePersonalChannel(channels, createChannel)).id; } catch (error) { toast.error("Couldn't open me", { description: error instanceof Error ? error.message : String(error), }); + return undefined; } }; + const open = async () => { + const channelId = await ensureFolderId(); + if (!channelId) return; + void navigate({ to: "/website/$channelId", params: { channelId } }); + }; + + const newTask = async () => { + const channelId = await ensureFolderId(); + if (!channelId) return; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "sidebar", + channel_id: channelId, + }); + void navigate({ to: "/website/$channelId/new", params: { channelId } }); + }; + + const newCanvas = async () => { + const channelId = await ensureFolderId(); + if (!channelId) return; + trackAndCreateCanvas( + channelId, + undefined, + "sidebar", + () => void createAndOpenCanvas({ channelId }), + ); + }; + + return ( + + +
+ + + + + + } + /> + } + /> + New… + + + void newTask()}> + + New task + + void newCanvas()}> + + New canvas + + + +
+
+ ); +} + +// Collapse state is keyed per section in the shared sidebar store, so it +// persists across navigation and restarts. Prefixed to stay clear of the Code +// sidebar's folder sections, which key the same set by folder path. +const STARRED_SECTION_ID = "channels:starred"; +const CHANNELS_SECTION_ID = "channels:all"; + +// A collapsible sidebar group ("Starred" / "Channels"). Base UI directly rather +// than quill's Collapsible: quill styles its trigger as a button (which fought +// the label styling) and animates the panel height (which janked on a list this +// long). Unstyled parts give a plain label row that snaps. +// +// The whole header row is the trigger. It rests as a "#" and swaps to a chevron +// on hover or keyboard focus, so the row only advertises the disclosure when +// you're actually reaching for it. +function ChannelGroup({ + sectionId, + label, + className, + children, +}: { + sectionId: string; + label: string; + className?: string; + children: ReactNode; +}) { + const collapsedSections = useSidebarStore((s) => s.collapsedSections); + const toggleSection = useSidebarStore((s) => s.toggleSection); + const isOpen = !collapsedSections.has(sectionId); + return ( - + {/* MenuLabel carries the sidebar's label styling; `render` keeps it a + real button so the whole row is clickable. */} + } />} + > + + + {isOpen ? ( + + ) : ( + + )} + + {label} + + {/* Stay mounted while collapsed. Every row builds a context menu, a + dropdown, a tooltip and two dialogs up front, so unmounting on close + makes each expand rebuild the lot (~940ms for 46 channels, vs ~80ms + to collapse). */} + +
{children}
+
+ ); } // The channel list — the Channels space sidebar body. The private "#me" // channel is pinned at the top; starred channels surface in their own section // so the ones you use most stay in reach; the rest sit under a "Channels" -// label with the "New" channel button. +// label. Creating anything goes through the floating ChannelsFab, mounted by +// the sidebar outside this scroll region. export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); - const [modalOpen, setModalOpen] = useState(false); // The "me" folder renders as the pinned personal row, not a shared channel. const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); @@ -561,56 +725,30 @@ export function ChannelsList() { // One shared provider groups every row tooltip so that once one shows, // moving to the next row reveals its tooltip instantly (no re-delay). - + {/* Bottom padding clears the floating create button (ChannelsFab), so the + last channel stays reachable at full scroll. */} + {starred.length > 0 && ( - <> - - - - Starred - - -
- {starred.map((channel) => ( - - ))} -
- - )} - - 0 && "mt-3")}> - - - - Contexts - - - - - - {!isLoading && channels.length === 0 && ( - - No contexts yet. Create one to get started. - + + {starred.map((channel) => ( + + ))} + )} -
+ + {!isLoading && channels.length === 0 && ( + + No channels yet. + + )} {others.map((channel) => ( ))} -
+
- -
); } diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 111ff227f5..1ed590fa55 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon } from "@phosphor-icons/react"; import { Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +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 { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -110,8 +111,13 @@ export function ChannelsSidebar() { {bodyChannelsEnabled ? ( <> - - + {/* The fab is a sibling of the scroll region, not a child, so it + stays pinned to the bottom-right instead of scrolling away. */} + + + + + ) : ( diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index d2c61fc89b..5920379390 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -12,7 +12,6 @@ import { FieldError, FieldLabel, Input, - Switch, Textarea, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; @@ -21,7 +20,7 @@ import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerat import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; +import { type CSSProperties, useState } from "react"; // Matches Slack's "Create a channel" naming constraint. const MAX_CONTEXT_NAME_LENGTH = 80; @@ -38,10 +37,12 @@ interface CreateChannelModalProps { } // Two dialogs in one, split on `existingContext`: -// - Create mode: names the context, creates it, and lands the user in its feed -// (the intro card there carries onboarding). An off-by-default toggle reveals -// the description textarea to also launch the context.md plan session at -// creation time. +// - Create mode: two steps. Step one names the channel; "Next" advances to step +// two, which asks what it's about. Nothing is created until that second step +// resolves — "Create" makes the channel and launches the context.md plan +// session seeded by the description, "Skip" makes the channel alone. Either +// way the user lands in the channel's feed, whose intro card carries the +// onboarding (and offers context.md later if skipped). // - Describe mode: the "Create your context.md" dialog (opened from the intro // card or the CONTEXT.md empty state). A single textarea whose text seeds // a plan-mode session that builds the context's CONTEXT.md with the user. @@ -56,8 +57,8 @@ export function CreateChannelModal({ const navigate = useNavigate(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); - // Create mode's opt-in "also plan the context.md now" toggle. - const [withContextMd, setWithContextMd] = useState(false); + // Create mode's step. Describe mode has no name step, so it starts past it. + const [step, setStep] = useState<"name" | "describe">("name"); // Reset the fields each time the modal opens so a previous draft never // lingers. Adjusted inline during render (prev-prop comparison) rather than in @@ -68,7 +69,7 @@ export function CreateChannelModal({ if (open) { setName(""); setDescription(""); - setWithContextMd(false); + setStep("name"); } } @@ -77,20 +78,16 @@ export function CreateChannelModal({ const remaining = MAX_CONTEXT_NAME_LENGTH - name.length; const nameError = isDescribeMode ? null : validateChannelName(trimmedName); - // The description textarea is live in describe mode and in create mode once - // the toggle is on; either way it must be filled to submit. - const needsDescription = isDescribeMode || withContextMd; const busy = isCreating || isStarting; - const canSubmit = - !busy && - (isDescribeMode ? true : !!trimmedName && !nameError) && - (!needsDescription || !!trimmedDescription); + const canAdvance = !busy && !!trimmedName && !nameError; + // "Create" seeds the plan session, so it needs the description; "Skip" is the + // way through without one. + const canDescribe = !busy && !!trimmedDescription; - // Create mode: create the context, then land in the channel — its feed opens - // with the intro (name, creation line, context.md card) and the "joined" row, - // both derived from the channel row. With the toggle on, also launch the - // plan session that builds context.md, seeded by the description. - const submitCreate = async () => { + // Create the channel and land in its feed — the intro (name, creation line, + // context.md card) and "joined" row there are derived from the channel row. + // With a description, also launch the plan session that builds context.md. + const submitCreate = async (withContextMd: boolean) => { let contextId: string; try { const channel = await createChannel(trimmedName); @@ -107,7 +104,7 @@ export function CreateChannelModal({ surface: "sidebar", success: false, }); - toast.error("Couldn't create context", { + toast.error("Couldn't create channel", { description: error instanceof Error ? error.message : String(error), }); return; @@ -160,15 +157,84 @@ export function CreateChannelModal({ }); }; - const submit = async () => { - if (!canSubmit) return; + // The description step's primary action: seed context.md, for a channel that + // already exists (describe mode) or one this dialog is about to create. + const submitDescribeStep = async () => { + if (!canDescribe) return; if (isDescribeMode) { await submitDescribe(); } else { - await submitCreate(); + await submitCreate(true); } }; + const descriptionField = ( + + {/* In create mode the nested dialog's title asks the question, so the + label would just repeat it. */} + {isDescribeMode && ( + + What's this channel about? + + )} +