fix: the empty-new-session bug — three live client defects
Live-driven via the owner's dev server (browser tools), reproduced on /chat/new: send fires, optimistic appends, POST never happens, and the page shows the frozen empty state. Three compounding defects: 1. session-core: default fetch stored UNBOUND — the browser's native fetch requires this === Window, so every call threw 'Illegal invocation' and the POST /api/chat never fired. Node's undici has no receiver discipline, which is why 994/994 tests passed while the app was dead. Now defaulted through a receiver-preserving wrapper; pinned by a strict-this regression test (the browser's semantics simulated. 2. session.ts: state memo keyed on [model, forceRender] — the reducer dispatch is STABLE by contract, so the memo computed once per mount: no optimistic bubble, no stream events, no error banner. The view was frozen at first render. Rekeyed on a version counter bumped by the model's onChange. 3. view.ts: turn_end carries the clean 'Question posed' placeholder as a toolResult message (details: null) — applyResult flipped the quiz part to output-available with a null output and AnsweredCard crashed on answer.dismissed (killed the whole page render). Fork 2 now holds on the message-attachment path too: clean question results with null details are ignored; the AnsweredCard renders only the learner's real answer. Regression pin extended in session-core.test.ts. Verified live end to end: new session → text send → probe questions in the dock → ask answer → graded ✓ quiz card with explanation → reload renders the full transcript from the native record with the pending question intact. EOF )
This commit is contained in:
@@ -192,7 +192,11 @@ export class SessionModel {
|
||||
constructor(opts: SessionModelOptions) {
|
||||
this.chatId = opts.chatId;
|
||||
this.base = opts.initialMessages;
|
||||
this.fetch = opts.fetch ?? globalThis.fetch;
|
||||
// BOUND: the browser's native fetch is a Window method — storing it
|
||||
// unbound makes every call throw "Illegal invocation" in the browser
|
||||
// (Node's undici doesn't care, so the tests never saw it). Always go
|
||||
// through a receiver-preserving wrapper.
|
||||
this.fetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||
this.pollMs = opts.pollMs ?? TURN_POLL_MS;
|
||||
this.waitMaxMs = opts.waitMaxMs ?? TURN_WAIT_MAX_MS;
|
||||
this.onChange = opts.onChange;
|
||||
|
||||
+11
-5
@@ -34,21 +34,27 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
modelRef.current = new SessionModel({ chatId, initialMessages });
|
||||
}
|
||||
const model = modelRef.current;
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0);
|
||||
// The render VERSION — bumped by every model change, so the state memo
|
||||
// below recomputes exactly when the model actually changed. (Using the
|
||||
// reducer's dispatch as the memo dep was a freeze bug: dispatch is
|
||||
// STABLE by contract, so the memo computed once per mount and the view
|
||||
// never saw a single model update — no optimistic bubble, no stream, no
|
||||
// error banner. The empty-session bug, 2026-09-26.)
|
||||
const [version, bump] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => {
|
||||
model.onChange = forceRender;
|
||||
model.onChange = bump;
|
||||
return () => {
|
||||
model.onChange = undefined;
|
||||
};
|
||||
}, [model, forceRender]);
|
||||
}, [model, bump]);
|
||||
|
||||
// Reattach on mount (StrictMode-safe: a newer resume cancels the older).
|
||||
useEffect(() => {
|
||||
if (initiallyInFlight) void model.resumeStream();
|
||||
}, [initiallyInFlight, model]);
|
||||
|
||||
// The view + state, derived per render from the model.
|
||||
const state = useMemo<SessionState>(() => model.getState(), [model, forceRender] as const);
|
||||
// The view + state, derived per model change from the model.
|
||||
const state = useMemo<SessionState>(() => model.getState(), [model, version] as const);
|
||||
|
||||
// The reattach settled terminal → the sync-and-continue seam (chat.tsx).
|
||||
const setReattachSettled = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user