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: () => , + default: (props: { autoOpenCreate?: boolean; prefillData?: CreateKeyPrefillData }) => { + createKeyProps(props); + return ; + }, })); 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 {