FN-7121: show queued chat previews above composer

Show queued Chat messages above the composer with a clear divider and covered placement behavior.

- Move the queued-message indicator out of the textarea wrapper so it spans above the input row.
- Add divider styling and responsive pending-message alignment for the composer.
- Extend ChatView coverage for placement, dismissal, and divider removal.
- Document the queued-message preview behavior and add a patch changeset.

Files changed:
 .changeset/fn-7121-queued-message-above-input.md   |  7 ++++
 docs/dashboard-guide.md                            |  2 +-
 packages/dashboard/app/components/ChatView.css     | 19 +++++++++--
 packages/dashboard/app/components/ChatView.tsx     | 35 ++++++++++++--------
 .../__tests__/ChatView.core-interactions.test.tsx  | 37 +++++++++++++++++++---
 5 files changed, 77 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-7121

Fusion-Task-Lineage: b5cb0577-ca4e-459b-9b9e-5c60efd7bdfd

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 10:29:36 -07:00
parent 9511880525
commit c61217e71a
5 changed files with 76 additions and 22 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show queued Chat messages above the input box with a divider.
category: fix
dev: Moves the existing single pending-message indicator out of the textarea wrapper and covers placement with ChatView tests.

View File

@@ -346,7 +346,7 @@ Chat view provides project-scoped conversations with agents.
- 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)
- 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 a follow-up user message while the assistant is still streaming, Chat now persists that queued text per session so leaving and returning to the view still restores and sends it once the active response finishes.
- If you queue a follow-up user message while the assistant is still streaming, Chat persists that queued text per session, shows the queued preview above the input box with a divider, and restores/sends it once the active response finishes if you leave and return.
- Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail.
- On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail.
- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows.

View File

@@ -1917,21 +1917,30 @@ Tablet Chat View has enough message-pane width for assistant prose, markdown, to
}
/* === Chat Pending Message === */
/*
FNXC:ChatComposer 2026-06-27-00:00:
The single queued-message banner spans the composer above the input row, and the divider below it makes the queued state visually distinct without rendering an empty rule when no pending message exists.
*/
.chat-pending-message {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-xs);
width: 100%;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--todo) 10%, transparent);
color: var(--text-muted);
font-size: 12px;
font-size: 0.75rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chat-pending-divider {
width: 100%;
border-top: 1px solid var(--border);
}
.chat-pending-message span {
overflow: hidden;
text-overflow: ellipsis;
@@ -1946,7 +1955,7 @@ Tablet Chat View has enough message-pane width for assistant prose, markdown, to
padding: var(--space-xs);
line-height: 1;
flex-shrink: 0;
font-size: 14px;
font-size: 0.875rem;
}
.chat-pending-message-dismiss:hover {
@@ -1975,6 +1984,10 @@ Tablet Chat View has enough message-pane width for assistant prose, markdown, to
thread takes the full viewport. The thread already renders a back
button (ChevronLeft) on mobile to flip back to the session list. */
@media (max-width: 768px) {
.chat-pending-message {
align-items: flex-start;
}
.chat-view__body {
flex-direction: column;
}

View File

@@ -3057,6 +3057,27 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
))}
</div>
)}
{pendingMessage && (
<>
{/*
FNXC:ChatComposer 2026-06-27-00:00:
The single-slot queued chat message must appear above the input box, separated by a divider, so users can notice the pending send without changing the one-pending-message queue model.
*/}
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
<div className="chat-pending-divider" aria-hidden="true" />
</>
)}
<div className="chat-input-row">
<button
type="button"
@@ -3128,20 +3149,6 @@ export function ChatView({ projectId, addToast, floating = false, onPopOut, onMa
}}
loading={fileMention.loading}
/>
{pendingMessage && (
<div className="chat-pending-message" data-testid="chat-pending-indicator">
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
<button
type="button"
className="chat-pending-message-dismiss"
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
data-testid="chat-pending-dismiss"
onClick={clearPendingMessage}
>
×
</button>
</div>
)}
</div>
{isStreaming ? (
<button

View File

@@ -732,21 +732,48 @@ describe("ChatView core interactions", () => {
expect(screen.getByTestId("chat-send-btn")).toBeInTheDocument();
});
it("renders pending message indicator and dismisses it", async () => {
it("renders pending message indicator above the input row and dismisses it", async () => {
const clearPendingMessage = vi.fn();
const activeSession = activeSessionFixture;
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,
messages: [],
pendingMessage: "Queued while streaming",
clearPendingMessage,
});
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-pending-indicator")).toHaveTextContent("Queued: Queued while streaming");
const indicators = screen.getAllByTestId("chat-pending-indicator");
expect(indicators).toHaveLength(1);
const indicator = indicators[0];
expect(indicator).toHaveTextContent("Queued: Queued while streaming");
const input = screen.getByTestId("chat-input");
const inputArea = input.closest(".chat-input-area");
const inputRow = input.closest(".chat-input-row");
const inputWrapper = input.closest(".chat-input-wrapper");
expect(inputArea).not.toBeNull();
expect(inputRow).not.toBeNull();
expect(inputWrapper).not.toBeNull();
expect(inputArea).toContainElement(indicator);
expect(inputWrapper).not.toContainElement(indicator);
expect(indicator.compareDocumentPosition(inputRow!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(inputArea!.querySelector(".chat-pending-divider")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("chat-pending-dismiss"));
expect(clearPendingMessage).toHaveBeenCalledTimes(1);
setupMockChat({
activeSession,
messages: [],
pendingMessage: "",
clearPendingMessage,
});
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-pending-indicator")).not.toBeInTheDocument();
expect(screen.getByTestId("chat-input").closest(".chat-input-area")!.querySelector(".chat-pending-divider")).not.toBeInTheDocument();
});
it("textarea is enabled during streaming", async () => {