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
88 changes: 88 additions & 0 deletions apps/code/src/renderer/components/AppNav.tsx
Original file line number Diff line number Diff line change
@@ -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/"),
},
Comment on lines +32 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Code item active-state gap for sibling top-level routes

/command-center, /mcp-servers, and /skills are rendered as root-level routes (not under /code/), so when a user navigates to any of them with the rail visible, no item in the rail is highlighted. Since isRailSpace is false for these paths, the Code chrome (header/sidebar) also renders alongside the rail, meaning the user is visually inside the Code context with an unlit Code button. Extending the isActive predicate to cover these paths (or alternatively treating them as non-rail-space paths and hiding the rail) would keep the active state consistent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/components/AppNav.tsx
Line: 38-45

Comment:
**Code item active-state gap for sibling top-level routes**

`/command-center`, `/mcp-servers`, and `/skills` are rendered as root-level routes (not under `/code/`), so when a user navigates to any of them with the rail visible, no item in the rail is highlighted. Since `isRailSpace` is false for these paths, the Code chrome (header/sidebar) also renders alongside the rail, meaning the user is visually inside the Code context with an unlit Code button. Extending the `isActive` predicate to cover these paths (or alternatively treating them as non-rail-space paths and hiding the rail) would keep the active state consistent.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

];

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 (
<Flex
direction="column"
align="center"
gap="2"
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);
const Icon = item.icon;
const badgeCount = item.id === "inbox" ? inboxCount : 0;
return (
<Box key={item.id} position="relative" className="no-drag">
<Button
size="icon-lg"
variant="default"
aria-selected={active}
aria-label={item.label}
title={item.label}
onClick={() => navigate({ to: item.to })}
>
<Icon size={20} weight="regular" />
</Button>
{badgeCount > 0 && (
<Badge
variant="destructive"
className="-top-1 -right-1 pointer-events-none absolute min-w-4 justify-center rounded-full px-1 tabular-nums"
title={`${badgeCount} actionable report${badgeCount === 1 ? "" : "s"}`}
>
{formatBadgeCount(badgeCount)}
</Badge>
)}
</Box>
);
})}
</Flex>
);
}
24 changes: 24 additions & 0 deletions apps/code/src/renderer/features/inbox/hooks/useInboxSignalCount.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions apps/code/src/renderer/hooks/useFeatureFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
21 changes: 21 additions & 0 deletions apps/code/src/renderer/routeTree.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 64 additions & 18 deletions apps/code/src/renderer/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AppNav } from "@components/AppNav";
import { HeaderRow } from "@components/HeaderRow";
import { HedgehogMode } from "@components/HedgehogMode";
import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
Expand All @@ -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";
Expand Down Expand Up @@ -102,6 +108,11 @@ function RootLayout() {
const reconcilingTaskIds = useRef<Set<string>>(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);
Expand Down Expand Up @@ -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 (
<Flex direction="column" height="100vh">
Expand All @@ -188,24 +218,40 @@ function RootLayout() {
);
}

const isRailSpace = isHomeRoute || isInboxRoute;

return (
<Flex direction="column" height="100vh">
<HeaderRow />
<Flex flexGrow="1" overflow="hidden">
<MainSidebar />
<Box flexGrow="1" overflow="hidden">
<Outlet />
</Box>
<Flex height="100vh" overflow="hidden">
{bluebirdEnabled && <AppNav />}
<Flex direction="column" flexGrow="1" overflow="hidden">
{isRailSpace ? (
<Box flexGrow="1" overflow="hidden">
<Outlet />
</Box>
) : (
<>
<HeaderRow />
<Flex flexGrow="1" overflow="hidden">
<MainSidebar />
<Box flexGrow="1" overflow="hidden">
<Outlet />
</Box>
</Flex>

<SpaceSwitcher
tasks={visualTaskOrder}
activeTaskId={activeTaskId}
allTasks={tasks ?? []}
isOnNewTask={
view.type === "task-input" || view.type === "task-pending"
}
onNavigateToTask={openTask}
onNewTask={openTaskInput}
/>
</>
)}
</Flex>

<SpaceSwitcher
tasks={visualTaskOrder}
activeTaskId={activeTaskId}
allTasks={tasks ?? []}
isOnNewTask={view.type === "task-input" || view.type === "task-pending"}
onNavigateToTask={openTask}
onNewTask={openTaskInput}
/>
<CommandMenu open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />
<KeyboardShortcutsSheet
open={shortcutsSheetOpen}
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/renderer/routes/inbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { InboxView } from "@features/inbox/components/InboxView";
import { createFileRoute } from "@tanstack/react-router";

// Top-level Inbox space: the existing inbox, full-screen via the app rail.
export const Route = createFileRoute("/inbox")({
component: InboxView,
});
23 changes: 19 additions & 4 deletions apps/code/src/renderer/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { createFileRoute } from "@tanstack/react-router";

// Home space: empty for now. The app rail's Home button lands here.
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({ to: "/code" });
},
component: HomeRoute,
});

function HomeRoute() {
return (
<Flex
direction="column"
align="center"
justify="center"
height="100%"
gap="2"
>
<Heading size="6">Home</Heading>
<Text className="text-gray-10">Nothing here yet.</Text>
</Flex>
);
}
Loading
Loading