web: React+Vite SPA — sessions, streaming chat, task panel, spawn flow (build clean)

This commit is contained in:
Raphael Westphal
2026-08-18 13:37:14 +02:00
parent 345025b070
commit 7d5f866527
22 changed files with 4253 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
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 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<EventFrame[]>([]);
const [loadError, setLoadError] = useState<string>("");
const [draft, setDraft] = useState<string>("");
const [sending, setSending] = useState<boolean>(false);
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
const lastSeqRef = useRef<number>(0);
const loadedRef = useRef<boolean>(false);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const session = store.sessions.find((s) => s.id === sessionId);
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
useEffect(() => {
if (sessionId.length === 0) return;
loadedRef.current = false;
setLoadError("");
setEvents([]);
lastSeqRef.current = 0;
let alive = true;
void (async () => {
try {
const evts = await fetchJson<EventFrame[]>(`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`);
if (!alive) return;
applyEvents(evts);
} catch (err) {
if (alive) setLoadError(errMessage(err));
} finally {
if (alive) loadedRef.current = true;
}
})();
return () => {
alive = false;
};
}, [sessionId, applyEvents]);
// ws subscription
useEffect(() => {
if (sessionId.length === 0 || store.state !== "open") return;
return store.subscribe(sessionId, applyEvents);
}, [sessionId, store.state, store, applyEvents]);
// missed events after reconnect (persisted only)
useEffect(() => {
if (store.state !== "open" || !loadedRef.current) return;
const after: number = lastSeqRef.current;
void fetchJson<EventFrame[]>(`${Route.SessionEvents(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`)
.then(applyEvents)
.catch(() => undefined);
}, [store.state, sessionId, applyEvents]);
const chat = useMemo(() => deriveChat(events), [events]);
const tasks = useMemo(() => deriveTasks(events), [events]);
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<void> => {
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) {
if (err instanceof ApiError && err.status === 409) pushToast("session offline");
else pushToast(errMessage(err));
} finally {
setSending(false);
}
};
const abort = async (): Promise<void> => {
try {
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
} catch (err) {
pushToast(errMessage(err));
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === SEND_KEY && !e.shiftKey) {
e.preventDefault();
void send();
}
};
if (sessionId.length === 0) {
return (
<div className="page">
<p className="empty">No session selected.</p>
</div>
);
}
return (
<div className="chat-layout">
<div className="chat-col">
<div className="chat-header">
<div className="title">{session?.name ?? session?.repo ?? sessionId}</div>
<div className="sub">{session?.model}</div>
<span
className={classNames("conn-dot", session?.online === true ? "open" : "closed")}
title={session?.online === true ? "online" : "offline"}
/>
<div className="spacer" />
<button
type="button"
className="task-toggle icon-btn"
aria-expanded={tasksOpen}
aria-label="Toggle task panel"
onClick={() => setTasksOpen(!tasksOpen)}
>
tasks
</button>
</div>
{tasksOpen && (
<div className="mobile-tasks">
<TaskPanel tasks={tasks} />
</div>
)}
{loadError.length > 0 ? (
<div className="page">
<p className="empty">Failed to load history: {loadError}</p>
<Link to="/">Back to sessions</Link>
</div>
) : (
<ChatStream messages={chat.messages} tools={chat.tools} busy={chat.busy} />
)}
<div className="composer">
<div className="composer-inner">
<textarea
ref={taRef}
rows={1}
value={draft}
placeholder="Message…"
aria-label="Message"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
{chat.busy ? (
<button type="button" className="abort-btn" aria-label="Abort current run" onClick={() => void abort()}>
stop
</button>
) : (
<button
type="button"
className="send-btn"
aria-label="Send message"
disabled={draft.trim().length === 0 || sending}
onClick={() => void send()}
>
</button>
)}
</div>
</div>
</div>
<aside className="task-col" aria-label="Task panel">
<TaskPanel tasks={tasks} />
</aside>
</div>
);
}