web: React+Vite SPA — sessions, streaming chat, task panel, spawn flow (build clean)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user