FN-7779: render No message placeholder for empty assistant chat replies

Empty final assistant chat messages (e.g. Grok CLI runs that finish with no text) now show a muted "No message" placeholder instead of a blank bubble, in both Chat and Planner Chat via the shared StandardChatSurface component.

- Add isEmptyAssistantMessage check in StandardChatMessageItem: only applies to final persisted assistant messages with no content, tool calls, thinking output, attachments, or failure info
- Render a new .chat-message-content--empty block with the "No message" i18n string when the check matches
- Add shared CSS rule combining --waiting and --empty muted/italic styling, switching to var(--font-size-sm)
- Add "chat.noMessage" translation key to en/app.json and regenerate resources.d.ts
- Document the new behavior in docs/dashboard-guide.md
- Add a patch changeset for @runfusion/fusion
- Add StandardChatSurface.empty-message.test.tsx covering empty/whitespace content, populated content, tool calls, thinking output, attachments, failure info, non-assistant roles, and streaming waiting/thinking states

Files changed:
$(cat /tmp/diffstat_fn7779.txt)

Fusion-Task-Id: FN-7779

Fusion-Task-Lineage: d3ae068e-acba-4b4c-85d6-a788aa24a53b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 07:40:22 -07:00
parent 2758dde301
commit 70330bcffb
7 changed files with 186 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show a No message placeholder for empty assistant chat replies.
category: fix
dev: Adds shared StandardChatSurface rendering and tests for empty assistant message bodies.

View File

@@ -494,6 +494,8 @@ Chat view provides project-scoped conversations with agents.
- Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model
- On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome.
- Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately)
<!-- FNXC:ChatEmptyMessage 2026-07-10-00:00: Empty final assistant responses can be legitimate provider output (for example a Grok CLI run ending without text). Document the shared Chat/Planner Chat behavior so operators see "No message" instead of interpreting a blank bubble as a rendering failure. -->
- Final assistant messages with no text, tool calls, thinking output, attachments, or failure details render a muted **No message** placeholder instead of a blank bubble. In-progress responses still use the existing **Working…** / **Thinking…** streaming state until the run finishes.
- In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, keeps the prior persisted conversation visible, then resumes streaming from the stored replay point; any new text, thinking, or tool-call updates append to that restored bubble instead of replacing it or starting from an empty "Working…" placeholder.
- If a regular Chat stream drops with a hidden-tab/browser-suspension error (for example `Load failed`) while the server is still generating, Chat suppresses the false error banner, re-attaches to the in-progress stream using the durable replay state, and reconciles the final assistant reply when generation completes.
- If you queue follow-up user messages while the assistant is still streaming, Chat persists them per session, stacks each queued preview above the input box with one shared divider, and restores/sends them one at a time in FIFO order once each active response finishes if you leave and return.

View File

