From 6cb2a8358b6daace0fddf122468d9d4eb050065a Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 4 Jun 2026 23:17:17 +0100 Subject: [PATCH 1/2] feat(code): top-level app nav rail (Home / Inbox / Code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Slack-like vertical rail that switches between three top-level spaces: Home (empty placeholder), Inbox (the existing inbox, full-screen), and Code (the existing app, unchanged). Gated behind the project-bluebird flag and default-on in dev — when the flag is off the app is byte-for-byte today's code-only shell, so this is a safe dark-launch. - AppNav rail with active-space highlighting and the inbox actionable-report badge (useInboxSignalCount). - / renders an empty Home space (was a redirect to /code); /inbox mounts the existing InboxView full-screen. - Root layout renders the rail + branches Home/Inbox to a chrome-less Outlet, Code keeps header/sidebar/space-switcher. Stranded rail-only paths redirect to /code once flags resolve when the flag is off. Co-Authored-By: Claude Opus 4.8 --- apps/code/src/renderer/components/AppNav.tsx | 96 +++++++++++++++++++ .../inbox/hooks/useInboxSignalCount.ts | 24 +++++ .../code/src/renderer/hooks/useFeatureFlag.ts | 16 ++++ apps/code/src/renderer/routeTree.gen.ts | 21 ++++ apps/code/src/renderer/routes/__root.tsx | 82 ++++++++++++---- apps/code/src/renderer/routes/inbox.tsx | 7 ++ apps/code/src/renderer/routes/index.tsx | 23 ++++- apps/code/src/shared/constants.ts | 3 + 8 files changed, 250 insertions(+), 22 deletions(-) create mode 100644 apps/code/src/renderer/components/AppNav.tsx create mode 100644 apps/code/src/renderer/features/inbox/hooks/useInboxSignalCount.ts create mode 100644 apps/code/src/renderer/routes/inbox.tsx diff --git a/apps/code/src/renderer/components/AppNav.tsx b/apps/code/src/renderer/components/AppNav.tsx new file mode 100644 index 0000000000..674c8c2e37 --- /dev/null +++ b/apps/code/src/renderer/components/AppNav.tsx @@ -0,0 +1,96 @@ +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"; +import { isMac } from "@utils/platform"; + +// macOS draws the traffic lights over the top-left of the window +// (titleBarStyle: hiddenInset). Reserve space so the first rail button clears +// them. +const MAC_TRAFFIC_LIGHT_INSET = 28; + +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 0000000000..09db012359 --- /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 de841080a2..c2d7852468 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 e6d4d9846e..9d5478348f 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 144f6d5df3..3c779821d6 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 73b6097d8f..e36f1f443e 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"; From c1c79c77066381fbd05ac7aace40c76010a88ead Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 4 Jun 2026 23:24:35 +0100 Subject: [PATCH 2/2] style(code): give the app nav rail 2.5rem top padding Clears the window titlebar / macOS traffic lights on all platforms (replaces the mac-only 28px inset). Co-Authored-By: Claude Opus 4.8 --- apps/code/src/renderer/components/AppNav.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/code/src/renderer/components/AppNav.tsx b/apps/code/src/renderer/components/AppNav.tsx index 674c8c2e37..e0e47afb16 100644 --- a/apps/code/src/renderer/components/AppNav.tsx +++ b/apps/code/src/renderer/components/AppNav.tsx @@ -3,12 +3,6 @@ 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"; -import { isMac } from "@utils/platform"; - -// macOS draws the traffic lights over the top-left of the window -// (titleBarStyle: hiddenInset). Reserve space so the first rail button clears -// them. -const MAC_TRAFFIC_LIGHT_INSET = 28; type AppNavItem = { id: "home" | "inbox" | "code"; @@ -59,9 +53,7 @@ export function AppNav() { direction="column" align="center" gap="2" - p="2" - className="drag h-full shrink-0 border-gray-6 border-r bg-gray-2" - style={{ paddingTop: isMac ? MAC_TRAFFIC_LIGHT_INSET : undefined }} + className="drag h-full shrink-0 border-gray-6 border-r bg-gray-2 px-2 pt-10 pb-2" > {NAV_ITEMS.map((item) => { const active = item.isActive(pathname);