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