frontend: Claude-style AI chat (tool cards, reasoning, queue, suggestions)

Wire the existing ai-elements into the chat:
- collapsible tool-call cards (name, params, result, status) for native tool
  parts on the streamed path
- a reasoning block (shimmer while thinking, "Thought for Ns") when the model
  emits reasoning; backend forwards it via toUIMessageStream({ sendReasoning })
- a message queue: typing + Enter while the assistant is responding queues the
  message (shown above the input, removable) and auto-sends when it goes idle
- starter suggestion chips on the empty state, each tied to a tool

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 20:06:57 +03:00
parent d096c4fe9d
commit 9086867782
4 changed files with 134 additions and 5 deletions
+3 -1
View File
@@ -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) =>
+3 -2
View File
@@ -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<HTMLTextAreaElement>) => {
+118 -2
View File
@@ -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<string | null>(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<string[]>([]);
const transport = useMemo(
() =>
new DefaultChatTransport<TemetroUIMessage>({
@@ -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 <file#>` 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() {
</div>
) : 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 ? (
<Queue>
<span className="px-1 text-muted-foreground text-xs">
{t("chat.queue.label", { count: queued.length })}
</span>
<QueueList>
{queued.map((q, i) => (
<QueueItem key={`${i}-${q}`}>
<div className="flex items-center gap-2">
<QueueItemIndicator />
<QueueItemContent>{q}</QueueItemContent>
<QueueItemActions>
<QueueItemAction
aria-label={t("chat.queue.remove")}
onClick={() =>
setQueued((prev) => prev.filter((_, j) => j !== i))
}
>
<X className="size-3.5" />
</QueueItemAction>
</QueueItemActions>
</div>
</QueueItem>
))}
</QueueList>
</Queue>
) : 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 (
<Reasoning
className="w-full"
isStreaming={isWorking && isLast}
key={key}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
);
}
if (part.type.startsWith("tool-")) {
const tp = part as ToolUIPart;
return (
<Tool key={key}>
<ToolHeader state={tp.state} type={tp.type} />
<ToolContent>
<ToolInput input={tp.input} />
<ToolOutput errorText={tp.errorText} output={tp.output} />
</ToolContent>
</Tool>
);
}
if (part.type === "text") {
return message.role === "user" ? (
<span className="whitespace-pre-wrap" key={key}>
@@ -345,6 +455,11 @@ export function ChatPanel() {
{errorAlert}
{veilGate}
{promptInput}
<Suggestions className="justify-center pt-1">
{suggestions.map((s) => (
<Suggestion key={s} onClick={send} suggestion={s} />
))}
</Suggestions>
</div>
</div>
</div>
@@ -369,6 +484,7 @@ export function ChatPanel() {
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 pb-4">
{errorAlert}
{veilGate}
{queuePanel}
{promptInput}
</div>
</div>
@@ -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.",