Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/core/src/canvas/channelName.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
},
);
});
15 changes: 15 additions & 0 deletions packages/core/src/canvas/channelName.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -108,6 +110,11 @@ export function CreateChannelModal({
</Text>
</TextField.Slot>
</TextField.Root>
{validationError && (
<Text color="red" className="text-sm">
{validationError}
</Text>
)}
<Text className="text-gray-10 text-sm">
Each channel gets its own dashboards, tasks, and settings. Use a
name that's easy to find.
Expand All @@ -117,7 +124,7 @@ export function CreateChannelModal({
<Flex gap="3" mt="5" justify="end">
<Button
variant="primary"
disabled={!trimmed || isCreating}
disabled={!trimmed || !!validationError || isCreating}
onClick={submit}
>
Create
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HashIcon, XIcon } from "@phosphor-icons/react";
import { validateChannelName } from "@posthog/core/canvas/channelName";
import { Button } from "@posthog/quill";
import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelMutations } from "@posthog/ui/features/canvas/hooks/useChannels";
Expand Down Expand Up @@ -31,9 +32,10 @@ export function RenameChannelModal({
const trimmed = name.trim();
const remaining = MAX_CHANNEL_NAME_LENGTH - name.length;
const unchanged = trimmed === channel.name;
const validationError = validateChannelName(trimmed);

const submit = async () => {
if (!trimmed || unchanged || isRenaming) return;
if (!trimmed || unchanged || validationError || isRenaming) return;
try {
await renameChannel(channel.id, trimmed);
onOpenChange(false);
Expand Down Expand Up @@ -102,12 +104,17 @@ export function RenameChannelModal({
</Text>
</TextField.Slot>
</TextField.Root>
{validationError && (
<Text color="red" className="text-sm">
{validationError}
</Text>
)}
</Flex>

<Flex gap="3" mt="5" justify="end">
<Button
variant="primary"
disabled={!trimmed || unchanged || isRenaming}
disabled={!trimmed || unchanged || !!validationError || isRenaming}
onClick={submit}
>
Rename
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/features/canvas/components/WebsiteLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -254,7 +261,7 @@ export function WebsiteLayout() {
gap="2"
className="h-10 shrink-0 border-gray-6 border-b px-3"
>
{breadcrumbs ?? <span />}
{breadcrumbs ?? headerContent ?? <span />}
{isDashboardDetail && channelId && dashboardId ? (
<DashboardEditControls
channelId={channelId}
Expand Down
140 changes: 10 additions & 130 deletions packages/ui/src/features/sidebar/components/SidebarMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
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,
useArchiveCacheKeys,
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";
Expand All @@ -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";

Expand Down Expand Up @@ -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<string, Task>();
for (const task of allTasks) {
Expand All @@ -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<string | null>(null);

Expand All @@ -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<{
Expand Down Expand Up @@ -470,64 +399,15 @@ function SidebarMenuComponent() {
id="side-bar-menu"
className="flex min-h-0 flex-col"
>
<Flex direction="column" className="shrink-0 gap-px px-2 py-2">
<Box mb="2">
<NewTaskItem
isActive={sidebarData.isHomeActive}
onClick={handleNewTaskClick}
/>
</Box>

{homeTabEnabled && (
<Box>
<HomeItem
isActive={sidebarData.isHomeViewActive}
onClick={handleHomeClick}
/>
</Box>
{/* Derive the command-center count from data SidebarMenu already holds,
so the nested nav section doesn't open its own task subscription. */}
<SidebarNavSection
commandCenterActiveCount={commandCenterCells.reduce(
(count, taskId) =>
taskId != null && taskMap.has(taskId) ? count + 1 : count,
0,
)}

<Box>
<SearchItem onClick={handleSearchClick} />
</Box>

<Box>
<InboxItem
isActive={sidebarData.isInboxActive}
onClick={handleInboxClick}
signalCount={inboxSignalCount}
/>
</Box>

<Box>
<AgentsItem
isActive={sidebarData.isAgentsActive}
onClick={handleAgentsClick}
/>
</Box>

<Box>
<SkillsItem
isActive={sidebarData.isSkillsActive}
onClick={handleSkillsClick}
/>
</Box>

<Box>
<McpServersItem
isActive={sidebarData.isMcpServersActive}
onClick={handleMcpServersClick}
/>
</Box>

<Box mb="2">
<CommandCenterItem
isActive={sidebarData.isCommandCenterActive}
onClick={handleCommandCenterClick}
activeCount={commandCenterActiveCount}
/>
</Box>
</Flex>
/>

<Separator className="mx-2 my-2 shrink-0" />

Expand Down
Loading
Loading