gitdataai/src/page/workspace/workplan/chat-conversation.tsx

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>
);
}