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:
@@ -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