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:
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix Quick Chat backend divergence and consolidate the chat render-mode toggle.
|
||||||
|
|
||||||
|
- Backend: Quick Chat and regular chat now go through a single agent-creation path (`createResolvedAgentSession`), eliminating the `createFnAgent` branch where pi-ai's `cleanupSessionResources(sessionId)` could tear down resources still in use by a newer generation. The `sendMessage` `finally` only disposes the agent if it still owns the `activeGenerations` slot, so a pre-empted generation no longer rips state out from under its successor.
|
||||||
|
- Frontend: extracted the SSE streaming-handler factory shared between `useChat` and `useQuickChat` (RAF coalescing, accumulators, tool-call dedup, fallback handling) into `createChatStreamHandlers`. Both hooks now compose it instead of duplicating ~85 LOC each.
|
||||||
|
- UX: removed per-message Markdown/plain-text eye toggles. A single thread-level toggle now lives in the chat header and flips every assistant bubble (including the streaming one) between rendered Markdown and plain text. Model-only chats also drop their per-message agent-identity row — the model is shown once in the thread header.
|
||||||
@@ -215,10 +215,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread-header-new-chat {
|
.chat-thread-header-new-chat {
|
||||||
margin-left: auto;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single thread-wide markdown / plain-text toggle, anchored to the right of
|
||||||
|
* the header next to "New Chat". Replaces the per-message eye toggle that
|
||||||
|
* used to live inside every assistant bubble. */
|
||||||
|
.chat-thread-header-render-toggle {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(var(--space-md) * 3);
|
||||||
|
height: calc(var(--space-md) * 3);
|
||||||
|
min-width: calc(var(--space-md) * 3);
|
||||||
|
min-height: calc(var(--space-md) * 3);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread-header-render-toggle:hover {
|
||||||
|
background: var(--bg-hover, var(--bg-secondary));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-thread-header-render-toggle--plain {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Messages */
|
/* Messages */
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -262,6 +292,15 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model-only chats hide the per-message agent identity. The toggle still
|
||||||
|
* needs a place to live, so collapse the avatar row to just the toggle on
|
||||||
|
* the right edge instead of a full identity strip. */
|
||||||
|
.chat-message-avatar.chat-message-avatar--toolbar-only {
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message-render-toggle {
|
.chat-message-render-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -541,13 +541,24 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
|
|||||||
|
|
||||||
interface ChatMessageItemProps {
|
interface ChatMessageItemProps {
|
||||||
message: ChatMessageInfo;
|
message: ChatMessageInfo;
|
||||||
|
/**
|
||||||
|
* When true, render assistant message content as plain text instead of
|
||||||
|
* Markdown. The per-message eye toggle has been removed in favor of a
|
||||||
|
* single thread-level toggle in the chat header, so this is a global
|
||||||
|
* mirror of that header state.
|
||||||
|
*/
|
||||||
forcePlain: boolean;
|
forcePlain: boolean;
|
||||||
agentName: string;
|
agentName: string;
|
||||||
|
/**
|
||||||
|
* Hide the per-message agent identity (icon + name + model tag) on
|
||||||
|
* assistant bubbles. In model-only chats the agent identity *is* the
|
||||||
|
* active model and it's already shown in the thread header.
|
||||||
|
*/
|
||||||
|
hideAssistantIdentity: boolean;
|
||||||
showAssistantModelTag: boolean;
|
showAssistantModelTag: boolean;
|
||||||
activeModelTag: string | null;
|
activeModelTag: string | null;
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
mentionAgentsByName: Map<string, Agent>;
|
mentionAgentsByName: Map<string, Agent>;
|
||||||
onToggleRender: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders a single chat message bubble. Memoized so the streaming bubble's
|
// Renders a single chat message bubble. Memoized so the streaming bubble's
|
||||||
@@ -557,11 +568,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
message,
|
message,
|
||||||
forcePlain,
|
forcePlain,
|
||||||
agentName,
|
agentName,
|
||||||
|
hideAssistantIdentity,
|
||||||
showAssistantModelTag,
|
showAssistantModelTag,
|
||||||
activeModelTag,
|
activeModelTag,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
mentionAgentsByName,
|
mentionAgentsByName,
|
||||||
onToggleRender,
|
|
||||||
}: ChatMessageItemProps) {
|
}: ChatMessageItemProps) {
|
||||||
const isAssistantMessage = message.role === "assistant";
|
const isAssistantMessage = message.role === "assistant";
|
||||||
|
|
||||||
@@ -659,20 +670,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
className={`chat-message chat-message--${message.role}`}
|
className={`chat-message chat-message--${message.role}`}
|
||||||
data-testid={`chat-message-${message.id}`}
|
data-testid={`chat-message-${message.id}`}
|
||||||
>
|
>
|
||||||
{isAssistantMessage && (
|
{isAssistantMessage && !hideAssistantIdentity && (
|
||||||
<div className="chat-message-avatar">
|
<div className="chat-message-avatar">
|
||||||
<Bot size={14} />
|
<Bot size={14} />
|
||||||
<span>{agentName}</span>
|
<span>{agentName}</span>
|
||||||
{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
|
|
||||||
data-testid="chat-message-render-toggle"
|
|
||||||
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
|
|
||||||
onClick={() => onToggleRender(message.id)}
|
|
||||||
>
|
|
||||||
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAssistantMessage
|
{isAssistantMessage
|
||||||
@@ -730,7 +732,11 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
|
||||||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||||||
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
const [mentionStartPos, setMentionStartPos] = useState(-1);
|
||||||
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set());
|
// Single thread-wide toggle: when true, all assistant content (including the
|
||||||
|
// streaming bubble) renders as plain text instead of Markdown. Replaces the
|
||||||
|
// earlier per-message toggle so the chat header owns this control instead
|
||||||
|
// of every reply having its own button.
|
||||||
|
const [showAllAsPlain, setShowAllAsPlain] = useState(false);
|
||||||
// Attachment state mirrors QuickEntryBox: pending files selected before send.
|
// Attachment state mirrors QuickEntryBox: pending files selected before send.
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
@@ -1479,22 +1485,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
? (activeModelTag ?? "Fusion")
|
? (activeModelTag ?? "Fusion")
|
||||||
: (activeSession?.agentId?.slice(0, 30) ?? "Fusion"));
|
: (activeSession?.agentId?.slice(0, 30) ?? "Fusion"));
|
||||||
|
|
||||||
const showAssistantModelTag = Boolean(activeModelTag && activeModelTag !== agentName);
|
// The model tag is already visible in the thread header — repeating it on
|
||||||
|
// every assistant message is noise. Keep it suppressed for regular chat
|
||||||
|
// (real agent name is the identity); QuickChat already collapses the tag
|
||||||
|
// because its `agentName` IS the model tag, so the per-message slot was
|
||||||
|
// always empty there too.
|
||||||
|
const showAssistantModelTag = false;
|
||||||
|
|
||||||
|
// In model-only chats (no real agent picked) the agent identity *is* the
|
||||||
|
// model name, which is already in the thread header. Repeating it on every
|
||||||
|
// assistant bubble is noise. Hide the per-message identity row entirely;
|
||||||
|
// the render-mode toggle still appears in a slim toolbar.
|
||||||
|
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
|
||||||
|
|
||||||
const pendingPreview = pendingMessage.length > 50
|
const pendingPreview = pendingMessage.length > 50
|
||||||
? `${pendingMessage.slice(0, 50)}…`
|
? `${pendingMessage.slice(0, 50)}…`
|
||||||
: pendingMessage;
|
: pendingMessage;
|
||||||
|
|
||||||
const toggleMessageRenderMode = useCallback((messageId: string) => {
|
const toggleAllAsPlain = useCallback(() => {
|
||||||
setPlainTextMessageIds((current) => {
|
setShowAllAsPlain((value) => !value);
|
||||||
const next = new Set(current);
|
|
||||||
if (next.has(messageId)) {
|
|
||||||
next.delete(messageId);
|
|
||||||
} else {
|
|
||||||
next.add(messageId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderAssistantContent = useCallback(
|
const renderAssistantContent = useCallback(
|
||||||
@@ -1677,6 +1686,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
<Bot size={16} />
|
<Bot size={16} />
|
||||||
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
<span className="chat-thread-header-title">{threadHeaderTitle}</span>
|
||||||
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||||
|
{hasThreadInView && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-thread-header-render-toggle${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
|
||||||
|
data-testid="chat-thread-render-toggle"
|
||||||
|
aria-label={showAllAsPlain ? "Show all messages as rendered Markdown" : "Show all messages as plain text"}
|
||||||
|
onClick={toggleAllAsPlain}
|
||||||
|
>
|
||||||
|
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-primary chat-thread-header-new-chat"
|
className="btn btn-sm btn-primary chat-thread-header-new-chat"
|
||||||
@@ -1699,32 +1719,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
<ChatMessageItem
|
<ChatMessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
forcePlain={plainTextMessageIds.has(message.id)}
|
forcePlain={showAllAsPlain}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
|
hideAssistantIdentity={hideAssistantIdentity}
|
||||||
showAssistantModelTag={showAssistantModelTag}
|
showAssistantModelTag={showAssistantModelTag}
|
||||||
activeModelTag={activeModelTag}
|
activeModelTag={activeModelTag}
|
||||||
activeSessionId={activeSession?.id ?? null}
|
activeSessionId={activeSession?.id ?? null}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
onToggleRender={toggleMessageRenderMode}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||||
<div className="chat-message-avatar">
|
{!hideAssistantIdentity && (
|
||||||
<Bot size={14} />
|
<div className="chat-message-avatar">
|
||||||
<span>{agentName}</span>
|
<Bot size={14} />
|
||||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
<span>{agentName}</span>
|
||||||
<button
|
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||||
type="button"
|
</div>
|
||||||
className={`chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
|
)}
|
||||||
data-testid="chat-message-render-toggle"
|
|
||||||
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
|
|
||||||
onClick={() => toggleMessageRenderMode("__streaming__")}
|
|
||||||
>
|
|
||||||
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{streamingText ? (
|
{streamingText ? (
|
||||||
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
|
renderAssistantContent(streamingText, showAllAsPlain)
|
||||||
) : (
|
) : (
|
||||||
<div className="chat-message-content chat-message-content--waiting">
|
<div className="chat-message-content chat-message-content--waiting">
|
||||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||||
@@ -1758,13 +1771,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
|||||||
<ChatMessageItem
|
<ChatMessageItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
forcePlain={plainTextMessageIds.has(message.id)}
|
forcePlain={showAllAsPlain}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
|
hideAssistantIdentity={hideAssistantIdentity}
|
||||||
showAssistantModelTag={showAssistantModelTag}
|
showAssistantModelTag={showAssistantModelTag}
|
||||||
activeModelTag={activeModelTag}
|
activeModelTag={activeModelTag}
|
||||||
activeSessionId={activeSession?.id ?? null}
|
activeSessionId={activeSession?.id ?? null}
|
||||||
mentionAgentsByName={mentionAgentsByName}
|
mentionAgentsByName={mentionAgentsByName}
|
||||||
onToggleRender={toggleMessageRenderMode}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ describe("ChatView", () => {
|
|||||||
expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders per-message eye toggles for assistant bubbles on desktop and isolates toggles by message", async () => {
|
it("thread-header toggle flips every assistant bubble between rendered Markdown and plain text", async () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
messages: [
|
messages: [
|
||||||
@@ -458,24 +458,27 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
const firstBubble = screen.getByTestId("chat-message-msg-001");
|
const firstBubble = screen.getByTestId("chat-message-msg-001");
|
||||||
const secondBubble = screen.getByTestId("chat-message-msg-002");
|
const secondBubble = screen.getByTestId("chat-message-msg-002");
|
||||||
const [firstToggle, secondToggle] = screen.getAllByTestId("chat-message-render-toggle");
|
const headerToggle = screen.getByTestId("chat-thread-render-toggle");
|
||||||
|
|
||||||
expect(firstToggle).toBeInTheDocument();
|
// Per-message toggles were intentionally removed; only the single
|
||||||
expect(secondToggle).toBeInTheDocument();
|
// thread-level toggle should exist.
|
||||||
|
expect(screen.queryAllByTestId("chat-message-render-toggle")).toHaveLength(0);
|
||||||
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
|
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
|
||||||
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
|
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(firstToggle);
|
await userEvent.click(headerToggle);
|
||||||
|
|
||||||
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
|
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
|
||||||
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
|
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
|
||||||
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
|
expect(within(secondBubble).getByText(/\*\*Second\*\* item/)).toBeInTheDocument();
|
||||||
|
expect(within(secondBubble).queryByText("Second", { selector: "strong" })).toBeNull();
|
||||||
|
|
||||||
await userEvent.click(firstToggle);
|
await userEvent.click(headerToggle);
|
||||||
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
|
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
|
||||||
|
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a dedicated streaming toggle sentinel without affecting persisted assistant messages", async () => {
|
it("thread-header toggle also drives the streaming bubble", async () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
|
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
|
||||||
@@ -487,19 +490,19 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
const persistedBubble = screen.getByTestId("chat-message-msg-001");
|
const persistedBubble = screen.getByTestId("chat-message-msg-001");
|
||||||
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
|
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
|
||||||
const [persistedToggle, streamingToggle] = screen.getAllByTestId("chat-message-render-toggle");
|
const headerToggle = screen.getByTestId("chat-thread-render-toggle");
|
||||||
|
|
||||||
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
||||||
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
|
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(streamingToggle);
|
await userEvent.click(headerToggle);
|
||||||
|
|
||||||
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
|
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
|
||||||
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
|
|
||||||
|
|
||||||
await userEvent.click(persistedToggle);
|
|
||||||
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
|
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
|
||||||
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
|
|
||||||
|
await userEvent.click(headerToggle);
|
||||||
|
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
|
||||||
|
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders tool calls from persisted messages", () => {
|
it("renders tool calls from persisted messages", () => {
|
||||||
@@ -869,7 +872,10 @@ describe("ChatView", () => {
|
|||||||
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
|
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Fusion in assistant message avatar for fn agent sessions", () => {
|
it("hides per-message assistant identity for fn agent (model-only) sessions", () => {
|
||||||
|
// Model-only chats use the active model as their identity, which is
|
||||||
|
// already shown in the thread header. We deliberately suppress the
|
||||||
|
// per-message avatar to avoid repeating it on every reply.
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
messages: [
|
messages: [
|
||||||
@@ -879,12 +885,11 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
|
||||||
expect(within(avatar!).getByText("Fusion")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows formatted model name in assistant message avatar for fn agent sessions", async () => {
|
it("hides per-message assistant identity for fn agent (model-only) sessions even when a model is configured", async () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: {
|
activeSession: {
|
||||||
id: "session-001",
|
id: "session-001",
|
||||||
@@ -902,14 +907,12 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
|
||||||
|
// The model name still appears once in the thread header.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(within(avatar!).getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
|
|
||||||
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows resolved agent name in streaming assistant avatar", async () => {
|
it("shows resolved agent name in streaming assistant avatar", async () => {
|
||||||
@@ -1858,7 +1861,11 @@ describe("ChatView", () => {
|
|||||||
expect(modelTag).not.toBeInTheDocument();
|
expect(modelTag).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows model tag in message avatar when non-fn session has model", () => {
|
it("does not repeat the model tag in per-message avatars for non-fn sessions", () => {
|
||||||
|
// Per-message model tags were intentionally removed — the model is shown
|
||||||
|
// once in the thread header. The avatar should still render with the
|
||||||
|
// agent name (no agent identity collapse for real agents) but no model
|
||||||
|
// tag inside it.
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: {
|
activeSession: {
|
||||||
id: "session-001",
|
id: "session-001",
|
||||||
@@ -1876,12 +1883,13 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
||||||
|
const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null;
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(avatar).toBeInTheDocument();
|
||||||
expect(avatar?.querySelector(".chat-model-tag")?.textContent).toContain("GPT");
|
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show duplicate model tag in message avatar for fn agent sessions", () => {
|
it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: {
|
activeSession: {
|
||||||
id: "session-001",
|
id: "session-001",
|
||||||
@@ -1899,10 +1907,8 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null;
|
const messageBubble = screen.getByTestId("chat-message-msg-001");
|
||||||
expect(avatar).toBeInTheDocument();
|
expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
|
||||||
expect(within(avatar!).getByText("GPT-4o")).toBeInTheDocument();
|
|
||||||
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
40
packages/dashboard/app/hooks/chatTypes.ts
Normal file
40
packages/dashboard/app/hooks/chatTypes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Shared chat type definitions used by both `useChat` (full chat panel) and
|
||||||
|
* `useQuickChat` (FAB) plus the `createChatStreamHandlers` factory they
|
||||||
|
* compose. Keeping the types here lets the streaming-handler factory live in
|
||||||
|
* its own file without re-importing from one of the hooks (which would create
|
||||||
|
* an awkward parent→sibling dependency cycle).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
attachments?: Array<{
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
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 };
|
||||||
@@ -30,38 +30,11 @@ export interface ChatSessionInfo {
|
|||||||
isGenerating?: boolean;
|
isGenerating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallInfo {
|
// Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)
|
||||||
toolName: string;
|
// keep working — single source of truth lives in chatTypes.ts.
|
||||||
args?: Record<string, unknown>;
|
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
isError: boolean;
|
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
result?: unknown;
|
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||||
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;
|
|
||||||
attachments?: Array<{
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
originalName: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseChatReturn {
|
export interface UseChatReturn {
|
||||||
// Session state
|
// Session state
|
||||||
@@ -547,127 +520,37 @@ export function useChat(
|
|||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
||||||
// Accumulate streaming text and tool calls in local variables
|
const { handlers } = createChatStreamHandlers({
|
||||||
let capturedText = "";
|
sessionId: activeSession.id,
|
||||||
let capturedThinking = "";
|
tempUserMessageId: tempId,
|
||||||
let capturedToolCalls: ToolCallInfo[] = [];
|
setStreamingText,
|
||||||
let capturedFallbackInfo: FallbackInfo | undefined;
|
setStreamingThinking,
|
||||||
|
setStreamingToolCalls,
|
||||||
// Coalesce per-token state updates to one render per animation frame.
|
cancelStreamingFlushesRef,
|
||||||
// ReactMarkdown re-parses the entire growing string on every render and
|
addToast,
|
||||||
// every prior message also re-renders, so unthrottled updates pin the
|
onFallbackSession: (data, sessionId) => {
|
||||||
// main thread for 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 nextModel = parseModelDescriptor(data.fallbackModel);
|
const nextModel = parseModelDescriptor(data.fallbackModel);
|
||||||
setSessions((prev) => prev.map((session) =>
|
setSessions((prev) => prev.map((session) =>
|
||||||
session.id === activeSession.id
|
session.id === sessionId ? { ...session, ...nextModel } : session,
|
||||||
? {
|
|
||||||
...session,
|
|
||||||
...nextModel,
|
|
||||||
}
|
|
||||||
: session,
|
|
||||||
));
|
));
|
||||||
setActiveSession((prev) => prev && prev.id === activeSession.id
|
setActiveSession((prev) => prev && prev.id === sessionId ? { ...prev, ...nextModel } : prev);
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
...nextModel,
|
|
||||||
}
|
|
||||||
: prev);
|
|
||||||
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
|
|
||||||
},
|
},
|
||||||
onDone: (data: { messageId: string; message?: ChatMessage }) => {
|
onDone: ({ messageId, message: finalMessage, accumulated }) => {
|
||||||
cancelStreamingFlushes();
|
|
||||||
const finalMessage = data.message;
|
|
||||||
const assistantMessage: ChatMessageInfo = finalMessage
|
const assistantMessage: ChatMessageInfo = finalMessage
|
||||||
? mapChatMessageToInfo(finalMessage)
|
? mapChatMessageToInfo(finalMessage)
|
||||||
: {
|
: {
|
||||||
id: data.messageId || `msg-${Date.now()}`,
|
id: messageId || `msg-${Date.now()}`,
|
||||||
sessionId: activeSession.id,
|
sessionId: activeSession.id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: capturedText,
|
content: accumulated.text,
|
||||||
thinkingOutput: capturedThinking,
|
thinkingOutput: accumulated.thinking,
|
||||||
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
|
toolCalls: accumulated.toolCalls.length > 0 ? accumulated.toolCalls : undefined,
|
||||||
fallbackInfo: capturedFallbackInfo,
|
fallbackInfo: accumulated.fallbackInfo,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track this message ID so SSE handler skips it if event arrives first
|
// Track this message ID so the SSE chatMessageAdded handler skips it
|
||||||
|
// if the broadcast event arrives before our optimistic add settles.
|
||||||
streamingMessageIdsRef.current.add(assistantMessage.id);
|
streamingMessageIdsRef.current.add(assistantMessage.id);
|
||||||
|
|
||||||
// Preserve user message and add assistant message
|
// Preserve user message and add assistant message
|
||||||
@@ -693,9 +576,8 @@ export function useChat(
|
|||||||
sendMessage(queuedMessage);
|
sendMessage(queuedMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (data: string) => {
|
onError: (data, tempUserMessageId) => {
|
||||||
cancelStreamingFlushes();
|
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessageId));
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== tempId));
|
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingThinking("");
|
setStreamingThinking("");
|
||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
@@ -713,9 +595,9 @@ export function useChat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, attachments, projectId);
|
streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
|
||||||
},
|
},
|
||||||
[activeSession, isStreaming, projectId, refreshSessions, addToast],
|
[activeSession, isStreaming, projectId, refreshSessions, addToast],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,30 +12,14 @@ import {
|
|||||||
|
|
||||||
export const FN_AGENT_ID = "__fn_agent__";
|
export const FN_AGENT_ID = "__fn_agent__";
|
||||||
|
|
||||||
export interface ToolCallInfo {
|
// Re-export shared chat types so existing consumers keep working — single
|
||||||
toolName: string;
|
// source of truth lives in chatTypes.ts and is shared with useChat.
|
||||||
args?: Record<string, unknown>;
|
// Note: useQuickChat's previous local `ChatMessageInfo` lacked the
|
||||||
isError: boolean;
|
// `attachments` field; the shared type adds it (a strict superset), which is
|
||||||
result?: unknown;
|
// safe for callers that ignore it.
|
||||||
status: "running" | "completed";
|
export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
}
|
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
|
||||||
|
import { createChatStreamHandlers } from "./createChatStreamHandlers";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelSelection {
|
interface ModelSelection {
|
||||||
modelProvider?: string;
|
modelProvider?: string;
|
||||||
@@ -559,121 +543,32 @@ export function useQuickChat(
|
|||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
||||||
// Accumulate streaming text and tool calls in local variables
|
const { handlers } = createChatStreamHandlers({
|
||||||
let capturedText = "";
|
sessionId: activeSession.id,
|
||||||
let capturedThinking = "";
|
tempUserMessageId: tempId,
|
||||||
let capturedToolCalls: ToolCallInfo[] = [];
|
setStreamingText,
|
||||||
let capturedFallbackInfo: FallbackInfo | undefined;
|
setStreamingThinking,
|
||||||
|
setStreamingToolCalls,
|
||||||
// Coalesce per-token state updates to one render per animation frame —
|
cancelStreamingFlushesRef,
|
||||||
// unthrottled setStreamingText pegs the main thread on long replies.
|
addToast,
|
||||||
let textRaf: number | null = null;
|
onFallbackSession: (data, sessionId) => {
|
||||||
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 nextModel = parseModelDescriptor(data.fallbackModel);
|
const nextModel = parseModelDescriptor(data.fallbackModel);
|
||||||
setSessions((prev) => prev.map((session) =>
|
setSessions((prev) => prev.map((session) =>
|
||||||
session.id === activeSession.id
|
session.id === sessionId ? { ...session, ...nextModel } : session,
|
||||||
? {
|
|
||||||
...session,
|
|
||||||
...nextModel,
|
|
||||||
}
|
|
||||||
: session,
|
|
||||||
));
|
));
|
||||||
setActiveSession((prev) => prev && prev.id === activeSession.id
|
setActiveSession((prev) => prev && prev.id === sessionId ? { ...prev, ...nextModel } : prev);
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
...nextModel,
|
|
||||||
}
|
|
||||||
: prev);
|
|
||||||
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
|
|
||||||
},
|
},
|
||||||
onDone: (data: { messageId: string; message?: ChatMessage }) => {
|
onDone: ({ messageId, message: finalMessage, accumulated }) => {
|
||||||
cancelStreamingFlushes();
|
|
||||||
const finalMessage = data.message;
|
|
||||||
const assistantMessage: ChatMessageInfo = finalMessage
|
const assistantMessage: ChatMessageInfo = finalMessage
|
||||||
? mapChatMessageToInfo(finalMessage)
|
? mapChatMessageToInfo(finalMessage)
|
||||||
: {
|
: {
|
||||||
id: data.messageId || `msg-${Date.now()}`,
|
id: messageId || `msg-${Date.now()}`,
|
||||||
sessionId: activeSession.id,
|
sessionId: activeSession.id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: capturedText,
|
content: accumulated.text,
|
||||||
thinkingOutput: capturedThinking || undefined,
|
thinkingOutput: accumulated.thinking || undefined,
|
||||||
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
|
toolCalls: accumulated.toolCalls.length > 0 ? accumulated.toolCalls : undefined,
|
||||||
fallbackInfo: capturedFallbackInfo,
|
fallbackInfo: accumulated.fallbackInfo,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -695,8 +590,7 @@ export function useQuickChat(
|
|||||||
void sendMessage(queuedMessage);
|
void sendMessage(queuedMessage);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (data: string) => {
|
onError: (data) => {
|
||||||
cancelStreamingFlushes();
|
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
setStreamingThinking("");
|
setStreamingThinking("");
|
||||||
setStreamingToolCalls([]);
|
setStreamingToolCalls([]);
|
||||||
@@ -718,9 +612,9 @@ export function useQuickChat(
|
|||||||
|
|
||||||
void reloadMessages();
|
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
|
// Preserve rejection semantics for awaiters while preventing unhandled rejection noise
|
||||||
|
|||||||
@@ -1013,17 +1013,21 @@ export class ChatManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Single agent-creation path for both regular chat and QuickChat. When
|
||||||
|
// the chat is bound to an agent that declares a runtime hint we pass it
|
||||||
|
// through; when there's no agent (e.g. QuickChat's model-only mode) or
|
||||||
|
// no hint, `createResolvedAgentSession` falls back to the default
|
||||||
|
// runtime via `resolveRuntime`. This avoids the previous divergence
|
||||||
|
// where QuickChat went through `createFnAgent` and hit pi-ai's shared
|
||||||
|
// `cleanupSessionResources(sessionId)` tear-down across overlapping
|
||||||
|
// sessions opened from the same CLI session file.
|
||||||
const agentRuntimeHint = agent ? extractRuntimeHint(agent.runtimeConfig) : undefined;
|
const agentRuntimeHint = agent ? extractRuntimeHint(agent.runtimeConfig) : undefined;
|
||||||
if (agentRuntimeHint) {
|
agentResult = await createResolvedAgentSession({
|
||||||
agentResult = await createResolvedAgentSession({
|
sessionPurpose: "executor",
|
||||||
sessionPurpose: "executor",
|
...(agentRuntimeHint ? { runtimeHint: agentRuntimeHint } : {}),
|
||||||
runtimeHint: agentRuntimeHint,
|
pluginRunner: this.pluginRunner,
|
||||||
pluginRunner: this.pluginRunner,
|
...sessionOptions,
|
||||||
...sessionOptions,
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
agentResult = await createFnAgent(sessionOptions);
|
|
||||||
}
|
|
||||||
this.activeGenerations.set(sessionId, { abortController, agentResult, generationId });
|
this.activeGenerations.set(sessionId, { abortController, agentResult, generationId });
|
||||||
|
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
@@ -1143,16 +1147,29 @@ export class ChatManager {
|
|||||||
data: errorMessage,
|
data: errorMessage,
|
||||||
}, broadcastOptions);
|
}, broadcastOptions);
|
||||||
} finally {
|
} finally {
|
||||||
// Only clear the active-generation slot if it still belongs to us. If a newer
|
// Only clear the active-generation slot if it still belongs to us. If a
|
||||||
// sendMessage pre-empted us via beginGeneration, the slot now holds that newer
|
// newer sendMessage pre-empted us via beginGeneration, the slot now holds
|
||||||
// generation's controller and must not be deleted by our cleanup.
|
// that newer generation's controller and must not be deleted by us.
|
||||||
const current = this.activeGenerations.get(sessionId);
|
const current = this.activeGenerations.get(sessionId);
|
||||||
if (current?.generationId === generationId) {
|
const stillOwnsSlot = current?.generationId === generationId;
|
||||||
|
if (stillOwnsSlot) {
|
||||||
this.activeGenerations.delete(sessionId);
|
this.activeGenerations.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always dispose agent session
|
// Dispose the agent session — but ONLY when we still own the slot.
|
||||||
if (agentResult) {
|
//
|
||||||
|
// pi-ai's `cleanupSessionResources(sessionId)` fires globally-registered
|
||||||
|
// cleanup callbacks keyed by sessionId, and two agents opened from the
|
||||||
|
// same CLI session file share that sessionId. If a newer generation has
|
||||||
|
// taken over for the same chat session, disposing this (older) agent
|
||||||
|
// tears down resources the newer agent is actively using — the model
|
||||||
|
// produces no output and the next turn looks like a silent failure.
|
||||||
|
//
|
||||||
|
// The newer generation will dispose its own agent in its own finally.
|
||||||
|
// The older agent's resources are largely garbage-collectible without
|
||||||
|
// an explicit dispose; the small leak per pre-empted generation is
|
||||||
|
// worth avoiding the cross-generation tear-down.
|
||||||
|
if (stillOwnsSlot && agentResult) {
|
||||||
try {
|
try {
|
||||||
agentResult.session.dispose?.();
|
agentResult.session.dispose?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1209,6 +1226,12 @@ export class ChatManager {
|
|||||||
*/
|
*/
|
||||||
export function __setCreateFnAgent(mock: typeof createFnAgent): void {
|
export function __setCreateFnAgent(mock: typeof createFnAgent): void {
|
||||||
createFnAgent = mock;
|
createFnAgent = mock;
|
||||||
|
// chat.ts now routes both regular chat and QuickChat through
|
||||||
|
// `createResolvedAgentSession`, which would normally bypass this mock and
|
||||||
|
// hit the real engine. Mirror the same fake into the resolved-session slot
|
||||||
|
// so existing test setups that only call `__setCreateFnAgent` continue to
|
||||||
|
// work.
|
||||||
|
createResolvedAgentSession = (async (options: any) => mock(options)) as typeof createResolvedAgentSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user