- Make loadConversation fully self-contained like sendMessage (handle errors internally via state, onError callback, and structured logging) - Remove duplicate try/catch+log from Chat.tsx imperative handle - Replace re-throw tests with delegation and no-throw tests - Add hook-level loadConversation error path tests (getIdea rejection) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
303 lines
9.2 KiB
TypeScript
303 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
|
import { useAuth } from "@/lib/auth/auth-context";
|
|
import { useChat } from "@/hooks/useChat";
|
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
|
import { MessageList } from "./MessageList";
|
|
import { ChatInput } from "./ChatInput";
|
|
import type { Message } from "@/hooks/useChat";
|
|
|
|
export interface ChatRef {
|
|
loadConversation: (conversationId: string) => Promise<void>;
|
|
startNewConversation: (projectId?: string | null) => void;
|
|
getCurrentConversationId: () => string | null;
|
|
}
|
|
|
|
export interface NewConversationData {
|
|
id: string;
|
|
title: string | null;
|
|
project_id: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ChatProps {
|
|
onConversationChange?: (
|
|
conversationId: string | null,
|
|
conversationData?: NewConversationData
|
|
) => void;
|
|
onProjectChange?: () => void;
|
|
initialProjectId?: string | null;
|
|
onInitialProjectHandled?: () => void;
|
|
}
|
|
|
|
const WAITING_QUIPS = [
|
|
"The AI is warming up... give it a moment.",
|
|
"Loading the neural pathways...",
|
|
"Waking up the LLM. It's not a morning model.",
|
|
"Brewing some thoughts...",
|
|
"The AI is stretching its parameters...",
|
|
"Summoning intelligence from the void...",
|
|
"Teaching electrons to think...",
|
|
"Consulting the silicon oracle...",
|
|
"The hamsters are spinning up the GPU...",
|
|
"Defragmenting the neural networks...",
|
|
];
|
|
|
|
export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|
{
|
|
onConversationChange,
|
|
onProjectChange: _onProjectChange,
|
|
initialProjectId,
|
|
onInitialProjectHandled: _onInitialProjectHandled,
|
|
},
|
|
ref
|
|
) {
|
|
void _onProjectChange;
|
|
void _onInitialProjectHandled;
|
|
|
|
const { user, isLoading: authLoading } = useAuth();
|
|
|
|
// Use the chat hook for state management
|
|
const {
|
|
messages,
|
|
isLoading: isChatLoading,
|
|
error,
|
|
conversationId,
|
|
conversationTitle,
|
|
sendMessage,
|
|
loadConversation,
|
|
startNewConversation,
|
|
clearError,
|
|
} = useChat({
|
|
model: "llama3.2",
|
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
|
});
|
|
|
|
// Connect to WebSocket for real-time updates (when we have a user)
|
|
const { isConnected: isWsConnected } = useWebSocket(
|
|
user?.id ?? "", // Use user ID as workspace ID for now
|
|
"", // Token not needed since we use cookies
|
|
{
|
|
// Future: Add handlers for chat-related events
|
|
// onChatMessage: (msg) => { ... }
|
|
}
|
|
);
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
|
const quipTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const quipIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Expose methods to parent via ref
|
|
useImperativeHandle(ref, () => ({
|
|
loadConversation: async (cId: string): Promise<void> => {
|
|
await loadConversation(cId);
|
|
},
|
|
startNewConversation: (projectId?: string | null): void => {
|
|
startNewConversation(projectId);
|
|
},
|
|
getCurrentConversationId: (): string | null => conversationId,
|
|
}));
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, scrollToBottom]);
|
|
|
|
// Notify parent of conversation changes
|
|
useEffect(() => {
|
|
if (conversationId && conversationTitle) {
|
|
onConversationChange?.(conversationId, {
|
|
id: conversationId,
|
|
title: conversationTitle,
|
|
project_id: initialProjectId ?? null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
} else {
|
|
onConversationChange?.(null);
|
|
}
|
|
}, [conversationId, conversationTitle, initialProjectId, onConversationChange]);
|
|
|
|
// Global keyboard shortcut: Ctrl+/ to focus input
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return (): void => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
// Show loading quips
|
|
useEffect(() => {
|
|
if (isChatLoading) {
|
|
// Show first quip after 3 seconds
|
|
quipTimerRef.current = setTimeout(() => {
|
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
|
}, 3000);
|
|
|
|
// Change quip every 5 seconds
|
|
quipIntervalRef.current = setInterval(() => {
|
|
setLoadingQuip(WAITING_QUIPS[Math.floor(Math.random() * WAITING_QUIPS.length)] ?? null);
|
|
}, 5000);
|
|
} else {
|
|
// Clear timers when loading stops
|
|
if (quipTimerRef.current) {
|
|
clearTimeout(quipTimerRef.current);
|
|
quipTimerRef.current = null;
|
|
}
|
|
if (quipIntervalRef.current) {
|
|
clearInterval(quipIntervalRef.current);
|
|
quipIntervalRef.current = null;
|
|
}
|
|
setLoadingQuip(null);
|
|
}
|
|
|
|
return (): void => {
|
|
if (quipTimerRef.current) clearTimeout(quipTimerRef.current);
|
|
if (quipIntervalRef.current) clearInterval(quipIntervalRef.current);
|
|
};
|
|
}, [isChatLoading]);
|
|
|
|
const handleSendMessage = useCallback(
|
|
async (content: string) => {
|
|
await sendMessage(content);
|
|
},
|
|
[sendMessage]
|
|
);
|
|
|
|
// Show loading state while auth is loading
|
|
if (authLoading) {
|
|
return (
|
|
<div
|
|
className="flex flex-1 items-center justify-center"
|
|
style={{ backgroundColor: "rgb(var(--color-background))" }}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="h-5 w-5 animate-spin rounded-full border-2 border-t-transparent"
|
|
style={{ borderColor: "rgb(var(--accent-primary))", borderTopColor: "transparent" }}
|
|
/>
|
|
<span style={{ color: "rgb(var(--text-secondary))" }}>Loading...</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-1 flex-col"
|
|
style={{ backgroundColor: "rgb(var(--color-background))" }}
|
|
>
|
|
{/* Connection Status Indicator */}
|
|
{user && !isWsConnected && (
|
|
<div
|
|
className="border-b px-4 py-2"
|
|
style={{
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
borderColor: "rgb(var(--border-default))",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="h-2 w-2 rounded-full"
|
|
style={{ backgroundColor: "rgb(var(--semantic-warning))" }}
|
|
/>
|
|
<span className="text-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
|
Reconnecting to server...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Messages Area */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
|
<MessageList
|
|
messages={messages as (Message & { thinking?: string })[]}
|
|
isLoading={isChatLoading}
|
|
loadingQuip={loadingQuip}
|
|
/>
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<div className="mx-4 mb-2 lg:mx-auto lg:max-w-4xl lg:px-8">
|
|
<div
|
|
className="flex items-center justify-between rounded-lg border px-4 py-3"
|
|
style={{
|
|
backgroundColor: "rgb(var(--semantic-error-light))",
|
|
borderColor: "rgb(var(--semantic-error) / 0.3)",
|
|
}}
|
|
role="alert"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<svg
|
|
className="h-4 w-4 flex-shrink-0"
|
|
style={{ color: "rgb(var(--semantic-error))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="12" y1="8" x2="12" y2="12" />
|
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
</svg>
|
|
<span className="text-sm" style={{ color: "rgb(var(--semantic-error-dark))" }}>
|
|
{error}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={clearError}
|
|
className="rounded p-1 transition-colors hover:bg-black/5"
|
|
aria-label="Dismiss error"
|
|
>
|
|
<svg
|
|
className="h-4 w-4"
|
|
style={{ color: "rgb(var(--semantic-error))" }}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Input Area */}
|
|
<div
|
|
className="sticky bottom-0 border-t"
|
|
style={{
|
|
backgroundColor: "rgb(var(--surface-0))",
|
|
borderColor: "rgb(var(--border-default))",
|
|
}}
|
|
>
|
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
|
<ChatInput
|
|
onSend={handleSendMessage}
|
|
disabled={isChatLoading || !user}
|
|
inputRef={inputRef}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|