refactor(ui): route dashboard URL state through nuqs parsers (#44537)

* docs(ui): add url-state agent skill

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(ui): migrate dashboard URL state to nuqs

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(ui): cover chat URL id sync with the real chat shell provider

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-04 22:49:51 -07:00 • committed by GitHub
parent be4481779e
commit 8f6546df9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 340 additions and 115 deletions

View file

@ -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

View file

@ -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

View file

@ -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 }) => (
<div>
@ -43,7 +42,10 @@ vi.mock("@/components/VirtualKeysPage/VirtualKeysTable", () => ({
}));
vi.mock("@/components/organisms/create_key_button", () => ({
default: () => <button type="button">Create Key</button>,
default: (props: { autoOpenCreate?: boolean; prefillData?: CreateKeyPrefillData }) => {
createKeyProps(props);
return <button type="button">Create Key</button>;
},
}));
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(<ApiKeysDashboard />);
renderWithProviders(<ApiKeysDashboard />);
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(<ApiKeysDashboard />);
renderWithProviders(<ApiKeysDashboard />);
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(<ApiKeysDashboard />, {
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(<ApiKeysDashboard />);
renderWithProviders(<ApiKeysDashboard />);
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(<ApiKeysDashboard />);
renderWithProviders(<ApiKeysDashboard />);
window.dispatchEvent(new Event("beforeunload"));

View file

@ -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<Team[] | null>(null);
const [keys, setKeys] = useState<KeyResponse[] | null>([]);
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]));

View file

@ -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(
<NuqsAdapter>
<QueryClientProvider client={createQueryClient()}>
<MCPServers {...defaultProps} />
</QueryClientProvider>
</NuqsAdapter>,
);
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([]);

View file

@ -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<MCPServerProps> = ({ accessToken, userRole, userID, i
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(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<MCPServer | null>(null);
const [deepLinkServerId, setDeepLinkServerId] = useState<string | null>(() =>
typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get("fill_env_vars"),
);
const [deepLinkServerId, setDeepLinkServerId] = useState<string | null>(() => fillEnvVarsParam);
const [searchQuery, setSearchQuery] = useState<string>("");
const [sortKey, setSortKey] = useState<SortKey>("created_desc");
const isInternalUser = userRole === "Internal User";
@ -251,19 +251,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID, i
[envVarStatuses],
);
// Deep-link via ?fill_env_vars=<server_id> — 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),

View file

@ -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<typeof renderWithoutNuqs>[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");

View file

@ -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<ObservedSnapshot | null>(() =>
returned.get("demo") === "1" ? createObservedDemo(28) : null,
);
const [{ demo, connected, connection_cancelled, connection_failed }, setQueryParams] =
useQueryStates(OBSERVED_ROI_QUERY_PARSERS);
const [sample, setSample] = useState<ObservedSnapshot | null>(() => (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() {

View file

@ -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<typeof renderWithoutNuqs>[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();

View file

@ -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<ROISummary | null>(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<ROISettings>("/roi-calculator/settings", { accessToken });
const reportRequest = apiClient
.get<ROIReportResponse>("/roi-calculator/report", { accessToken })
@ -128,13 +124,13 @@ export default function ROICalculatorView({
});
Promise.all([
settingsRequest,
demoRequested
demoRequestedOnLoad
? apiClient
.get<ROIReportResponse>("/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 && (
<DemoNotice
onExit={() => {
updateDemoUrl(false);
setDemo(null);
setSampleSummary(null);
}}
/>

View file

@ -0,0 +1,6 @@
import { createParser } from "nuqs";
export const parseAsDemoFlag = createParser({
parse: (value) => (value === "1" ? true : null),
serialize: () => "1",
});

View file

@ -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(() => {

View file

@ -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();

View file

@ -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<void> {
render(<ChatConversationPage />);
async function sendOneMessage(onUrlUpdate?: OnUrlUpdateFunction): Promise<void> {
renderWithProviders(<ChatConversationPage />, { 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<OnUrlUpdateFunction>();
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(<ChatConversationPage />);
renderWithProviders(<ChatConversationPage />);
const banner = await screen.findByText("Chat history won't be saved in this browser session");
const dismiss = within(banner.parentElement!).getByRole("button");

View file

@ -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,
],
);

View file

@ -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: () => <div data-testid="mcp-connect-picker" />,
}));
vi.mock("react-markdown", () => ({
default: ({ children }: { children: string }) => <div>{children}</div>,
}));
vi.mock("remark-gfm", () => ({ default: () => undefined }));
vi.mock("react-syntax-highlighter", () => ({
Prism: ({ children }: { children: string }) => <pre>{children}</pre>,
}));
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(
<NuqsAdapter>
<ChatShellProvider
accessToken="sk-test"
userId="url-test-user"
userEmail="t@example.com"
userRole="Admin"
premiumUser={false}
>
<ChatConversationPage />
</ChatShellProvider>
</NuqsAdapter>,
);
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();
});
});

View file

@ -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(() => ({

View file

@ -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 <span>{activeConversationId}</span>;
}
describe("ChatShellProvider URL state", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("reads the active conversation ID from the nuqs adapter", () => {
renderWithProviders(
<ChatShellProvider
accessToken="token"
userId="test-user"
userEmail="test@example.com"
userRole="Admin"
premiumUser={false}
>
<ActiveConversationId />
</ChatShellProvider>,
{ searchParams: "?id=conversation-123" },
);
expect(screen.getByText("conversation-123")).toBeInTheDocument();
});
});

View file

@ -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<string[]>([]);
const {