From 0010460e4035c3bf0f5e99b772c990bb67a8a222 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 17:29:34 -0700 Subject: [PATCH 1/6] feat(canvas): add code-pane nav section to channels pane Extract the sidebar navigation section (New task, Home, Search, Inbox, Agents, Skills, MCP servers, Command Center) out of SidebarMenu into a self-contained SidebarNavSection component, and render it in both the Code pane and the Channels pane. The new component wires every item's active state, badge count, and click handler internally so it works in either layout. Items route to the existing code-space views; from the Channels pane that switches the layout back to Code, while Search opens the command menu in place. Generated-By: PostHog Code Task-Id: 93824635-f0b3-4b9d-bf15-f31a4e505939 --- .../sidebar/components/SidebarMenu.tsx | 134 +----------------- .../sidebar/components/SidebarNavSection.tsx | 126 ++++++++++++++++ packages/ui/src/router/routes/__root.tsx | 4 + 3 files changed, 133 insertions(+), 131 deletions(-) create mode 100644 packages/ui/src/features/sidebar/components/SidebarNavSection.tsx diff --git a/packages/ui/src/features/sidebar/components/SidebarMenu.tsx b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx index 2a32db61a..667bd1eec 100644 --- a/packages/ui/src/features/sidebar/components/SidebarMenu.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx @@ -1,11 +1,5 @@ -import { - INBOX_PIPELINE_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, - isReportUpForReview, -} from "@posthog/core/inbox/reportFiltering"; import { useHostTRPCClient } from "@posthog/host-router/react"; import { Separator } from "@posthog/quill"; -import { HOME_TAB_FLAG } from "@posthog/shared/constants"; import type { Task } from "@posthog/shared/types"; import { archiveTasksImperative, @@ -13,8 +7,6 @@ import { useArchiveTask, } from "@posthog/ui/features/archive/useArchiveTask"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { useInboxReports } from "@posthog/ui/features/inbox/hooks/useInboxReports"; import { useArchivingTasksStore } from "@posthog/ui/features/sidebar/archivingTasksStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useTaskSelectionStore } from "@posthog/ui/features/sidebar/taskSelectionStore"; @@ -31,32 +23,18 @@ import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; import { DotsCircleSpinner } from "@posthog/ui/primitives/DotsCircleSpinner"; import { toast } from "@posthog/ui/primitives/toast"; import { - navigateToAgents, navigateToCommandCenter, - navigateToHome, - navigateToInbox, - navigateToMcpServers, - navigateToSkills, navigateToTaskDetail, } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; -import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; +import { openTask } from "@posthog/ui/router/useOpenTask"; import { logger } from "@posthog/ui/shell/logger"; -import { useRendererWindowFocusStore } from "@posthog/ui/shell/rendererWindowFocusStore"; import { Box, Flex } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArchiveRunningTaskDialog } from "./ArchiveRunningTaskDialog"; -import { AgentsItem } from "./items/AgentsItem"; -import { CommandCenterItem } from "./items/CommandCenterItem"; -import { HomeItem } from "./items/HomeItem"; -import { InboxItem } from "./items/InboxItem"; -import { McpServersItem } from "./items/McpServersItem"; -import { NewTaskItem } from "./items/NewTaskItem"; -import { SearchItem } from "./items/SearchItem"; -import { SkillsItem } from "./items/SkillsItem"; import { SidebarItem } from "./SidebarItem"; +import { SidebarNavSection } from "./SidebarNavSection"; import { TaskListView } from "./TaskListView"; import { TasksHeader } from "./TasksHeader"; @@ -86,22 +64,9 @@ function SidebarMenuComponent() { const { renameTask } = useRenameTask(); const { togglePin } = usePinnedTasks(); - const homeTabEnabled = useFeatureFlag(HOME_TAB_FLAG); - const sidebarData = useSidebarData({ activeView: view, }); - const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); - const { data: inboxProbe } = useInboxReports( - { status: INBOX_PIPELINE_STATUS_FILTER }, - { - refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, - refetchIntervalInBackground: false, - staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 15_000, - }, - ); - const inboxResults = inboxProbe?.results ?? []; - const inboxSignalCount = inboxResults.filter(isReportUpForReview).length; const taskMap = new Map(); for (const task of allTasks) { @@ -110,9 +75,6 @@ function SidebarMenuComponent() { const commandCenterCells = useCommandCenterStore((s) => s.cells); const assignTaskToCommandCenter = useCommandCenterStore((s) => s.assignTask); - const commandCenterActiveCount = commandCenterCells.filter( - (taskId) => taskId != null && taskMap.has(taskId), - ).length; const previousTaskIdRef = useRef(null); @@ -134,39 +96,6 @@ function SidebarMenuComponent() { previousTaskIdRef.current = currentTaskId; }, [view, markAsViewed]); - const handleNewTaskClick = () => { - openTaskInput(); - }; - - const handleHomeClick = () => { - navigateToHome(); - }; - - const handleInboxClick = () => { - navigateToInbox(); - }; - - const handleAgentsClick = () => { - navigateToAgents(); - }; - - const handleCommandCenterClick = () => { - navigateToCommandCenter(); - }; - - const handleSkillsClick = () => { - navigateToSkills(); - }; - - const handleMcpServersClick = () => { - navigateToMcpServers(); - }; - - const openCommandMenu = useCommandMenuStore((s) => s.open); - const handleSearchClick = () => { - openCommandMenu(); - }; - const queryClient = useQueryClient(); const [archiveConfirm, setArchiveConfirm] = useState<{ @@ -470,64 +399,7 @@ function SidebarMenuComponent() { id="side-bar-menu" className="flex min-h-0 flex-col" > - - - - - - {homeTabEnabled && ( - - - - )} - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx new file mode 100644 index 000000000..ff8b59170 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -0,0 +1,126 @@ +import { + INBOX_PIPELINE_STATUS_FILTER, + INBOX_REFETCH_INTERVAL_MS, + isReportUpForReview, +} from "@posthog/core/inbox/reportFiltering"; +import { HOME_TAB_FLAG } from "@posthog/shared/constants"; +import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useInboxReports } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { + navigateToAgents, + navigateToCommandCenter, + navigateToHome, + navigateToInbox, + navigateToMcpServers, + navigateToSkills, +} from "@posthog/ui/router/navigationBridge"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; +import { useRendererWindowFocusStore } from "@posthog/ui/shell/rendererWindowFocusStore"; +import { Box, Flex } from "@radix-ui/themes"; +import { AgentsItem } from "./items/AgentsItem"; +import { CommandCenterItem } from "./items/CommandCenterItem"; +import { HomeItem } from "./items/HomeItem"; +import { InboxItem } from "./items/InboxItem"; +import { McpServersItem } from "./items/McpServersItem"; +import { NewTaskItem } from "./items/NewTaskItem"; +import { SearchItem } from "./items/SearchItem"; +import { SkillsItem } from "./items/SkillsItem"; + +// The sidebar navigation section shared by the Code pane (above the task list) +// and the Channels pane. It is fully self-contained — every item's active +// state, badge count, and click handler is wired here — so it can be dropped +// into either layout. Items route to the code-space views; from the Channels +// pane that switches the layout back to Code (Search opens the command menu in +// place). +export function SidebarNavSection() { + const view = useAppView(); + const homeTabEnabled = useFeatureFlag(HOME_TAB_FLAG); + + // Active flags are pure functions of the current view — mirror what + // useSidebarData derives, without pulling in its task-loading. + const isHomeActive = + view.type === "task-input" || view.type === "task-pending"; + const isHomeViewActive = view.type === "home"; + const isInboxActive = view.type === "inbox"; + const isAgentsActive = view.type === "agents"; + const isCommandCenterActive = view.type === "command-center"; + const isSkillsActive = view.type === "skills"; + const isMcpServersActive = view.type === "mcp-servers"; + + const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); + const { data: inboxProbe } = useInboxReports( + { status: INBOX_PIPELINE_STATUS_FILTER }, + { + refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 15_000, + }, + ); + const inboxResults = inboxProbe?.results ?? []; + const inboxSignalCount = inboxResults.filter(isReportUpForReview).length; + + const showAllUsers = useSidebarStore((s) => s.showAllUsers); + const showInternal = useSidebarStore((s) => s.showInternal); + const { data: allTasks = [] } = useTasks({ showAllUsers, showInternal }); + const taskIds = new Set(allTasks.map((t) => t.id)); + const commandCenterCells = useCommandCenterStore((s) => s.cells); + const commandCenterActiveCount = commandCenterCells.filter( + (taskId) => taskId != null && taskIds.has(taskId), + ).length; + + const openCommandMenu = useCommandMenuStore((s) => s.open); + + return ( + + + + + + {homeTabEnabled && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 4c335092c..4ed869cff 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -1,4 +1,5 @@ import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { Separator } from "@posthog/quill"; import { BILLING_FLAG, HOME_TAB_FLAG, @@ -18,6 +19,7 @@ import { useIntegrations } from "@posthog/ui/features/integrations/useIntegratio import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; import { MainSidebar } from "@posthog/ui/features/sidebar/components/MainSidebar"; +import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; @@ -221,6 +223,8 @@ function RootLayout() { Channels + + From c6626a766359e1401c79f78caf5c68d1c24163d8 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 17:45:30 -0700 Subject: [PATCH 2/6] feat(canvas): keep channels nav in-space for mirrored pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the self-contained nav destinations (Home, Skills, MCP servers, Command Center) under /website so clicking them from the channels sidebar renders inside the channels chrome instead of switching back to Code. Each mirror route renders the same shared view component, so pages stay single-source — only the route entry is duplicated. - Add /website/{home,skills,mcp-servers,command-center} routes rendering the shared HomeView/SkillsView/McpServersView/CommandCenterView. - Add navigateToWebsite* bridge functions; SidebarNavSection picks the /website variant when it renders inside the channels space. - useAppView maps the /website mirrors to the same view types so sidebar active-state highlighting works identically in either space. - WebsiteLayout surfaces the shared header-store title in its top bar for these channel-less pages, since the channels layout has no HeaderRow. Inbox and Agents are deep, route-coupled subsystems; they intentionally still jump to the /code routes for now. Generated-By: PostHog Code Task-Id: 93824635-f0b3-4b9d-bf15-f31a4e505939 --- .../canvas/components/WebsiteLayout.tsx | 9 +- .../sidebar/components/SidebarNavSection.tsx | 39 ++++++--- packages/ui/src/router/navigationBridge.ts | 23 +++++ packages/ui/src/router/routeTree.gen.ts | 84 +++++++++++++++++++ .../router/routes/website/command-center.tsx | 10 +++ .../ui/src/router/routes/website/home.tsx | 9 ++ .../src/router/routes/website/mcp-servers.tsx | 9 ++ .../ui/src/router/routes/website/skills.tsx | 9 ++ packages/ui/src/router/useAppView.ts | 6 ++ 9 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/router/routes/website/command-center.tsx create mode 100644 packages/ui/src/router/routes/website/home.tsx create mode 100644 packages/ui/src/router/routes/website/mcp-servers.tsx create mode 100644 packages/ui/src/router/routes/website/skills.tsx diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index b9f6db054..48fb76368 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -27,6 +27,7 @@ import { } from "@posthog/ui/features/canvas/stores/dashboardEditStore"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { toast } from "@posthog/ui/primitives/toast"; +import { useHeaderStore } from "@posthog/ui/shell/headerStore"; import { Box, Flex } from "@radix-ui/themes"; import { Outlet, @@ -160,6 +161,12 @@ export function WebsiteLayout() { const { data: tasks } = useTasks(); const { channels } = useChannels(); + // App pages mirrored into the Channels space (Home, Skills, MCP servers, + // Command Center) are channel-less and push their title into the shared + // header store. With no code HeaderRow here, surface that title in this bar so + // the mirrored pages read the same as in Code. + const headerContent = useHeaderStore((s) => s.content); + const channelId = params.channelId; const dashboardId = params.dashboardId; const taskId = params.taskId; @@ -254,7 +261,7 @@ export function WebsiteLayout() { gap="2" className="h-10 shrink-0 border-gray-6 border-b px-3" > - {breadcrumbs ?? } + {breadcrumbs ?? headerContent ?? } {isDashboardDetail && channelId && dashboardId ? ( s.location.pathname.startsWith("/website"), + }); + const goHome = inChannels ? navigateToWebsiteHome : navigateToHome; + const goSkills = inChannels ? navigateToWebsiteSkills : navigateToSkills; + const goMcpServers = inChannels + ? navigateToWebsiteMcpServers + : navigateToMcpServers; + const goCommandCenter = inChannels + ? navigateToWebsiteCommandCenter + : navigateToCommandCenter; + // Active flags are pure functions of the current view — mirror what // useSidebarData derives, without pulling in its task-loading. const isHomeActive = @@ -83,7 +105,7 @@ export function SidebarNavSection() { {homeTabEnabled && ( - + )} @@ -104,20 +126,17 @@ export function SidebarNavSection() { - + - + diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 331b597bc..612181a5c 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -95,6 +95,29 @@ export function navigateToMcpServers(): void { void getRouterOrNull()?.navigate({ to: "/mcp-servers" }); } +// Channels-space mirrors. These render the same shared views as their /code (or +// top-level) counterparts but under /website, so navigating from the channels +// sidebar keeps the channels chrome instead of switching back to Code. The +// SidebarNavSection picks the right variant based on the active space. + +export function navigateToWebsiteHome(): void { + void getRouterOrNull()?.navigate({ to: "/website/home" }); +} + +export function navigateToWebsiteSkills(): void { + void getRouterOrNull()?.navigate({ to: "/website/skills" }); +} + +export function navigateToWebsiteMcpServers(): void { + void getRouterOrNull()?.navigate({ to: "/website/mcp-servers" }); +} + +export function navigateToWebsiteCommandCenter(): void { + void getRouterOrNull()?.navigate({ to: "/website/command-center" }); + // Parity with navigateToCommandCenter's analytics tracking. + track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); +} + export function navigateToSettings( category: SettingsCategory, options?: { replace?: boolean }, diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index e7ff3eca1..ebd56959f 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -17,6 +17,10 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as WebsiteIndexRouteImport } from './routes/website/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as CodeIndexRouteImport } from './routes/code/index' +import { Route as WebsiteSkillsRouteImport } from './routes/website/skills' +import { Route as WebsiteMcpServersRouteImport } from './routes/website/mcp-servers' +import { Route as WebsiteHomeRouteImport } from './routes/website/home' +import { Route as WebsiteCommandCenterRouteImport } from './routes/website/command-center' import { Route as SettingsCategoryRouteImport } from './routes/settings/$category' import { Route as FoldersFolderIdRouteImport } from './routes/folders/$folderId' import { Route as CodeInboxRouteImport } from './routes/code/inbox' @@ -86,6 +90,26 @@ const CodeIndexRoute = CodeIndexRouteImport.update({ path: '/code/', getParentRoute: () => rootRouteImport, } as any) +const WebsiteSkillsRoute = WebsiteSkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => WebsiteRoute, +} as any) +const WebsiteMcpServersRoute = WebsiteMcpServersRouteImport.update({ + id: '/mcp-servers', + path: '/mcp-servers', + getParentRoute: () => WebsiteRoute, +} as any) +const WebsiteHomeRoute = WebsiteHomeRouteImport.update({ + id: '/home', + path: '/home', + getParentRoute: () => WebsiteRoute, +} as any) +const WebsiteCommandCenterRoute = WebsiteCommandCenterRouteImport.update({ + id: '/command-center', + path: '/command-center', + getParentRoute: () => WebsiteRoute, +} as any) const SettingsCategoryRoute = SettingsCategoryRouteImport.update({ id: '/settings/$category', path: '/settings/$category', @@ -244,6 +268,10 @@ export interface FileRoutesByFullPath { '/code/inbox': typeof CodeInboxRouteWithChildren '/folders/$folderId': typeof FoldersFolderIdRoute '/settings/$category': typeof SettingsCategoryRoute + '/website/command-center': typeof WebsiteCommandCenterRoute + '/website/home': typeof WebsiteHomeRoute + '/website/mcp-servers': typeof WebsiteMcpServersRoute + '/website/skills': typeof WebsiteSkillsRoute '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute '/website/': typeof WebsiteIndexRoute @@ -279,6 +307,10 @@ export interface FileRoutesByTo { '/code/home': typeof CodeHomeRoute '/folders/$folderId': typeof FoldersFolderIdRoute '/settings/$category': typeof SettingsCategoryRoute + '/website/command-center': typeof WebsiteCommandCenterRoute + '/website/home': typeof WebsiteHomeRoute + '/website/mcp-servers': typeof WebsiteMcpServersRoute + '/website/skills': typeof WebsiteSkillsRoute '/code': typeof CodeIndexRoute '/settings': typeof SettingsIndexRoute '/website': typeof WebsiteIndexRoute @@ -314,6 +346,10 @@ export interface FileRoutesById { '/code/inbox': typeof CodeInboxRouteWithChildren '/folders/$folderId': typeof FoldersFolderIdRoute '/settings/$category': typeof SettingsCategoryRoute + '/website/command-center': typeof WebsiteCommandCenterRoute + '/website/home': typeof WebsiteHomeRoute + '/website/mcp-servers': typeof WebsiteMcpServersRoute + '/website/skills': typeof WebsiteSkillsRoute '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute '/website/': typeof WebsiteIndexRoute @@ -354,6 +390,10 @@ export interface FileRouteTypes { | '/code/inbox' | '/folders/$folderId' | '/settings/$category' + | '/website/command-center' + | '/website/home' + | '/website/mcp-servers' + | '/website/skills' | '/code/' | '/settings/' | '/website/' @@ -389,6 +429,10 @@ export interface FileRouteTypes { | '/code/home' | '/folders/$folderId' | '/settings/$category' + | '/website/command-center' + | '/website/home' + | '/website/mcp-servers' + | '/website/skills' | '/code' | '/settings' | '/website' @@ -423,6 +467,10 @@ export interface FileRouteTypes { | '/code/inbox' | '/folders/$folderId' | '/settings/$category' + | '/website/command-center' + | '/website/home' + | '/website/mcp-servers' + | '/website/skills' | '/code/' | '/settings/' | '/website/' @@ -526,6 +574,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeIndexRouteImport parentRoute: typeof rootRouteImport } + '/website/skills': { + id: '/website/skills' + path: '/skills' + fullPath: '/website/skills' + preLoaderRoute: typeof WebsiteSkillsRouteImport + parentRoute: typeof WebsiteRoute + } + '/website/mcp-servers': { + id: '/website/mcp-servers' + path: '/mcp-servers' + fullPath: '/website/mcp-servers' + preLoaderRoute: typeof WebsiteMcpServersRouteImport + parentRoute: typeof WebsiteRoute + } + '/website/home': { + id: '/website/home' + path: '/home' + fullPath: '/website/home' + preLoaderRoute: typeof WebsiteHomeRouteImport + parentRoute: typeof WebsiteRoute + } + '/website/command-center': { + id: '/website/command-center' + path: '/command-center' + fullPath: '/website/command-center' + preLoaderRoute: typeof WebsiteCommandCenterRouteImport + parentRoute: typeof WebsiteRoute + } '/settings/$category': { id: '/settings/$category' path: '/settings/$category' @@ -726,6 +802,10 @@ declare module '@tanstack/react-router' { } interface WebsiteRouteChildren { + WebsiteCommandCenterRoute: typeof WebsiteCommandCenterRoute + WebsiteHomeRoute: typeof WebsiteHomeRoute + WebsiteMcpServersRoute: typeof WebsiteMcpServersRoute + WebsiteSkillsRoute: typeof WebsiteSkillsRoute WebsiteIndexRoute: typeof WebsiteIndexRoute WebsiteChannelIdContextRoute: typeof WebsiteChannelIdContextRoute WebsiteChannelIdNewRoute: typeof WebsiteChannelIdNewRoute @@ -735,6 +815,10 @@ interface WebsiteRouteChildren { } const WebsiteRouteChildren: WebsiteRouteChildren = { + WebsiteCommandCenterRoute: WebsiteCommandCenterRoute, + WebsiteHomeRoute: WebsiteHomeRoute, + WebsiteMcpServersRoute: WebsiteMcpServersRoute, + WebsiteSkillsRoute: WebsiteSkillsRoute, WebsiteIndexRoute: WebsiteIndexRoute, WebsiteChannelIdContextRoute: WebsiteChannelIdContextRoute, WebsiteChannelIdNewRoute: WebsiteChannelIdNewRoute, diff --git a/packages/ui/src/router/routes/website/command-center.tsx b/packages/ui/src/router/routes/website/command-center.tsx new file mode 100644 index 000000000..2e3924e04 --- /dev/null +++ b/packages/ui/src/router/routes/website/command-center.tsx @@ -0,0 +1,10 @@ +import { CommandCenterView } from "@posthog/ui/features/command-center/components/CommandCenterView"; +import { createFileRoute } from "@tanstack/react-router"; + +// Channels-space mirror of /command-center. Renders the same shared +// CommandCenterView so the page stays single-source; only the route entry is +// duplicated so navigating here keeps the channels chrome (rail + channel +// sidebar). +export const Route = createFileRoute("/website/command-center")({ + component: CommandCenterView, +}); diff --git a/packages/ui/src/router/routes/website/home.tsx b/packages/ui/src/router/routes/website/home.tsx new file mode 100644 index 000000000..e4d9b3882 --- /dev/null +++ b/packages/ui/src/router/routes/website/home.tsx @@ -0,0 +1,9 @@ +import { HomeView } from "@posthog/ui/features/home/components/HomeView"; +import { createFileRoute } from "@tanstack/react-router"; + +// Channels-space mirror of /code/home. Renders the same shared HomeView so the +// page stays single-source; only the route entry is duplicated so navigating +// here keeps the channels chrome (rail + channel sidebar). +export const Route = createFileRoute("/website/home")({ + component: HomeView, +}); diff --git a/packages/ui/src/router/routes/website/mcp-servers.tsx b/packages/ui/src/router/routes/website/mcp-servers.tsx new file mode 100644 index 000000000..6c99cefc7 --- /dev/null +++ b/packages/ui/src/router/routes/website/mcp-servers.tsx @@ -0,0 +1,9 @@ +import { McpServersView } from "@posthog/ui/features/mcp-servers/components/McpServersView"; +import { createFileRoute } from "@tanstack/react-router"; + +// Channels-space mirror of /mcp-servers. Renders the same shared McpServersView +// so the page stays single-source; only the route entry is duplicated so +// navigating here keeps the channels chrome (rail + channel sidebar). +export const Route = createFileRoute("/website/mcp-servers")({ + component: McpServersView, +}); diff --git a/packages/ui/src/router/routes/website/skills.tsx b/packages/ui/src/router/routes/website/skills.tsx new file mode 100644 index 000000000..e18f14d5e --- /dev/null +++ b/packages/ui/src/router/routes/website/skills.tsx @@ -0,0 +1,9 @@ +import { SkillsView } from "@posthog/ui/features/skills/SkillsView"; +import { createFileRoute } from "@tanstack/react-router"; + +// Channels-space mirror of /skills. Renders the same shared SkillsView so the +// page stays single-source; only the route entry is duplicated so navigating +// here keeps the channels chrome (rail + channel sidebar). +export const Route = createFileRoute("/website/skills")({ + component: SkillsView, +}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index daa24a14d..c2511abe1 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -52,6 +52,9 @@ function deriveFromMatches(matches: Match[]): AppView { case "/folders/$folderId": return { type: "folder-settings", folderId: last.params.folderId }; case "/code/home": + // Channels-space mirrors share the same view type so the sidebar's + // active-state highlighting works identically in either space. + case "/website/home": return { type: "home" }; case "/code/inbox": return { type: "inbox" }; @@ -60,10 +63,13 @@ function deriveFromMatches(matches: Match[]): AppView { case "/code/archived": return { type: "archived" }; case "/command-center": + case "/website/command-center": return { type: "command-center" }; case "/skills": + case "/website/skills": return { type: "skills" }; case "/mcp-servers": + case "/website/mcp-servers": return { type: "mcp-servers" }; case "/settings/$category": case "/settings/": From 27cec42eeeccbc2aaed2926e956b7033b3443987 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 17:49:15 -0700 Subject: [PATCH 3/6] feat(canvas): add user panel to bottom of channels sidebar Render the same ProjectSwitcher (name, email, org, current project) at the bottom of the channels sidebar, using the identical wrapper as the code sidebar's SidebarContent. The component is self-contained (no props), so it sources its own auth/project data in either space. Generated-By: PostHog Code Task-Id: 93824635-f0b3-4b9d-bf15-f31a4e505939 --- packages/ui/src/router/routes/__root.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 4ed869cff..ebd70fedc 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -19,6 +19,7 @@ import { useIntegrations } from "@posthog/ui/features/integrations/useIntegratio import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; import { MainSidebar } from "@posthog/ui/features/sidebar/components/MainSidebar"; +import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; @@ -228,6 +229,11 @@ function RootLayout() { + {/* User panel — same component and wrapper as the bottom of the + code sidebar (see SidebarContent). */} + + + From 2aaf22db10ac4f413bca15ddde53e563111cfa53 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 19:16:08 -0700 Subject: [PATCH 4/6] feat(canvas): open New task in channels space from channels sidebar The New task button in the channels sidebar now opens the new-task screen at /website/new instead of jumping to /code, keeping the channels chrome. The route renders the same shared TaskInput (with the same prefill) as the /code/ index, so the page stays single-source. openTaskInput gains a `space` option; SidebarNavSection passes space: "website" when it renders inside the channels space. Generated-By: PostHog Code Task-Id: 93824635-f0b3-4b9d-bf15-f31a4e505939 --- .../sidebar/components/SidebarNavSection.tsx | 4 ++- packages/ui/src/router/navigationBridge.ts | 4 +++ packages/ui/src/router/routeTree.gen.ts | 21 +++++++++++++++ packages/ui/src/router/routes/website/new.tsx | 26 +++++++++++++++++++ packages/ui/src/router/useAppView.ts | 4 +++ packages/ui/src/router/useOpenTask.ts | 9 ++++++- 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/router/routes/website/new.tsx diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 10ec006d8..fdbc49148 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -54,6 +54,8 @@ export function SidebarNavSection() { const inChannels = useRouterState({ select: (s) => s.location.pathname.startsWith("/website"), }); + const goNewTask = () => + openTaskInput(inChannels ? { space: "website" } : undefined); const goHome = inChannels ? navigateToWebsiteHome : navigateToHome; const goSkills = inChannels ? navigateToWebsiteSkills : navigateToSkills; const goMcpServers = inChannels @@ -100,7 +102,7 @@ export function SidebarNavSection() { return ( - + {homeTabEnabled && ( diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 612181a5c..764491037 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -100,6 +100,10 @@ export function navigateToMcpServers(): void { // sidebar keeps the channels chrome instead of switching back to Code. The // SidebarNavSection picks the right variant based on the active space. +export function navigateToWebsiteNew(): void { + void getRouterOrNull()?.navigate({ to: "/website/new" }); +} + export function navigateToWebsiteHome(): void { void getRouterOrNull()?.navigate({ to: "/website/home" }); } diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index ebd56959f..c78118dd1 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as WebsiteIndexRouteImport } from './routes/website/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as CodeIndexRouteImport } from './routes/code/index' import { Route as WebsiteSkillsRouteImport } from './routes/website/skills' +import { Route as WebsiteNewRouteImport } from './routes/website/new' import { Route as WebsiteMcpServersRouteImport } from './routes/website/mcp-servers' import { Route as WebsiteHomeRouteImport } from './routes/website/home' import { Route as WebsiteCommandCenterRouteImport } from './routes/website/command-center' @@ -95,6 +96,11 @@ const WebsiteSkillsRoute = WebsiteSkillsRouteImport.update({ path: '/skills', getParentRoute: () => WebsiteRoute, } as any) +const WebsiteNewRoute = WebsiteNewRouteImport.update({ + id: '/new', + path: '/new', + getParentRoute: () => WebsiteRoute, +} as any) const WebsiteMcpServersRoute = WebsiteMcpServersRouteImport.update({ id: '/mcp-servers', path: '/mcp-servers', @@ -271,6 +277,7 @@ export interface FileRoutesByFullPath { '/website/command-center': typeof WebsiteCommandCenterRoute '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute + '/website/new': typeof WebsiteNewRoute '/website/skills': typeof WebsiteSkillsRoute '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute @@ -310,6 +317,7 @@ export interface FileRoutesByTo { '/website/command-center': typeof WebsiteCommandCenterRoute '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute + '/website/new': typeof WebsiteNewRoute '/website/skills': typeof WebsiteSkillsRoute '/code': typeof CodeIndexRoute '/settings': typeof SettingsIndexRoute @@ -349,6 +357,7 @@ export interface FileRoutesById { '/website/command-center': typeof WebsiteCommandCenterRoute '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute + '/website/new': typeof WebsiteNewRoute '/website/skills': typeof WebsiteSkillsRoute '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute @@ -393,6 +402,7 @@ export interface FileRouteTypes { | '/website/command-center' | '/website/home' | '/website/mcp-servers' + | '/website/new' | '/website/skills' | '/code/' | '/settings/' @@ -432,6 +442,7 @@ export interface FileRouteTypes { | '/website/command-center' | '/website/home' | '/website/mcp-servers' + | '/website/new' | '/website/skills' | '/code' | '/settings' @@ -470,6 +481,7 @@ export interface FileRouteTypes { | '/website/command-center' | '/website/home' | '/website/mcp-servers' + | '/website/new' | '/website/skills' | '/code/' | '/settings/' @@ -581,6 +593,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteSkillsRouteImport parentRoute: typeof WebsiteRoute } + '/website/new': { + id: '/website/new' + path: '/new' + fullPath: '/website/new' + preLoaderRoute: typeof WebsiteNewRouteImport + parentRoute: typeof WebsiteRoute + } '/website/mcp-servers': { id: '/website/mcp-servers' path: '/mcp-servers' @@ -805,6 +824,7 @@ interface WebsiteRouteChildren { WebsiteCommandCenterRoute: typeof WebsiteCommandCenterRoute WebsiteHomeRoute: typeof WebsiteHomeRoute WebsiteMcpServersRoute: typeof WebsiteMcpServersRoute + WebsiteNewRoute: typeof WebsiteNewRoute WebsiteSkillsRoute: typeof WebsiteSkillsRoute WebsiteIndexRoute: typeof WebsiteIndexRoute WebsiteChannelIdContextRoute: typeof WebsiteChannelIdContextRoute @@ -818,6 +838,7 @@ const WebsiteRouteChildren: WebsiteRouteChildren = { WebsiteCommandCenterRoute: WebsiteCommandCenterRoute, WebsiteHomeRoute: WebsiteHomeRoute, WebsiteMcpServersRoute: WebsiteMcpServersRoute, + WebsiteNewRoute: WebsiteNewRoute, WebsiteSkillsRoute: WebsiteSkillsRoute, WebsiteIndexRoute: WebsiteIndexRoute, WebsiteChannelIdContextRoute: WebsiteChannelIdContextRoute, diff --git a/packages/ui/src/router/routes/website/new.tsx b/packages/ui/src/router/routes/website/new.tsx new file mode 100644 index 000000000..1211854af --- /dev/null +++ b/packages/ui/src/router/routes/website/new.tsx @@ -0,0 +1,26 @@ +import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { createFileRoute } from "@tanstack/react-router"; + +// Channels-space mirror of the /code/ new-task screen. Renders the same shared +// TaskInput (reading the same prefill) so the page stays single-source; only +// the route entry is duplicated so opening it from the channels sidebar keeps +// the channels chrome. (Per-channel new tasks live at /website/$channelId/new.) +export const Route = createFileRoute("/website/new")({ + component: WebsiteNewTaskRoute, +}); + +function WebsiteNewTaskRoute() { + const view = useAppView(); + + return ( + + ); +} diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index c2511abe1..e74e69d22 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -49,6 +49,10 @@ function deriveFromMatches(matches: Match[]): AppView { } case "/code/tasks/pending/$key": return { type: "task-pending", pendingTaskKey: last.params.key }; + // Channels-space new-task screen — same task-input view (and prefill merge + // below) as the /code/ index, so the New task item highlights identically. + case "/website/new": + return { type: "task-input" }; case "/folders/$folderId": return { type: "folder-settings", folderId: last.params.folderId }; case "/code/home": diff --git a/packages/ui/src/router/useOpenTask.ts b/packages/ui/src/router/useOpenTask.ts index 9bb7fc64e..bf166d39e 100644 --- a/packages/ui/src/router/useOpenTask.ts +++ b/packages/ui/src/router/useOpenTask.ts @@ -55,6 +55,9 @@ export interface TaskInputNavigationOptions { initialModel?: string; initialMode?: string; reportAssociation?: { reportId: string; title: string }; + // Which space's new-task screen to open. Both render the same TaskInput; the + // channels variant keeps the channels chrome instead of switching to Code. + space?: "code" | "website"; } /** @@ -90,7 +93,11 @@ export function openTaskInput( : undefined, }, }); - nav.navigateToCode(); + if (options.space === "website") { + nav.navigateToWebsiteNew(); + } else { + nav.navigateToCode(); + } } export function useOpenTaskInput(): typeof openTaskInput { From faf6705fc4e53f193da331942a4df36fffdd5a42 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Mon, 15 Jun 2026 20:35:50 -0700 Subject: [PATCH 5/6] feat(canvas): validate channel names to lowercase, numbers, hyphens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel's name is used verbatim as its server-side filesystem path segment, so constrain it to [a-z0-9-] only — no spaces, uppercase, or other characters. Add a shared validateChannelName helper in core (with tests) and wire it into the Create and Rename channel modals: an inline red error shows under the field and the submit button is disabled until the name is valid, matching the existing GitBranchDialog validation pattern. Generated-By: PostHog Code Task-Id: 8af89649-aa10-4e81-baf6-2c60f2116012 --- packages/core/src/canvas/channelName.test.ts | 29 +++++++++++++++++++ packages/core/src/canvas/channelName.ts | 15 ++++++++++ .../canvas/components/CreateChannelModal.tsx | 11 +++++-- .../canvas/components/RenameChannelModal.tsx | 11 +++++-- 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/canvas/channelName.test.ts create mode 100644 packages/core/src/canvas/channelName.ts diff --git a/packages/core/src/canvas/channelName.test.ts b/packages/core/src/canvas/channelName.test.ts new file mode 100644 index 000000000..9f9ad1873 --- /dev/null +++ b/packages/core/src/canvas/channelName.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { validateChannelName } from "./channelName"; + +describe("validateChannelName", () => { + it.each([ + "mobile", + "web-analytics", + "team-1", + "a", + "123", + "a-b-c", + " mobile ", // surrounding whitespace is trimmed before validating + ])("returns null for valid name %j", (name) => { + expect(validateChannelName(name)).toBeNull(); + }); + + it.each(["", " "])("returns null for empty/blank name %j", (name) => { + expect(validateChannelName(name)).toBeNull(); + }); + + it.each(["Mobile", "my channel", "team_1", "café", "a.b", "a/b", "emoji🚀"])( + "returns an error for invalid name %j", + (name) => { + expect(validateChannelName(name)).toBe( + "Use only lowercase letters, numbers, and hyphens.", + ); + }, + ); +}); diff --git a/packages/core/src/canvas/channelName.ts b/packages/core/src/canvas/channelName.ts new file mode 100644 index 000000000..6d1af1fc9 --- /dev/null +++ b/packages/core/src/canvas/channelName.ts @@ -0,0 +1,15 @@ +// A channel's name is used verbatim as its server-side filesystem path segment, +// so it must be directory-safe: lowercase letters, numbers, and hyphens only. +export const CHANNEL_NAME_PATTERN = /^[a-z0-9-]+$/; + +// Returns an error message for an invalid name, or null when valid. Empty is +// treated as valid here — callers already gate on a non-empty trimmed value, so +// this validator only judges the character set. +export function validateChannelName(name: string): string | null { + const trimmed = name.trim(); + if (!trimmed) return null; + if (!CHANNEL_NAME_PATTERN.test(trimmed)) { + return "Use only lowercase letters, numbers, and hyphens."; + } + return null; +} diff --git a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx index c8659a4ca..d65e2be1e 100644 --- a/packages/ui/src/features/canvas/components/CreateChannelModal.tsx +++ b/packages/ui/src/features/canvas/components/CreateChannelModal.tsx @@ -1,4 +1,5 @@ import { HashIcon, XIcon } from "@phosphor-icons/react"; +import { validateChannelName } from "@posthog/core/canvas/channelName"; import { Button } from "@posthog/quill"; import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels"; import { toast } from "@posthog/ui/primitives/toast"; @@ -33,9 +34,10 @@ export function CreateChannelModal({ const trimmed = name.trim(); const remaining = MAX_CHANNEL_NAME_LENGTH - name.length; + const validationError = validateChannelName(trimmed); const submit = async () => { - if (!trimmed || isCreating) return; + if (!trimmed || validationError || isCreating) return; try { const channel = await createChannel(trimmed); onOpenChange(false); @@ -108,6 +110,11 @@ export function CreateChannelModal({ + {validationError && ( + + {validationError} + + )} Each channel gets its own dashboards, tasks, and settings. Use a name that's easy to find. @@ -117,7 +124,7 @@ export function CreateChannelModal({