317 lines
9.2 KiB
TypeScript
317 lines
9.2 KiB
TypeScript
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<EventFrame[]>([]);
|
|
const [loadError, setLoadError] = useState<string>("");
|
|
const [draft, setDraft] = useState<string>("");
|
|
const [sending, setSending] = useState<boolean>(false);
|
|
const [tasksOpen, setTasksOpen] = useState<boolean>(false);
|
|
const [hasOlder, setHasOlder] = useState<boolean>(false);
|
|
const [loadingOlder, setLoadingOlder] = useState<boolean>(false);
|
|
|
|
const lastSeqRef = useRef<number>(0);
|
|
const loadedRef = useRef<boolean>(false);
|
|
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
|
// guards async fetches against session switches (S1)
|
|
const sessionIdRef = useRef<string>(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<void> => {
|
|
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<EventFrame[]>(
|
|
`${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<EventFrame[]>(
|
|
`${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<void> => {
|
|
// 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<EventFrame[]>(
|
|
`${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<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) {
|
|
// 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<void> => {
|
|
try {
|
|
await fetchJson(Route.SessionAbort(sessionId), { method: "POST" });
|
|
} catch (err) {
|
|
pushToast(errMessage(err));
|
|
}
|
|
};
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): 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 (
|
|
<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>
|
|
{(chat.usage.inputTokens > 0 || chat.usage.outputTokens > 0) && (
|
|
<span className="usage-chip" title="tokens this session">
|
|
↑{chat.usage.inputTokens.toLocaleString()} ↓
|
|
{chat.usage.outputTokens.toLocaleString()}
|
|
{chat.usage.totalCost > 0 &&
|
|
` · $${chat.usage.totalCost.toFixed(2)}`}
|
|
</span>
|
|
)}
|
|
<span
|
|
className={classNames(
|
|
"conn-dot",
|
|
session?.online === true ? "open" : "closed",
|
|
)}
|
|
title={session?.online === true ? "online" : "offline"}
|
|
/>
|
|
<div className="spacer" />
|
|
{session?.agent === true && (
|
|
<button
|
|
type="button"
|
|
className="icon-btn danger"
|
|
aria-label={`Close pi ${session.name ?? session.id}`}
|
|
onClick={() => void closePi()}
|
|
>
|
|
■ close
|
|
</button>
|
|
)}
|
|
<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
|
|
key={sessionId}
|
|
messages={chat.messages}
|
|
tools={chat.tools}
|
|
busy={busy}
|
|
hasOlder={hasOlder}
|
|
loadingOlder={loadingOlder}
|
|
onLoadOlder={() => void loadOlder()}
|
|
/>
|
|
)}
|
|
<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}
|
|
/>
|
|
{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>
|
|
);
|
|
}
|