Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record).
88 lines
3.1 KiB
TypeScript
88 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* THE SESSION HOOK (Wave D, owner-approved 2026-09-26): the React adapter
|
|
* over the pure SessionModel (components/session-core.ts — the state
|
|
* machine, testable without a renderer). All mechanics live in the model;
|
|
* this hook re-renders on change and derives the view (core/view.ts
|
|
* projectView — the golden-master grouping, client-side).
|
|
*
|
|
* Every turn is a server-side background process (owner ruling
|
|
* 2026-09-25); the hook is a view. Sends are ONE explicit input event
|
|
* each ({chatId, input}); the turn stream is the VERBATIM AgentEvent
|
|
* passthrough. Status vocabulary (pre-Wave-D parity): ready | submitted
|
|
* | streaming | error.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
|
import type { ChatInput, LhMessage, QuestionAnswerData } from "@/src/core";
|
|
import { SessionModel, type SessionState } from "@/components/session-core";
|
|
|
|
export interface UseChatSessionOptions {
|
|
chatId: string;
|
|
/** The native record seed (server-rendered page or a full sync). */
|
|
initialMessages: LhMessage[];
|
|
/** Reattach on mount: a turn is running server-side with no live view. */
|
|
initiallyInFlight?: boolean;
|
|
}
|
|
|
|
export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) {
|
|
// The model is per-mount (chatId-keyed component); onChange forces the
|
|
// re-render. The ref pattern keeps it stable across renders.
|
|
const modelRef = useRef<SessionModel | null>(null);
|
|
if (modelRef.current === null) {
|
|
modelRef.current = new SessionModel({ chatId, initialMessages });
|
|
}
|
|
const model = modelRef.current;
|
|
const [, forceRender] = useReducer((n: number) => n + 1, 0);
|
|
useEffect(() => {
|
|
model.onChange = forceRender;
|
|
return () => {
|
|
model.onChange = undefined;
|
|
};
|
|
}, [model, forceRender]);
|
|
|
|
// 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 reattach settled terminal → the sync-and-continue seam (chat.tsx).
|
|
const setReattachSettled = useCallback(
|
|
(fn: () => void) => {
|
|
model.setReattachSettled(fn);
|
|
},
|
|
[model],
|
|
);
|
|
|
|
const send = useCallback((input: ChatInput) => void model.send(input), [model]);
|
|
const submitAnswer = useCallback(
|
|
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
|
void model.submitAnswer(tool, toolCallId, output),
|
|
[model],
|
|
);
|
|
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
|
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
|
const stop = useCallback(() => model.stop(), [model]);
|
|
const clearError = useCallback(() => model.clearError(), [model]);
|
|
const resumeStream = useCallback(() => void model.resumeStream(), [model]);
|
|
|
|
return {
|
|
messages: state.messages,
|
|
nativeMessages: state.nativeMessages,
|
|
status: state.status,
|
|
error: state.error,
|
|
send,
|
|
submitAnswer,
|
|
setMessages,
|
|
adoptRecord,
|
|
stop,
|
|
clearError,
|
|
resumeStream,
|
|
setReattachSettled,
|
|
};
|
|
}
|