From 1d9b1b1d226b7fd515afeef17de355c79b026c25 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 20 Jun 2026 18:34:48 +0300 Subject: [PATCH] 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 --- .../components/chat/chat-history-panel.tsx | 155 ++++++++++++++++++ frontend/components/chat/chat-panel.tsx | 19 ++- frontend/components/chat/patient-cards.tsx | 26 +++ .../components/meetings/meetings-view.tsx | 90 +++++++++- .../meetings/schedule-meeting-dialog.tsx | 6 +- .../components/meetings/use-call-invites.ts | 5 +- .../components/messages/messages-view.tsx | 36 +++- .../components/settings/settings-view.tsx | 6 +- frontend/components/ui/toast.tsx | 24 ++- frontend/lib/i18n/locales/en/translation.json | 19 ++- frontend/lib/nav.ts | 4 +- 11 files changed, 360 insertions(+), 30 deletions(-) create mode 100644 frontend/components/chat/chat-history-panel.tsx diff --git a/frontend/components/chat/chat-history-panel.tsx b/frontend/components/chat/chat-history-panel.tsx new file mode 100644 index 0000000..3890ebc --- /dev/null +++ b/frontend/components/chat/chat-history-panel.tsx @@ -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([]); + 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 ( + <> +
+ + +
+ + + + + {t("chat.history.title")} + + {t("chat.history.open")} + + + + +
+ + setQuery(e.target.value)} + placeholder={t("chat.history.search")} + value={query} + /> +
+
+ {filtered.length === 0 ? ( +

+ {t("chat.history.empty")} +

+ ) : ( + filtered.map((thread) => { + const active = activeThread === thread.id; + return ( + + ); + }) + )} +
+
+
+
+ + ); +} diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index a5ab099..8e222be 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -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() { ) : 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 ; } if (part.type === "data-veilNotice") { + // Only the first veilNotice in the whole conversation renders. + if (message.id !== firstVeilMessageId) return null; return ( @@ -701,7 +710,11 @@ export function ChatPanel() { if (messages.length === 0) { return ( -
+
+
+ +
+

{t("chat.heading")} @@ -717,12 +730,16 @@ export function ChatPanel() {

+
); } return (
+
+ +
{messages.map((message, i) => diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx index 6586e41..f61ab05 100644 --- a/frontend/components/chat/patient-cards.tsx +++ b/frontend/components/chat/patient-cards.tsx @@ -66,6 +66,10 @@ const statusVariant: Record = { 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 {children}; + } return ( ); + const hasVitals = Boolean( + vitals.bp || + vitals.hr || + vitals.temp || + vitals.spo2 || + patient.vitalsTrend.points.length, + ); + return ( {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 ? ( {t("patientCard.labs.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 ( } + expandable={patient.allergies.length > 0 || patient.alerts.length > 0} title={t("patientCard.allergies.title")} > @@ -532,6 +557,7 @@ function VisitsCard({ patient }: { patient: Patient }) { description={t("patientCard.visits.recent", { count: patient.encounters.length, })} + expandable={patient.encounters.length > 0} detail={} title={t("patientCard.visits.title")} > diff --git a/frontend/components/meetings/meetings-view.tsx b/frontend/components/meetings/meetings-view.tsx index 915aa22..f7f1582 100644 --- a/frontend/components/meetings/meetings-view.tsx +++ b/frontend/components/meetings/meetings-view.tsx @@ -55,7 +55,11 @@ export function MeetingsView() { const searchParams = useSearchParams(); const deepLinkRoom = searchParams.get("room"); + // ?with= 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(null); + const openedWith = useRef(null); const [tab, setTab] = useState("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 (
{/* Header: Rooms / Calendar tabs */} @@ -263,8 +292,9 @@ export function MeetingsView() { ) : ( // Calendar tab
-
+
{t("meetings.schedule.cta")} + +
+ + {t("meetings.upcoming.title")} + + {upcoming.length === 0 ? ( +

+ {t("meetings.upcoming.empty")} +

+ ) : ( +
+ {upcoming.map((e) => ( + + ))} +
+ )} +
@@ -293,9 +356,27 @@ export function MeetingsView() {
{dayEvents.length === 0 ? ( -

- {t("meetings.calendarEmpty")} -

+ + + + + + {t("meetings.calendarEmpty")} + + {t("meetings.calendarEmptyHint")} + + + + + + ) : ( dayEvents.map((e) => (
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) => { diff --git a/frontend/components/meetings/use-call-invites.ts b/frontend/components/meetings/use-call-invites.ts index 767bf5e..81fa830 100644 --- a/frontend/components/meetings/use-call-invites.ts +++ b/frontend/components/meetings/use-call-invites.ts @@ -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)}`, diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 63b99e7..2cb188f 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -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=. 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 ( - +
- + +
); }) )} diff --git a/frontend/components/settings/settings-view.tsx b/frontend/components/settings/settings-view.tsx index b1a19b8..a8e7607 100644 --- a/frontend/components/settings/settings-view.tsx +++ b/frontend/components/settings/settings-view.tsx @@ -43,15 +43,15 @@ export function SettingsView() { return (
-
+

{t(`settings.tabs.${activeTab}`)}

-
- {toast.actionProps && ( - + {toast.actionProps && ( + + {toast.actionProps.children} + + )} + - {toast.actionProps.children} - - )} + + +
); diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 391a17d..643ab57 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -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", diff --git a/frontend/lib/nav.ts b/frontend/lib/nav.ts index 47a9158..bb38858 100644 --- a/frontend/lib/nav.ts +++ b/frontend/lib/nav.ts @@ -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", },