733 lines
22 KiB
TypeScript
733 lines
22 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import { useParams, useNavigate, useLocation } from "react-router";
|
|
import { ArrowLeft, Sparkles } from "lucide-react";
|
|
import { motion, AnimatePresence } from "motion/react";
|
|
import { client } from "@/client";
|
|
import {
|
|
PromptInput,
|
|
PromptInputBody,
|
|
PromptInputTextarea,
|
|
PromptInputSubmit,
|
|
PromptInputFooter,
|
|
PromptInputTools,
|
|
PromptInputProvider,
|
|
usePromptInputController,
|
|
} from "@/components/ai-elements/prompt-input";
|
|
import type {
|
|
Message,
|
|
Conversation,
|
|
Phase,
|
|
StreamingState,
|
|
ToolCallState,
|
|
} from "./chat/types";
|
|
import { EMPTY_STREAM } from "./chat/types";
|
|
import { ModelSelectorPopover } from "./chat/model-selector-popover";
|
|
import { RepoMentionPopover } from "./chat/repo-mention-popover";
|
|
import { MessageBubble } from "./chat/message-bubble";
|
|
import { StreamingView } from "./chat/streaming-view";
|
|
import { CodePreviewProvider } from "./chat/code-preview-context";
|
|
import { CodePreviewPanel } from "./chat/code-preview-panel";
|
|
import { MessageNavDots } from "./chat/message-nav-dots";
|
|
import { mentionAtCursor } from "@/components/ai-elements/mention-textarea-overlay";
|
|
import { extractMentions } from "@/lib/ir/parser";
|
|
import { MentionChip } from "@/lib/ir/mention-chip";
|
|
|
|
// ---- Helpers ----
|
|
|
|
const formatDateLabel = (date: string) =>
|
|
new Date(date).toLocaleDateString([], {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
|
|
function parseSSELine(line: string): Record<string, unknown> | null {
|
|
const trimmed = line.trim();
|
|
if (!trimmed.startsWith("data: ")) return null;
|
|
try {
|
|
return JSON.parse(trimmed.slice(6));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function drainSSEBuffer(buffer: string): {
|
|
chunks: Record<string, unknown>[];
|
|
remainder: string;
|
|
} {
|
|
const lines = buffer.split("\n");
|
|
const remainder = lines.pop() ?? "";
|
|
const chunks: Record<string, unknown>[] = [];
|
|
for (const line of lines) {
|
|
const parsed = parseSSELine(line);
|
|
if (parsed) chunks.push(parsed);
|
|
}
|
|
return { chunks, remainder };
|
|
}
|
|
|
|
function makeUserMessage(text: string, conversationId: string): Message {
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
conversation_id: conversationId,
|
|
role: "user",
|
|
content: text,
|
|
content_type: "text",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function makeAssistantMessage(
|
|
id: string,
|
|
content: string,
|
|
conversationId: string,
|
|
): Message {
|
|
return {
|
|
id,
|
|
conversation_id: conversationId,
|
|
role: "assistant",
|
|
content,
|
|
content_type: "text",
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function safeStringify(v: unknown): string | undefined {
|
|
if (typeof v === "string") return v;
|
|
if (v == null) return undefined;
|
|
try {
|
|
return JSON.stringify(v);
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|
|
|
|
// ---- Main component ----
|
|
|
|
export default function WorkplanChatConversation() {
|
|
const { projectName = "", conversationId = "" } = useParams();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const prefill = (location.state as { prefill?: string } | null)?.prefill;
|
|
|
|
const [conversation, setConversation] = useState<Conversation | null>(null);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [stream, setStream] = useState<StreamingState>(EMPTY_STREAM);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const sendingRef = useRef(false);
|
|
|
|
const [sending, setSending] = useState(false);
|
|
const [modelProvider, setModelProvider] = useState<string>("");
|
|
const setSendingBoth = useCallback((v: boolean) => {
|
|
sendingRef.current = v;
|
|
setSending(v);
|
|
}, []);
|
|
|
|
// ---- Data fetching ----
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!conversationId) return;
|
|
try {
|
|
const [convRes, msgRes] = await Promise.all([
|
|
client.agentGetConversation(conversationId),
|
|
client.agentListMessages(conversationId, { limit: 100 }),
|
|
]);
|
|
setConversation(convRes.data);
|
|
setMessages(msgRes.data as unknown as Message[]);
|
|
requestAnimationFrame(() => {
|
|
scrollRef.current?.scrollTo({
|
|
top: scrollRef.current.scrollHeight,
|
|
behavior: "instant",
|
|
});
|
|
});
|
|
} catch {
|
|
// Non-critical; retried on next navigation.
|
|
}
|
|
}, [conversationId]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// ---- Auto-scroll ----
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
requestAnimationFrame(() => {
|
|
scrollRef.current?.scrollTo({
|
|
top: scrollRef.current.scrollHeight,
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, stream, scrollToBottom]);
|
|
|
|
// ---- SSE stream processing ----
|
|
|
|
function reduceStreamChunk(
|
|
prev: StreamingState,
|
|
chunk: Record<string, unknown>,
|
|
): {
|
|
next: StreamingState;
|
|
addAssistantMessage?: { id: string; content: string };
|
|
reloadData?: boolean;
|
|
} {
|
|
const next: StreamingState = {
|
|
...prev,
|
|
toolCalls: prev.toolCalls.map((tc) => ({ ...tc })),
|
|
};
|
|
|
|
switch (chunk.type) {
|
|
case "phase_change":
|
|
next.phase = chunk.phase as Phase;
|
|
return { next };
|
|
|
|
case "thinking":
|
|
if (typeof chunk.content === "string") {
|
|
next.phase = "think";
|
|
next.thinkText += chunk.content;
|
|
}
|
|
return { next };
|
|
|
|
case "delta":
|
|
if (typeof chunk.content === "string") {
|
|
next.phase = "answer";
|
|
next.answerText += chunk.content;
|
|
}
|
|
return { next };
|
|
|
|
case "tool_call_started": {
|
|
next.phase = "act";
|
|
const tc: ToolCallState = {
|
|
toolCallId: chunk.tool_call_id as string,
|
|
toolName: chunk.tool_name as string,
|
|
arguments: chunk.arguments as string,
|
|
};
|
|
if (!next.toolCalls.some((t) => t.toolCallId === tc.toolCallId)) {
|
|
next.toolCalls = [...next.toolCalls, tc];
|
|
}
|
|
return { next };
|
|
}
|
|
|
|
case "tool_call_finished": {
|
|
next.phase = "act";
|
|
const idx = next.toolCalls.findIndex(
|
|
(t) => t.toolCallId === (chunk.tool_call_id as string),
|
|
);
|
|
if (idx !== -1) {
|
|
const updated = { ...next.toolCalls[idx] };
|
|
updated.output = safeStringify(chunk.output);
|
|
updated.error = chunk.error as string | null;
|
|
next.toolCalls = [
|
|
...next.toolCalls.slice(0, idx),
|
|
updated,
|
|
...next.toolCalls.slice(idx + 1),
|
|
];
|
|
}
|
|
return { next };
|
|
}
|
|
|
|
case "done": {
|
|
const output = safeStringify(chunk.output) ?? next.answerText;
|
|
const messageId =
|
|
(chunk.message_id as string) || crypto.randomUUID();
|
|
return {
|
|
next: EMPTY_STREAM,
|
|
addAssistantMessage: { id: messageId, content: output },
|
|
reloadData: !!chunk.message_id,
|
|
};
|
|
}
|
|
|
|
case "title_updated":
|
|
return { next, reloadData: true };
|
|
|
|
case "subagent_started":
|
|
return {
|
|
next: {
|
|
...next,
|
|
phase: "act" as Phase,
|
|
toolCalls: [
|
|
...next.toolCalls,
|
|
{
|
|
toolCallId: chunk.subagent_id as string,
|
|
toolName: `Subagent: ${chunk.role || "specialist"}`,
|
|
arguments: chunk.task as string,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
case "subagent_completed": {
|
|
next.phase = "act";
|
|
const saIdx = next.toolCalls.findIndex(
|
|
(t) => t.toolCallId === (chunk.subagent_id as string),
|
|
);
|
|
if (saIdx !== -1) {
|
|
const updated = { ...next.toolCalls[saIdx] };
|
|
updated.output = safeStringify(chunk.output);
|
|
next.toolCalls = [
|
|
...next.toolCalls.slice(0, saIdx),
|
|
updated,
|
|
...next.toolCalls.slice(saIdx + 1),
|
|
];
|
|
}
|
|
return { next };
|
|
}
|
|
|
|
case "subagent_failed":
|
|
return { next, reloadData: false };
|
|
|
|
case "error":
|
|
return {
|
|
next: EMPTY_STREAM,
|
|
addAssistantMessage: {
|
|
id: crypto.randomUUID(),
|
|
content: `Error: ${chunk.error || "Stream error"}`,
|
|
},
|
|
};
|
|
|
|
default:
|
|
return { next };
|
|
}
|
|
}
|
|
|
|
const processChunk = useCallback(
|
|
(chunk: Record<string, unknown>) => {
|
|
setStream((prev) => {
|
|
const { next, addAssistantMessage, reloadData } = reduceStreamChunk(
|
|
prev,
|
|
chunk,
|
|
);
|
|
|
|
if (addAssistantMessage && !reloadData) {
|
|
const msg = makeAssistantMessage(
|
|
addAssistantMessage.id,
|
|
addAssistantMessage.content,
|
|
conversationId,
|
|
);
|
|
queueMicrotask(() => {
|
|
setMessages((prevMessages) => [...prevMessages, msg]);
|
|
});
|
|
}
|
|
|
|
if (reloadData) {
|
|
queueMicrotask(() => loadData());
|
|
}
|
|
|
|
return next;
|
|
});
|
|
},
|
|
[conversationId, loadData],
|
|
);
|
|
|
|
// ---- Send message ----
|
|
|
|
const sendMessage = useCallback(
|
|
async (text: string) => {
|
|
if (!text.trim() || !conversation || sendingRef.current) return;
|
|
|
|
setSendingBoth(true);
|
|
setStream(EMPTY_STREAM);
|
|
|
|
const controller = new AbortController();
|
|
abortRef.current = controller;
|
|
|
|
setMessages((prev) => [
|
|
...prev,
|
|
makeUserMessage(text, conversationId),
|
|
]);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/v1/agent/conversations/${conversationId}/stream`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
session_id: conversation.session_id,
|
|
conversation_id: conversationId,
|
|
input: text,
|
|
stream: true,
|
|
}),
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error("No response body");
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const { chunks, remainder } = drainSSEBuffer(buffer);
|
|
buffer = remainder;
|
|
|
|
for (const chunk of chunks) {
|
|
processChunk(chunk);
|
|
}
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
const { chunks } = drainSSEBuffer(buffer + "\n");
|
|
for (const chunk of chunks) {
|
|
processChunk(chunk);
|
|
}
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && err.name === "AbortError") return;
|
|
setMessages((prev) => [
|
|
...prev,
|
|
makeAssistantMessage(
|
|
crypto.randomUUID(),
|
|
err instanceof Error
|
|
? `Error: ${err.message}`
|
|
: "Request failed",
|
|
conversationId,
|
|
),
|
|
]);
|
|
} finally {
|
|
setSendingBoth(false);
|
|
abortRef.current = null;
|
|
}
|
|
},
|
|
[conversation, conversationId, processChunk, setSendingBoth],
|
|
);
|
|
|
|
// ---- Prefill (quick-start) ----
|
|
|
|
const prefilledRef = useRef(false);
|
|
useEffect(() => {
|
|
if (prefill && !prefilledRef.current && conversation && !sending) {
|
|
prefilledRef.current = true;
|
|
const timer = setTimeout(() => {
|
|
sendMessage(prefill);
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [prefill, conversation, sending, sendMessage]);
|
|
|
|
// ---- Stop generation ----
|
|
|
|
const handleStop = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
}, []);
|
|
|
|
// ---- Computed values ----
|
|
|
|
const messageGroups = useMemo(() => {
|
|
const groups: { date: string; messages: Message[] }[] = [];
|
|
for (const msg of messages) {
|
|
const ds = formatDateLabel(msg.created_at);
|
|
const last = groups[groups.length - 1];
|
|
if (last?.date === ds) {
|
|
last.messages.push(msg);
|
|
} else {
|
|
groups.push({ date: ds, messages: [msg] });
|
|
}
|
|
}
|
|
return groups;
|
|
}, [messages]);
|
|
|
|
const hasActiveStream = sending || stream.phase !== "idle";
|
|
|
|
return (
|
|
<PromptInputProvider>
|
|
<CodePreviewProvider>
|
|
<div className="flex h-full">
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<ChatInner
|
|
projectName={projectName}
|
|
navigate={navigate}
|
|
conversation={conversation}
|
|
messageGroups={messageGroups}
|
|
stream={stream}
|
|
hasActiveStream={hasActiveStream}
|
|
sending={sending}
|
|
modelProvider={modelProvider}
|
|
onModelChange={(_name, provider) => {
|
|
setModelProvider(provider);
|
|
}}
|
|
scrollRef={scrollRef}
|
|
sendMessage={sendMessage}
|
|
handleStop={handleStop}
|
|
/>
|
|
</div>
|
|
<CodePreviewPanel />
|
|
</div>
|
|
</CodePreviewProvider>
|
|
</PromptInputProvider>
|
|
);
|
|
}
|
|
|
|
// ---- Inner component ----
|
|
|
|
function ChatInner({
|
|
projectName,
|
|
navigate,
|
|
conversation,
|
|
messageGroups,
|
|
stream,
|
|
hasActiveStream,
|
|
sending,
|
|
modelProvider,
|
|
onModelChange,
|
|
scrollRef,
|
|
sendMessage,
|
|
handleStop,
|
|
}: {
|
|
projectName: string;
|
|
navigate: ReturnType<typeof useNavigate>;
|
|
conversation: Conversation | null;
|
|
messageGroups: { date: string; messages: Message[] }[];
|
|
stream: StreamingState;
|
|
hasActiveStream: boolean;
|
|
sending: boolean;
|
|
modelProvider: string;
|
|
onModelChange: (name: string, provider: string) => void;
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
sendMessage: (text: string) => Promise<void>;
|
|
handleStop: () => void;
|
|
}) {
|
|
const { textInput } = usePromptInputController();
|
|
const allMessages = useMemo(
|
|
() => messageGroups.flatMap((g) => g.messages),
|
|
[messageGroups],
|
|
);
|
|
|
|
const handleSubmit = useCallback(
|
|
(text: string) => {
|
|
if (text.trim()) {
|
|
sendMessage(text);
|
|
textInput.clear();
|
|
}
|
|
},
|
|
[sendMessage, textInput],
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// ---- Mention-aware backspace ----
|
|
if (e.key === "Backspace") {
|
|
const ta = e.currentTarget;
|
|
const { selectionStart, selectionEnd } = ta;
|
|
if (selectionStart === selectionEnd && selectionStart > 0) {
|
|
const hit = mentionAtCursor(textInput.value, selectionStart);
|
|
if (hit) {
|
|
e.preventDefault();
|
|
const newText =
|
|
textInput.value.slice(0, hit.start) +
|
|
textInput.value.slice(hit.end);
|
|
textInput.setInput(newText);
|
|
// Restore cursor to the position where the mention was.
|
|
requestAnimationFrame(() => {
|
|
ta.selectionStart = ta.selectionEnd = hit.start;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Enter to submit ----
|
|
if (e.key === "Enter" && !e.shiftKey && !sending) {
|
|
e.preventDefault();
|
|
const text = textInput.value;
|
|
if (text.trim()) {
|
|
sendMessage(text);
|
|
textInput.clear();
|
|
}
|
|
}
|
|
},
|
|
[sendMessage, textInput, sending],
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden bg-background">
|
|
{/* Header */}
|
|
<header className="flex h-12 shrink-0 items-center gap-2.5 border-b border-border/60 bg-card/50 px-4 backdrop-blur-sm">
|
|
<motion.button
|
|
type="button"
|
|
whileHover={{ scale: 1.05, x: -1 }}
|
|
whileTap={{ scale: 0.92 }}
|
|
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() => navigate(`/${projectName}/workplan/chat`)}
|
|
aria-label="Back to chat list"
|
|
>
|
|
<ArrowLeft className="size-4" aria-hidden="true" />
|
|
</motion.button>
|
|
|
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
<div className="grid size-6 place-items-center rounded-md bg-primary/10">
|
|
<Sparkles className="size-3 text-primary/70" />
|
|
</div>
|
|
<h1 className="truncate text-sm font-semibold text-foreground">
|
|
{conversation?.title && conversation.title.trim().length > 2 ? conversation.title : "New conversation"}
|
|
</h1>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Messages */}
|
|
<div
|
|
className="scrollbar-none relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6"
|
|
ref={scrollRef}
|
|
role="log"
|
|
aria-label="Conversation messages"
|
|
aria-live="polite"
|
|
>
|
|
<MessageNavDots
|
|
messages={allMessages}
|
|
scrollContainerRef={scrollRef}
|
|
/>
|
|
|
|
<AnimatePresence>
|
|
{messageGroups.length === 0 && !hasActiveStream && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.96 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.96 }}
|
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
className="flex h-full items-center justify-center"
|
|
>
|
|
<EmptyConversationState />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="mx-auto max-w-3xl space-y-6">
|
|
{messageGroups.map((group) => (
|
|
<div key={group.date}>
|
|
{/* Elegant date separator */}
|
|
<div className="my-8 flex items-center justify-center gap-3">
|
|
<div className="h-px w-12 bg-gradient-to-r from-transparent to-border/50" />
|
|
<span className="rounded-full bg-muted/50 px-3 py-1 text-[11px] font-medium text-muted-foreground/60 shadow-sm">
|
|
{group.date}
|
|
</span>
|
|
<div className="h-px w-12 bg-gradient-to-l from-transparent to-border/50" />
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{group.messages.map((msg) => (
|
|
<motion.div
|
|
key={msg.id}
|
|
data-msg-index={allMessages.indexOf(msg)}
|
|
data-msg-role={msg.role}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, ease: "easeOut" }}
|
|
>
|
|
<MessageBubble msg={msg} modelProvider={modelProvider} />
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<AnimatePresence>
|
|
{hasActiveStream && (
|
|
<motion.div
|
|
key="streaming"
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 6 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<StreamingView stream={stream} modelProvider={modelProvider} />
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Composer */}
|
|
<footer className="shrink-0 border-t border-border/60 bg-card/70 backdrop-blur-md">
|
|
<div className="mx-auto max-w-3xl px-4 py-3">
|
|
{/* Repo mention popover — positioned above the input */}
|
|
<div className="relative">
|
|
<RepoMentionPopover
|
|
workspaceName={projectName}
|
|
textValue={textInput.value}
|
|
onInsertMention={(mentionText, replaceFrom, replaceTo) => {
|
|
const before = textInput.value.slice(0, replaceFrom);
|
|
const after = textInput.value.slice(replaceTo);
|
|
textInput.setInput(before + mentionText + after);
|
|
}}
|
|
/>
|
|
</div>
|
|
{/* Active mention chips — shows which repos are referenced */}
|
|
{(() => {
|
|
const mentions = extractMentions(textInput.value);
|
|
if (mentions.length === 0) return null;
|
|
return (
|
|
<div className="mb-1.5 flex flex-wrap gap-1.5">
|
|
{mentions.map((m, i) => (
|
|
<MentionChip
|
|
key={`${m.entityId}-${i}`}
|
|
entityType={m.entityType}
|
|
entityId={m.entityId}
|
|
entityLabel={m.entityLabel}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
})()}
|
|
<PromptInput
|
|
globalDrop={false}
|
|
onSubmit={({ text }) => handleSubmit(text)}
|
|
>
|
|
<PromptInputBody>
|
|
<PromptInputTextarea
|
|
className="min-h-[48px] max-h-[200px] resize-none rounded-xl border-border/60 bg-muted/40 text-sm shadow-inner transition-colors focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-primary/20"
|
|
placeholder="Type a message… (use @ to mention a repo)"
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
</PromptInputBody>
|
|
<PromptInputFooter>
|
|
<PromptInputTools>
|
|
<ModelSelectorPopover
|
|
sessionId={conversation?.session_id}
|
|
onModelChange={onModelChange}
|
|
/>
|
|
</PromptInputTools>
|
|
<PromptInputSubmit
|
|
status={sending ? "streaming" : undefined}
|
|
onStop={handleStop}
|
|
/>
|
|
</PromptInputFooter>
|
|
</PromptInput>
|
|
<p className="mt-1.5 text-center text-[11px] text-muted-foreground/30">
|
|
AI responses may be inaccurate. Verify important information.
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Empty state shown when conversation has no messages. */
|
|
function EmptyConversationState() {
|
|
return (
|
|
<div className="text-center">
|
|
<div className="mx-auto grid size-16 place-items-center rounded-2xl bg-gradient-to-br from-primary/10 to-primary/[0.02] ring-1 ring-primary/10 shadow-sm">
|
|
<Sparkles className="size-7 text-primary/30" />
|
|
</div>
|
|
<p className="mt-4 text-sm font-medium text-muted-foreground/60">
|
|
Start a conversation
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground/35">
|
|
Type a message below to begin
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|