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:
gsxdsm
2026-05-05 11:04:48 -07:00
parent 02eb1c5b30
commit 32c19af7d3
9 changed files with 491 additions and 373 deletions

View File

@@ -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