fix(FN-XXXX): show chat streaming feedback during Claude waits
This commit is contained in:
5
.changeset/fix-chat-streaming-feedback.md
Normal file
5
.changeset/fix-chat-streaming-feedback.md
Normal 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.
|
||||
@@ -823,7 +823,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
const messagesContainer = messagesContainerRef.current;
|
||||
if (!messagesContainer) return;
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}, [messages, streamingText]);
|
||||
}, [messages, streamingText, streamingThinking, isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyboardOverlap <= 0) {
|
||||
@@ -1587,7 +1587,58 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
|
||||
{/* Messages */}
|
||||
<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>
|
||||
) : messages.length === 0 && !activeSession ? (
|
||||
renderEmptyState()
|
||||
@@ -1610,43 +1661,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
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} />
|
||||
|
||||
@@ -1289,7 +1289,7 @@ export function QuickChatFAB({
|
||||
const messagesEl = messagesRef.current;
|
||||
if (!messagesEl) return;
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}, [messages, streamingText, streamingThinking, isOpen]);
|
||||
}, [messages, streamingText, streamingThinking, isStreaming, isOpen]);
|
||||
|
||||
const sessionOptions = useMemo(
|
||||
() => sessions.map((session, index) => ({
|
||||
@@ -2043,7 +2043,53 @@ export function QuickChatFAB({
|
||||
)}
|
||||
|
||||
<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>
|
||||
) : messages.length === 0 && !streamingText && !streamingThinking && !isStreaming ? (
|
||||
<div className="quick-chat-panel-empty">No messages yet. Start the conversation!</div>
|
||||
@@ -2058,41 +2104,6 @@ export function QuickChatFAB({
|
||||
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>
|
||||
|
||||
@@ -1512,6 +1512,24 @@ describe("ChatView", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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" },
|
||||
|
||||
@@ -57,6 +57,16 @@ const agentSession: ChatSession = {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -183,4 +193,23 @@ describe("QuickChatFAB session-first UX", () => {
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,89 @@ describe("createEventBridge", () => {
|
||||
expect(textEnd1.contentIndex).toBe(1);
|
||||
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", () => {
|
||||
@@ -795,6 +878,30 @@ describe("createEventBridge", () => {
|
||||
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", () => {
|
||||
const bridge = createBridgeWithStart();
|
||||
|
||||
|
||||
@@ -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
|
||||
* into pi's AssistantMessageEventStream events.
|
||||
@@ -98,6 +121,19 @@ export function createEventBridge(
|
||||
|
||||
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 {
|
||||
// Emit start event on first message — tells pi to begin incremental rendering
|
||||
if (!started) {
|
||||
@@ -226,14 +262,18 @@ export function createEventBridge(
|
||||
|
||||
const block = blocks[idx];
|
||||
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;
|
||||
contentBlock.text = block.text;
|
||||
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.text,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
@@ -246,14 +286,18 @@ export function createEventBridge(
|
||||
|
||||
const block = blocks[idx];
|
||||
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;
|
||||
contentBlock.thinking = block.text;
|
||||
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: idx,
|
||||
delta: event.delta!.thinking,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user