+ );
+}
+
+// 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..149be9e6d3 100644
--- a/frontend/src/app/settings-pages/namespace-compute.tsx
+++ b/frontend/src/app/settings-pages/namespace-compute.tsx
@@ -1,13 +1,16 @@
import type { Rivet } from "@rivet-gg/cloud";
-import { faCircleExclamation, Icon } from "@rivet-gg/icons";
+import { faCircleExclamation, faTrash, Icon } from "@rivet-gg/icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { type ReactNode, useEffect } from "react";
+import { useNavigate, useSearch } from "@tanstack/react-router";
+import { type ReactNode, useEffect, useState } from "react";
import { useFormState } from "react-hook-form";
import z from "zod";
+import { resolvePoolName } from "@/app/pool-switcher";
import {
Alert,
AlertDescription,
AlertTitle,
+ Button,
Code,
cn,
createSchemaForm,
@@ -23,6 +26,7 @@ import {
import { useCloudNamespaceDataProvider } from "@/components/actors";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
+import { features } from "@/lib/features";
import { SettingsCard } from "./settings-card";
const MEMORY_RE = /^(\d+)(Mi|Gi)$/;
@@ -206,13 +210,25 @@ 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);
+ // Only non-default pools are deletable (the default anchors resolution) and
+ // only when more than one exists; gated on danger-zone like other
+ // destructive surfaces.
+ const defaultPool = resolvePoolName(pools, undefined);
+ const canDeletePool =
+ features.dangerZone && pools.length > 1 && selectedPool !== defaultPool;
+
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 +262,13 @@ function NamespaceComputeContentInner() {
}
return (
+ // Remount on pool switch; RHF only reads defaultValues at mount, so
+ // without this the form keeps the previous pool's values.
{
+ // Switch back to the default pool before the deleted pool's query
+ // 404s, then refresh the list so the switcher drops it.
+ await navigate({
+ to: ".",
+ search: (old) => ({
+ ...(old as Record),
+ pool: defaultPool,
+ }),
+ });
+ await queryClient.invalidateQueries(
+ dataProvider.currentNamespaceManagedPoolsQueryOptions(),
+ );
+ },
+ });
+
+ // Reset the confirm prompt if the user walks away without confirming,
+ // matching the actor destroy button's dwell window.
+ useEffect(() => {
+ if (!isConfirming) return;
+ const timer = setTimeout(() => setIsConfirming(false), 4000);
+ return () => clearTimeout(timer);
+ }, [isConfirming]);
+
+ return (
+ }
+ onClick={(e) => {
+ e?.stopPropagation();
+ if (e?.shiftKey || isConfirming) {
+ mutate({ pool });
+ return;
+ }
+ setIsConfirming(true);
+ }}
+ >
+ {isPending
+ ? "Deleting..."
+ : isConfirming
+ ? "Are you sure? This cannot be undone."
+ : "Delete pool"}
+
+ );
+}
+
function isPoolBusy(
status: Rivet.ManagedPoolsGetResponse.ManagedPool.Status | undefined,
) {
@@ -286,10 +373,16 @@ function PoolErrorAlert({
}
function ComputeForm({
+ pool,
+ defaultPool,
+ canDelete,
config,
status,
error,
}: {
+ pool: string;
+ defaultPool: string;
+ canDelete: boolean;
config: Rivet.ManagedPoolsGetResponse.ManagedPool.Config;
status: Rivet.ManagedPoolsGetResponse.ManagedPool.Status;
error: Rivet.ManagedPoolsGetResponse.ManagedPool.Error_ | undefined;
@@ -358,7 +451,7 @@ function ComputeForm({
overrides.args = args.length === 0 ? null : args;
}
await mutateAsync({
- pool: "default",
+ pool,
displayName: config.displayName || "Default",
runnerConfig: {
maxConcurrentActors: values.maxConcurrentActors,
@@ -380,7 +473,7 @@ function ComputeForm({
});
await queryClient.invalidateQueries(
dataProvider.currentNamespaceManagedPoolQueryOptions({
- pool: "default",
+ pool,
}),
);
form.reset(values);
@@ -449,37 +542,69 @@ function ComputeForm({
label="Drain on version upgrade"
/>
-
+
);
}
-// Renders the discard/deploy controls once the form has edits or a deploy is
-// in flight, so the tab reads as a plain settings sheet otherwise. While the
-// pool works through a deploy the submit button is locked and tracks the
-// polled pool status.
+// Delete/discard/deploy controls. The deploy side only shows once the form is
+// dirty or a deploy is in flight; the delete button sits on the left.
function DeployFooter({
status,
+ pool,
+ defaultPool,
+ canDelete,
}: {
status: Rivet.ManagedPoolsGetResponse.ManagedPool.Status;
+ pool: string;
+ defaultPool: string;
+ canDelete: boolean;
}) {
const { isDirty } = useFormState();
+
+ // A tearing-down pool is not a deploy: show "Deleting..." instead of the
+ // deploy button so the footer never reads "Deploying..." during teardown.
+ if (status === "destroying") {
+ return (
+
);
}
diff --git a/frontend/src/components/actors/actor-details-shared.tsx b/frontend/src/components/actors/actor-details-shared.tsx
index e358b4cf2e..c0c4885513 100644
--- a/frontend/src/components/actors/actor-details-shared.tsx
+++ b/frontend/src/components/actors/actor-details-shared.tsx
@@ -7,7 +7,10 @@ import {
} from "@/components/deployment-logs";
import { features } from "@/lib/features";
import { ActorConfigTab } from "./actor-config-tab";
-import { useCloudNamespaceDataProvider } from "./data-provider";
+import {
+ useCloudNamespaceDataProvider,
+ useDataProvider,
+} from "./data-provider";
import type { InspectorTabDescriptor } from "./inspector-tab-registry";
import type { ActorId } from "./queries";
@@ -62,7 +65,13 @@ export function useHasManagedPool(): boolean {
*/
function DeploymentLogsTab({ actorId }: { actorId: ActorId }) {
const provider = useCloudNamespaceDataProvider();
+ const { data: actor } = useQuery(
+ useDataProvider().actorQueryOptions(actorId),
+ );
const logsRef = useRef([]);
+ // The actor's pool is its runner name selector; fall back to the default
+ // pool until the actor loads or when it has no explicit selector.
+ const pool = actor?.runnerNameSelector || "default";
return (