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";