diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 0cacdfc..38087d1 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -143,7 +143,9 @@ chatRouter.post("/", async (req, res, next) => { tools, stopWhen: stepCountIs(6), }); - writer.merge(result.toUIMessageStream()); + // Forward reasoning parts (when the model emits them) so the client + // can render a Claude-style thinking block. + writer.merge(result.toUIMessageStream({ sendReasoning: true })); } }, onError: (error) => diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index dc07c5f..f0d7367 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -57,7 +57,8 @@ export function ChatInput({ const submit = useCallback(async () => { const trimmed = value.trim(); - if ((!trimmed && files.length === 0) || isGenerating) { + // Allow submitting while generating — the panel queues it (Claude-style). + if (!trimmed && files.length === 0) { return; } const parts: string[] = []; @@ -86,7 +87,7 @@ export function ChatInput({ onSubmit(parts.join("\n\n")); setValue(""); setFiles([]); - }, [value, files, isGenerating, onSubmit]); + }, [value, files, onSubmit]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index 46ff655..bd608d6 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; +import { DefaultChatTransport, type ToolUIPart } from "ai"; import { AlertTriangle, ShieldCheck, X } from "lucide-react"; import { nanoid } from "nanoid"; import { useSearchParams } from "next/navigation"; @@ -24,7 +24,29 @@ import { MessageContent, MessageResponse, } from "@/components/ai-elements/message"; +import { + Queue, + QueueItem, + QueueItemAction, + QueueItemActions, + QueueItemContent, + QueueItemIndicator, + QueueList, +} from "@/components/ai-elements/queue"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +import { + Tool, + ToolContent, + ToolHeader, + ToolInput, + ToolOutput, +} from "@/components/ai-elements/tool"; import { ActionPreviewCard } from "@/components/chat/action-preview-card"; import { ChatInput } from "@/components/chat/chat-input"; import { ImportPreviewCard } from "@/components/chat/import-preview-card"; @@ -63,6 +85,10 @@ export function ChatPanel() { const [consented, setConsented] = useState(false); const [pendingConsent, setPendingConsent] = useState(null); + // Claude-style message queue: messages submitted while the assistant is busy + // (or waiting on the Veil gate) wait here and auto-send when it goes idle. + const [queued, setQueued] = useState([]); + const transport = useMemo( () => new DefaultChatTransport({ @@ -118,6 +144,12 @@ export function ChatPanel() { const trimmed = text.trim(); if (!trimmed) return; + // Busy or awaiting the Veil gate → queue and auto-send when idle. + if (status === "submitted" || status === "streaming" || pendingConsent) { + setQueued((q) => [...q, trimmed]); + return; + } + // Fast-path: `/patient ` renders cards directly, no LLM. const match = trimmed.match(PATIENT_COMMAND); if (match) { @@ -158,9 +190,26 @@ export function ChatPanel() { } runAgentWith(trimmed, model); }, - [consented, isCloudModel, model, runAgentWith, setMessages, t], + [ + consented, + isCloudModel, + model, + pendingConsent, + runAgentWith, + setMessages, + status, + t, + ], ); + // Drain the queue one message at a time whenever the chat returns to idle. + useEffect(() => { + if (status !== "ready" || pendingConsent || queued.length === 0) return; + const [next, ...rest] = queued; + setQueued(rest); + if (next) void send(next); + }, [status, pendingConsent, queued, send]); + // Veil gate actions. const confirmConsent = useCallback(() => { setConsented(true); @@ -234,6 +283,43 @@ export function ChatPanel() { ) : null; + // Starter prompts shown on the empty state, each tied to an existing tool. + const suggestions = [ + t("chat.suggestions.schedule"), + t("chat.suggestions.tasks"), + t("chat.suggestions.prescriptions"), + t("chat.suggestions.import"), + ]; + + const queuePanel = + queued.length > 0 ? ( + + + {t("chat.queue.label", { count: queued.length })} + + + {queued.map((q, i) => ( + +
+ + {q} + + + setQueued((prev) => prev.filter((_, j) => j !== i)) + } + > + + + +
+
+ ))} +
+
+ ) : null; + // 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) => { @@ -263,6 +349,30 @@ export function ChatPanel() { {message.parts.map((part, i) => { const key = `${message.id}-${i}`; + if (part.type === "reasoning") { + return ( + + + {part.text} + + ); + } + if (part.type.startsWith("tool-")) { + const tp = part as ToolUIPart; + return ( + + + + + + + + ); + } if (part.type === "text") { return message.role === "user" ? ( @@ -345,6 +455,11 @@ export function ChatPanel() { {errorAlert} {veilGate} {promptInput} + + {suggestions.map((s) => ( + + ))} + @@ -369,6 +484,7 @@ export function ChatPanel() {
{errorAlert} {veilGate} + {queuePanel} {promptInput}
diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index f99dc5a..93541da 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -772,6 +772,16 @@ "patientNotFound": "I couldn't find a patient with file number {{fileNumber}}.", "thinking": "Thinking…", "steps": "Steps", + "suggestions": { + "schedule": "Show today's schedule", + "tasks": "List open tasks", + "prescriptions": "Show recent prescriptions", + "import": "Import a patient database" + }, + "queue": { + "label": "Queued · {{count}}", + "remove": "Remove from queue" + }, "veil": { "title": "Veil", "body": "This message will be processed by {{provider}}, an external provider. Veil de-identifies patient information (names, MRNs, providers) before it leaves your infrastructure, and restores it in the answer.",