feat(frontend): move settings into composer footer as live selectors
- Remove the microphone icon from the composer footer - Left: workspace selector in its place - Right: model + thinking-level selectors (persist via PUT /settings) next to the context-usage gauge - Delete the Settings dialog (form + button); only "New session" remains Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@ import { useSessionStore } from "../store/sessionStore";
|
||||
import { WidgetHost } from "./WidgetHost";
|
||||
import { Transcript } from "./Transcript";
|
||||
import { NavSessions } from "./NavSessions";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||
import { WorkflowBar } from "./WorkflowBar";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -113,7 +112,6 @@ export function AppShell() {
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
<SettingsDialog />
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1.5 pt-1">
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { SettingsDialog } from "./SettingsDialog";
|
||||
|
||||
function renderDialog() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SettingsDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get("http://localhost:8787/workspaces", () =>
|
||||
HttpResponse.json([
|
||||
{ name: "psd", file: "psd.yaml" },
|
||||
{ name: "tht-test", file: "tht-test.yaml" },
|
||||
]),
|
||||
),
|
||||
http.get("http://localhost:8787/models", () =>
|
||||
HttpResponse.json({
|
||||
models: [
|
||||
{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true },
|
||||
{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true },
|
||||
],
|
||||
}),
|
||||
),
|
||||
http.get("http://localhost:8787/settings", () =>
|
||||
HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("opens and pre-selects current settings", async () => {
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||||
|
||||
const workspace = (await screen.findByLabelText(/workspace/i)) as HTMLSelectElement;
|
||||
expect(workspace.value).toBe("psd");
|
||||
const thinking = screen.getByLabelText(/thinking/i) as HTMLSelectElement;
|
||||
expect(thinking.value).toBe("medium");
|
||||
// thinking options are exactly low/medium/high
|
||||
expect(Array.from(thinking.options).map((o) => o.value)).toEqual(["low", "medium", "high"]);
|
||||
});
|
||||
|
||||
test("provider change filters the model list", async () => {
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||||
|
||||
const provider = (await screen.findByLabelText(/provider/i)) as HTMLSelectElement;
|
||||
await userEvent.selectOptions(provider, "anthropic");
|
||||
const model = screen.getByLabelText(/model/i) as HTMLSelectElement;
|
||||
const optionValues = Array.from(model.options).map((o) => o.value);
|
||||
expect(optionValues).toContain("claude-opus-4-8");
|
||||
expect(optionValues).not.toContain("glm-5.2");
|
||||
});
|
||||
|
||||
test("Salva PUTs the selected settings", async () => {
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.put("http://localhost:8787/settings", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" });
|
||||
}),
|
||||
);
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||||
await screen.findByLabelText(/workspace/i);
|
||||
await userEvent.selectOptions(screen.getByLabelText(/thinking/i), "high");
|
||||
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("degrades to free-text provider/model when models list is empty", async () => {
|
||||
server.use(
|
||||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||||
http.get("http://localhost:8787/settings", () =>
|
||||
HttpResponse.json({ workspace: "psd", provider: "", model: "", thinking: "low" }),
|
||||
),
|
||||
);
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /settings/i }));
|
||||
expect(await screen.findByTestId("provider-freetext")).toHaveProperty("tagName", "INPUT");
|
||||
expect(screen.getByTestId("model-freetext")).toHaveProperty("tagName", "INPUT");
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
// frontend/src/shell/SettingsDialog.tsx
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { listWorkspaces } from "../api/workspaces";
|
||||
import { listModels } from "../api/models";
|
||||
import { getSettings, putSettings } from "../api/settings";
|
||||
|
||||
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
||||
|
||||
export function SettingsDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [thinking, setThinking] = useState("medium");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: workspaces = [] } = useQuery({
|
||||
queryKey: ["workspaces"],
|
||||
queryFn: listWorkspaces,
|
||||
enabled: open,
|
||||
});
|
||||
const { data: modelsData } = useQuery({
|
||||
queryKey: ["models"],
|
||||
queryFn: listModels,
|
||||
enabled: open,
|
||||
});
|
||||
const { data: current } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const models = useMemo(() => modelsData?.models ?? [], [modelsData]);
|
||||
const hasModels = models.length > 0;
|
||||
const providers = useMemo(
|
||||
() => Array.from(new Set(models.map((m) => m.provider))),
|
||||
[models],
|
||||
);
|
||||
const modelsForProvider = useMemo(
|
||||
() => models.filter((m) => !provider || m.provider === provider),
|
||||
[models, provider],
|
||||
);
|
||||
|
||||
// Hydrate the form once the current settings arrive.
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
setWorkspace(current.workspace ?? "");
|
||||
setProvider(current.provider ?? "");
|
||||
setModel(current.model ?? "");
|
||||
setThinking(current.thinking && THINKING_LEVELS.includes(current.thinking as never)
|
||||
? current.thinking
|
||||
: "medium");
|
||||
}, [current]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await putSettings({
|
||||
workspace: workspace || undefined,
|
||||
provider: provider || undefined,
|
||||
model: model || undefined,
|
||||
thinking,
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save settings.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSave} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-workspace">
|
||||
Workspace
|
||||
</label>
|
||||
<select
|
||||
id="set-workspace"
|
||||
value={workspace}
|
||||
onChange={(e) => setWorkspace(e.target.value)}
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">—</option>
|
||||
) : (
|
||||
workspaces.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-provider">
|
||||
Provider
|
||||
</label>
|
||||
{hasModels ? (
|
||||
<select
|
||||
id="set-provider"
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
setModel(""); // reset model when provider changes
|
||||
}}
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
>
|
||||
<option value="">— all —</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="set-provider"
|
||||
type="text"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
placeholder="e.g. anthropic"
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
data-testid="provider-freetext"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-model">
|
||||
Model
|
||||
</label>
|
||||
{hasModels ? (
|
||||
<select
|
||||
id="set-model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
>
|
||||
<option value="">— default —</option>
|
||||
{modelsForProvider.map((m) => (
|
||||
<option key={`${m.provider}/${m.id}`} value={m.id}>
|
||||
{m.provider}/{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
id="set-model"
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="e.g. glm-5.2"
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
data-testid="model-freetext"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-thinking">
|
||||
Thinking
|
||||
</label>
|
||||
<select
|
||||
id="set-thinking"
|
||||
value={thinking}
|
||||
onChange={(e) => setThinking(e.target.value)}
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
>
|
||||
{THINKING_LEVELS.map((lvl) => (
|
||||
<option key={lvl} value={lvl}>
|
||||
{lvl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!hasModels && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pi returned no models (not running or no API key): free text entry.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
// frontend/src/shell/SteerInput.tsx
|
||||
import { useState } from "react";
|
||||
import { CornerDownLeft, Mic } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CornerDownLeft } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { postSteer, createSession } from "../api/sessions";
|
||||
import { getSettings } from "../api/settings";
|
||||
import { getSettings, putSettings, type Settings } from "../api/settings";
|
||||
import { listWorkspaces } from "../api/workspaces";
|
||||
import { listModels } from "../api/models";
|
||||
|
||||
const THINKING_LEVELS = ["low", "medium", "high"] as const;
|
||||
|
||||
interface Props {
|
||||
/** Active session, or null when no session has been started yet. */
|
||||
@@ -82,37 +86,105 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr
|
||||
}
|
||||
|
||||
/**
|
||||
* Status strip beneath the composer: voice input (inert for now), the active
|
||||
* model + thinking level pulled from the global settings, and a context-usage
|
||||
* gauge (placeholder until the backend reports token usage).
|
||||
* Status strip beneath the composer. Left: the active workspace selector.
|
||||
* Right: live model + thinking-level selectors (these replace the old Settings
|
||||
* dialog and persist through PUT /settings) plus a context-usage gauge
|
||||
* (placeholder until the backend reports token usage).
|
||||
*/
|
||||
export function ComposerFooter() {
|
||||
const qc = useQueryClient();
|
||||
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: getSettings });
|
||||
const model = settings?.model?.trim() || "Default model";
|
||||
const thinking = settings?.thinking?.trim() || "medium";
|
||||
const { data: workspaces = [] } = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces });
|
||||
const { data: modelsData } = useQuery({ queryKey: ["models"], queryFn: listModels });
|
||||
const models = modelsData?.models ?? [];
|
||||
|
||||
const workspace = settings?.workspace ?? "";
|
||||
const model = settings?.model ?? "";
|
||||
const thinking = settings?.thinking ?? "medium";
|
||||
|
||||
// PUT /settings replaces the whole object, so always send the merged settings.
|
||||
async function update(patch: Partial<Settings>) {
|
||||
await putSettings({ workspace, provider: settings?.provider, model, thinking, ...patch });
|
||||
qc.invalidateQueries({ queryKey: ["settings"] });
|
||||
}
|
||||
|
||||
function onModelChange(id: string) {
|
||||
const m = models.find((x) => x.id === id);
|
||||
update({ model: id, provider: m?.provider });
|
||||
}
|
||||
|
||||
const knownModel = models.some((m) => m.id === model);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-border/60 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Voice input (coming soon)"
|
||||
aria-label="Voice input (coming soon)"
|
||||
className="grid size-6 place-items-center rounded-md text-muted-foreground/60"
|
||||
>
|
||||
<Mic className="size-4" />
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/60 px-3 py-1.5">
|
||||
<FooterSelect aria-label="Workspace" value={workspace} onChange={(v) => update({ workspace: v })}>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value="">— workspace —</option>
|
||||
) : (
|
||||
workspaces.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</FooterSelect>
|
||||
|
||||
<div className="flex items-center gap-2.5 text-[0.7rem] text-muted-foreground">
|
||||
<span className="font-medium text-foreground/70">{model}</span>
|
||||
<span className="size-1 rounded-full bg-border" aria-hidden />
|
||||
<span className="capitalize">{thinking}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<FooterSelect aria-label="Model" value={model} onChange={onModelChange}>
|
||||
{models.length === 0 ? (
|
||||
<option value={model}>{model || "— model —"}</option>
|
||||
) : (
|
||||
<>
|
||||
{!knownModel && <option value={model}>{model || "— model —"}</option>}
|
||||
{models.map((m) => (
|
||||
<option key={`${m.provider}/${m.id}`} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</FooterSelect>
|
||||
<FooterSelect aria-label="Thinking level" value={thinking} onChange={(v) => update({ thinking: v })} className="capitalize">
|
||||
{THINKING_LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</FooterSelect>
|
||||
<ContextGauge />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact, theme-styled native select used in the composer footer. */
|
||||
function FooterSelect({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & Pick<React.SelectHTMLAttributes<HTMLSelectElement>, "aria-label">) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...rest}
|
||||
className={[
|
||||
"max-w-[10rem] cursor-pointer truncate rounded-md border border-border bg-card px-1.5 py-0.5 text-[0.7rem] text-foreground/80 outline-none transition-colors hover:bg-muted focus:border-primary/40 focus:ring-2 focus:ring-ring/15",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */
|
||||
function ContextGauge({ pct = 0 }: { pct?: number }) {
|
||||
const r = 6;
|
||||
|
||||
Reference in New Issue
Block a user