import { useEffect, useMemo, useRef, useState } from "react"; import { useAppStore } from "../store/useAppStore"; import { useCurrentPersona } from "../store/personaSelectors"; import { useModeStore } from "../store/useModeStore"; import { useSendChat } from "../lib/useChat"; import { sendUp } from "../lib/ws"; import { HistoryList } from "../components/messages/HistoryList"; import { ChatInput } from "../components/ChatInput"; import { EmptyState } from "../components/primitives"; import { Icon } from "../components/Icon"; import { SessionStatusHint, ApprovalNoticeHint } from "../components/status"; import { personaChatBackground, type Orientation } from "../lib/persona"; /** * The single shared page (design/07 §1). All behavior modes live in ONE message * stream; each message carries its own `mode` (assigned by the agent at runtime, * design/01 §3 hot-switch), so its bubble renders per that mode. There is no * manual mode selector — the agent / backend drives the mode. */ export function ChatShell(): React.ReactNode { const messages = useAppStore((s) => s.messages); // The chat wallpaper follows the *active session's* role (design/22 §4.6). const currentPersona = useCurrentPersona(); const { send, sending } = useSendChat(); const bottomRef = useRef(null); // Chat wallpaper follows the current orientation (landscape when wide, else // portrait) and re-evaluates on window resize. const [orientation, setOrientation] = useState(() => window.innerWidth > window.innerHeight ? "landscape" : "portrait" ); useEffect(() => { const onResize = () => setOrientation(window.innerWidth > window.innerHeight ? "landscape" : "portrait"); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); // The persona's own wallpaper for the current orientation; undefined keeps the // plain Miuix surface (never another role's image). const chatBackground = personaChatBackground(currentPersona, orientation); // The latest assistant message is the one being streamed right now; only it // carries the live 唤醒路径 node sequence (WakePath), so the capacity state // reflects the current turn rather than a collapsed historical message. const lastAssistantId = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "assistant") return messages[i].id; } return undefined; }, [messages]); // Scroll to the newest message only when a *new* message reaches the tail // (append/session-switch), not when the user hot-pages older history into the // list — otherwise "load earlier" would yank the viewport to the bottom. const lastMessageId = messages[messages.length - 1]?.id; useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [lastMessageId]); return (
{chatBackground ? (
) : null}
{messages.length === 0 ? ( } title="开始与昔涟对话" hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变" /> ) : ( )}
void send(t, image)} sending={sending} onVoice={() => { // Voice is not wired to a live pipeline yet (real call deferred). Activate // the voice_input uplink and switch the shell to voice mode so the agent // (or the demo) reflects a voice session. useModeStore.getState().setMode("voice"); sendUp({ type: "voice_input", data: { mode: "voice" } }); }} />
); }