feat(chat): unify QuickChat backend path and consolidate render toggle
Backend - chat.ts now routes both regular chat and QuickChat through createResolvedAgentSession instead of branching to createFnAgent for the no-runtime-hint case. This removes the divergent path where pi-ai's cleanupSessionResources(sessionId) could tear down resources the next generation depends on. - sendMessage's finally only disposes the agent if it still owns the activeGenerations slot. A newer generation that has pre-empted us cleans up its own agent in its own finally — disposing here would yank the underlying CLI process out from under it. - __setCreateFnAgent test helper now mirrors its mock into the createResolvedAgentSession slot so existing test setups still work after the unification. Frontend - Extract createChatStreamHandlers (RAF coalescing, accumulators, tool-call dedup, fallback handling) — useChat and useQuickChat were duplicating ~85 LOC each. Both now compose the shared factory. - Move shared chat types into chatTypes.ts. The hooks re-export them for backward compatibility with existing consumers. - Removed per-message Markdown/plain-text eye toggles. A single thread-level toggle in the chat header now flips every assistant bubble (including the streaming one) between rendered Markdown and plain text. - Model-only chats hide the per-message agent identity row entirely; the model name is already in the thread header. Tests - Updated ChatView tests to reflect the new render-toggle contract (single header toggle drives all bubbles) and the model-only avatar suppression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
212
packages/dashboard/app/hooks/createChatStreamHandlers.ts
Normal file
212
packages/dashboard/app/hooks/createChatStreamHandlers.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { ChatMessage } from "@fusion/core";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||
|
||||
/**
|
||||
* Inputs for the chat streaming-handler factory.
|
||||
*
|
||||
* The shared factory owns the per-stream accumulator state (text, thinking,
|
||||
* tool calls, fallback info), the requestAnimationFrame coalescing of state
|
||||
* updates, and the SSE event → state-setter wiring. Caller-specific behaviour
|
||||
* for the terminal events (`onDone`, `onError`) and the optional
|
||||
* `onFallbackSession` model-swap is provided through callbacks so that
|
||||
* `useChat` and `useQuickChat` can plug in their own session-management
|
||||
* semantics without re-implementing the streaming machinery.
|
||||
*/
|
||||
export interface CreateChatStreamHandlersOptions {
|
||||
/** Active session id — used by `onFallbackSession` for parent-side updates. */
|
||||
sessionId: string;
|
||||
/** Optimistic temp id of the user message added before the stream started. */
|
||||
tempUserMessageId: string;
|
||||
/**
|
||||
* The latest text/thinking/tool-call snapshots that are committed to React
|
||||
* state. We pass setters (not values) so the factory can flush per-frame
|
||||
* without rerunning the parent's effects.
|
||||
*/
|
||||
setStreamingText: Dispatch<SetStateAction<string>>;
|
||||
setStreamingThinking: Dispatch<SetStateAction<string>>;
|
||||
setStreamingToolCalls: Dispatch<SetStateAction<ToolCallInfo[]>>;
|
||||
/**
|
||||
* Caller-side `cancelStreamingFlushes` ref slot. The factory writes its own
|
||||
* cancel function here so `stopStreaming` (in either parent hook) can call
|
||||
* it to abort pending RAF flushes regardless of which sendMessage owns them.
|
||||
*/
|
||||
cancelStreamingFlushesRef: RefObject<(() => void) | null>;
|
||||
/** Optional toast helper, used to surface fallback-model warnings + errors. */
|
||||
addToast?: (message: string, level: "error" | "warning" | "success") => void;
|
||||
/** Caller-supplied terminal handlers — bind in their own state setters. */
|
||||
onDone: (data: {
|
||||
messageId: string;
|
||||
message?: ChatMessage;
|
||||
accumulated: {
|
||||
text: string;
|
||||
thinking: string;
|
||||
toolCalls: ToolCallInfo[];
|
||||
fallbackInfo?: FallbackInfo;
|
||||
};
|
||||
}) => void;
|
||||
onError: (data: string, tempUserMessageId: string) => void;
|
||||
/**
|
||||
* Fallback-model side effect for the parent (e.g. updating the session list
|
||||
* or the active session's model fields). The factory still emits the toast.
|
||||
*/
|
||||
onFallbackSession?: (data: FallbackInfo, sessionId: string) => void;
|
||||
}
|
||||
|
||||
export interface ChatStreamHandlers {
|
||||
onThinking: (delta: string) => void;
|
||||
onText: (delta: string) => void;
|
||||
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => void;
|
||||
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
|
||||
onFallback: (data: FallbackInfo) => void;
|
||||
onDone: (data: { messageId: string; message?: ChatMessage }) => void;
|
||||
onError: (data: string) => void;
|
||||
}
|
||||
|
||||
export interface CreateChatStreamHandlersResult {
|
||||
handlers: ChatStreamHandlers;
|
||||
/** Cancel any pending RAF flushes for this stream. Idempotent. */
|
||||
cancelFlushes: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSE handler bundle that `streamChatResponse` consumes. This is the
|
||||
* portion of the chat send/stream flow that was identical between `useChat`
|
||||
* and `useQuickChat`; extracting it keeps both hooks in sync when we tweak
|
||||
* coalescing, tool-call dedup, fallback toasts, etc. The terminal events
|
||||
* (`onDone`/`onError`) and parent-side fallback bookkeeping stay caller-owned
|
||||
* because each hook handles message persistence and error recovery
|
||||
* differently.
|
||||
*
|
||||
* The factory writes its `cancelFlushes` into `cancelStreamingFlushesRef.current`
|
||||
* so the parent's `stopStreaming` can drain pending RAF callbacks before
|
||||
* clearing transient streaming state — preventing a flushed delta from
|
||||
* flashing back into the UI after a stop.
|
||||
*/
|
||||
export function createChatStreamHandlers(
|
||||
options: CreateChatStreamHandlersOptions,
|
||||
): CreateChatStreamHandlersResult {
|
||||
const {
|
||||
sessionId,
|
||||
tempUserMessageId,
|
||||
setStreamingText,
|
||||
setStreamingThinking,
|
||||
setStreamingToolCalls,
|
||||
cancelStreamingFlushesRef,
|
||||
addToast,
|
||||
onDone,
|
||||
onError,
|
||||
onFallbackSession,
|
||||
} = options;
|
||||
|
||||
let capturedText = "";
|
||||
let capturedThinking = "";
|
||||
let capturedToolCalls: ToolCallInfo[] = [];
|
||||
let capturedFallbackInfo: FallbackInfo | undefined;
|
||||
|
||||
// Coalesce per-token state updates to one render per animation frame.
|
||||
// ReactMarkdown re-parses the entire growing string on every render and
|
||||
// every prior message also re-renders, so unthrottled setState here pegs
|
||||
// the main thread on long replies.
|
||||
let textRaf: number | null = null;
|
||||
let thinkingRaf: number | null = null;
|
||||
const flushText = (): void => {
|
||||
textRaf = null;
|
||||
setStreamingText(capturedText);
|
||||
};
|
||||
const flushThinking = (): void => {
|
||||
thinkingRaf = null;
|
||||
setStreamingThinking(capturedThinking);
|
||||
};
|
||||
const cancelFlushes = (): void => {
|
||||
if (textRaf !== null) {
|
||||
cancelAnimationFrame(textRaf);
|
||||
textRaf = null;
|
||||
}
|
||||
if (thinkingRaf !== null) {
|
||||
cancelAnimationFrame(thinkingRaf);
|
||||
thinkingRaf = null;
|
||||
}
|
||||
};
|
||||
cancelStreamingFlushesRef.current = cancelFlushes;
|
||||
|
||||
const handlers: ChatStreamHandlers = {
|
||||
onThinking: (delta: string) => {
|
||||
capturedThinking += delta;
|
||||
if (thinkingRaf === null) {
|
||||
thinkingRaf = requestAnimationFrame(flushThinking);
|
||||
}
|
||||
},
|
||||
onText: (delta: string) => {
|
||||
capturedText += delta;
|
||||
if (textRaf === null) {
|
||||
textRaf = requestAnimationFrame(flushText);
|
||||
}
|
||||
},
|
||||
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
|
||||
capturedToolCalls = [
|
||||
...capturedToolCalls,
|
||||
{
|
||||
toolName: data.toolName,
|
||||
args: data.args,
|
||||
isError: false,
|
||||
status: "running",
|
||||
},
|
||||
];
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
|
||||
const nextToolCalls = [...capturedToolCalls];
|
||||
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
|
||||
const candidate = nextToolCalls[i];
|
||||
if (candidate?.toolName === data.toolName && candidate.status === "running") {
|
||||
nextToolCalls[i] = {
|
||||
...candidate,
|
||||
status: "completed",
|
||||
isError: data.isError,
|
||||
result: data.result,
|
||||
};
|
||||
capturedToolCalls = nextToolCalls;
|
||||
setStreamingToolCalls(nextToolCalls);
|
||||
return;
|
||||
}
|
||||
}
|
||||
capturedToolCalls = [
|
||||
...nextToolCalls,
|
||||
{
|
||||
toolName: data.toolName,
|
||||
isError: data.isError,
|
||||
result: data.result,
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onFallback: (data: FallbackInfo) => {
|
||||
capturedFallbackInfo = data;
|
||||
onFallbackSession?.(data, sessionId);
|
||||
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
|
||||
},
|
||||
onDone: (data: { messageId: string; message?: ChatMessage }) => {
|
||||
cancelFlushes();
|
||||
onDone({
|
||||
messageId: data.messageId,
|
||||
message: data.message,
|
||||
accumulated: {
|
||||
text: capturedText,
|
||||
thinking: capturedThinking,
|
||||
toolCalls: capturedToolCalls,
|
||||
fallbackInfo: capturedFallbackInfo,
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (data: string) => {
|
||||
cancelFlushes();
|
||||
onError(data, tempUserMessageId);
|
||||
},
|
||||
};
|
||||
|
||||
return { handlers, cancelFlushes };
|
||||
}
|
||||
|
||||
export type { ChatMessageInfo };
|
||||
Reference in New Issue
Block a user