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({
+ {validationError && (
+
+ {validationError}
+
+ )}
+
+
+ {/* User panel — same component and wrapper as the bottom of the
+ code sidebar (see SidebarContent). */}
+
+
+
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/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/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..e74e69d22 100644
--- a/packages/ui/src/router/useAppView.ts
+++ b/packages/ui/src/router/useAppView.ts
@@ -49,9 +49,16 @@ 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":
+ // 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 +67,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/":
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 {