diff --git a/frontend/src/app/pool-switcher.stories.tsx b/frontend/src/app/pool-switcher.stories.tsx
new file mode 100644
index 0000000000..c3798ab0bf
--- /dev/null
+++ b/frontend/src/app/pool-switcher.stories.tsx
@@ -0,0 +1,105 @@
+import type { Story } from "@ladle/react";
+import { useState } from "react";
+import "../../.ladle/ladle.css";
+import {
+ PoolSwitcher,
+ type PoolSwitcherPool,
+ poolHeaderText,
+} from "./pool-switcher";
+
+function Frame({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
+
+// Mirrors how a page composes the pool-scoped title with the switcher: the
+// switcher only renders when there is more than one pool, otherwise the title
+// stays generic. This is the real integration unit, not the switcher alone.
+function TitleWithSwitcher({
+ pools,
+ suffix,
+ generic,
+}: {
+ pools: PoolSwitcherPool[];
+ suffix: string;
+ generic: string;
+}) {
+ const [value, setValue] = useState(pools[0]?.name ?? "default");
+ return (
+
+
+ {poolHeaderText(pools, suffix, generic)}
+
+ {pools.length > 1 ? (
+
+ ) : null}
+
+ );
+}
+
+const SINGLE: PoolSwitcherPool[] = [
+ { name: "default", config: { displayName: "Default" } },
+];
+
+const TWO: PoolSwitcherPool[] = [
+ { name: "default", config: { displayName: "Default" } },
+ { name: "backend", config: { displayName: "Backend" } },
+];
+
+const MANY: PoolSwitcherPool[] = [
+ { name: "default", config: { displayName: "Default" } },
+ { name: "backend", config: { displayName: "Backend Workers" } },
+ { name: "gpu", config: { displayName: "GPU Inference" } },
+ { name: "batch", config: { displayName: "Batch Jobs" } },
+ // No config: falls back to the machine name.
+ { name: "legacy-pool" },
+];
+
+const LONG: PoolSwitcherPool[] = [
+ { name: "default", config: { displayName: "Default" } },
+ {
+ name: "long",
+ config: {
+ displayName:
+ "Extremely long managed pool display name that should truncate gracefully in both the title and the switcher list",
+ },
+ },
+];
+
+// Single pool: the switcher is not rendered and the title stays generic.
+export const SinglePool: Story = () => (
+
+
+
+);
+
+export const TwoPoolsLogs: Story = () => (
+
+
+
+);
+
+export const TwoPoolsConfig: Story = () => (
+
+
+
+);
+
+export const ManyPools: Story = () => (
+
+
+
+);
+
+export const LongDisplayName: Story = () => (
+
+
+
+);
diff --git a/frontend/src/app/pool-switcher.tsx b/frontend/src/app/pool-switcher.tsx
new file mode 100644
index 0000000000..161867031d
--- /dev/null
+++ b/frontend/src/app/pool-switcher.tsx
@@ -0,0 +1,186 @@
+import { faCheck, Icon } from "@rivet-gg/icons";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/components/lib/utils";
+
+/**
+ * Minimal shape of a managed pool needed to render the switcher. Kept
+ * structural (rather than importing the SDK type) so the component can be
+ * driven from fixtures in stories without the cloud data-provider stack.
+ */
+export interface PoolSwitcherPool {
+ name: string;
+ config?: { displayName?: string };
+}
+
+/** Human label for a pool, falling back to its machine name. */
+export function poolDisplayName(pool: PoolSwitcherPool): string {
+ return pool.config?.displayName || pool.name;
+}
+
+/**
+ * Resolves which pool a surface should show given the `?pool=` search param and
+ * the available pools. Prefers the requested pool, then a pool literally named
+ * "default", then the first pool, then the "default" string as a last resort.
+ */
+export function resolvePoolName(
+ pools: PoolSwitcherPool[],
+ requested: unknown,
+): string {
+ if (
+ typeof requested === "string" &&
+ pools.some((pool) => pool.name === requested)
+ ) {
+ return requested;
+ }
+ if (pools.some((pool) => pool.name === "default")) {
+ return "default";
+ }
+ return pools[0]?.name ?? "default";
+}
+
+/**
+ * Static header text shown before the pool switcher button. The pool name lives
+ * in the switcher button itself, so the header reads e.g. "Pool Logs [Backend v]".
+ * With a single pool the switcher is hidden and the generic title ("Logs" /
+ * "Compute") is used instead.
+ */
+export function poolHeaderText(
+ pools: PoolSwitcherPool[],
+ suffix: string,
+ generic: string,
+): string {
+ return pools.length <= 1 ? generic : `Pool ${suffix}`;
+}
+
+// Up/down "unfold" chevron, matching the segment switchers in context-switcher.
+function UnfoldIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+interface PoolSwitcherProps {
+ pools: PoolSwitcherPool[];
+ value: string;
+ onChange: (poolName: string) => void;
+ className?: string;
+}
+
+/**
+ * Compact switch button placed to the right of a pool-scoped title. Opens a
+ * searchable list of the namespace's managed pools. Callers should only render
+ * this when there is more than one pool.
+ */
+export function PoolSwitcher({
+ pools,
+ value,
+ onChange,
+ className,
+}: PoolSwitcherProps) {
+ const [open, setOpen] = useState(false);
+ // Controls which item cmdk highlights. Reset to the current pool each time
+ // the popover opens so it opens focused on the active selection.
+ const [commandValue, setCommandValue] = useState(value);
+ const selected = pools.find((pool) => pool.name === value);
+ const label = selected ? poolDisplayName(selected) : value;
+
+ return (
+ {
+ setOpen(next);
+ if (next) setCommandValue(value);
+ }}
+ >
+
+
+
+
+
+
+
+ No pools found.
+
+ {pools.map((pool) => {
+ const isCurrent = pool.name === value;
+ return (
+ {
+ setOpen(false);
+ if (!isCurrent) {
+ onChange(pool.name);
+ }
+ }}
+ >
+
+
+ {poolDisplayName(pool)}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/settings-drawer.tsx b/frontend/src/app/settings-drawer.tsx
index b728d7918d..edbbd128a5 100644
--- a/frontend/src/app/settings-drawer.tsx
+++ b/frontend/src/app/settings-drawer.tsx
@@ -27,6 +27,11 @@ import {
} from "@/components/actors/data-provider";
import { features } from "@/lib/features";
import { BillingUsageGauge } from "./billing/billing-usage-gauge";
+import {
+ PoolSwitcher,
+ poolHeaderText,
+ resolvePoolName,
+} from "./pool-switcher";
import { BillingPanel } from "./settings-pages/billing-panel";
import { NamespaceComputeContent } from "./settings-pages/namespace-compute";
import {
@@ -166,6 +171,8 @@ export function SettingsDrawer({
const titleNode: ReactNode =
activeTab === "settings" && onNamespace ? (
+ ) : activeTab === "compute" && onNamespace ? (
+
) : activeTab === "billing" && onProject ? (
) : (
@@ -421,6 +428,56 @@ function NamespaceSettingsTitleInner({ fallback }: { fallback: string }) {
return <>{displayName ? `${displayName} settings` : fallback}>;
}
+function ComputeSettingsTitle({ fallback }: { fallback: string }) {
+ // Same mid-transition guard as NamespaceSettingsTitle: the drawer can render
+ // this title while the namespace match tree is still resolving. The
+ // `dataProvider` check lives here (not in the inner component) so the inner
+ // component's hooks stay unconditional across namespace switches, matching
+ // the NamespaceComputeContent outer/inner split.
+ const match = useMatch({
+ from: "/_context/orgs/$organization/projects/$project/ns/$namespace",
+ shouldThrow: false,
+ });
+ const dataProvider = useCloudNamespaceDataProvider();
+ if (!match || !match.loaderData || !dataProvider) {
+ return <>{fallback}>;
+ }
+ return ;
+}
+
+function ComputeSettingsTitleInner({ fallback }: { fallback: string }) {
+ const dataProvider = useCloudNamespaceDataProvider();
+ const navigate = useNavigate();
+ const { pool: poolParam } = useSearch({ strict: false });
+ const { data: pools = [] } = useQuery(
+ dataProvider.currentNamespaceManagedPoolsQueryOptions(),
+ );
+ const selectedPool = resolvePoolName(pools, poolParam);
+
+ if (pools.length <= 1) {
+ return <>{fallback}>;
+ }
+
+ return (
+
+ {poolHeaderText(pools, "Config", fallback)}
+
+ navigate({
+ to: ".",
+ search: (old) => ({
+ ...(old as Record),
+ pool: name,
+ }),
+ })
+ }
+ />
+
+ );
+}
+
function ProjectBillingTitle({ fallback }: { fallback: string }) {
const match = useMatch({
from: "/_context/orgs/$organization/projects/$project",
diff --git a/frontend/src/app/settings-pages/namespace-compute.tsx b/frontend/src/app/settings-pages/namespace-compute.tsx
index 3abb27e6ea..6610a81a9f 100644
--- a/frontend/src/app/settings-pages/namespace-compute.tsx
+++ b/frontend/src/app/settings-pages/namespace-compute.tsx
@@ -1,9 +1,11 @@
import type { Rivet } from "@rivet-gg/cloud";
import { faCircleExclamation, Icon } from "@rivet-gg/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useSearch } from "@tanstack/react-router";
import { type ReactNode, useEffect } from "react";
import { useFormState } from "react-hook-form";
import z from "zod";
+import { resolvePoolName } from "@/app/pool-switcher";
import {
Alert,
AlertDescription,
@@ -206,13 +208,19 @@ export function NamespaceComputeContent() {
function NamespaceComputeContentInner() {
const dataProvider = useCloudNamespaceDataProvider();
+ const { pool: poolParam } = useSearch({ strict: false });
+ const { data: pools = [] } = useQuery(
+ dataProvider.currentNamespaceManagedPoolsQueryOptions(),
+ );
+ const selectedPool = resolvePoolName(pools, poolParam);
+
const {
data: pool,
isPending,
isError,
} = useQuery({
...dataProvider.currentNamespaceManagedPoolQueryOptions({
- pool: "default",
+ pool: selectedPool,
}),
// Poll while a deploy is in flight so the footer button tracks the
// pool status; back off entirely once the pool settles.
@@ -246,7 +254,11 @@ function NamespaceComputeContentInner() {
}
return (
+ // Remount on pool switch; RHF only reads defaultValues at mount, so
+ // without this the form keeps the previous pool's values.
{
+ const validated = searchSchema.parse(search);
+ // `pool` is scoped to the pages that actually use it: the Logs route
+ // re-declares it in its own validateSearch, and the compute settings tab
+ // needs it while open. Drop it everywhere else so the selected pool does
+ // not cling to unrelated pages after navigating away.
+ if (validated.settings !== "compute") {
+ delete (validated as { pool?: string }).pool;
+ }
+ return validated;
+ },
context: ({ context }) => {
if (features.platform) {
return {
diff --git a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx
index 3e4aa9c06d..3d33952dc7 100644
--- a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx
+++ b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/logs.tsx
@@ -1,10 +1,20 @@
import type { Rivet } from "@rivet-gg/cloud";
import { faPause, faPlay, Icon } from "@rivet-gg/icons";
import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query";
-import { createFileRoute, Link, redirect } from "@tanstack/react-router";
+import {
+ createFileRoute,
+ Link,
+ redirect,
+ useNavigate,
+} from "@tanstack/react-router";
import { startTransition, useRef, useState } from "react";
import { z } from "zod";
import { Content } from "@/app/layout";
+import {
+ PoolSwitcher,
+ poolHeaderText,
+ resolvePoolName,
+} from "@/app/pool-switcher";
import { Button, H1, Skeleton } from "@/components";
import {
useCloudNamespaceDataProvider,
@@ -22,6 +32,7 @@ export const Route = createFileRoute(
)({
validateSearch: z.object({
search: z.string().optional(),
+ pool: z.string().optional(),
}),
component: RouteComponent,
beforeLoad: ({ params }) => {
@@ -32,11 +43,17 @@ export const Route = createFileRoute(
});
}
},
- loader: async ({ context }) => {
+ loaderDeps: ({ search }) => ({ pool: search.pool }),
+ loader: async ({ context, deps }) => {
const dataProvider = context.dataProvider;
+ // Prefetch the pool resolved from `?pool=` so a deep link to a non-default
+ // pool doesn't refetch client-side after the loader settles.
+ const pools = await context.queryClient.ensureQueryData(
+ dataProvider.currentNamespaceManagedPoolsQueryOptions(),
+ );
await context.queryClient.prefetchQuery(
dataProvider.currentNamespaceManagedPoolQueryOptions({
- pool: "default",
+ pool: resolvePoolName(pools, deps.pool),
safe: true,
}),
);
@@ -45,12 +62,20 @@ export const Route = createFileRoute(
});
function RouteComponent() {
- const { namespace, project } = Route.useParams();
+ const params = Route.useParams();
+ const { namespace, project } = params;
const dataProvider = useCloudNamespaceDataProvider();
+ const navigate = useNavigate();
+
+ const { data: pools = [] } = useSuspenseQuery(
+ dataProvider.currentNamespaceManagedPoolsQueryOptions(),
+ );
+ const { pool: poolParam, search: initialSearch } = Route.useSearch();
+ const selectedPool = resolvePoolName(pools, poolParam);
const { data: pool } = useSuspenseQuery(
dataProvider.currentNamespaceManagedPoolQueryOptions({
- pool: "default",
+ pool: selectedPool,
safe: true,
}),
);
@@ -65,7 +90,6 @@ function RouteComponent() {
0,
);
- const { search: initialSearch } = Route.useSearch();
const [search, setSearch] = useState(initialSearch ?? "");
const [isPaused, setIsPaused] = useState(false);
const [region, setRegion] = useState("all");
@@ -81,7 +105,7 @@ function RouteComponent() {