返回 JoyAI-Echo
ChatPane.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { Clapperboard } from "lucide-react";
3
4 import { Composer } from "@/components/Composer";
5 import { MessageList } from "@/components/MessageList";
6 import { useNanobotStream } from "@/hooks/useNanobotStream";
7 import { useSessionHistory } from "@/hooks/useSessions";
8 import type { ChatSummary } from "@/lib/types";
9
10 interface ChatPaneProps {
11 session: ChatSummary | null;
12 /** Provision a new chat and mark it active. Returns the new chat_id or null. */
13 onNewChat: () => Promise<string | null>;
14 }
15
16 /**
17 * The chat surface: persisted history on top, live stream below, composer
18 * pinned at the bottom. When no session is active we render a centered
19 * welcome card with a fully-functional composer — typing a first message
20 * quietly provisions a new chat and routes the message through.
21 */
22 export function ChatPane({ session, onNewChat }: ChatPaneProps) {
23 const chatId = session?.chatId ?? null;
24 const historyKey = session?.key ?? null;
25 const { messages: historical, loading } = useSessionHistory(historyKey);
26 const [booting, setBooting] = useState(false);
27 const pendingFirstRef = useRef<string | null>(null);
28
29 const initial = useMemo(() => historical, [historical]);
30 const { messages, isStreaming, send, setMessages } = useNanobotStream(
31 chatId,
32 initial,
33 );
34
35 useEffect(() => {
36 if (!loading && chatId) setMessages(historical);
37 // eslint-disable-next-line react-hooks/exhaustive-deps
38 }, [loading, chatId, historical]);
39
40 // Once a session becomes active, flush any first-message stashed from the
41 // welcome composer so the user's keystroke "just sends".
42 useEffect(() => {
43 if (!chatId) return;
44 const pending = pendingFirstRef.current;
45 if (!pending) return;
46 pendingFirstRef.current = null;
47 send(pending);
48 setBooting(false);
49 }, [chatId, send]);
50
51 const handleWelcomeSend = useCallback(
52 async (content: string) => {
53 if (booting) return;
54 setBooting(true);
55 pendingFirstRef.current = content;
56 const newId = await onNewChat();
57 if (!newId) {
58 // Creation failed — release the lock so the user can retry.
59 pendingFirstRef.current = null;
60 setBooting(false);
61 }
62 },
63 [booting, onNewChat],
64 );
65
66 if (!session) {
67 return (
68 <section className="flex min-h-0 flex-1 flex-col">
69 <div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
70 <div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
71 <Clapperboard
72 className="h-12 w-12 text-foreground/60 drop-shadow-sm"
73 aria-label="Echo Director"
74 />
75 <h1 className="text-xl font-medium tracking-tight text-foreground/90">
76 What's on your mind?
77 </h1>
78 <p className="max-w-md text-center text-sm text-muted-foreground">
79 Your conversations are persisted locally under the Echo Director
80 workspace. Start typing and I'll open a new chat.
81 </p>
82 </div>
83 <div className="w-full animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
84 <Composer
85 compact
86 disabled={booting}
87 onSend={handleWelcomeSend}
88 placeholder={
89 booting ? "Opening a new chat…" : "Type your message…"
90 }
91 />
92 </div>
93 </div>
94 </section>
95 );
96 }
97
98 return (
99 <section className="relative flex min-h-0 flex-1 flex-col">
100 <MessageList messages={messages} isStreaming={isStreaming} />
101 <Composer
102 onSend={send}
103 disabled={!chatId}
104 placeholder="Type your message…"
105 />
106 </section>
107 );
108 }
109
109 lines Plain Text