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