frontend: declutter chat input to a single model+effort picker

Replace the five clinical context selects (access, response mode, specialty,
facility, time range) with one model picker styled like the reference design:
a primary model list with descriptions, an Effort submenu, and a "More models"
submenu. Move the Add-patient pill up into the toolbar and drop the now-empty
bottom context-selector card so the input is a single clean rounded surface.

Model/effort selection is lifted to ChatPanel so it can travel with each send
(wired to the backend agent in a later phase). Adds a shared model catalog
(lib/ai-models.ts) reused by the picker, Settings, and the chat wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-13 18:23:45 +03:00
parent 9545fdd360
commit c6a1b98427
5 changed files with 300 additions and 220 deletions
+35 -186
View File
@@ -1,78 +1,31 @@
"use client";
import type { ChatStatus } from "ai";
import {
ArrowUp,
Building2,
CalendarRange,
ChevronDown,
Hand,
Mic,
Plus,
Square,
Stethoscope,
UserPlus,
X,
} from "lucide-react";
import { ArrowUp, Mic, Plus, Square, UserPlus, X } from "lucide-react";
import {
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/chat/model-picker";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import {
Menu,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuTrigger,
} from "@/components/ui/menu";
import type { Effort } from "@/lib/ai-models";
import { cn } from "@/lib/utils";
type ChatInputProps = {
onSubmit: (text: string) => void;
status: ChatStatus;
onStop?: () => void;
model: string;
effort: Effort;
onModelChange: (model: string) => void;
onEffortChange: (effort: Effort) => void;
};
type Option = { value: string; label: string };
type OptionKey = { value: string; labelKey: string };
const ACCESS_OPTIONS: OptionKey[] = [
{ value: "standard", labelKey: "chat.input.access.standard" },
{ value: "break-glass", labelKey: "chat.input.access.breakGlass" },
{ value: "read-only", labelKey: "chat.input.access.readOnly" },
];
const RESPONSE_OPTIONS: OptionKey[] = [
{ value: "concise", labelKey: "chat.input.response.concise" },
{ value: "detailed", labelKey: "chat.input.response.detailed" },
{ value: "comprehensive", labelKey: "chat.input.response.comprehensive" },
];
const SPECIALTY_OPTIONS: OptionKey[] = [
{ value: "internal-medicine", labelKey: "chat.input.specialtyOptions.internalMedicine" },
{ value: "cardiology", labelKey: "chat.input.specialtyOptions.cardiology" },
{ value: "pediatrics", labelKey: "chat.input.specialtyOptions.pediatrics" },
{ value: "emergency", labelKey: "chat.input.specialtyOptions.emergency" },
{ value: "all", labelKey: "chat.input.specialtyOptions.all" },
];
const FACILITY_OPTIONS: OptionKey[] = [
{ value: "main-hospital", labelKey: "chat.input.facilityOptions.mainHospital" },
{ value: "north-clinic", labelKey: "chat.input.facilityOptions.northClinic" },
{ value: "telehealth", labelKey: "chat.input.facilityOptions.telehealth" },
];
const TIME_OPTIONS: OptionKey[] = [
{ value: "30d", labelKey: "chat.input.timeOptions.30d" },
{ value: "12m", labelKey: "chat.input.timeOptions.12m" },
{ value: "5y", labelKey: "chat.input.timeOptions.5y" },
{ value: "all", labelKey: "chat.input.timeOptions.all" },
];
const iconButton =
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
const pillButton =
@@ -80,79 +33,19 @@ const pillButton =
const contextPill =
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
function SelectPill({
ariaLabel,
triggerClassName,
chevronClassName,
icon,
prefix,
value,
onValueChange,
options,
align = "start",
}: {
ariaLabel: string;
triggerClassName: string;
chevronClassName: string;
icon: ReactNode;
prefix?: string;
value: string;
onValueChange: (value: string) => void;
options: Option[];
align?: "start" | "center" | "end";
}) {
const selected = options.find((option) => option.value === value);
return (
<Menu>
<MenuTrigger
render={
<button aria-label={ariaLabel} className={triggerClassName} type="button" />
}
>
{icon}
{prefix ? (
<span className="font-medium text-foreground">{prefix}</span>
) : null}
<span className="truncate">{selected?.label}</span>
<ChevronDown className={chevronClassName} />
</MenuTrigger>
<MenuPopup align={align}>
<MenuRadioGroup onValueChange={onValueChange} value={value}>
{options.map((option) => (
<MenuRadioItem key={option.value} value={option.value}>
{option.label}
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuPopup>
</Menu>
);
}
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
export function ChatInput({
onSubmit,
status,
onStop,
model,
effort,
onModelChange,
onEffortChange,
}: ChatInputProps) {
const { t } = useTranslation();
const toOptions = useCallback(
(opts: OptionKey[]): Option[] =>
opts.map((o) => ({ value: o.value, label: t(o.labelKey) })),
[t],
);
const accessOptions = useMemo(() => toOptions(ACCESS_OPTIONS), [toOptions]);
const responseOptions = useMemo(() => toOptions(RESPONSE_OPTIONS), [toOptions]);
const specialtyOptions = useMemo(
() => toOptions(SPECIALTY_OPTIONS),
[toOptions],
);
const facilityOptions = useMemo(() => toOptions(FACILITY_OPTIONS), [toOptions]);
const timeOptions = useMemo(() => toOptions(TIME_OPTIONS), [toOptions]);
const [value, setValue] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [access, setAccess] = useState("standard");
const [responseMode, setResponseMode] = useState("detailed");
const [specialty, setSpecialty] = useState("internal-medicine");
const [facility, setFacility] = useState("main-hospital");
const [timeRange, setTimeRange] = useState("12m");
const [addOpen, setAddOpen] = useState(false);
// Bumped on each open so the dialog remounts with a fresh file number + form.
const [addKey, setAddKey] = useState(0);
@@ -216,10 +109,10 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
event.preventDefault();
submit();
}}
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-muted shadow-sm"
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-input shadow-sm"
>
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
<div className="rounded-b-[22px] bg-input">
{/* Textarea + toolbar, filling the rounded card. */}
<div className="bg-input">
<textarea
aria-label={t("chat.input.message")}
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
@@ -270,28 +163,26 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
>
<Plus className="size-[18px]" />
</button>
<SelectPill
ariaLabel={t("chat.input.accessLevel")}
chevronClassName="size-4 opacity-70"
icon={<Hand className="size-4" />}
onValueChange={setAccess}
options={accessOptions}
triggerClassName={pillButton}
value={access}
/>
<button
className={cn(contextPill, "ml-0.5")}
onClick={() => {
setAddKey((k) => k + 1);
setAddOpen(true);
}}
type="button"
>
<UserPlus className="size-4" />
<span>{t("chat.input.addPatient")}</span>
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
<SelectPill
align="end"
ariaLabel={t("chat.input.responseMode")}
chevronClassName="size-4 opacity-70"
icon={null}
onValueChange={setResponseMode}
options={responseOptions}
prefix={t("chat.input.clinical")}
<ModelPicker
effort={effort}
model={model}
onEffortChange={onEffortChange}
onModelChange={onModelChange}
triggerClassName={cn(pillButton, "mr-1")}
value={responseMode}
/>
<button
aria-label={t("chat.input.dictate")}
@@ -321,48 +212,6 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
</div>
</div>
</div>
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
<SelectPill
ariaLabel={t("chat.input.specialty")}
chevronClassName="size-3.5 opacity-70"
icon={<Stethoscope className="size-4" />}
onValueChange={setSpecialty}
options={specialtyOptions}
triggerClassName={contextPill}
value={specialty}
/>
<SelectPill
ariaLabel={t("chat.input.facility")}
chevronClassName="size-3.5 opacity-70"
icon={<Building2 className="size-4" />}
onValueChange={setFacility}
options={facilityOptions}
triggerClassName={contextPill}
value={facility}
/>
<SelectPill
ariaLabel={t("chat.input.timeRange")}
chevronClassName="size-3.5 opacity-70"
icon={<CalendarRange className="size-4" />}
onValueChange={setTimeRange}
options={timeOptions}
triggerClassName={contextPill}
value={timeRange}
/>
<button
className={cn(contextPill, "ml-auto")}
onClick={() => {
setAddKey((k) => k + 1);
setAddOpen(true);
}}
type="button"
>
<UserPlus className="size-4" />
<span>{t("chat.input.addPatient")}</span>
</button>
</div>
</form>
<PatientFormDialog
+14 -1
View File
@@ -18,6 +18,7 @@ import {
} from "@/components/ai-elements/message";
import { ChatInput } from "@/components/chat/chat-input";
import { PatientResult } from "@/components/chat/patient-cards";
import { DEFAULT_EFFORT, DEFAULT_MODEL_ID, type Effort } from "@/lib/ai-models";
import { getPatient, type Patient } from "@/lib/patients";
type ChatMessage =
@@ -39,6 +40,10 @@ export function ChatPanel() {
const { t } = useTranslation();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>("ready");
// Model + effort selected in the input; travels with each send (wired to the
// backend agent in a later phase). Defaults come from the shared catalog.
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
const [effort, setEffort] = useState<Effort>(DEFAULT_EFFORT);
const send = useCallback(async (text: string) => {
const trimmed = text.trim();
@@ -127,7 +132,15 @@ export function ChatPanel() {
}, [requestedPatient, send]);
const promptInput = (
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
<ChatInput
effort={effort}
model={model}
onEffortChange={setEffort}
onModelChange={setModel}
onStop={handleStop}
onSubmit={send}
status={status}
/>
);
if (messages.length === 0) {
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Menu,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator,
MenuSub,
MenuSubPopup,
MenuSubTrigger,
MenuTrigger,
} from "@/components/ui/menu";
import {
type AiModel,
type Effort,
EFFORT_LEVELS,
getModel,
MORE_MODELS,
PRIMARY_MODELS,
} from "@/lib/ai-models";
import { cn } from "@/lib/utils";
type ModelPickerProps = {
model: string;
effort: Effort;
onModelChange: (model: string) => void;
onEffortChange: (effort: Effort) => void;
triggerClassName: string;
};
function ModelRow({ model }: { model: AiModel }) {
const { t } = useTranslation();
return (
<div className="flex flex-col">
<span className="text-foreground">{model.label}</span>
<span className="text-xs text-muted-foreground">
{t(model.descriptionKey)}
</span>
</div>
);
}
/**
* The single model + effort control in the chat input, styled like the
* reference design: a list of primary models, an Effort submenu, and a
* "More models" submenu. Selection is owned by the chat panel so it travels
* with each send.
*/
export function ModelPicker({
model,
effort,
onModelChange,
onEffortChange,
triggerClassName,
}: ModelPickerProps) {
const { t } = useTranslation();
const selected = getModel(model);
const showEffort = selected?.supportsEffort ?? false;
return (
<Menu>
<MenuTrigger
render={
<button
aria-label={t("chat.input.model")}
className={triggerClassName}
type="button"
/>
}
>
<span className="truncate font-medium text-foreground">
{selected?.label ?? t("chat.input.model")}
</span>
{showEffort ? (
<span className="text-muted-foreground">
{t(`chat.input.effortOptions.${effort}`)}
</span>
) : null}
<ChevronDown className="size-4 opacity-70" />
</MenuTrigger>
<MenuPopup align="end" className="min-w-64">
<MenuRadioGroup onValueChange={onModelChange} value={model}>
{PRIMARY_MODELS.map((m) => (
<MenuRadioItem key={m.id} value={m.id}>
<ModelRow model={m} />
</MenuRadioItem>
))}
</MenuRadioGroup>
<MenuSeparator />
{showEffort ? (
<MenuSub>
<MenuSubTrigger>
<span>{t("chat.input.effort")}</span>
<span className="ms-auto text-muted-foreground">
{t(`chat.input.effortOptions.${effort}`)}
</span>
</MenuSubTrigger>
<MenuSubPopup>
<MenuRadioGroup
onValueChange={(value) => onEffortChange(value as Effort)}
value={effort}
>
{EFFORT_LEVELS.map((level) => (
<MenuRadioItem key={level} value={level}>
{t(`chat.input.effortOptions.${level}`)}
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuSubPopup>
</MenuSub>
) : null}
<MenuSub>
<MenuSubTrigger>{t("chat.input.moreModels")}</MenuSubTrigger>
<MenuSubPopup className={cn("min-w-60")}>
<MenuRadioGroup onValueChange={onModelChange} value={model}>
{MORE_MODELS.map((m) => (
<MenuRadioItem key={m.id} value={m.id}>
<ModelRow model={m} />
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuSubPopup>
</MenuSub>
</MenuPopup>
</Menu>
);
}
+97
View File
@@ -0,0 +1,97 @@
// Catalog of models the chat can run, shared by the chat-input model picker,
// the Settings → AI panel, and the chat→backend wiring. The actual provider
// call is made by the backend (`backend/src/services/ai/provider.ts`); here we
// only describe the menu of choices. `id` is the provider's model id sent to
// the backend; `descriptionKey` is an i18n key under `chat.input.models.*`.
export type Effort = "low" | "medium" | "high";
export const EFFORT_LEVELS: Effort[] = ["low", "medium", "high"];
export const DEFAULT_EFFORT: Effort = "medium";
// The three supported cloud providers (API key) plus local Ollama.
export type AiProvider = "anthropic" | "openai" | "gemini" | "ollama";
export type AiModel = {
/** Provider model id passed to the backend. */
id: string;
provider: AiProvider;
/** Short display name shown in the picker trigger. */
label: string;
/** i18n key for the one-line description under the label. */
descriptionKey: string;
/** Shown directly in the model list; non-primary live under "More models". */
primary?: boolean;
/** Whether the Effort control applies to this model. */
supportsEffort?: boolean;
};
// Curated catalog. Cloud model ids are best-effort current ids and can be
// adjusted without touching the UI. Ollama is a stand-in for "your local model"
// — the concrete tag is configured in Settings → AI.
export const AI_MODELS: AiModel[] = [
{
id: "claude-opus-4-8",
provider: "anthropic",
label: "Opus 4.8",
descriptionKey: "chat.input.models.opus48",
primary: true,
supportsEffort: true,
},
{
id: "claude-sonnet-4-6",
provider: "anthropic",
label: "Sonnet 4.6",
descriptionKey: "chat.input.models.sonnet46",
primary: true,
supportsEffort: true,
},
{
id: "claude-haiku-4-5",
provider: "anthropic",
label: "Haiku 4.5",
descriptionKey: "chat.input.models.haiku45",
primary: true,
},
{
id: "gpt-4o",
provider: "openai",
label: "GPT-4o",
descriptionKey: "chat.input.models.gpt4o",
supportsEffort: true,
},
{
id: "gpt-4o-mini",
provider: "openai",
label: "GPT-4o mini",
descriptionKey: "chat.input.models.gpt4oMini",
},
{
id: "gemini-2.0-flash",
provider: "gemini",
label: "Gemini 2.0 Flash",
descriptionKey: "chat.input.models.gemini20Flash",
},
{
id: "gemini-1.5-pro",
provider: "gemini",
label: "Gemini 1.5 Pro",
descriptionKey: "chat.input.models.gemini15Pro",
supportsEffort: true,
},
{
id: "ollama",
provider: "ollama",
label: "Local (Ollama)",
descriptionKey: "chat.input.models.ollama",
},
];
export const DEFAULT_MODEL_ID = AI_MODELS[0].id;
export function getModel(id: string): AiModel | undefined {
return AI_MODELS.find((m) => m.id === id);
}
export const PRIMARY_MODELS = AI_MODELS.filter((m) => m.primary);
export const MORE_MODELS = AI_MODELS.filter((m) => !m.primary);
+20 -33
View File
@@ -616,45 +616,29 @@
"placeholder": "Look up a patient — try /patient 10293",
"message": "Message",
"addPatient": "Add patient",
"clinical": "Clinical",
"attachFile": "Attach file",
"attachFiles": "Attach files",
"removeFile": "Remove {{name}}",
"dictate": "Dictate",
"send": "Send",
"stop": "Stop",
"accessLevel": "Access level",
"responseMode": "Response mode",
"specialty": "Specialty",
"facility": "Facility",
"timeRange": "Time range",
"access": {
"standard": "Standard access",
"breakGlass": "Break-glass (emergency)",
"readOnly": "Read-only"
"model": "Model",
"moreModels": "More models",
"effort": "Effort",
"effortOptions": {
"low": "Low",
"medium": "Medium",
"high": "High"
},
"response": {
"concise": "Concise",
"detailed": "Detailed",
"comprehensive": "Comprehensive"
},
"specialtyOptions": {
"internalMedicine": "Internal Medicine",
"cardiology": "Cardiology",
"pediatrics": "Pediatrics",
"emergency": "Emergency",
"all": "All specialties"
},
"facilityOptions": {
"mainHospital": "Main Hospital",
"northClinic": "North Clinic",
"telehealth": "Telehealth"
},
"timeOptions": {
"30d": "Last 30 days",
"12m": "Last 12 months",
"5y": "Last 5 years",
"all": "All time"
"models": {
"opus48": "For complex clinical reasoning",
"sonnet46": "Balanced for everyday tasks",
"haiku45": "Fastest for quick answers",
"gpt4o": "OpenAI multimodal flagship",
"gpt4oMini": "Fast, low-cost OpenAI model",
"gemini20Flash": "Fast Google model",
"gemini15Pro": "Google long-context model",
"ollama": "Runs locally on your infrastructure"
}
}
},
@@ -662,7 +646,10 @@
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
"edit": "Edit",
"sex": { "F": "Female", "M": "Male" },
"sex": {
"F": "Female",
"M": "Male"
},
"summary": {
"fullName": "Full name",
"mrn": "MRN",