diff --git a/apps/code/src/renderer/components/AppNav.tsx b/apps/code/src/renderer/components/AppNav.tsx
new file mode 100644
index 000000000..e0e47afb1
--- /dev/null
+++ b/apps/code/src/renderer/components/AppNav.tsx
@@ -0,0 +1,88 @@
+import { useInboxSignalCount } from "@features/inbox/hooks/useInboxSignalCount";
+import { CodeIcon, HouseIcon, TrayIcon } from "@phosphor-icons/react";
+import { Badge, Button } from "@posthog/quill";
+import { Box, Flex } from "@radix-ui/themes";
+import { useNavigate, useRouterState } from "@tanstack/react-router";
+
+type AppNavItem = {
+ id: "home" | "inbox" | "code";
+ label: string;
+ icon: typeof HouseIcon;
+ to: "/" | "/inbox" | "/code";
+ isActive: (pathname: string) => boolean;
+};
+
+// Slack-like app rail switching between top-level "spaces": Home (the / scene),
+// Inbox (the full-screen inbox), and Code (the existing /code app).
+const NAV_ITEMS: AppNavItem[] = [
+ {
+ id: "home",
+ label: "Home",
+ icon: HouseIcon,
+ to: "/",
+ isActive: (pathname) => pathname === "/",
+ },
+ {
+ id: "inbox",
+ label: "Inbox",
+ icon: TrayIcon,
+ to: "/inbox",
+ isActive: (pathname) => pathname === "/inbox",
+ },
+ {
+ id: "code",
+ label: "Code",
+ icon: CodeIcon,
+ to: "/code",
+ isActive: (pathname) =>
+ pathname === "/code" || pathname.startsWith("/code/"),
+ },
+];
+
+function formatBadgeCount(count: number): string {
+ return count > 99 ? "99+" : String(count);
+}
+
+export function AppNav() {
+ const navigate = useNavigate();
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
+ const inboxCount = useInboxSignalCount();
+
+ return (
+
+ {NAV_ITEMS.map((item) => {
+ const active = item.isActive(pathname);
+ const Icon = item.icon;
+ const badgeCount = item.id === "inbox" ? inboxCount : 0;
+ return (
+
+
+ {badgeCount > 0 && (
+
+ {formatBadgeCount(badgeCount)}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxSignalCount.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxSignalCount.ts
new file mode 100644
index 000000000..09db01235
--- /dev/null
+++ b/apps/code/src/renderer/features/inbox/hooks/useInboxSignalCount.ts
@@ -0,0 +1,24 @@
+import { useInboxReports } from "@features/inbox/hooks/useInboxReports";
+import { isReportUpForReview } from "@features/inbox/utils/filterReports";
+import {
+ INBOX_PIPELINE_STATUS_FILTER,
+ INBOX_REFETCH_INTERVAL_MS,
+} from "@features/inbox/utils/inboxConstants";
+import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore";
+
+/**
+ * Count of actionable inbox reports assigned to the user. Uses the same query
+ * args as the sidebar inbox probe, so they share one cache (no extra polling).
+ */
+export function useInboxSignalCount(): number {
+ const polling = useRendererWindowFocusStore((s) => s.focused);
+ const { data } = useInboxReports(
+ { status: INBOX_PIPELINE_STATUS_FILTER },
+ {
+ refetchInterval: polling ? INBOX_REFETCH_INTERVAL_MS : false,
+ refetchIntervalInBackground: false,
+ staleTime: polling ? INBOX_REFETCH_INTERVAL_MS : 15_000,
+ },
+ );
+ return (data?.results ?? []).filter(isReportUpForReview).length;
+}
diff --git a/apps/code/src/renderer/hooks/useFeatureFlag.ts b/apps/code/src/renderer/hooks/useFeatureFlag.ts
index de841080a..c2d785246 100644
--- a/apps/code/src/renderer/hooks/useFeatureFlag.ts
+++ b/apps/code/src/renderer/hooks/useFeatureFlag.ts
@@ -21,3 +21,19 @@ export function useFeatureFlag(
return enabled;
}
+
+/**
+ * True once PostHog has resolved feature flags at least once. Use this to defer
+ * flag-dependent redirects until a flag's value is trustworthy, rather than
+ * acting on the `false` default that every flag reports before load.
+ */
+export function useFeatureFlagsLoaded(): boolean {
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ // onFeatureFlagsLoaded fires immediately if flags are already resolved.
+ return onFeatureFlagsLoaded(() => setLoaded(true));
+ }, []);
+
+ return loaded;
+}
diff --git a/apps/code/src/renderer/routeTree.gen.ts b/apps/code/src/renderer/routeTree.gen.ts
index e6d4d9846..9d5478348 100644
--- a/apps/code/src/renderer/routeTree.gen.ts
+++ b/apps/code/src/renderer/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SkillsRouteImport } from './routes/skills'
import { Route as McpServersRouteImport } from './routes/mcp-servers'
+import { Route as InboxRouteImport } from './routes/inbox'
import { Route as CommandCenterRouteImport } from './routes/command-center'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
@@ -32,6 +33,11 @@ const McpServersRoute = McpServersRouteImport.update({
path: '/mcp-servers',
getParentRoute: () => rootRouteImport,
} as any)
+const InboxRoute = InboxRouteImport.update({
+ id: '/inbox',
+ path: '/inbox',
+ getParentRoute: () => rootRouteImport,
+} as any)
const CommandCenterRoute = CommandCenterRouteImport.update({
id: '/command-center',
path: '/command-center',
@@ -86,6 +92,7 @@ const CodeTasksPendingKeyRoute = CodeTasksPendingKeyRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/command-center': typeof CommandCenterRoute
+ '/inbox': typeof InboxRoute
'/mcp-servers': typeof McpServersRoute
'/skills': typeof SkillsRoute
'/code/archived': typeof CodeArchivedRoute
@@ -100,6 +107,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/command-center': typeof CommandCenterRoute
+ '/inbox': typeof InboxRoute
'/mcp-servers': typeof McpServersRoute
'/skills': typeof SkillsRoute
'/code/archived': typeof CodeArchivedRoute
@@ -115,6 +123,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/command-center': typeof CommandCenterRoute
+ '/inbox': typeof InboxRoute
'/mcp-servers': typeof McpServersRoute
'/skills': typeof SkillsRoute
'/code/archived': typeof CodeArchivedRoute
@@ -131,6 +140,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/command-center'
+ | '/inbox'
| '/mcp-servers'
| '/skills'
| '/code/archived'
@@ -145,6 +155,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/command-center'
+ | '/inbox'
| '/mcp-servers'
| '/skills'
| '/code/archived'
@@ -159,6 +170,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/command-center'
+ | '/inbox'
| '/mcp-servers'
| '/skills'
| '/code/archived'
@@ -174,6 +186,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
CommandCenterRoute: typeof CommandCenterRoute
+ InboxRoute: typeof InboxRoute
McpServersRoute: typeof McpServersRoute
SkillsRoute: typeof SkillsRoute
CodeArchivedRoute: typeof CodeArchivedRoute
@@ -202,6 +215,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof McpServersRouteImport
parentRoute: typeof rootRouteImport
}
+ '/inbox': {
+ id: '/inbox'
+ path: '/inbox'
+ fullPath: '/inbox'
+ preLoaderRoute: typeof InboxRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/command-center': {
id: '/command-center'
path: '/command-center'
@@ -278,6 +298,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
CommandCenterRoute: CommandCenterRoute,
+ InboxRoute: InboxRoute,
McpServersRoute: McpServersRoute,
SkillsRoute: SkillsRoute,
CodeArchivedRoute: CodeArchivedRoute,
diff --git a/apps/code/src/renderer/routes/__root.tsx b/apps/code/src/renderer/routes/__root.tsx
index 144f6d5df..3c779821d 100644
--- a/apps/code/src/renderer/routes/__root.tsx
+++ b/apps/code/src/renderer/routes/__root.tsx
@@ -1,3 +1,4 @@
+import { AppNav } from "@components/AppNav";
import { HeaderRow } from "@components/HeaderRow";
import { HedgehogMode } from "@components/HedgehogMode";
import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
@@ -16,12 +17,17 @@ import {
workspaceApi,
} from "@features/workspace/hooks/useWorkspace";
import { useAppView } from "@hooks/useAppView";
-import { useFeatureFlag } from "@hooks/useFeatureFlag";
+import { useFeatureFlag, useFeatureFlagsLoaded } from "@hooks/useFeatureFlag";
import { useIntegrations } from "@hooks/useIntegrations";
import { openTask, openTaskInput } from "@hooks/useOpenTask";
import { Box, Flex } from "@radix-ui/themes";
+import { navigateToCode } from "@renderer/navigationBridge";
import { useTRPC } from "@renderer/trpc/client";
-import { BILLING_FLAG, SYNC_CLOUD_TASKS_FLAG } from "@shared/constants";
+import {
+ BILLING_FLAG,
+ PROJECT_BLUEBIRD_FLAG,
+ SYNC_CLOUD_TASKS_FLAG,
+} from "@shared/constants";
import { useCommandMenuStore } from "@stores/commandMenuStore";
import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore";
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
@@ -102,6 +108,11 @@ function RootLayout() {
const reconcilingTaskIds = useRef>(new Set());
const billingEnabled = useFeatureFlag(BILLING_FLAG);
const syncCloudTasksEnabled = useFeatureFlag(SYNC_CLOUD_TASKS_FLAG);
+ // Default on in dev so the rail shows locally without PostHog serving the flag.
+ const bluebirdEnabled = useFeatureFlag(
+ PROJECT_BLUEBIRD_FLAG,
+ import.meta.env.DEV,
+ );
const sidebarData = useSidebarData({ activeView: view });
const visualTaskOrder = useVisualTaskOrder(sidebarData);
@@ -159,12 +170,31 @@ function RootLayout() {
toggleCommandMenu();
}, [toggleCommandMenu]);
- // Settings is a full-page route — drop the app chrome (header/sidebar/
+ // Settings is a full-page route — drop the app chrome (rail/header/sidebar/
// space-switcher) so the panel occupies the full window.
const isSettingsRoute = useRouterState({
select: (s) => s.matches.some((m) => m.routeId.startsWith("/settings")),
});
+ // The Home and Inbox spaces render full-screen (rail only, no code chrome).
+ const onHomePath = useRouterState({
+ select: (s) => s.location.pathname === "/",
+ });
+ const onInboxPath = useRouterState({
+ select: (s) => s.location.pathname === "/inbox",
+ });
+ const isHomeRoute = bluebirdEnabled && onHomePath;
+ const isInboxRoute = bluebirdEnabled && onInboxPath;
+
+ // With the rail hidden there's no way to leave a rail-only space, so a user
+ // stranded on / or /inbox (cold-boot restore, stale deep link) goes to /code
+ // — but only once flags resolve, so a flagged user isn't bounced mid-load.
+ const flagsLoaded = useFeatureFlagsLoaded();
+ useEffect(() => {
+ if (!flagsLoaded || bluebirdEnabled) return;
+ if (onHomePath || onInboxPath) navigateToCode();
+ }, [flagsLoaded, bluebirdEnabled, onHomePath, onInboxPath]);
+
if (isSettingsRoute) {
return (
@@ -188,24 +218,40 @@ function RootLayout() {
);
}
+ const isRailSpace = isHomeRoute || isInboxRoute;
+
return (
-
-
-
-
-
-
-
+
+ {bluebirdEnabled && }
+
+ {isRailSpace ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
-
{
- throw redirect({ to: "/code" });
- },
+ component: HomeRoute,
});
+
+function HomeRoute() {
+ return (
+
+ Home
+ Nothing here yet.
+
+ );
+}
diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts
index 73b6097d8..e36f1f443 100644
--- a/apps/code/src/shared/constants.ts
+++ b/apps/code/src/shared/constants.ts
@@ -3,6 +3,9 @@ export const INBOX_GATED_DUE_TO_SCALE_FLAG = "inbox-gated-due-to-scale";
export const EXPERIMENT_SUGGESTIONS_FLAG =
"posthog-code-experiment-suggestions";
export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks";
+// Gates the top-level app nav rail (Home / Inbox / Code spaces). When off, the
+// app is the code-only shell it is today.
+export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
export const BRANCH_PREFIX = "posthog-code/";
export const DATA_DIR = ".posthog-code";
export const WORKTREES_DIR = ".posthog-code/worktrees";