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:
@@ -12,30 +12,14 @@ import {
|
||||
|
||||
export const FN_AGENT_ID = "__fn_agent__";
|
||||
|
||||
export interface ToolCallInfo {
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
isError: boolean;
|
||||
result?: unknown;
|
||||
status: "running" | "completed";
|
||||
}
|
||||
|
||||
export interface FallbackInfo {
|
||||
primaryModel: string;
|
||||
fallbackModel: string;
|
||||
triggerPoint: "session-creation" | "prompt-time";
|
||||
}
|
||||
|
||||
export interface ChatMessageInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
thinkingOutput?: string | null;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
fallbackInfo?: FallbackInfo;
|
||||
createdAt: string;
|
||||
}
|
||||
// Re-export shared chat types so existing consumers keep working — single
|
||||
// source of truth lives in chatTypes.ts and is shared with useChat.
|
||||
// Note: useQuickChat's previous local `ChatMessageInfo` lacked the
|
||||
// `attachments` field; the shared type adds it (a strict superset), which is
|
||||
// safe for callers that ignore it.
|
||||
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||
|
||||
interface ModelSelection {
|
||||
modelProvider?: string;
|
||||
@@ -559,121 +543,32 @@ export function useQuickChat(
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Accumulate streaming text and tool calls in local variables
|
||||
let capturedText = "";
|
||||
let capturedThinking = "";
|
||||
let capturedToolCalls: ToolCallInfo[] = [];
|
||||
let capturedFallbackInfo: FallbackInfo | undefined;
|
||||
|
||||
// Coalesce per-token state updates to one render per animation frame —
|
||||
// unthrottled setStreamingText pegs the main thread on long replies.
|
||||
let textRaf: number | null = null;
|
||||
let thinkingRaf: number | null = null;
|
||||
const flushText = () => {
|
||||
textRaf = null;
|
||||
setStreamingText(capturedText);
|
||||
};
|
||||
const flushThinking = () => {
|
||||
thinkingRaf = null;
|
||||
setStreamingThinking(capturedThinking);
|
||||
};
|
||||
const cancelStreamingFlushes = () => {
|
||||
if (textRaf !== null) {
|
||||
cancelAnimationFrame(textRaf);
|
||||
textRaf = null;
|
||||
}
|
||||
if (thinkingRaf !== null) {
|
||||
cancelAnimationFrame(thinkingRaf);
|
||||
thinkingRaf = null;
|
||||
}
|
||||
};
|
||||
cancelStreamingFlushesRef.current = cancelStreamingFlushes;
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
capturedThinking += data;
|
||||
if (thinkingRaf === null) {
|
||||
thinkingRaf = requestAnimationFrame(flushThinking);
|
||||
}
|
||||
},
|
||||
onText: (data: string) => {
|
||||
capturedText += data;
|
||||
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;
|
||||
const { handlers } = createChatStreamHandlers({
|
||||
sessionId: activeSession.id,
|
||||
tempUserMessageId: tempId,
|
||||
setStreamingText,
|
||||
setStreamingThinking,
|
||||
setStreamingToolCalls,
|
||||
cancelStreamingFlushesRef,
|
||||
addToast,
|
||||
onFallbackSession: (data, sessionId) => {
|
||||
const nextModel = parseModelDescriptor(data.fallbackModel);
|
||||
setSessions((prev) => prev.map((session) =>
|
||||
session.id === activeSession.id
|
||||
? {
|
||||
...session,
|
||||
...nextModel,
|
||||
}
|
||||
: session,
|
||||
session.id === sessionId ? { ...session, ...nextModel } : session,
|
||||
));
|
||||
setActiveSession((prev) => prev && prev.id === activeSession.id
|
||||
? {
|
||||
...prev,
|
||||
...nextModel,
|
||||
}
|
||||
: prev);
|
||||
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
|
||||
setActiveSession((prev) => prev && prev.id === sessionId ? { ...prev, ...nextModel } : prev);
|
||||
},
|
||||
onDone: (data: { messageId: string; message?: ChatMessage }) => {
|
||||
cancelStreamingFlushes();
|
||||
const finalMessage = data.message;
|
||||
onDone: ({ messageId, message: finalMessage, accumulated }) => {
|
||||
const assistantMessage: ChatMessageInfo = finalMessage
|
||||
? mapChatMessageToInfo(finalMessage)
|
||||
: {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
id: messageId || `msg-${Date.now()}`,
|
||||
sessionId: activeSession.id,
|
||||
role: "assistant",
|
||||
content: capturedText,
|
||||
thinkingOutput: capturedThinking || undefined,
|
||||
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
|
||||
fallbackInfo: capturedFallbackInfo,
|
||||
content: accumulated.text,
|
||||
thinkingOutput: accumulated.thinking || undefined,
|
||||
toolCalls: accumulated.toolCalls.length > 0 ? accumulated.toolCalls : undefined,
|
||||
fallbackInfo: accumulated.fallbackInfo,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -695,8 +590,7 @@ export function useQuickChat(
|
||||
void sendMessage(queuedMessage);
|
||||
}
|
||||
},
|
||||
onError: (data: string) => {
|
||||
cancelStreamingFlushes();
|
||||
onError: (data) => {
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
@@ -718,9 +612,9 @@ export function useQuickChat(
|
||||
|
||||
void reloadMessages();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, attachments, projectId);
|
||||
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
|
||||
});
|
||||
|
||||
// Preserve rejection semantics for awaiters while preventing unhandled rejection noise
|
||||
|
||||
Reference in New Issue
Block a user