Files
stack/apps/web/src/components/chat/Chat.tsx
T
Jason Woltje 69bdfa5df1 fix: code review cleanup
- Fixed TypeScript exactOptionalPropertyTypes errors in chat components
- Removed console.error statements (errors are handled via state)
- Fixed type compatibility issues with undefined vs null values
- All chat-related files now pass strict TypeScript checks
2026-01-29 23:36:01 -06:00

283 lines
9.1 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 }),
onError: (_err) => {
// Error is handled by the useChat hook's state
},
});
// 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 (conversationId: string) => {
await loadConversation(conversationId);
},
startNewConversation: (projectId?: string | null) => {
startNewConversation(projectId);
},
getCurrentConversationId: () => 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) => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => 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 () => {
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 Array<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>
);
});