mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 11:27:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user