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:
+53
-7
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user