import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import type { EventFrame } from "./protocol"; import { Route } from "./protocol"; import { ApiError, errMessage, fetchJson } from "./api"; import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents, } from "./derive"; import type { SessionsStore } from "./store"; import { classNames } from "./store"; import ChatStream from "./ChatStream"; import TaskPanel from "./TaskPanel"; const HISTORY_LIMIT: number = 1000; const LATEST_QUERY: string = "latest=1"; const TEXTAREA_MAX_H: number = 200; const SEND_KEY: string = "Enter"; interface Props { store: SessionsStore; pushToast: (text: string) => void; } export default function ChatView({ store, pushToast }: Props) { const { id } = useParams<{ id: string }>(); const sessionId: string = id ?? ""; const [events, setEvents] = useState([]); const [loadError, setLoadError] = useState(""); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [tasksOpen, setTasksOpen] = useState(false); const [hasOlder, setHasOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const lastSeqRef = useRef(0); const loadedRef = useRef(false); const taRef = useRef(null); // guards async fetches against session switches (S1) const sessionIdRef = useRef(sessionId); sessionIdRef.current = sessionId; const session = store.sessions.find((s) => s.id === sessionId); // Close the pi behind an agent session (stops + removes the container). const closePi = useCallback(async (): Promise => { if (sessionId.length === 0) return; try { await fetchJson(Route.SessionContainer(sessionId), { method: "DELETE" }); pushToast(`closed pi ${session?.name ?? sessionId}`); await store.refresh(); } catch (err) { pushToast(`close failed: ${errMessage(err)}`); } }, [sessionId, session, store, pushToast]); const applyEvents = useCallback((incoming: EventFrame[]): void => { setEvents((prev) => { const merged = mergeEvents(prev, incoming); lastSeqRef.current = Math.max( lastSeqRef.current, lastPersistedSeq(merged), ); return merged; }); }, []); // history load on mount / session switch: newest page, ascending (B1) useEffect(() => { if (sessionId.length === 0) return; loadedRef.current = false; setLoadError(""); setEvents([]); setHasOlder(false); lastSeqRef.current = 0; // a draft (or stuck sending state) from the previous session must not // leak into the new one setDraft(""); setSending(false); let alive = true; void (async () => { try { const evts = await fetchJson( `${Route.SessionEvents(sessionId)}?${LATEST_QUERY}&limit=${HISTORY_LIMIT}`, ); if (!alive) return; applyEvents(evts); setHasOlder(evts.length >= HISTORY_LIMIT); } catch (err) { if (alive) setLoadError(errMessage(err)); } finally { if (alive) loadedRef.current = true; } })(); return () => { alive = false; }; }, [sessionId, applyEvents]); // ws subscription: dep on store.subscribe (stable per manager) rather than // the whole store object — every sessions-list change would otherwise // churn unsubscribe/resubscribe and can drop events in the gap useEffect(() => { if (sessionId.length === 0 || store.state !== "open") return; return store.subscribe(sessionId, applyEvents); }, [sessionId, store.state, store.subscribe, applyEvents]); // missed events after reconnect (persisted only); a response for a // previous session must not merge here nor touch the cursor (S1) useEffect(() => { if (store.state !== "open" || !loadedRef.current) return; const id: string = sessionId; const after: number = lastSeqRef.current; void fetchJson( `${Route.SessionEvents(id)}?after=${after}&limit=${HISTORY_LIMIT}`, ) .then((evts) => { if (sessionIdRef.current === id) applyEvents(evts); }) .catch(() => undefined); }, [store.state, sessionId, applyEvents]); const chat = useMemo(() => deriveChat(events), [events]); const tasks = useMemo(() => deriveTasks(events), [events]); // a closed/crashed container can never emit agent_settled: an offline // session must never look busy (B2) const busy: boolean = chat.busy && session?.online !== false; const minSeq: number = useMemo( () => (events.length === 0 ? 0 : Math.min(...events.map((e) => e.seq))), [events], ); // previous page (seq < oldest loaded), ascending (B1) const loadOlder = useCallback(async (): Promise => { // the button only renders while a full page is loaded, so minSeq > 0 if (sessionIdRef.current !== sessionId || loadingOlder) return; setLoadingOlder(true); try { const evts = await fetchJson( `${Route.SessionEvents(sessionId)}?before=${minSeq}&limit=${HISTORY_LIMIT}`, ); if (sessionIdRef.current !== sessionId) return; applyEvents(evts); setHasOlder(evts.length >= HISTORY_LIMIT); } catch (err) { pushToast(errMessage(err)); } finally { setLoadingOlder(false); } }, [sessionId, minSeq, loadingOlder, applyEvents, pushToast]); const autosize = useCallback((): void => { const el = taRef.current; if (el === null) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_H)}px`; }, []); useEffect(autosize, [draft, autosize]); const send = async (): Promise => { const text = draft.trim(); if (text.length === 0 || sending) return; setDraft(""); setSending(true); try { await fetchJson(Route.SessionPrompt(sessionId), { method: "POST", body: JSON.stringify({ message: text }), }); } catch (err) { // the message never reached the session: put it back (S6) setDraft(text); if (err instanceof ApiError && err.status === 409) pushToast("session offline"); else pushToast(errMessage(err)); } finally { setSending(false); } }; const abort = async (): Promise => { try { await fetchJson(Route.SessionAbort(sessionId), { method: "POST" }); } catch (err) { pushToast(errMessage(err)); } }; const onKeyDown = (e: React.KeyboardEvent): void => { // IME composition: Enter confirms the candidate window, not a send if (e.nativeEvent.isComposing) return; if (e.key === SEND_KEY && !e.shiftKey) { e.preventDefault(); void send(); } }; if (sessionId.length === 0) { return (

No session selected.

); } return (
{session?.name ?? session?.repo ?? sessionId}
{session?.model}
{(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && ( ↑{chat.usage.inputTokens.toLocaleString()} ↓ {chat.usage.outputTokens.toLocaleString()} {chat.usage.totalCost > 0 && ` · $${chat.usage.totalCost.toFixed(2)}`} )}
{session?.agent === true && ( )}
{tasksOpen && (
)} {loadError.length > 0 ? (

Failed to load history: {loadError}

Back to sessions
) : ( void loadOlder()} /> )}