From 70330bcffbf8179a715021434e8e070e0bd2b892 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 07:40:22 -0700 Subject: [PATCH] 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) --- .../fn-7779-empty-chat-message-placeholder.md | 7 + docs/dashboard-guide.md | 2 + .../dashboard/app/components/ChatView.css | 7 +- .../app/components/StandardChatSurface.tsx | 15 +- ...StandardChatSurface.empty-message.test.tsx | 157 ++++++++++++++++++ packages/i18n/locales/en/app.json | 1 + packages/i18n/src/resources.d.ts | 1 + 7 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7779-empty-chat-message-placeholder.md create mode 100644 packages/dashboard/app/components/__tests__/StandardChatSurface.empty-message.test.tsx diff --git a/.changeset/fn-7779-empty-chat-message-placeholder.md b/.changeset/fn-7779-empty-chat-message-placeholder.md new file mode 100644 index 0000000000..6188cb6e6b --- /dev/null +++ b/.changeset/fn-7779-empty-chat-message-placeholder.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index eb11057a8d..31c19dd17e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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) + +- 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. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index fdee42469f..4246b66937 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -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 */ diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx index 2ba956e35f..66346e8280 100644 --- a/packages/dashboard/app/components/StandardChatSurface.tsx +++ b/packages/dashboard/app/components/StandardChatSurface.tsx @@ -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(() => { if (isAssistantMessage) return null; @@ -436,8 +446,11 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({ if (failureInfo) { return
{failureInfo.summary}
{(failureInfo.errorClass || failureInfo.code) &&
{failureInfo.errorClass && {failureInfo.errorClass}}{failureInfo.code && {failureInfo.code}}
}{(failureInfo.detail || failureInfo.reference) &&
{failureInfo.detail &&
{linkifyFilePaths(failureInfo.detail)}
}{renderFailureReference(failureInfo.reference, t)}
}
; } + if (isEmptyAssistantMessage) { + return
{t("chat.noMessage", "No message")}
; + } return renderStandardAssistantContent(message.content, forcePlain); - }, [failureInfo, forcePlain, isAssistantMessage, message.content, t]); + }, [failureInfo, forcePlain, isAssistantMessage, isEmptyAssistantMessage, message.content, t]); return (
{showAssistantIdentity &&
{activeModelProvider ? : }{agentName}{showAssistantModelTag && activeModelTag && {activeModelTag}}
} diff --git a/packages/dashboard/app/components/__tests__/StandardChatSurface.empty-message.test.tsx b/packages/dashboard/app/components/__tests__/StandardChatSurface.empty-message.test.tsx new file mode 100644 index 0000000000..246fa7ecb8 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/StandardChatSurface.empty-message.test.tsx @@ -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 = {}, forcePlain = false) { + const message: ChatMessageInfo = { ...baseMessage, ...overrides }; + return render( + , + ); +} + +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( + , + ); + + 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( + , + ); + + expect(screen.getByText("Thinking…")).toBeInTheDocument(); + expect(screen.getByText("Thinking about it")).toBeInTheDocument(); + expectNoPlaceholder(); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index aa73095b4d..140ec4668f 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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.", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index 77904665e4..11061a4058 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -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.",