mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
be4481779e
commit
8f6546df9f
19 changed files with 340 additions and 115 deletions
20
ui/litellm-dashboard/.agents/skills/url-state/SKILL.md
Normal file
20
ui/litellm-dashboard/.agents/skills/url-state/SKILL.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { createParser } from "nuqs";
|
||||
|
||||
export const parseAsDemoFlag = createParser({
|
||||
parse: (value) => (value === "1" ? true : null),
|
||||
serialize: () => "1",
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue