feat(ui): simplify LiteAdmin on the homepage (#46301)

* feat(ui): simplify LiteAdmin on the homepage

* fix(ui): preserve contrast on LiteAdmin primary actions
This commit is contained in:
moe-berri 2026-10-13 18:38:46 -04:00 • committed by GitHub
parent 0ebec123fd
commit b77108a409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 224 additions and 54 deletions

View file

@ -150,7 +150,7 @@ describe("HomePage", () => {
</QueryClientProvider>,
);
const entry = screen.getByRole("region", { name: "Ask LiteAdmin" });
expect(await within(entry).findByPlaceholderText("Ask LiteAdmin…")).toBeInTheDocument();
expect(await within(entry).findByPlaceholderText("What would you like to do?")).toBeInTheDocument();
expect(within(entry).getByRole("button", { name: "Send message" })).toBeDisabled();
expect(await screen.findByText("$501.74")).toBeInTheDocument();
});

View file

@ -29,11 +29,14 @@ export default function HomePage() {
return (
<Page>
<PageHeader>
<PageHeader className="flex flex-wrap items-center justify-between gap-3">
<PageHeaderTitle>Home</PageHeaderTitle>
{!isViewOnly && (
<PageHeaderControls>
<Button render={<a href={uiHref("api-keys?create=true")} />}>
<PageHeaderControls className="mt-0">
<Button
render={<a href={uiHref("api-keys?create=true")} />}
className="bg-info text-info-foreground shadow-xs hover:bg-info/90"
>
<Plus />
Create New Virtual Key
</Button>

View file

@ -184,7 +184,7 @@ async function openWidget() {
}
function send(text: string) {
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: text } });
fireEvent.change(screen.getByRole("textbox"), { target: { value: text } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
}
@ -209,7 +209,7 @@ describe("LiteAdmin in the gateway", () => {
renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
await selectModel();
const input = within(home).getByPlaceholderText("Ask LiteAdmin…");
const input = within(home).getByPlaceholderText("What would you like to do?");
fireEvent.change(input, { target: { value: "First question" } });
if (method === "keyboard") fireEvent.keyDown(input, { key: "Enter" });
else fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
@ -235,6 +235,31 @@ describe("LiteAdmin in the gateway", () => {
]);
});
it("prefills each Home suggestion without sending until the edited draft is submitted", async () => {
const requests = gateway([answer("Budget details received.")]);
renderWidget(undefined, true);
await selectModel();
const input = screen.getByRole("textbox", { name: "Message LiteAdmin" });
for (const label of ["Create a key", "Add a model", "Set a budget"]) {
fireEvent.click(screen.getByRole("button", { name: label, exact: true }));
expect(input).toHaveValue(label);
expect(input).toHaveFocus();
expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
}
fireEvent.change(input, { target: { value: "Set a budget of $10 for my team" } });
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
fireEvent.keyDown(input, { key: "Enter" });
expect(await screen.findByText("Budget details received.")).toBeInTheDocument();
const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
expect(completions).toHaveLength(1);
expect(completions[0].body.model).toBe("chat-model");
expect(completions[0].body.messages).toContainEqual({ role: "user", content: "Set a budget of $10 for my team" });
expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
});
it("preserves the draft and chosen model when opening, closing and leaving Home", async () => {
const requests = gateway([answer("Draft received.")], {
models: [
@ -244,12 +269,14 @@ describe("LiteAdmin in the gateway", () => {
});
const view = renderWidget(undefined, true);
await selectModel();
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Keep this draft" } });
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
fireEvent.change(screen.getByPlaceholderText("What would you like to do?"), {
target: { value: "Keep this draft" },
});
fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Keep this draft");
view.refresh(false);
expect(home).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
@ -293,16 +320,16 @@ describe("LiteAdmin in the gateway", () => {
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "First draft" } });
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("First draft");
expect(screen.getAllByPlaceholderText("Ask LiteAdmin…")).toHaveLength(1);
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("First draft");
expect(screen.getAllByPlaceholderText("What would you like to do?")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
expect(screen.getByRole("combobox", { name: "LiteAdmin model" })).toHaveValue("other-chat-model");
expect(screen.getAllByLabelText("LiteAdmin conversation")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Second draft");
fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
await screen.findByText("Second follow-up.");
expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
@ -531,13 +558,13 @@ describe("LiteAdmin in the gateway", () => {
const { client } = renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
expect(await within(home).findByText("Add a chat model to your gateway to use LiteAdmin.")).toBeInTheDocument();
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toBeDisabled();
expect(within(home).getByPlaceholderText("What would you like to do?")).toBeDisabled();
expect(within(home).getByRole("button", { name: "Send message" })).toBeDisabled();
options.models = [{ model_group: "chat-model", mode: "chat" }];
await act(async () => client.invalidateQueries({ queryKey: ["liteadmin-models"] }));
await selectModel();
for (const text of [" ", "x".repeat(MAX_INPUT_LENGTH + 1)]) {
const input = within(home).getByPlaceholderText("Ask LiteAdmin…");
const input = within(home).getByPlaceholderText("What would you like to do?");
fireEvent.change(input, { target: { value: text } });
fireEvent.keyDown(input, { key: "Enter" });
const submit = within(home).getByRole("button", { name: "Send message" });
@ -566,15 +593,17 @@ describe("LiteAdmin in the gateway", () => {
await act(async () => settingsReady.resolve());
const consent = await within(home).findByRole("button", { name: "Use configured gateway" });
expect(within(home).getByText(EXTERNAL_INFERENCE)).toBeInTheDocument();
expect(within(home).queryByPlaceholderText("Ask LiteAdmin…")).not.toBeInTheDocument();
expect(within(home).queryByPlaceholderText("What would you like to do?")).not.toBeInTheDocument();
fireEvent.click(consent);
await selectModel();
fireEvent.change(within(home).getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Old gateway draft" } });
fireEvent.change(within(home).getByPlaceholderText("What would you like to do?"), {
target: { value: "Old gateway draft" },
});
settings.target = `${EXTERNAL_INFERENCE}/changed`;
await act(async () => client.invalidateQueries({ queryKey: ["proxySettings"] }));
fireEvent.click(await within(home).findByRole("button", { name: "Use configured gateway" }));
await selectModel();
expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("");
expect(home).toBeVisible();
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
});

View file

@ -8,12 +8,13 @@ import {
useLayoutEffect,
useRef,
useState,
type ComponentProps,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { Plus, Sparkles, X } from "lucide-react";
import { History, Plus, Sparkles, X } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
@ -32,6 +33,7 @@ import { cn } from "@/lib/cva.config";
import { isProxyAdminRole } from "@/utils/roles";
import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent";
import { LiteAdminConversation } from "./LiteAdminConversation";
import { LiteAdminHomeComposer } from "./LiteAdminHomeComposer";
import { useLiteAdmin, type ConversationEntry, type LiteAdminSession } from "./useLiteAdmin";
type ManagementSession = Omit<LiteAdminSession, "inferenceBaseUrl">;
@ -100,17 +102,18 @@ export function LiteAdminHome() {
if (!state) return null;
const { open, show, setHomeContainer } = state;
return (
<section aria-label="Ask LiteAdmin" hidden={open} className="rounded-2xl border bg-card">
<div className="flex items-center justify-between gap-3 p-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-medium">
<section aria-label="Ask LiteAdmin" hidden={open} className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<h2 className="flex items-center gap-2 text-sm font-semibold">
<Sparkles className="size-4 text-info" />
LiteAdmin
</h2>
<p className="mt-1 text-sm text-muted-foreground">Ask about your gateway. Review changes in chat.</p>
<p className="border-l pl-3 text-sm text-muted-foreground">Control your entire gateway.</p>
</div>
<Button variant="ghost" size="sm" onClick={show}>
Open chat
<Button variant="ghost" size="sm" onClick={show} className="text-muted-foreground">
<History className="size-3.5" />
Recent chats
</Button>
</div>
<div ref={setHomeContainer} />
@ -417,22 +420,24 @@ function LiteAdminChat({
};
if (!active) return null;
const homeContainer = !open ? state?.homeContainer : null;
const composerProps = {
value: input,
onChange: setInput,
onSubmit: send,
onCancel: chat.phase === "applying" ? undefined : chat.stop,
disabled: busy || !selectedModel,
isLoading: busy,
submitDisabled,
};
const composer = (
<div className="space-y-3 border-t p-3">
<ModelPicker models={models} value={selectedModel} onChange={setModel} disabled={busy} />
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
<ChatComposer
value={input}
onChange={setInput}
onSubmit={send}
onCancel={chat.phase === "applying" ? undefined : chat.stop}
placeholder="Ask LiteAdmin…"
disabled={busy || !selectedModel}
isLoading={busy}
submitDisabled={submitDisabled}
/>
<p className="text-xs text-muted-foreground">Use a model you trust with your gateway data.</p>
</div>
<LiteAdminComposer
{...composerProps}
models={models}
model={selectedModel}
onModelChange={setModel}
home={Boolean(homeContainer)}
tooLong={tooLong}
/>
);
return (
<TabsContent value={id} className="flex min-h-0 flex-1 flex-col">
@ -448,32 +453,73 @@ function LiteAdminChat({
);
}
function LiteAdminComposer({
models,
model,
onModelChange,
home,
tooLong,
...props
}: Omit<ComponentProps<typeof LiteAdminHomeComposer>, "modelPicker"> & {
models: UseQueryResult<ModelGroup[]>;
model: string | null;
onModelChange: (value: string | null) => void;
home: boolean;
tooLong: boolean;
}) {
return (
<div className={cn("space-y-3", !home && "border-t p-3")}>
{!home && <ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} />}
<ModelStatus models={models} />
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
{home ? (
<LiteAdminHomeComposer
{...props}
modelPicker={
<ModelPicker models={models} value={model} onChange={onModelChange} disabled={props.isLoading} compact />
}
/>
) : (
<>
<ChatComposer {...props} placeholder="Ask LiteAdmin…" />
<p className="text-xs text-muted-foreground">Use a model you trust with your gateway data.</p>
</>
)}
</div>
);
}
function ModelPicker({
models,
value,
onChange,
disabled,
compact = false,
}: {
models: UseQueryResult<ModelGroup[]>;
value: string | null;
onChange: (value: string | null) => void;
disabled: boolean;
compact?: boolean;
}) {
if (models.isPending) return <Skeleton className="h-8 w-full" aria-label="Loading models" />;
return (
<SearchSelect
aria-label="LiteAdmin model"
options={(models.data ?? []).map((item) => ({ value: item.model_group, label: item.model_group }))}
value={value}
onValueChange={onChange}
placeholder="Choose a chat model"
disabled={disabled}
allowClear={false}
className={compact ? "border-transparent shadow-none dark:bg-transparent" : undefined}
/>
);
}
function ModelStatus({ models }: { models: UseQueryResult<ModelGroup[]> }) {
return (
<>
{models.isPending ? (
<Skeleton className="h-8" aria-label="Loading models" />
) : (
<SearchSelect
aria-label="LiteAdmin model"
options={(models.data ?? []).map((item) => ({ value: item.model_group, label: item.model_group }))}
value={value}
onValueChange={onChange}
placeholder="Choose a chat model"
disabled={disabled}
allowClear={false}
/>
)}
{models.isError && (
<Alert variant="destructive">
<AlertDescription>

View file

@ -0,0 +1,92 @@
import { useRef, type ReactNode } from "react";
import { ArrowUp, Box, CreditCard, KeyRound, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
const suggestions = [
{ label: "Create a key", icon: KeyRound },
{ label: "Add a model", icon: Box },
{ label: "Set a budget", icon: CreditCard },
];
export function LiteAdminHomeComposer({
value,
onChange,
onSubmit,
onCancel,
disabled,
isLoading,
submitDisabled,
modelPicker,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onCancel?: () => void;
disabled: boolean;
isLoading: boolean;
submitDisabled: boolean;
modelPicker: ReactNode;
}) {
const inputRef = useRef<HTMLTextAreaElement>(null);
return (
<InputGroup className="h-auto flex-col rounded-xl border-border bg-card shadow-xs">
<InputGroupTextarea
ref={inputRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
if (!submitDisabled && !isLoading) onSubmit();
}
}}
placeholder="What would you like to do?"
aria-label="Message LiteAdmin"
disabled={disabled}
rows={2}
className="min-h-24 max-h-48 w-full rounded-t-xl px-5 pt-5 pb-2 text-sm placeholder:text-muted-foreground [field-sizing:content]"
/>
<div className="flex w-full flex-wrap items-center justify-between gap-3 px-4 pb-4">
<div className="flex flex-wrap gap-2">
{suggestions.map(({ label, icon: Icon }) => (
<Button
key={label}
type="button"
variant="outline"
size="sm"
disabled={disabled}
className="gap-2 bg-muted/30 font-normal text-muted-foreground shadow-none"
onClick={() => {
onChange(label);
inputRef.current?.focus();
}}
>
<Icon className="size-3.5" />
{label}
</Button>
))}
</div>
<div className="ml-auto flex min-w-0 max-w-full items-center gap-2">
<div className="w-48 min-w-0 text-muted-foreground">{modelPicker}</div>
{isLoading && onCancel ? (
<Button type="button" size="icon-sm" aria-label="Stop request" onClick={onCancel}>
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button
type="button"
size="icon-sm"
aria-label="Send message"
disabled={submitDisabled || isLoading}
onClick={onSubmit}
className="rounded-lg bg-info text-info-foreground shadow-xs hover:bg-info/90"
>
<ArrowUp className="size-4" />
</Button>
)}
</div>
</div>
</InputGroup>
);
}