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:
7
.changeset/fn-7779-empty-chat-message-placeholder.md
Normal file
7
.changeset/fn-7779-empty-chat-message-placeholder.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
1
packages/i18n/src/resources.d.ts
vendored
1
packages/i18n/src/resources.d.ts
vendored
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user