fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+

This commit is contained in:
Raphael Westphal
2026-08-18 18:49:52 +02:00
parent 64e45e1a82
commit 6aac763563
25 changed files with 2564 additions and 959 deletions
+53 -7
View File
@@ -15,6 +15,7 @@ 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";
@@ -32,10 +33,15 @@ export default function ChatView({ store, pushToast }: Props) {
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);
@@ -62,21 +68,23 @@ export default function ChatView({ store, pushToast }: Props) {
});
}, []);
// history load on mount / session switch
// 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;
let alive = true;
void (async () => {
try {
const evts = await fetchJson<EventFrame[]>(
`${Route.SessionEvents(sessionId)}?after=0&limit=${HISTORY_LIMIT}`,
`${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 {
@@ -94,20 +102,52 @@ export default function ChatView({ store, pushToast }: Props) {
return store.subscribe(sessionId, applyEvents);
}, [sessionId, store.state, store, applyEvents]);
// missed events after reconnect (persisted only)
// 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(sessionId)}?after=${after}&limit=${HISTORY_LIMIT}`,
`${Route.SessionEvents(id)}?after=${after}&limit=${HISTORY_LIMIT}`,
)
.then(applyEvents)
.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;
@@ -128,6 +168,8 @@ export default function ChatView({ store, pushToast }: Props) {
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));
@@ -215,9 +257,13 @@ export default function ChatView({ store, pushToast }: Props) {
</div>
) : (
<ChatStream
key={sessionId}
messages={chat.messages}
tools={chat.tools}
busy={chat.busy}
busy={busy}
hasOlder={hasOlder}
loadingOlder={loadingOlder}
onLoadOlder={() => void loadOlder()}
/>
)}
<div className="composer">
@@ -231,7 +277,7 @@ export default function ChatView({ store, pushToast }: Props) {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
/>
{chat.busy ? (
{busy ? (
<button
type="button"
className="abort-btn"