frontend: chat/meetings/messages/settings UX fixes

- settings: keep tab nav on its own row so "Developers" no longer wraps
- chat: show the "Veil active" chip once per conversation, not every turn
- chat: record cards only offer "Click for more" when they have detail
- chat: chat-history panel (pill + sheet) top-left of the AI chat with
  "Start new chat"; rename sidebar "New chat" -> "Ask temetro" (Sparkles)
- meetings: disable past dates, add an Upcoming Meetings list, and use the
  Empty component for empty days; scheduler can be pre-targeted via ?with
- messages: add a call button left of each inbox row -> Meetings (?with)
- toast: add a dismiss (x) button; call invites now ring 30s with "Accept"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 18:34:48 +03:00
parent 913a217e1d
commit 1d9b1b1d22
11 changed files with 360 additions and 30 deletions
@@ -0,0 +1,155 @@
"use client";
import { PanelLeft, Plus, Search, Trash2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { type MouseEvent, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Sheet,
SheetDescription,
SheetHeader,
SheetPanel,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import {
deleteThread,
listThreads,
THREADS_CHANGED_EVENT,
type ThreadSummary,
} from "@/lib/ai-chat-history";
import { cn } from "@/lib/utils";
// The pill (panel toggle + search) that sits top-left of the AI chat, next to
// the sidebar. Opens a sheet listing saved chats with a "Start new chat" button
// — so chat history is reachable from inside the chat, not just the sidebar.
export function ChatHistoryPanel() {
const { t } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const activeThread = searchParams.get("thread");
const [open, setOpen] = useState(false);
const [threads, setThreads] = useState<ThreadSummary[]>([]);
const [query, setQuery] = useState("");
useEffect(() => {
const refresh = () => {
listThreads()
.then(setThreads)
.catch(() => {
/* not signed in / no clinic — show nothing */
});
};
refresh();
window.addEventListener(THREADS_CHANGED_EVENT, refresh);
return () => window.removeEventListener(THREADS_CHANGED_EVENT, refresh);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return threads;
return threads.filter((x) => x.title.toLowerCase().includes(q));
}, [threads, query]);
const go = (href: string) => {
setOpen(false);
router.push(href);
};
const remove = async (event: MouseEvent, id: string) => {
event.preventDefault();
event.stopPropagation();
setThreads((prev) => prev.filter((x) => x.id !== id));
await deleteThread(id).catch(() => {
/* ignore */
});
};
return (
<>
<div className="flex items-center gap-0.5 rounded-full border bg-card/40 p-0.5">
<button
aria-label={t("chat.history.open")}
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(true)}
type="button"
>
<PanelLeft className="size-4" />
</button>
<button
aria-label={t("chat.history.search")}
className="flex size-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(true)}
type="button"
>
<Search className="size-4" />
</button>
</div>
<Sheet onOpenChange={setOpen} open={open}>
<SheetPopup side="left">
<SheetHeader>
<SheetTitle>{t("chat.history.title")}</SheetTitle>
<SheetDescription className="sr-only">
{t("chat.history.open")}
</SheetDescription>
</SheetHeader>
<SheetPanel className="flex min-h-0 flex-1 flex-col gap-3">
<Button className="w-full justify-start" onClick={() => go("/")}>
<Plus className="size-4" />
{t("chat.history.startNew")}
</Button>
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
<Input
className="pl-8"
onChange={(e) => setQuery(e.target.value)}
placeholder={t("chat.history.search")}
value={query}
/>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
{filtered.length === 0 ? (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
{t("chat.history.empty")}
</p>
) : (
filtered.map((thread) => {
const active = activeThread === thread.id;
return (
<button
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent",
active
? "bg-accent text-foreground"
: "text-muted-foreground",
)}
key={thread.id}
onClick={() => go(`/?thread=${thread.id}`)}
type="button"
>
<span className="min-w-0 flex-1 truncate">
{thread.title}
</span>
<span
aria-label={t("chat.history.delete")}
className="shrink-0 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
onClick={(event) => remove(event, thread.id)}
>
<Trash2 className="size-3.5" />
</span>
</button>
);
})
)}
</div>
</SheetPanel>
</SheetPopup>
</Sheet>
</>
);
}
+18 -1
View File
@@ -56,6 +56,7 @@ import {
import { ActionPreviewCard } from "@/components/chat/action-preview-card";
import { AnalyticsCard } from "@/components/chat/analytics-card";
import { BatchActionPreviewCard } from "@/components/chat/batch-action-preview-card";
import { ChatHistoryPanel } from "@/components/chat/chat-history-panel";
import { ChatInput } from "@/components/chat/chat-input";
import { ClinicCard } from "@/components/chat/clinic-card";
import { InventoryListCard } from "@/components/chat/inventory-list-card";
@@ -468,6 +469,12 @@ export function ChatPanel() {
</Queue>
) : null;
// Veil runs once per conversation, so the "Veil active" chip should only show
// on the first assistant message that carries a veilNotice — not every turn.
const firstVeilMessageId = messages.find((m) =>
m.parts.some((p) => p.type === "data-veilNotice"),
)?.id;
// Render one assistant/user message: a Chain-of-Thought trace built from any
// `data-step` parts, then the rest of the parts (text + record cards) in order.
const renderMessage = (message: TemetroUIMessage, isLast: boolean) => {
@@ -670,6 +677,8 @@ export function ChatPanel() {
return <AnalyticsCard data={part.data} key={key} />;
}
if (part.type === "data-veilNotice") {
// Only the first veilNotice in the whole conversation renders.
if (message.id !== firstVeilMessageId) return null;
return (
<Badge className="gap-1 self-start" key={key} variant="secondary">
<ShieldCheck className="size-3" />
@@ -701,7 +710,11 @@ export function ChatPanel() {
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-8">
<div className="relative flex flex-1 flex-col overflow-y-auto">
<div className="flex items-center px-4 pt-3">
<ChatHistoryPanel />
</div>
<div className="flex flex-1 flex-col items-center justify-center px-4 py-8">
<div className="flex w-full max-w-3xl shrink-0 flex-col items-center gap-10">
<h1 className="text-center font-semibold text-3xl text-balance tracking-tight sm:text-4xl">
{t("chat.heading")}
@@ -717,12 +730,16 @@ export function ChatPanel() {
</Suggestions>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex items-center px-4 pt-3">
<ChatHistoryPanel />
</div>
<Conversation>
<ConversationContent className="mx-auto w-full max-w-3xl">
{messages.map((message, i) =>
@@ -66,6 +66,10 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
const rowCard =
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
// Same footprint as `rowCard` but with no clickable affordance — used when a
// card has nothing extra to reveal, so it shouldn't promise "Click for more".
const rowCardStatic = "w-72 shrink-0 gap-0 text-left";
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
const compactCard =
@@ -156,18 +160,26 @@ function AlertBadges({ alerts }: { alerts: string[] }) {
// A compact card that previews `children` and opens a roomier dialog of `detail`
// on click. A muted "Click for more" footer signals the card is expandable.
// When `expandable` is false (the card holds nothing beyond its preview), it
// renders as a plain, non-clickable card with no footer — so empty sections
// don't misleadingly promise more.
function ExpandableCard({
title,
description,
detail,
children,
expandable = true,
}: {
title: ReactNode;
description?: ReactNode;
detail: ReactNode;
children: ReactNode;
expandable?: boolean;
}) {
const { t } = useTranslation();
if (!expandable) {
return <Card className={cn(rowCardStatic, compactCard)}>{children}</Card>;
}
return (
<Dialog>
<DialogTrigger
@@ -283,9 +295,18 @@ function VitalsCard({ patient }: { patient: Patient }) {
</div>
);
const hasVitals = Boolean(
vitals.bp ||
vitals.hr ||
vitals.temp ||
vitals.spo2 ||
patient.vitalsTrend.points.length,
);
return (
<ExpandableCard
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
expandable={hasVitals}
detail={
<div className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
@@ -326,6 +347,7 @@ function LabsCard({ patient }: { patient: Patient }) {
description={t("patientCard.labs.asOf", {
at: patient.labs[0]?.takenAt ?? "—",
})}
expandable={patient.labs.length > 0}
detail={
patient.labs.length === 0 ? (
<Empty>{t("patientCard.labs.empty")}</Empty>
@@ -385,6 +407,7 @@ function MedicationsCard({ patient }: { patient: Patient }) {
description={t("patientCard.medications.active", {
count: patient.medications.length,
})}
expandable={patient.medications.length > 0}
detail={list}
title={t("patientCard.medications.title")}
>
@@ -421,6 +444,7 @@ function ProblemsCard({ patient }: { patient: Patient }) {
description={t("patientCard.problems.active", {
count: patient.problems.length,
})}
expandable={patient.problems.length > 0}
detail={list}
title={t("patientCard.problems.title")}
>
@@ -476,6 +500,7 @@ function AllergiesCard({ patient }: { patient: Patient }) {
return (
<ExpandableCard
detail={<AllergiesList patient={patient} />}
expandable={patient.allergies.length > 0 || patient.alerts.length > 0}
title={t("patientCard.allergies.title")}
>
<CardHeader>
@@ -532,6 +557,7 @@ function VisitsCard({ patient }: { patient: Patient }) {
description={t("patientCard.visits.recent", {
count: patient.encounters.length,
})}
expandable={patient.encounters.length > 0}
detail={<VisitsList patient={patient} />}
title={t("patientCard.visits.title")}
>
+86 -4
View File
@@ -55,7 +55,11 @@ export function MeetingsView() {
const searchParams = useSearchParams();
const deepLinkRoom = searchParams.get("room");
// ?with=<userId> from the Messages inbox "call" button — open the scheduler
// pre-targeted at that person so the user can connect with them.
const deepLinkWith = searchParams.get("with");
const openedDeepLink = useRef<string | null>(null);
const openedWith = useRef<string | null>(null);
const [tab, setTab] = useState<Tab>("rooms");
@@ -108,6 +112,14 @@ export function MeetingsView() {
setActiveRoom(room);
}, [deepLinkRoom, rooms]);
// Open the scheduler pre-targeted at a person (?with=) from the inbox.
useEffect(() => {
if (!deepLinkWith || openedWith.current === deepLinkWith) return;
openedWith.current = deepLinkWith;
setTab("calendar");
setScheduleOpen(true);
}, [deepLinkWith]);
const createRoom = async (event: FormEvent) => {
event.preventDefault();
const name = newName.trim();
@@ -138,6 +150,23 @@ export function MeetingsView() {
[events, selectedDay],
);
// Midnight today — used to disable past calendar dates and filter "upcoming".
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
// Next few meetings from today onward, soonest first.
const upcoming = useMemo(() => {
const now = new Date();
return events
.filter((e) => new Date(`${e.date}T${e.time}`) >= now)
.sort((a, b) =>
`${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`),
)
.slice(0, 4);
}, [events]);
return (
<div className="flex h-full w-full flex-col gap-3 p-4">
{/* Header: Rooms / Calendar tabs */}
@@ -263,8 +292,9 @@ export function MeetingsView() {
) : (
// Calendar tab
<div className="flex min-h-0 flex-1 gap-4">
<div className="flex shrink-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3">
<div className="flex shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border bg-card/30 p-3">
<Calendar
disabled={{ before: today }}
mode="single"
modifiers={{ hasMeeting: meetingDays }}
modifiersClassNames={{
@@ -279,6 +309,39 @@ export function MeetingsView() {
<Plus className="size-4" />
{t("meetings.schedule.cta")}
</Button>
<div className="flex min-h-0 flex-col gap-1.5">
<span className="px-1 font-medium text-muted-foreground text-xs uppercase tracking-wide">
{t("meetings.upcoming.title")}
</span>
{upcoming.length === 0 ? (
<p className="px-1 py-2 text-muted-foreground text-xs">
{t("meetings.upcoming.empty")}
</p>
) : (
<div className="flex flex-col gap-1">
{upcoming.map((e) => (
<button
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
key={e.id}
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
type="button"
>
<span className="truncate font-medium text-foreground text-sm">
{e.title}
</span>
<span className="text-muted-foreground text-xs tabular-nums">
{new Date(`${e.date}T00:00:00`).toLocaleDateString(
"en-US",
{ month: "short", day: "numeric" },
)}{" "}
· {e.time}
</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl border bg-card/30">
@@ -293,9 +356,27 @@ export function MeetingsView() {
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-3">
{dayEvents.length === 0 ? (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
{t("meetings.calendarEmpty")}
</p>
<Empty className="border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<CalendarDays />
</EmptyMedia>
<EmptyTitle>{t("meetings.calendarEmpty")}</EmptyTitle>
<EmptyDescription>
{t("meetings.calendarEmptyHint")}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
onClick={() => setScheduleOpen(true)}
type="button"
variant="outline"
>
<Plus className="size-4" />
{t("meetings.schedule.cta")}
</Button>
</EmptyContent>
</Empty>
) : (
dayEvents.map((e) => (
<div
@@ -356,6 +437,7 @@ export function MeetingsView() {
<ScheduleMeetingDialog
defaultDate={keyOf(selectedDay)}
defaultParticipants={deepLinkWith ? [deepLinkWith] : undefined}
onCreated={loadEvents}
onOpenChange={setScheduleOpen}
open={scheduleOpen}
@@ -34,11 +34,13 @@ export function ScheduleMeetingDialog({
open,
onOpenChange,
defaultDate,
defaultParticipants,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultDate?: string; // YYYY-MM-DD
defaultParticipants?: string[]; // member ids to preselect
onCreated: () => void;
}) {
const { t } = useTranslation();
@@ -57,11 +59,11 @@ export function ScheduleMeetingDialog({
setTitle("");
setDate(defaultDate ?? "");
setTime("09:00");
setPicked(new Set());
setPicked(new Set(defaultParticipants ?? []));
listClinicMembers()
.then(setMembers)
.catch(() => setMembers([]));
}, [open, defaultDate]);
}, [open, defaultDate, defaultParticipants]);
const toggle = (id: string) =>
setPicked((prev) => {
@@ -21,10 +21,13 @@ export function useCallInvites() {
const onInvite = ({ roomId, roomName, fromName }: CallInvite) => {
toastManager.add({
type: "info",
// Ring long enough for the callee to react; the toast's "x" declines it.
timeout: 30_000,
title: t("meetings.invite.toastTitle", { name: fromName }),
description: roomName,
actionProps: {
children: t("meetings.invite.join"),
// "Accept" drops the user straight into the caller's room.
children: t("meetings.invite.accept"),
onClick: () =>
router.push(
`/messages/meetings?room=${encodeURIComponent(roomId)}`,
+30 -6
View File
@@ -9,9 +9,10 @@ import {
Plus,
Search,
SendHorizonal,
Video,
X,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import {
type ChangeEvent,
type FormEvent,
@@ -146,6 +147,8 @@ export function MessagesView() {
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
const router = useRouter();
// Deep link from a notification: /messages?conversation=<id>.
const searchParams = useSearchParams();
const deepLinkConversation = searchParams.get("conversation");
@@ -455,16 +458,36 @@ export function MessagesView() {
) : (
visible.map((c) => {
const last = c.lastMessage;
const otherId = c.isGroup
? ""
: (c.participants.find((p) => p.id !== myId)?.id ?? "");
return (
<button
<div
className={cn(
"flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
"flex w-full items-center gap-1 rounded-lg pr-2 transition-colors hover:bg-accent/50",
selected?.id === c.id && "bg-accent hover:bg-accent",
)}
key={c.id}
onClick={() => open(c.id)}
type="button"
>
<button
aria-label={t("messages.startCall", { name: c.name })}
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
onClick={() =>
router.push(
otherId
? `/messages/meetings?with=${encodeURIComponent(otherId)}`
: "/messages/meetings",
)
}
type="button"
>
<Video className="size-4" />
</button>
<button
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
onClick={() => open(c.id)}
type="button"
>
<Avatar className="size-9 shrink-0">
<AvatarFallback>{initials(c.name)}</AvatarFallback>
</Avatar>
@@ -510,7 +533,8 @@ export function MessagesView() {
)}
</div>
</div>
</button>
</button>
</div>
);
})
)}
@@ -43,15 +43,15 @@ export function SettingsView() {
return (
<div className="mx-auto w-full max-w-3xl px-6 py-10">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold tracking-tight">
{t(`settings.tabs.${activeTab}`)}
</h1>
<nav className="flex flex-wrap items-center gap-1">
<nav className="-mx-1 flex flex-nowrap items-center gap-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{visibleTabs.map((item) => (
<button
className={cn(
"rounded-lg px-3 py-1.5 text-sm transition-colors",
"shrink-0 rounded-lg px-3 py-1.5 text-sm transition-colors",
activeTab === item.id
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
+17 -7
View File
@@ -7,6 +7,7 @@ import {
InfoIcon,
LoaderCircleIcon,
TriangleAlertIcon,
XIcon,
} from "lucide-react";
import type React from "react";
import { cn } from "@/lib/utils";
@@ -163,14 +164,23 @@ function Toasts({
/>
</div>
</div>
{toast.actionProps && (
<Toast.Action
className={buttonVariants({ size: "xs" })}
data-slot="toast-action"
<div className="flex shrink-0 items-center gap-1">
{toast.actionProps && (
<Toast.Action
className={buttonVariants({ size: "xs" })}
data-slot="toast-action"
>
{toast.actionProps.children}
</Toast.Action>
)}
<Toast.Close
aria-label="Dismiss"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
data-slot="toast-close"
>
{toast.actionProps.children}
</Toast.Action>
)}
<XIcon className="size-4" />
</Toast.Close>
</div>
</Toast.Content>
</Toast.Root>
);
+15 -4
View File
@@ -110,7 +110,7 @@
}
},
"nav": {
"newChat": "New chat",
"newChat": "Ask temetro",
"patients": "Patients",
"appointments": "Appointments",
"invoices": "Invoices",
@@ -749,7 +749,9 @@
"ring": "Ring",
"invited": "Invited",
"join": "Join",
"toastTitle": "{{name}} invited you to a call"
"toastTitle": "{{name}} invited you to a call",
"accept": "Accept",
"decline": "Decline"
},
"schedule": {
"cta": "Schedule meeting",
@@ -764,6 +766,11 @@
"saving": "Scheduling…",
"failedTitle": "Couldn't schedule meeting",
"failedBody": "Please try again."
},
"calendarEmptyHint": "Schedule a meeting to see it on this day.",
"upcoming": {
"title": "Upcoming",
"empty": "No upcoming meetings."
}
},
"messages": {
@@ -816,7 +823,8 @@
"apptProvider": "Provider",
"apptPatient": "Patient",
"apptStatus": "Status"
}
},
"startCall": "Start a call with {{name}}"
},
"analysis": {
"title": "Overview",
@@ -1020,7 +1028,10 @@
"title": "Chats",
"untitled": "New chat",
"empty": "No saved chats yet.",
"delete": "Delete chat"
"delete": "Delete chat",
"open": "Chat history",
"search": "Search chats",
"startNew": "Start new chat"
},
"suggestions": {
"schedule": "Show today's schedule",
+2 -2
View File
@@ -10,9 +10,9 @@ import {
Mail,
NotebookPen,
Pill,
Plus,
Receipt,
Settings,
Sparkles,
Users,
Video,
} from "lucide-react";
@@ -54,7 +54,7 @@ export const navItems: NavItem[] = [
{
id: "new-chat",
labelKey: "nav.newChat",
icon: Plus,
icon: Sparkles,
link: "/",
access: "clinical",
},