diff --git a/ui/litellm-dashboard/.agents/skills/url-state/SKILL.md b/ui/litellm-dashboard/.agents/skills/url-state/SKILL.md
new file mode 100644
index 00000000000..986118d81a7
--- /dev/null
+++ b/ui/litellm-dashboard/.agents/skills/url-state/SKILL.md
@@ -0,0 +1,20 @@
+---
+name: url-state
+description: Read or write dashboard URL query state (tabs, filters, pagination, deep links, demo flags) with nuqs instead of raw history or URLSearchParams
+---
+
+# URL state
+
+Query params the dashboard reacts to go through nuqs: `useQueryState` or `useQueryStates` with parsers from `"nuqs"`. Do not call `window.history.pushState` or `replaceState`, hand-edit a `URLSearchParams`, or parse `useSearchParams().get(...)` for that state. Raw writes bypass nuqs, so other hooks on the same key go stale and queued nuqs updates can overwrite them
+
+Validate in the parser, not after reading. Use `parseAsStringLiteral` for enums, `parseAsInteger`, `parseAsBoolean`, `parseAsArrayOf` for lists, `.withDefault` for defaults, and `createParser` when the wire format is custom (for example `demo=1`). Keep the parser map in a module const and share it between every reader and writer of the same key
+
+Clear a param with `setter(null)`; nuqs keeps unrelated params and the hash. Updates replace history by default, pass `{ history: "push" }` when the change should be a back-button step. A param consumed once on arrival (a deep link or OAuth return flag) is captured in a `useState` initializer from the hook value and then cleared with the setter in a mount effect
+
+Raw reads are fine for one-time reads that never re-render on URL changes: OAuth callback pages, login, the legacy `?page=` redirect, and `networking.tsx`
+
+Reuse the existing helpers before adding a hook: `useUrlTab` in `src/hooks/useUrlTab.ts`, `useUrlTableState` in `src/components/shared/DataTable`, and the route modules `src/components/lens/route.ts` and `src/components/logs/request/logDetailRouting.ts`
+
+In tests, render with `renderWithProviders` from `tests/test-utils.tsx`, passing `searchParams` for the initial URL and `onUrlUpdate` to assert writes. When a test asserts `window.location` directly, wrap the render in `NuqsAdapter` from `nuqs/adapters/react`; writes flush asynchronously, so assert them inside `waitFor`. For inputs bound to URL state, set values with one `fireEvent.change` rather than `user.type`, which flakes when a re-render between keystrokes moves focus
+
+See the [nuqs docs](https://nuqs.dev/docs) for parser and option details
diff --git a/ui/litellm-dashboard/AGENTS.md b/ui/litellm-dashboard/AGENTS.md
index e5d876fad84..c890c33ce9b 100644
--- a/ui/litellm-dashboard/AGENTS.md
+++ b/ui/litellm-dashboard/AGENTS.md
@@ -1,3 +1,5 @@
+For URL query state (tabs, filters, pagination, deep links), follow [.agents/skills/url-state/SKILL.md](.agents/skills/url-state/SKILL.md)
+
Never put LiteLLM tokens or API keys in `localStorage`. `localStorage` survives browser close. Prefer `httpOnly` cookies, or `sessionStorage` at most, understanding that any web storage is readable by injected scripts (XSS), and only httpOnly cookies are not
When you fix lint violations that are grandfathered in `eslint-suppressions.json`, run `eslint . --prune-suppressions` and commit the updated baseline so the gate ratchets down instead of leaving a stale suppression
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx
index 2e3b000dfec..c14c2dae55b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.test.tsx
@@ -1,9 +1,12 @@
-import { render, screen } from "@testing-library/react";
+import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { CreateKeyPrefillData } from "@/components/organisms/create_key_button";
+import { renderWithProviders } from "@/../tests/test-utils";
-const { teamListCall, authorizedSession } = vi.hoisted(() => ({
+const { teamListCall, authorizedSession, createKeyProps } = vi.hoisted(() => ({
teamListCall: vi.fn(() => new Promise(() => {})),
authorizedSession: vi.fn(),
+ createKeyProps: vi.fn(),
}));
const session = (overrides: { userRole?: string; isViewOnly?: boolean } = {}) => ({
@@ -29,10 +32,6 @@ vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
teamListCall,
}));
-vi.mock("next/navigation", () => ({
- useSearchParams: () => new URLSearchParams(""),
-}));
-
vi.mock("@/components/VirtualKeysPage/VirtualKeysTable", () => ({
VirtualKeysTable: ({ headerActions }: { headerActions?: React.ReactNode }) => (
@@ -43,7 +42,10 @@ vi.mock("@/components/VirtualKeysPage/VirtualKeysTable", () => ({
}));
vi.mock("@/components/organisms/create_key_button", () => ({
- default: () =>
Create Key ,
+ default: (props: { autoOpenCreate?: boolean; prefillData?: CreateKeyPrefillData }) => {
+ createKeyProps(props);
+ return
Create Key ;
+ },
}));
import ApiKeysDashboard from "./ApiKeysDashboard";
@@ -51,27 +53,50 @@ import ApiKeysDashboard from "./ApiKeysDashboard";
describe("ApiKeysDashboard", () => {
beforeEach(() => {
teamListCall.mockClear();
+ createKeyProps.mockClear();
authorizedSession.mockReturnValue(session());
sessionStorage.clear();
});
it("scopes the team list to the signed-in user for non-admin roles", () => {
authorizedSession.mockReturnValue(session({ userRole: "Internal User" }));
- render(
);
+ renderWithProviders(
);
expect(teamListCall).toHaveBeenCalledWith("sk-access", 1, 100, { userID: "u-123" });
});
it("renders the keys table with a Create Key action for roles that can write", () => {
- render(
);
+ renderWithProviders(
);
expect(screen.getByRole("table", { name: "Virtual Keys" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create Key" })).toBeInTheDocument();
+ expect(createKeyProps).toHaveBeenLastCalledWith(
+ expect.objectContaining({ autoOpenCreate: false, prefillData: undefined }),
+ );
+ });
+
+ it("passes validated URL prefill data to the create key action", () => {
+ renderWithProviders(
, {
+ searchParams: "?create=true&owned_by=bogus&key_type=management&models=a,%20,b&team_id=%20t1%20",
+ });
+
+ expect(createKeyProps).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ autoOpenCreate: true,
+ prefillData: {
+ owned_by: undefined,
+ team_id: "t1",
+ key_alias: undefined,
+ models: ["a", "b"],
+ key_type: "management",
+ },
+ }),
+ );
});
it("hides Create Key for view-only roles", () => {
authorizedSession.mockReturnValue(session({ isViewOnly: true }));
- render(
);
+ renderWithProviders(
);
expect(screen.getByRole("table", { name: "Virtual Keys" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create Key" })).not.toBeInTheDocument();
@@ -80,7 +105,7 @@ describe("ApiKeysDashboard", () => {
it("leaves other pages' session state intact when the tab reloads", () => {
sessionStorage.setItem("chatHistory", '[{"role":"user","content":"hi"}]');
sessionStorage.setItem("selectedModel", "gpt-5.5");
- render(
);
+ renderWithProviders(
);
window.dispatchEvent(new Event("beforeunload"));
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx
index 915df2f5ded..854a65c842f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/api-keys/ApiKeysDashboard.tsx
@@ -4,58 +4,50 @@ import { Page } from "@/components/shared/Page";
import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { KeyResponse, Team } from "@/components/key_team_helpers/key_list";
-import CreateKey, { CreateKeyPrefillData } from "@/components/organisms/create_key_button";
+import CreateKey, { type CreateKeyPrefillData } from "@/components/organisms/create_key_button";
import { VirtualKeysTable } from "@/components/VirtualKeysPage/VirtualKeysTable";
-import { useSearchParams } from "next/navigation";
+import { parseAsArrayOf, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import { useEffect, useMemo, useState } from "react";
+const CREATE_KEY_URL_PARAMS = {
+ create: parseAsBoolean.withDefault(false),
+ owned_by: parseAsStringLiteral(["you", "service_account", "another_user"] as const),
+ team_id: parseAsString,
+ key_alias: parseAsString,
+ models: parseAsArrayOf(parseAsString),
+ key_type: parseAsStringLiteral(["default", "llm_api", "management"] as const),
+};
+
export default function ApiKeysDashboard() {
const { userId: userID, userRole, accessToken, isViewOnly } = useAuthorized();
- const searchParams = useSearchParams()!;
+ const [{ create, owned_by, team_id, key_alias, models, key_type }] = useQueryStates(CREATE_KEY_URL_PARAMS);
const [teams, setTeams] = useState
(null);
const [keys, setKeys] = useState([]);
- const autoOpenCreate = searchParams.get("create") === "true";
+ const autoOpenCreate = create;
const prefillData: CreateKeyPrefillData | undefined = useMemo(() => {
if (!autoOpenCreate) return undefined;
- const ownedBy = searchParams.get("owned_by");
- const teamId = searchParams.get("team_id");
- const keyAlias = searchParams.get("key_alias");
- const modelsParam = searchParams.get("models");
- const keyType = searchParams.get("key_type");
-
- if (!ownedBy && !teamId && !keyAlias && !modelsParam && !keyType) {
+ if ([owned_by, team_id, key_alias, models, key_type].every((value) => value === null)) {
return undefined;
}
- const validOwnedByValues = ["you", "service_account", "another_user"];
- const validatedOwnedBy =
- ownedBy && validOwnedByValues.includes(ownedBy) ? (ownedBy as CreateKeyPrefillData["owned_by"]) : undefined;
-
- const validKeyTypes = ["default", "llm_api", "management"];
- const validatedKeyType =
- keyType && validKeyTypes.includes(keyType) ? (keyType as CreateKeyPrefillData["key_type"]) : undefined;
-
- const sanitizedKeyAlias = keyAlias ? keyAlias.trim().slice(0, 256) : undefined;
-
- const sanitizedModels = modelsParam
- ? modelsParam
- .split(",")
+ const sanitizedModels = models
+ ? models
.slice(0, 100)
.map((m) => m.trim().slice(0, 256))
.filter((m) => m.length > 0)
: undefined;
return {
- owned_by: validatedOwnedBy,
- team_id: teamId?.trim() || undefined,
- key_alias: sanitizedKeyAlias,
+ owned_by: owned_by ?? undefined,
+ team_id: team_id?.trim() || undefined,
+ key_alias: key_alias === null ? undefined : key_alias.trim().slice(0, 256),
models: sanitizedModels && sanitizedModels.length > 0 ? sanitizedModels : undefined,
- key_type: validatedKeyType,
+ key_type: key_type ?? undefined,
};
- }, [searchParams, autoOpenCreate]);
+ }, [autoOpenCreate, key_alias, key_type, models, owned_by, team_id]);
const addKey = (data: KeyResponse) => {
setKeys((prevData) => (prevData ? [...prevData, data] : [data]));
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx
index 2f4fcfd5f69..acf1c24a197 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.test.tsx
@@ -1,8 +1,10 @@
import React from "react";
-import { render, waitFor, screen, act, within } from "@testing-library/react";
+import { render as renderWithoutNuqs, waitFor, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NuqsAdapter } from "nuqs/adapters/react";
+import { renderWithProviders as render } from "@/../tests/test-utils";
import MCPServers, { compareServers, type SortKey } from "./mcp_servers";
import type { MCPServer } from "@/components/mcp_tools/types";
import * as networking from "@/components/networking";
@@ -17,6 +19,16 @@ vi.mock("@/components/networking", () => ({
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
updateConfigFieldSetting: vi.fn().mockResolvedValue(undefined),
deleteConfigFieldSetting: vi.fn().mockResolvedValue(undefined),
+ modelHubCall: vi.fn().mockResolvedValue({ data: [] }),
+ getMCPUserEnvVars: vi.fn((_accessToken: string, serverId: string) =>
+ Promise.resolve({
+ server_id: serverId,
+ required: [{ name: "API_KEY", description: "API key", is_set: false }],
+ missing_count: 1,
+ }),
+ ),
+ storeMCPUserEnvVars: vi.fn(),
+ clearMCPUserEnvVars: vi.fn(),
listMCPUserEnvVarStatus: vi.fn().mockResolvedValue([]),
fetchMCPGatewaySessions: vi.fn(),
terminateMCPGatewaySessions: vi.fn(),
@@ -140,6 +152,11 @@ describe("MCPServers", () => {
beforeEach(() => {
vi.clearAllMocks();
stubUiConfig({});
+ window.history.replaceState(null, "", "/");
+ });
+
+ afterEach(() => {
+ window.history.replaceState(null, "", "/");
});
it("should render the MCPServers component with title", async () => {
@@ -162,6 +179,41 @@ describe("MCPServers", () => {
expect(screen.getByText("MCP Servers")).toBeInTheDocument();
});
+ it("opens the user env vars modal from a deep link and removes only that query parameter", async () => {
+ const server: MCPServer = {
+ server_id: "deep-link-server",
+ server_name: "Deep Link Server",
+ alias: "deep-link-server",
+ url: "https://example.com/mcp",
+ created_at: "",
+ updated_at: "",
+ created_by: "user",
+ updated_by: "user",
+ };
+ vi.mocked(networking.fetchMCPServers).mockResolvedValue([server]);
+ vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([{ server_id: server.server_id, status: "healthy" }]);
+ vi.mocked(networking.listMCPUserEnvVarStatus).mockResolvedValue([
+ {
+ server_id: server.server_id,
+ server_name: server.server_name,
+ required: [{ name: "API_KEY", description: "API key", is_set: false }],
+ missing_count: 1,
+ },
+ ]);
+ window.history.replaceState(null, "", "/?fill_env_vars=deep-link-server&other=1");
+ renderWithoutNuqs(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByRole("heading", { name: "Set your credentials" })).toBeVisible();
+ expect(within(screen.getByRole("dialog")).getByText("Deep Link Server")).toBeVisible();
+ await waitFor(() => expect(window.location.search).toBe("?other=1"));
+ });
+
it.each(["Admin", "Internal User"])("links a %s to their MCP connections page", async (userRole) => {
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx
index 13510cadf39..682cd5ad3cb 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/alert-dialog";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
+import { useQueryState } from "nuqs";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPServerHealth } from "@/app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
import { toast } from "@/lib/toast";
@@ -217,11 +218,10 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, i
const [prefillData, setPrefillData] = useState(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState(null);
+ const [fillEnvVarsParam, setFillEnvVarsParam] = useQueryState("fill_env_vars");
// Per-user env-var fill modal target + deep-link source captured once from the URL.
const [envVarsModalServer, setEnvVarsModalServer] = useState(null);
- const [deepLinkServerId, setDeepLinkServerId] = useState(() =>
- typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("fill_env_vars"),
- );
+ const [deepLinkServerId, setDeepLinkServerId] = useState(() => fillEnvVarsParam);
const [searchQuery, setSearchQuery] = useState("");
const [sortKey, setSortKey] = useState("created_desc");
const isInternalUser = userRole === "Internal User";
@@ -251,19 +251,9 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, i
[envVarStatuses],
);
- // Deep-link via ?fill_env_vars= — the link users follow from the
- // friendly error the proxy returns when a per-user var is missing. The id is
- // captured into state above and resolved to a server below; here we only strip
- // the param so a refresh doesn't reopen the modal.
useEffect(() => {
- if (!deepLinkServerId || typeof window === "undefined") return;
- const params = new URLSearchParams(window.location.search);
- if (!params.has("fill_env_vars")) return;
- params.delete("fill_env_vars");
- const newSearch = params.toString();
- const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : "") + window.location.hash;
- window.history.replaceState({}, "", newUrl);
- }, [deepLinkServerId]);
+ if (fillEnvVarsParam !== null) setFillEnvVarsParam(null);
+ }, [fillEnvVarsParam, setFillEnvVarsParam]);
const deepLinkServer = useMemo(
() => (deepLinkServerId ? serversWithHealth.find((s) => s.server_id === deepLinkServerId) ?? null : null),
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx
index 58307bcb869..b16fe5aa7d3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.integration.test.tsx
@@ -1,10 +1,13 @@
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, render as renderWithoutNuqs, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
+import { NuqsAdapter } from "nuqs/adapters/react";
import ObservedROIView from "./ObservedROIView";
import { createObservedDemo } from "./observedDemo";
import type { ObservedSettings, ObservedSnapshot, ObservedStatus } from "./observedData";
+const render = (ui: Parameters[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter });
+
const settings: ObservedSettings = {
source_provider: "gitlab",
api_url: "https://gitlab.com/api/v4",
@@ -151,7 +154,7 @@ describe("observed ROI dashboard", () => {
expect(screen.getByRole("tab", { name: "Engineers 3", selected: true })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connections" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Link accounts" })).not.toBeInTheDocument();
- expect(window.location.search).toBe("?demo=1");
+ await waitFor(() => expect(window.location.search).toBe("?demo=1"));
await user.click(screen.getByRole("button", { name: "View Alex Rivera's merged changes" }));
expect(await screen.findByRole("dialog", { name: "Alex Rivera" })).toHaveTextContent("alex-demo@example.com");
expect(screen.getByRole("heading", { name: "Merged changes" })).toBeInTheDocument();
@@ -172,7 +175,7 @@ describe("observed ROI dashboard", () => {
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("heading", { name: "Connect your repositories" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect GitHub or GitLab" })).toBeEnabled();
- expect(window.location.search).toBe("");
+ await waitFor(() => expect(window.location.search).toBe(""));
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
@@ -192,12 +195,12 @@ describe("observed ROI dashboard", () => {
await user.click(screen.getByRole("button", { name: "Preview sample report" }));
expect(screen.queryByRole("button", { name: "Cancel sync" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "2 repositories" })).toBeInTheDocument();
- expect(window.location.search).toBe("?from=review&demo=1");
+ await waitFor(() => expect(window.location.search).toBe("?from=review&demo=1"));
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("button", { name: "Cancel sync" })).toBeEnabled();
expect(screen.getByRole("button", { name: "1 repository" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Merge requests", selected: true })).toBeInTheDocument();
- expect(window.location.search).toBe("?from=review");
+ await waitFor(() => expect(window.location.search).toBe("?from=review"));
expect(window.location.hash).toBe("#report");
expect(requests.mock.calls.every(([, init]) => init.method === "GET")).toBe(true);
});
@@ -217,7 +220,7 @@ describe("observed ROI dashboard", () => {
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.queryByText("Alex Rivera")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
- expect(window.location.search).toBe("");
+ await waitFor(() => expect(window.location.search).toBe(""));
if (state === "failed") expect(await screen.findByRole("alert")).toHaveTextContent("Live data unavailable");
});
@@ -367,7 +370,7 @@ describe("observed ROI dashboard", () => {
.queryAllByRole("alert")
.map((alert) => alert.textContent),
).toEqual(alerts);
- expect(window.location.search).toBe("");
+ await waitFor(() => expect(window.location.search).toBe(""));
fireEvent.change(within(dialog).getByLabelText("Repositories"), { target: { value: "org/changed" } });
await user.click(within(dialog).getByRole("button", { name: "Save and sync" }));
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Provider unavailable");
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx
index 29199b67f22..4a9d20fb870 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ObservedROIView.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
+import { parseAsString, useQueryStates } from "nuqs";
import { Link2, RefreshCw } from "lucide-react";
import { apiClient } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
@@ -14,6 +15,14 @@ import ObservedReport from "./ObservedReport";
import { useObservedReport, type ObservedViewData } from "./useObservedReport";
import { syncMessage, type ObservedSnapshot } from "./observedData";
import { createObservedDemo } from "./observedDemo";
+import { parseAsDemoFlag } from "./demoUrlState";
+
+const OBSERVED_ROI_QUERY_PARSERS = {
+ demo: parseAsDemoFlag,
+ connected: parseAsString,
+ connection_cancelled: parseAsString,
+ connection_failed: parseAsString,
+};
function SyncActions({
data,
@@ -107,35 +116,25 @@ export default function ObservedROIView({
isViewOnly?: boolean;
}) {
const { data, error, refresh } = useObservedReport(accessToken);
- const [returned] = useState(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location.search));
- const [sample, setSample] = useState(() =>
- returned.get("demo") === "1" ? createObservedDemo(28) : null,
- );
+ const [{ demo, connected, connection_cancelled, connection_failed }, setQueryParams] =
+ useQueryStates(OBSERVED_ROI_QUERY_PARSERS);
+ const [sample, setSample] = useState(() => (demo === true ? createObservedDemo(28) : null));
const [connections, setConnections] = useState(
- ["github", "gitlab"].includes(returned.get("connected") ?? "") ||
- returned.has("connection_cancelled") ||
- returned.has("connection_failed"),
+ ["github", "gitlab"].includes(connected ?? "") || connection_cancelled !== null || connection_failed !== null,
);
const [connectionError, setConnectionError] = useState(() => {
- if (returned.has("connection_failed")) return "Connection failed or expired. Try again or use a token";
- if (returned.has("connection_cancelled")) return "Connection cancelled. Choose an app or token to try again";
+ if (connection_failed !== null) return "Connection failed or expired. Try again or use a token";
+ if (connection_cancelled !== null) return "Connection cancelled. Choose an app or token to try again";
return "";
});
- const [afterAuthorization, setAfterAuthorization] = useState(Boolean(returned.get("connected")));
+ const [afterAuthorization, setAfterAuthorization] = useState(Boolean(connected));
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState("");
useEffect(() => {
- const url = new URL(window.location.href);
- url.searchParams.delete("connected");
- url.searchParams.delete("connection_cancelled");
- url.searchParams.delete("connection_failed");
- window.history.replaceState(window.history.state, "", url);
- }, []);
+ setQueryParams({ connected: null, connection_cancelled: null, connection_failed: null });
+ }, [setQueryParams]);
function previewSample(enabled: boolean) {
- const url = new URL(window.location.href);
- if (enabled) url.searchParams.set("demo", "1");
- else url.searchParams.delete("demo");
- window.history.replaceState(window.history.state, "", url);
+ setQueryParams({ demo: enabled ? true : null });
setSample(enabled ? createObservedDemo(28) : null);
}
function closeConnections() {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx
index ef618dbb02f..30cf5e09eee 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.integration.test.tsx
@@ -1,10 +1,13 @@
import userEvent from "@testing-library/user-event";
-import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render as renderWithoutNuqs, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NuqsAdapter } from "nuqs/adapters/react";
import { apiClient } from "@/components/networking";
import ROICalculatorView from "./ROICalculatorView";
+const render = (ui: Parameters[0]) => renderWithoutNuqs(ui, { wrapper: NuqsAdapter });
+
vi.mock("@/components/networking", () => ({
apiClient: {
delete: vi.fn(),
@@ -520,7 +523,7 @@ describe("ROICalculatorView", () => {
fireEvent.click(screen.getByRole("button", { name: "Preview sample report" }));
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
- expect(window.location.search).toBe("?demo=1");
+ await waitFor(() => expect(window.location.search).toBe("?demo=1"));
expect(screen.getByRole("tab", { name: "Branches" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("searchbox")).toHaveValue("");
expect(screen.getByRole("cell", { name: "$9.10" })).toBeVisible();
@@ -543,7 +546,7 @@ describe("ROICalculatorView", () => {
expect(screen.getByText("Improve request routing")).toBeVisible();
expect(screen.queryByText("Sample usage breakdown")).not.toBeInTheDocument();
- expect(window.location.search).toBe("");
+ await waitFor(() => expect(window.location.search).toBe(""));
expect(screen.getByRole("button", { name: "Syncing…" })).toBeDisabled();
expect(apiClient.post).not.toHaveBeenCalled();
expect(apiClient.put).not.toHaveBeenCalled();
@@ -569,7 +572,7 @@ describe("ROICalculatorView", () => {
fireEvent.click(screen.getByRole("button", { name: "Exit demo" }));
expect(screen.getByRole("progressbar")).toBeVisible();
expect(screen.getByText("$20.00")).toBeVisible();
- expect(window.location.search).toBe("");
+ await waitFor(() => expect(window.location.search).toBe(""));
});
it.each(["report", "sync"])("loads a demo link when the live %s request fails", async (failedRequest) => {
@@ -615,7 +618,7 @@ describe("ROICalculatorView", () => {
expect(screen.getByRole("button", { name: "Settings" })).toBeEnabled();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(apiClient.post).not.toHaveBeenCalled();
- expect(window.location.search).toBe("?from=review");
+ await waitFor(() => expect(window.location.search).toBe("?from=review"));
expect(window.location.hash).toBe("#overview");
});
@@ -634,7 +637,7 @@ describe("ROICalculatorView", () => {
expect(screen.getByText("Loading ROI Calculator…")).toBeVisible();
expect(screen.queryByRole("heading", { name: "Connect your repositories" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Run analysis" })).not.toBeInTheDocument();
- expect(window.location.search).toBe("");
+ await waitFor(() => expect(window.location.search).toBe(""));
pending.resolve(pendingRequest === "report" ? { report: summary } : idleStatus);
expect(await screen.findByText("Gateway AI cost")).toBeVisible();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx
index 912bf35d4db..36cb3d8d736 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/ROICalculatorView.tsx
@@ -15,6 +15,7 @@ import { Tabs } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isProxyAdminTierRole } from "@/utils/roles";
+import { useQueryState } from "nuqs";
import ROISettingsPanel from "./ROISettingsPanel";
import { MatchedPeopleToggle } from "./MatchedPeopleToggle";
import { IdentityMatchDialog, type PersonMatchSelection, PullReasoningDialog } from "./ROICalculatorDialogs";
@@ -29,6 +30,7 @@ import type {
ROISummary,
ROISyncStatus,
} from "./roiCalculatorData";
+import { parseAsDemoFlag } from "./demoUrlState";
type View = "overview" | "people" | "branches";
@@ -45,13 +47,6 @@ const IDLE_STATUS: ROISyncStatus = {
error: null,
};
-function updateDemoUrl(enabled: boolean) {
- const url = new URL(window.location.href);
- if (enabled) url.searchParams.set("demo", "1");
- else url.searchParams.delete("demo");
- window.history.replaceState(null, "", url);
-}
-
export default function ROICalculatorView({
accessToken,
userRole = null,
@@ -61,6 +56,8 @@ export default function ROICalculatorView({
userRole?: string | null;
isViewOnly?: boolean;
}) {
+ const [demo, setDemo] = useQueryState("demo", parseAsDemoFlag);
+ const [demoRequestedOnLoad] = React.useState(demo === true);
const [sampleSummary, setSampleSummary] = React.useState(null);
const adminReadOnly = isViewOnly && isProxyAdminTierRole(userRole ?? "");
const readOnly = adminReadOnly || sampleSummary !== null;
@@ -95,7 +92,6 @@ export default function ROICalculatorView({
React.useEffect(() => {
if (!accessToken) return;
let cancelled = false;
- const demoRequested = new URLSearchParams(window.location.search).get("demo") === "1";
const settingsRequest = apiClient.get("/roi-calculator/settings", { accessToken });
const reportRequest = apiClient
.get("/roi-calculator/report", { accessToken })
@@ -128,13 +124,13 @@ export default function ROICalculatorView({
});
Promise.all([
settingsRequest,
- demoRequested
+ demoRequestedOnLoad
? apiClient
.get("/roi-calculator/report", { accessToken, query: { mode: "demo" } })
.catch((reason: unknown) => {
if (!cancelled) {
setDemoError(`Could not load demo data: ${extractErrorMessage(reason)}`);
- updateDemoUrl(false);
+ setDemo(null);
}
return liveData;
})
@@ -156,7 +152,7 @@ export default function ROICalculatorView({
return () => {
cancelled = true;
};
- }, [accessToken]);
+ }, [accessToken, demoRequestedOnLoad, setDemo]);
React.useEffect(() => {
if (!accessToken || !settingsLoaded) return;
@@ -280,7 +276,7 @@ export default function ROICalculatorView({
});
setSampleSummary(response.report);
setDemoError(null);
- updateDemoUrl(true);
+ setDemo(true);
setView("branches");
setQuery("");
} catch (reason) {
@@ -355,7 +351,7 @@ export default function ROICalculatorView({
{sampleSummary && (
{
- updateDemoUrl(false);
+ setDemo(null);
setSampleSummary(null);
}}
/>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/demoUrlState.ts b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/demoUrlState.ts
new file mode 100644
index 00000000000..ba5388c4c76
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/roi-calculator/_components/demoUrlState.ts
@@ -0,0 +1,6 @@
+import { createParser } from "nuqs";
+
+export const parseAsDemoFlag = createParser({
+ parse: (value) => (value === "1" ? true : null),
+ serialize: () => "1",
+});
diff --git a/ui/litellm-dashboard/src/app/chat/layout.test.tsx b/ui/litellm-dashboard/src/app/chat/layout.test.tsx
index 6c78bca0b5a..ac4fe2068fb 100644
--- a/ui/litellm-dashboard/src/app/chat/layout.test.tsx
+++ b/ui/litellm-dashboard/src/app/chat/layout.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { screen } from "@testing-library/react";
+import { renderWithProviders as render } from "@/../tests/test-utils";
import ChatLayout from "./layout";
const { mockUseAuthorized, mockUseUISettings, mockReplace, mockUiHref, state } = vi.hoisted(() => {
diff --git a/ui/litellm-dashboard/src/app/chat/layout.tsx b/ui/litellm-dashboard/src/app/chat/layout.tsx
index 2e0db2c6bdc..0bed5bda4a8 100644
--- a/ui/litellm-dashboard/src/app/chat/layout.tsx
+++ b/ui/litellm-dashboard/src/app/chat/layout.tsx
@@ -10,7 +10,7 @@ import { ChatShellProvider } from "@/contexts/ChatShellContext";
import ChatShell from "@/components/chat/ChatShell";
import { uiHref } from "@/utils/uiHref";
-// ChatShellProvider uses useSearchParams(), which requires a Suspense boundary for static export.
+// The nuqs Next adapter uses useSearchParams, so keep the chat tree behind Suspense.
function ChatLayoutContent({ children }: { children: React.ReactNode }) {
const { accessToken, userRole, userId, userEmail, premiumUser } = useAuthorized();
const { data: uiSettings, isLoading: isUISettingsLoading } = useUISettings();
diff --git a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
index ec884a90d7b..2020a66bd1c 100644
--- a/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/chat/page.integration.test.tsx
@@ -1,8 +1,9 @@
-import React from "react";
-import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatHistory } from "@/components/chat/useChatHistory";
import ChatConversationPage from "./page";
+import { renderWithProviders } from "@/../tests/test-utils";
+import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
const { mockMakeOpenAIResponsesRequest, shellState } = vi.hoisted(() => ({
mockMakeOpenAIResponsesRequest: vi.fn(),
@@ -68,8 +69,8 @@ const ON_TIMING_DATA_INDEX = 7;
const ON_USAGE_DATA_INDEX = 8;
const ON_TOTAL_LATENCY_INDEX = 24;
-async function sendOneMessage(): Promise {
- render( );
+async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise {
+ renderWithProviders( , { onUrlUpdate });
expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("How can I help you today?"), {
target: { value: "How much did this cost?" },
@@ -120,6 +121,19 @@ describe("/ui/chat request metrics", () => {
expect(typeof call[ON_TOTAL_LATENCY_INDEX]).toBe("function");
});
+ it("puts the new conversation ID in the URL after the first message is sent", async () => {
+ mockMakeOpenAIResponsesRequest.mockResolvedValue(undefined);
+ const onUrlUpdate = vi.fn();
+
+ await sendOneMessage(onUrlUpdate);
+
+ await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
+ const newConversationId = onUrlUpdate.mock.lastCall?.[0].searchParams.get("id");
+ expect(newConversationId).toBeTruthy();
+ expect(onUrlUpdate.mock.lastCall?.[0].options.history).toBe("push");
+ expect(localStorage.getItem("litellm_chat_history_v1:metrics-test-user")).toContain(newConversationId);
+ });
+
it("shows no metrics bar for a turn the provider reported no usage for", async () => {
mockMakeOpenAIResponsesRequest.mockImplementation(async (...args: unknown[]) => {
const updateTextUI = args[1] as (role: string, delta: string) => void;
@@ -141,7 +155,7 @@ describe("/ui/chat storage banner", () => {
});
it("keeps the dismiss control amber on hover instead of the ghost variant's foreground", async () => {
- render( );
+ renderWithProviders( );
const banner = await screen.findByText("Chat history won't be saved in this browser session");
const dismiss = within(banner.parentElement!).getByRole("button");
diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx
index 3625b3e489a..fdf6a1e02a3 100644
--- a/ui/litellm-dashboard/src/app/chat/page.tsx
+++ b/ui/litellm-dashboard/src/app/chat/page.tsx
@@ -18,6 +18,7 @@ import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api
import type { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
import type { MCPEvent } from "@/components/chat/types";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
+import { useQueryState } from "nuqs";
const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"];
const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model";
@@ -50,6 +51,7 @@ function getProviderFromModelName(modelName: string): string {
}
export default function ChatConversationPage() {
+ const [, setConversationIdInUrl] = useQueryState("id", { history: "push" });
const router = useRouter();
const {
accessToken,
@@ -140,7 +142,7 @@ export default function ChatConversationPage() {
if (!convId) {
convId = createConversation(model);
setResponsesSessionId(null); // new conversation starts a fresh session
- window.history.pushState(null, "", `${window.location.pathname}?id=${convId}`);
+ setConversationIdInUrl(convId);
}
appendMessage(convId, { role: "user", content: trimmed });
@@ -258,6 +260,7 @@ export default function ChatConversationPage() {
updateLastAssistantMessage,
isStreaming,
responsesSessionId,
+ setConversationIdInUrl,
],
);
diff --git a/ui/litellm-dashboard/src/app/chat/page.url-state.integration.test.tsx b/ui/litellm-dashboard/src/app/chat/page.url-state.integration.test.tsx
new file mode 100644
index 00000000000..b8ab0013a05
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/chat/page.url-state.integration.test.tsx
@@ -0,0 +1,83 @@
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NuqsAdapter } from "nuqs/adapters/react";
+import { ChatShellProvider } from "@/contexts/ChatShellContext";
+import ChatConversationPage from "./page";
+
+const { mockMakeOpenAIResponsesRequest } = vi.hoisted(() => ({
+ mockMakeOpenAIResponsesRequest: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+}));
+
+vi.mock("@/components/llm_calls/fetch_models", () => ({
+ fetchAvailableModels: vi.fn(async () => [{ model_group: "gpt-5.4-mini" }]),
+}));
+
+vi.mock("@/components/llm_calls/responses_api", () => ({
+ makeOpenAIResponsesRequest: mockMakeOpenAIResponsesRequest,
+}));
+
+vi.mock("@/components/chat/MCPConnectPicker", () => ({
+ default: () =>
,
+}));
+
+vi.mock("react-markdown", () => ({
+ default: ({ children }: { children: string }) => {children}
,
+}));
+
+vi.mock("remark-gfm", () => ({ default: () => undefined }));
+
+vi.mock("react-syntax-highlighter", () => ({
+ Prism: ({ children }: { children: string }) => {children} ,
+}));
+
+vi.mock("react-syntax-highlighter/dist/esm/styles/prism", () => ({ coy: {}, oneDark: {}, oneLight: {}, prism: {} }));
+
+describe("chat page URL state with ChatShellProvider", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ window.history.replaceState(null, "", "/chat");
+ mockMakeOpenAIResponsesRequest.mockReset();
+ });
+
+ it("keeps the active conversation in sync with the URL across the first send and back navigation", async () => {
+ mockMakeOpenAIResponsesRequest.mockResolvedValue(undefined);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByRole("button", { name: /gpt-5\.4-mini/ })).toBeInTheDocument();
+ fireEvent.change(screen.getByPlaceholderText("How can I help you today?"), {
+ target: { value: "How much did this cost?" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Send" }));
+
+ await waitFor(() => expect(window.location.search).toMatch(/^\?id=/));
+ const conversationId = new URLSearchParams(window.location.search).get("id");
+ expect(conversationId).toBeTruthy();
+ expect(await screen.findByText("How much did this cost?")).toBeInTheDocument();
+ await waitFor(() =>
+ expect(localStorage.getItem("litellm_chat_history_v1:url-test-user")).toContain(conversationId),
+ );
+
+ act(() => window.history.back());
+ await waitFor(() => expect(window.location.search).toBe(""));
+
+ expect(await screen.findByPlaceholderText("How can I help you today?")).toBeInTheDocument();
+ expect(screen.queryByText("How much did this cost?")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
index bff6d30a5c8..ac92bb15071 100644
--- a/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
+++ b/ui/litellm-dashboard/src/components/chat/ChatShell.test.tsx
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, screen } from "@testing-library/react";
+import { renderWithProviders as render } from "@/../tests/test-utils";
import ChatShell from "./ChatShell";
const { mockPush, mockUsePathname, mockUseChatShell } = vi.hoisted(() => ({
diff --git a/ui/litellm-dashboard/src/contexts/ChatShellContext.integration.test.tsx b/ui/litellm-dashboard/src/contexts/ChatShellContext.integration.test.tsx
new file mode 100644
index 00000000000..69dcabc1268
--- /dev/null
+++ b/ui/litellm-dashboard/src/contexts/ChatShellContext.integration.test.tsx
@@ -0,0 +1,36 @@
+import { screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { renderWithProviders } from "@/../tests/test-utils";
+import { ChatShellProvider, useChatShell } from "./ChatShellContext";
+
+function ActiveConversationId() {
+ const { activeConversationId } = useChatShell();
+ return {activeConversationId} ;
+}
+
+describe("ChatShellProvider URL state", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("reads the active conversation ID from the nuqs adapter", () => {
+ renderWithProviders(
+
+
+ ,
+ { searchParams: "?id=conversation-123" },
+ );
+
+ expect(screen.getByText("conversation-123")).toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx b/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx
index bb1b9d1e670..fb7aad711f2 100644
--- a/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx
+++ b/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx
@@ -1,7 +1,7 @@
"use client";
import React, { createContext, useContext, useState } from "react";
-import { useSearchParams } from "next/navigation";
+import { useQueryState } from "nuqs";
import { useChatHistory } from "@/components/chat/useChatHistory";
import type { AssistantMessageUpdate, ChatMessage, Conversation } from "@/components/chat/types";
@@ -53,8 +53,7 @@ export function ChatShellProvider({
premiumUser,
children,
}: ChatShellProviderProps) {
- const searchParams = useSearchParams();
- const urlConversationId = searchParams.get("id");
+ const [urlConversationId] = useQueryState("id");
const [selectedMCPServers, setSelectedMCPServers] = useState([]);
const {