From 20bf181e347cd01bf50902391136494c425a3dc9 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 28 Jun 2026 20:50:51 +0200 Subject: [PATCH] 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 --- frontend/src/shell/AppShell.tsx | 2 - frontend/src/shell/SettingsDialog.test.tsx | 93 --------- frontend/src/shell/SettingsDialog.tsx | 228 --------------------- frontend/src/shell/SteerInput.tsx | 116 +++++++++-- 4 files changed, 94 insertions(+), 345 deletions(-) delete mode 100644 frontend/src/shell/SettingsDialog.test.tsx delete mode 100644 frontend/src/shell/SettingsDialog.tsx diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 74e68cb3..695d623f 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -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 -
diff --git a/frontend/src/shell/SettingsDialog.test.tsx b/frontend/src/shell/SettingsDialog.test.tsx deleted file mode 100644 index 134a7ebf..00000000 --- a/frontend/src/shell/SettingsDialog.test.tsx +++ /dev/null @@ -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( - - - , - ); -} - -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"); -}); diff --git a/frontend/src/shell/SettingsDialog.tsx b/frontend/src/shell/SettingsDialog.tsx deleted file mode 100644 index 19aa4c6e..00000000 --- a/frontend/src/shell/SettingsDialog.tsx +++ /dev/null @@ -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(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 ( - - - - - Settings - -
-
- - -
- -
- - {hasModels ? ( - - ) : ( - 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" - /> - )} -
- -
- - {hasModels ? ( - - ) : ( - 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" - /> - )} -
- -
- - -
- - {!hasModels && ( -

- Pi returned no models (not running or no API key): free text entry. -

- )} - {error && ( -

- {error} -

- )} - -
- - -
-
-
-
- ); -} diff --git a/frontend/src/shell/SteerInput.tsx b/frontend/src/shell/SteerInput.tsx index a1690d7d..6913f0b3 100644 --- a/frontend/src/shell/SteerInput.tsx +++ b/frontend/src/shell/SteerInput.tsx @@ -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) { + 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 ( -
- +
+ update({ workspace: v })}> + {workspaces.length === 0 ? ( + + ) : ( + workspaces.map((w) => ( + + )) + )} + -
- {model} - - {thinking} +
+ + {models.length === 0 ? ( + + ) : ( + <> + {!knownModel && } + {models.map((m) => ( + + ))} + + )} + + update({ thinking: v })} className="capitalize"> + {THINKING_LEVELS.map((l) => ( + + ))} +
); } +/** 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, "aria-label">) { + return ( + + ); +} + /** Circular context-usage gauge. `pct` in [0,1]; 0 renders an empty track. */ function ContextGauge({ pct = 0 }: { pct?: number }) { const r = 6;