Files
stack/apps/web/src/components/chat/Chat.tsx
Jason Woltje 69cc3f8e1e
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
fix(web): Remove re-throw from loadConversation to prevent unhandled rejections
- 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>
2026-02-06 20:33:52 -06:00

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