fix(FN-XXXX): show chat streaming feedback during Claude waits

This commit is contained in:
gsxdsm
2026-05-01 17:30:59 -07:00
parent 2e5c4f1ff3
commit 7ec394ae7f
7 changed files with 308 additions and 80 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Keep chat and quick chat visibly in a connecting or thinking state during long Claude CLI responses, and repair missing spaces in some streamed sentence boundaries.

View File

@@ -823,7 +823,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const messagesContainer = messagesContainerRef.current; const messagesContainer = messagesContainerRef.current;
if (!messagesContainer) return; if (!messagesContainer) return;
messagesContainer.scrollTop = messagesContainer.scrollHeight; messagesContainer.scrollTop = messagesContainer.scrollHeight;
}, [messages, streamingText]); }, [messages, streamingText, streamingThinking, isStreaming]);
useEffect(() => { useEffect(() => {
if (keyboardOverlap <= 0) { if (keyboardOverlap <= 0) {
@@ -1587,7 +1587,58 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{/* Messages */} {/* Messages */}
<div className="chat-messages" ref={messagesContainerRef}> <div className="chat-messages" ref={messagesContainerRef}>
{messagesLoading ? ( {isStreaming ? (
<>
{messages.map((message) => (
<ChatMessageItem
key={message.id}
message={message}
forcePlain={plainTextMessageIds.has(message.id)}
agentName={agentName}
showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
onToggleRender={toggleMessageRenderMode}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
<div className="chat-message-avatar">
<Bot size={14} />
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<button
type="button"
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 ? (
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? "Thinking…" : "Connecting…"}
</div>
)}
{renderToolCalls(streamingToolCalls)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
</>
) : messagesLoading ? (
<div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>Loading messages...</div> <div style={{ color: "var(--text-secondary)", fontSize: "13px" }}>Loading messages...</div>
) : messages.length === 0 && !activeSession ? ( ) : messages.length === 0 && !activeSession ? (
renderEmptyState() renderEmptyState()
@@ -1610,43 +1661,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
onToggleRender={toggleMessageRenderMode} onToggleRender={toggleMessageRenderMode}
/> />
))} ))}
{isStreaming && (
<div className="chat-message chat-message--assistant chat-message--streaming">
<div className="chat-message-avatar">
<Bot size={14} />
<span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<button
type="button"
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 ? (
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__"))
) : (
<div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? "Thinking…" : "Connecting…"}
</div>
)}
{renderToolCalls(streamingToolCalls)}
{streamingThinking && (
<details className="chat-message-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
</details>
)}
<div className="chat-typing-indicator">
<span />
<span />
<span />
</div>
</div>
)}
</> </>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@@ -1289,7 +1289,7 @@ export function QuickChatFAB({
const messagesEl = messagesRef.current; const messagesEl = messagesRef.current;
if (!messagesEl) return; if (!messagesEl) return;
messagesEl.scrollTop = messagesEl.scrollHeight; messagesEl.scrollTop = messagesEl.scrollHeight;
}, [messages, streamingText, streamingThinking, isOpen]); }, [messages, streamingText, streamingThinking, isStreaming, isOpen]);
const sessionOptions = useMemo( const sessionOptions = useMemo(
() => sessions.map((session, index) => ({ () => sessions.map((session, index) => ({
@@ -2043,7 +2043,53 @@ export function QuickChatFAB({
)} )}
<div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages"> <div className="quick-chat-panel-messages" ref={messagesRef} data-testid="quick-chat-messages">
{sessionsLoading || messagesLoading ? ( {sessionsLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div>
) : isStreaming ? (
<>
{messages.map((message: ChatMessageInfo) => (
<QuickChatMessageItem
key={message.id}
message={message}
forcePlain={message.role !== "user" && plainTextMessageIds.has(message.id)}
mentionAgentsByName={mentionAgentsByName}
onToggleRender={toggleMessageRenderMode}
/>
))}
<div
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
data-testid="quick-chat-streaming-message"
>
{streamingText ? (
<>
<div data-testid="quick-chat-streaming-text">
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
</div>
<button
type="button"
className={`quick-chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " quick-chat-message-render-toggle--plain" : ""}`}
data-testid="quick-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>
</>
) : (
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
{streamingThinking ? "Thinking…" : "Connecting…"}
</p>
)}
{renderToolCalls(streamingToolCalls, true)}
{streamingThinking && (
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
</details>
)}
</div>
</>
) : messagesLoading ? (
<div className="quick-chat-panel-empty">Loading conversation</div> <div className="quick-chat-panel-empty">Loading conversation</div>
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? ( ) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? (
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div> <div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
@@ -2058,41 +2104,6 @@ export function QuickChatFAB({
onToggleRender={toggleMessageRenderMode} onToggleRender={toggleMessageRenderMode}
/> />
))} ))}
{/* Streaming message bubble */}
{isStreaming && (
<div
className="quick-chat-panel-message quick-chat-panel-message--received quick-chat-panel-message--streaming"
data-testid="quick-chat-streaming-message"
>
{streamingText ? (
<>
<div data-testid="quick-chat-streaming-text">
{renderAssistantMessageContent(streamingText, plainTextMessageIds.has("__streaming__"))}
</div>
<button
type="button"
className={`quick-chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " quick-chat-message-render-toggle--plain" : ""}`}
data-testid="quick-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>
</>
) : (
<p className="quick-chat-panel-waiting" data-testid="quick-chat-waiting">
Thinking
</p>
)}
{renderToolCalls(streamingToolCalls, true)}
{streamingThinking && (
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
<summary>Thinking</summary>
<pre className="chat-message-thinking-content">{streamingThinking}</pre>
</details>
)}
</div>
)}
</> </>
)} )}
</div> </div>

View File

@@ -1512,6 +1512,24 @@ describe("ChatView", () => {
}); });
describe("streaming states", () => { describe("streaming states", () => {
it("keeps the streaming indicator visible while message history is still loading", () => {
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" },
messages: [],
messagesLoading: true,
isStreaming: true,
streamingText: "",
streamingThinking: "",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const streamingMessage = document.querySelector(".chat-message--streaming") as HTMLElement | null;
expect(streamingMessage).toBeInTheDocument();
expect(streamingMessage?.textContent).toContain("Connecting");
expect(screen.queryByText("Loading messages...")).not.toBeInTheDocument();
});
it("shows waiting indicator when streaming starts before text arrives", () => { it("shows waiting indicator when streaming starts before text arrives", () => {
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" },

View File

@@ -57,6 +57,16 @@ const agentSession: ChatSession = {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("QuickChatFAB session-first UX", () => { describe("QuickChatFAB session-first UX", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -183,4 +193,23 @@ describe("QuickChatFAB session-first UX", () => {
}); });
expect(mockCreateChatSession).not.toHaveBeenCalled(); expect(mockCreateChatSession).not.toHaveBeenCalled();
}); });
it("shows the streaming indicator instead of the loading placeholder while waiting for a long reply", async () => {
const deferredMessages = createDeferredPromise<{ messages: never[] }>();
mockFetchChatMessages.mockImplementation(() => deferredMessages.promise);
mockStreamChatResponse.mockImplementation(() => ({ close: vi.fn(), isConnected: () => false }));
render(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const input = await screen.findByTestId("quick-chat-input");
await waitFor(() => expect(input).not.toBeDisabled());
fireEvent.change(input, { target: { value: "Explain the current architecture" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
expect(await screen.findByTestId("quick-chat-streaming-message")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-waiting")).toHaveTextContent("Connecting…");
expect(screen.queryByText("Loading conversation…")).not.toBeInTheDocument();
});
}); });

View File

@@ -178,6 +178,89 @@ describe("createEventBridge", () => {
expect(textEnd1.contentIndex).toBe(1); expect(textEnd1.contentIndex).toBe(1);
expect(textEnd1.content).toBe("Second"); expect(textEnd1.content).toBe("Second");
}); });
it("repairs a missing sentence boundary between consecutive text blocks", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "compare them." },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: { type: "text_delta", text: "Good overview." },
});
const output = bridge.getOutput();
const combinedText = output.content
.filter((content): content is any => content.type === "text")
.map((content: any) => content.text)
.join("");
expect(combinedText).toBe("compare them. Good overview.");
expect(stream.events[4]).toEqual(
expect.objectContaining({
type: "text_delta",
contentIndex: 1,
delta: " Good overview.",
}),
);
});
it("does not insert spaces into lowercase continuations like property access", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "console." },
});
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
bridge.handleEvent({
type: "content_block_start",
index: 1,
content_block: { type: "text", text: "" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 1,
delta: { type: "text_delta", text: "log('hi')" },
});
const output = bridge.getOutput();
const combinedText = output.content
.filter((content): content is any => content.type === "text")
.map((content: any) => content.text)
.join("");
expect(combinedText).toBe("console.log('hi')");
});
}); });
describe("message_start usage tracking", () => { describe("message_start usage tracking", () => {
@@ -795,6 +878,30 @@ describe("createEventBridge", () => {
expect(thinkingBlock.thinking).toBe("First thought. Second thought."); expect(thinkingBlock.thinking).toBe("First thought. Second thought.");
}); });
it("repairs a missing sentence boundary between thinking deltas", () => {
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: { type: "thinking" },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "I will inspect the hook." },
});
bridge.handleEvent({
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "Good, now the component." },
});
const output = bridge.getOutput();
const thinkingBlock = output.content[0] as any;
expect(thinkingBlock.thinking).toBe("I will inspect the hook. Good, now the component.");
});
it("emits thinking_end for thinking block stop", () => { it("emits thinking_end for thinking block stop", () => {
const bridge = createBridgeWithStart(); const bridge = createBridgeWithStart();

View File

@@ -60,6 +60,29 @@ function mapStopReason(
} }
} }
function normalizeStreamingDelta(previousText: string, nextDelta: string): string {
if (!previousText || !nextDelta) {
return nextDelta;
}
const previousChar = previousText.slice(-1);
const nextChar = nextDelta[0] ?? "";
if (/\s/.test(previousChar) || /\s/.test(nextChar)) {
return nextDelta;
}
// Claude sometimes splits adjacent sentences across separate deltas or text
// blocks without preserving the separating space. Only repair the specific
// "sentence punctuation + uppercase/quoted sentence start" case so code,
// domains, and lowercase continuations remain untouched.
if (/[.!?]/.test(previousChar) && /[A-Z0-9"'([]/.test(nextChar)) {
return ` ${nextDelta}`;
}
return nextDelta;
}
/** /**
* Create an event bridge that translates Claude API streaming events * Create an event bridge that translates Claude API streaming events
* into pi's AssistantMessageEventStream events. * into pi's AssistantMessageEventStream events.
@@ -98,6 +121,19 @@ export function createEventBridge(
let started = false; let started = false;
function getPreviousContentText(contentIndex: number, type: "text" | "thinking"): string {
for (let i = contentIndex - 1; i >= 0; i--) {
const contentBlock = output.content[i];
if (type === "text" && contentBlock?.type === "text") {
return contentBlock.text;
}
if (type === "thinking" && contentBlock?.type === "thinking") {
return contentBlock.thinking;
}
}
return "";
}
function handleEvent(event: ClaudeApiEvent): void { function handleEvent(event: ClaudeApiEvent): void {
// Emit start event on first message — tells pi to begin incremental rendering // Emit start event on first message — tells pi to begin incremental rendering
if (!started) { if (!started) {
@@ -226,14 +262,18 @@ export function createEventBridge(
const block = blocks[idx]; const block = blocks[idx];
if (block.type === "text") { if (block.type === "text") {
block.text += event.delta!.text; const delta = normalizeStreamingDelta(
block.text || getPreviousContentText(idx, "text"),
event.delta!.text,
);
block.text += delta;
const contentBlock = output.content[idx] as TextContent; const contentBlock = output.content[idx] as TextContent;
contentBlock.text = block.text; contentBlock.text = block.text;
stream.push({ stream.push({
type: "text_delta", type: "text_delta",
contentIndex: idx, contentIndex: idx,
delta: event.delta!.text, delta,
partial: output, partial: output,
}); });
} }
@@ -246,14 +286,18 @@ export function createEventBridge(
const block = blocks[idx]; const block = blocks[idx];
if (block.type === "thinking") { if (block.type === "thinking") {
block.text += event.delta!.thinking; const delta = normalizeStreamingDelta(
block.text || getPreviousContentText(idx, "thinking"),
event.delta!.thinking,
);
block.text += delta;
const contentBlock = output.content[idx] as ThinkingContent; const contentBlock = output.content[idx] as ThinkingContent;
contentBlock.thinking = block.text; contentBlock.thinking = block.text;
stream.push({ stream.push({
type: "thinking_delta", type: "thinking_delta",
contentIndex: idx, contentIndex: idx,
delta: event.delta!.thinking, delta,
partial: output, partial: output,
}); });
} }