@@ -1760,11 +1760,12 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi
40% { opacity: 1; }
}
/* Chat waiting state */
.chat-message-content--waiting {
/* Chat waiting/empty states */
.chat-message-content--waiting,
.chat-message-content--empty {
color: var(--text-muted);
font-style: italic;
font-size: 13px;
font-size: var(--font-size-sm);
}
/* Input area */

View File

@@ -391,6 +391,16 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
}
}, [isEditing]);
const failureInfo = isAssistantMessage ? message.failureInfo : undefined;
/*
* FNXC:ChatEmptyMessage 2026-07-10-00:00:
* Empty assistant responses, including Grok CLI runs that finish with no text, must show a muted "No message" placeholder instead of a blank bubble. Only final persisted assistant messages with no renderable body qualify; tool calls, thinking output, attachments, or failure info already carry meaningful content and must not trigger the placeholder.
*/
const isEmptyAssistantMessage = isAssistantMessage
&& message.content.trim().length === 0
&& !failureInfo
&& (!message.toolCalls || message.toolCalls.length === 0)
&& !message.thinkingOutput
&& (!message.attachments || message.attachments.length === 0);
const showAssistantIdentity = isAssistantMessage && (!hideAssistantIdentity || Boolean(failureInfo));
const renderedUserContent = useMemo<ReactNode>(() => {
if (isAssistantMessage) return null;
@@ -436,8 +446,11 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
if (failureInfo) {
return <div className="chat-message-content chat-message-content--failure"><div className="chat-message-failure-summary-row"><span className="status-dot status-dot--error" aria-hidden="true" /><span className="chat-message-failure-label">{t("chat.responseFailed", "Response failed")}</span></div><div className="chat-message-failure-summary">{failureInfo.summary}</div>{(failureInfo.errorClass || failureInfo.code) && <div className="chat-message-failure-badges">{failureInfo.errorClass && <span className="chat-message-failure-badge">{failureInfo.errorClass}</span>}{failureInfo.code && <span className="chat-message-failure-badge">{failureInfo.code}</span>}</div>}{(failureInfo.detail || failureInfo.reference) && <details className="chat-message-failure-details"><summary><TriangleAlert size={14} aria-hidden="true" /><span>{t("chat.failureDetails", "Failure details")}</span></summary>{failureInfo.detail && <pre className="chat-message-failure-detail">{linkifyFilePaths(failureInfo.detail)}</pre>}{renderFailureReference(failureInfo.reference, t)}</details>}</div>;
}
if (isEmptyAssistantMessage) {
return <div className="chat-message-content chat-message-content--empty" data-testid="chat-message-empty">{t("chat.noMessage", "No message")}</div>;
}
return renderStandardAssistantContent(message.content, forcePlain);
}, [failureInfo, forcePlain, isAssistantMessage, message.content, t]);
}, [failureInfo, forcePlain, isAssistantMessage, isEmptyAssistantMessage, message.content, t]);
return (
<div className={`chat-message chat-message--${message.role}${failureInfo ? " chat-message--failure" : ""}${isEditing ? " chat-message--editing" : ""}`} data-testid={`chat-message-${message.id}`} data-message-id={message.id}>
{showAssistantIdentity && <div className="chat-message-avatar">{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}<span>{agentName}</span>{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}</div>}

View File

@@ -0,0 +1,157 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StandardChatMessageItem, StandardStreamingMessage } from "../StandardChatSurface";
import type { ChatMessageInfo, ToolCallInfo } from "../../hooks/chatTypes";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}),
}));
const baseMessage: ChatMessageInfo = {
id: "msg-assistant-empty",
sessionId: "session-1",
role: "assistant",
content: "",
createdAt: "2026-07-10T00:00:00.000Z",
};
function renderMessage(overrides: Partial<ChatMessageInfo> = {}, forcePlain = false) {
const message: ChatMessageInfo = { ...baseMessage, ...overrides };
return render(
<StandardChatMessageItem
message={message}
forcePlain={forcePlain}
agentName="Assistant"
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
activeSessionId="session-1"
/>,
);
}
function expectNoPlaceholder() {
expect(screen.queryByTestId("chat-message-empty")).not.toBeInTheDocument();
expect(screen.queryByText("No message")).not.toBeInTheDocument();
}
describe("StandardChatSurface empty assistant messages", () => {
afterEach(() => {
cleanup();
});
it.each([
{ name: "empty markdown", content: "", forcePlain: false },
{ name: "empty plain", content: "", forcePlain: true },
{ name: "whitespace markdown", content: " \n", forcePlain: false },
{ name: "whitespace plain", content: " \n", forcePlain: true },
])("renders No message for $name assistant content", ({ content, forcePlain }) => {
renderMessage({ content }, forcePlain);
expect(screen.getByTestId("chat-message-empty")).toHaveTextContent("No message");
});
it("does not render the placeholder for populated assistant content", () => {
renderMessage({ content: "Hello **there**" });
expectNoPlaceholder();
expect(screen.getByText("Hello")).toBeInTheDocument();
});
it("does not render the placeholder when tool calls are the assistant content", () => {
const toolCalls: ToolCallInfo[] = [
{ toolName: "read_file", status: "completed", isError: false, result: "done" },
];
renderMessage({ content: "", toolCalls });
expectNoPlaceholder();
expect(screen.getByText("read_file")).toBeInTheDocument();
});
it("does not render the placeholder when thinking output is present", () => {
renderMessage({ content: "", thinkingOutput: "Reasoning through the request" });
expectNoPlaceholder();
expect(screen.getByText("Thinking")).toBeInTheDocument();
expect(screen.getByText("Reasoning through the request")).toBeInTheDocument();
});
it("does not render the placeholder when attachments are present", () => {
renderMessage({
content: "",
attachments: [
{
id: "attachment-1",
filename: "artifact.txt",
originalName: "artifact.txt",
mimeType: "text/plain",
size: 12,
createdAt: "2026-07-10T00:00:00.000Z",
},
],
});
expectNoPlaceholder();
expect(screen.getByTestId("chat-message-attachment")).toHaveTextContent("artifact.txt");
});
it("does not render the placeholder when failure info is present", () => {
renderMessage({ content: "", failureInfo: { summary: "Provider failed" } });
expectNoPlaceholder();
expect(screen.getByText("Response failed")).toBeInTheDocument();
expect(screen.getByText("Provider failed")).toBeInTheDocument();
});
it.each([
{ role: "user" as const, content: "" },
{ role: "system" as const, content: "" },
])("does not render the assistant placeholder for $role messages", ({ role, content }) => {
renderMessage({ id: `msg-${role}`, role, content });
expectNoPlaceholder();
});
it("keeps the streaming waiting state separate from the empty final-message placeholder", () => {
render(
<StandardStreamingMessage
streamingText=""
streamingThinking=""
streamingToolCalls={[]}
forcePlain={false}
agentName="Assistant"
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
/>,
);
expect(screen.getByText("Working…")).toBeInTheDocument();
expect(document.querySelector(".chat-message-content--waiting")).toBeInTheDocument();
expectNoPlaceholder();
});
it("keeps the streaming thinking state separate from the empty final-message placeholder", () => {
render(
<StandardStreamingMessage
streamingText=""
streamingThinking="Thinking about it"
streamingToolCalls={[]}
forcePlain={false}
agentName="Assistant"
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
/>,
);
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText("Thinking about it")).toBeInTheDocument();
expectNoPlaceholder();
});
});

View File

@@ -1316,6 +1316,7 @@
"newChatTitle": "New Chat",
"noAgentsAvailable": "No agents available",
"noConversationsYet": "No conversations yet",
"noMessage": "No message",
"noMessages": "No messages",
"noMessagesYet": "No messages yet. Start the conversation!",
"noRoomsYet": "No rooms yet.",

View File

@@ -1308,6 +1308,7 @@ export default interface Resources {
"newChatTitle": "New Chat",
"noAgentsAvailable": "No agents available",
"noConversationsYet": "No conversations yet",
"noMessage": "No message",
"noMessages": "No messages",
"noMessagesYet": "No messages yet. Start the conversation!",
"noRoomsYet": "No rooms yet.",