FN-7918: make chat go-to-top contextual and inline edit pencil compact

Reworks chat message footer affordances: the scroll-to-top control now only becomes visible once a message's top is actually clipped above the visible thread viewport, and the edit pencil moves from a standalone action row into the timestamp footer beside user messages.

- ChatView measures assistant message tops on scroll/message changes (rAF-scheduled) and tracks which message IDs are currently clipped above the `.chat-messages` container edge
- StandardChatMessageItem accepts a new `isTopClipped` prop; the go-to-top button stays DOM-mounted (for tests/a11y) but is visually hidden via CSS until clipped
- Merged the assistant thinking/copy/scroll-to-top actions into a single collapsible footer row instead of separate action rows
- Moved the user-message edit pencil into an inline `chat-message-time-row` next to the relative timestamp instead of a standalone action row above it
- Updated ChatView.css for the new inline layout, collapsed-row state, and hidden/visible scroll-to-top button states
- Updated message-edit and scroll-to-top tests to cover the new inline placement and clipped-visibility behavior
- Added changeset and docs/dashboard-guide.md note describing the new behavior

Files changed:
 .changeset/fn-7918-chat-inline-icons.md            |  7 ++
 docs/dashboard-guide.md                            |  6 +-
 packages/dashboard/app/components/ChatView.css     | 80 +++++++++++++++-------
 packages/dashboard/app/components/ChatView.tsx     | 52 +++++++++++++-
 .../app/components/StandardChatSurface.tsx         | 33 +++++++--
 .../__tests__/ChatView.message-edit.test.tsx       | 34 ++++++++-
 .../__tests__/ChatView.scroll-to-top.test.tsx      | 75 +++++++++++++++++++-
 7 files changed, 253 insertions(+), 34 deletions(-)

Fusion-Task-Id: FN-7918
Fusion-Task-Lineage: 76206cd2-94a8-47be-b282-94943e184d01
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 23:22:06 -07:00
parent c8999369c3
commit e84fda936a
7 changed files with 253 additions and 34 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make Chat go-to-top contextual and inline, with the edit pencil compact beside timestamps.
category: feature
dev: StandardChatMessageItem gains an isTopClipped prop; ChatView measures clipped message tops on scroll to gate go-to-top visibility. Edit pencil moved from a standalone row into the timestamp footer.

View File

@@ -548,8 +548,10 @@ Chat view provides project-scoped conversations with agents.
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model.
- Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked.
<!-- FNXC:ChatMessageEdit 2026-07-07-09:00: Document the message-edit affordance and its resume-from-edit ("forget everything after") semantics, including the model-loop-only scope. -->
- Your own messages in a **direct (model-loop) chat** can be edited: hover/tap a user message and use the **Edit message** (pencil) action to swap it for an inline textarea, then **Save** (or Cmd/Ctrl+Enter) or **Cancel** (or Escape). Saving an edit **resumes the conversation from that point** — the edited turn and every turn after it are discarded from both the visible transcript and the model's memory, so the agent responds fresh from the edited content with no bias from what was removed. This is the only way to correct or steer an earlier turn without leaving a stale, misleading message in the thread.
<!-- FNXC:ChatMessageEdit 2026-07-12-23:20: Document the message-edit affordance and its resume-from-edit ("forget everything after") semantics, including the model-loop-only scope and inline timestamp placement. -->
- Your own messages in a **direct (model-loop) chat** can be edited: hover/tap the compact **Edit message** (pencil) action inline with the message timestamp to swap it for an inline textarea, then **Save** (or Cmd/Ctrl+Enter) or **Cancel** (or Escape). Saving an edit **resumes the conversation from that point** — the edited turn and every turn after it are discarded from both the visible transcript and the model's memory, so the agent responds fresh from the edited content with no bias from what was removed. This is the only way to correct or steer an earlier turn without leaving a stale, misleading message in the thread.
<!-- FNXC:ChatMessageScrollToTop 2026-07-12-23:20: Chat message go-to-top is contextual; document that it appears only after a message top has scrolled above the chat viewport and sits inline with Thinking/copy actions. -->
- Assistant messages expose a **Scroll message to top** up-arrow only after that message's top has moved above the visible chat viewport. When visible, the control sits inline with the message's **Thinking** row (or the same footer action row when no thinking details are present) instead of adding a separate action line.
- Message editing applies to direct/model-loop chat sessions, **including task-detail Planner Chat** (the synthetic `task-planner:<id>` session, a model-loop session under the hood). It is **not** available in **Chat Rooms** (multi-agent, different persistence) or in **CLI-agent-backed sessions** (the transcript is owned by a live terminal, not a rewindable model session). The edit action is also disabled while a response is actively streaming, to avoid racing a live generation, and never renders on optimistic/in-flight rows that have no persisted message id yet.
- Editing is truncate-and-resend, not append: the edited message and everything after it are removed first, then the edited text is sent as a new turn through the normal streaming path — so the resulting transcript looks the same as if you had deleted the old messages and typed the correction from scratch, but in one action.
<!-- FNXC:TaskDetailPlannerChat 2026-07-07-10:15: Document Planner Chat edit-and-resend and the steering/refinement side-effect decision on discard. -->

View File

@@ -1292,12 +1292,24 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
overflow-wrap: anywhere;
}
.chat-message-thinking-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.chat-message-thinking-row--collapsed {
display: none;
}
.chat-message-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-xs);
margin-top: var(--space-xs);
margin-left: auto;
}
.chat-message-copy-action,
@@ -1305,8 +1317,6 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--space-xs);
margin-left: auto;
width: calc(var(--space-lg) * 2);
height: calc(var(--space-lg) * 2);
min-width: calc(var(--space-lg) * 2);
@@ -1321,10 +1331,8 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.chat-message-actions .chat-message-copy-action,
.chat-message-actions .chat-message-scroll-to-top-action {
margin-top: 0;
margin-left: 0;
.chat-message-scroll-to-top-action--hidden {
display: none;
}
.chat-message-copy-action:hover,
@@ -1350,16 +1358,19 @@ Narrow chat hosts need full-width bubbles for prose, code, tool output, failures
}
/*
FNXC:ChatMessageEdit 2026-07-07-09:00:
User messages in direct (model-loop) chat can carry an inline edit affordance mirroring the
assistant copy/scroll-to-top action row pattern above, plus an inline textarea editor. Editing
an earlier message resumes the conversation from that point, forgetting everything after it, so
keep the affordance visually consistent with existing chat-message-actions styling rather than a
one-off treatment. Rendered only for user messages on direct/model-loop sessions (never rooms,
CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEditAction`.
FNXC:ChatMessageEdit 2026-07-12-23:13:
User messages in direct (model-loop) chat carry a compact edit affordance inline with the timestamp footer instead of a standalone action row. Editing an earlier message still resumes the conversation from that point, forgetting everything after it; the footer placement keeps the affordance discoverable without adding vertical space. Rendered only for user messages on direct/model-loop sessions (never rooms, CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEditAction`.
*/
.chat-message-actions--user {
justify-content: flex-start;
.chat-message-time-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
margin-top: var(--space-2xs);
}
.chat-message-time-row .chat-message-time {
margin-top: 0;
}
.chat-message-edit-action {
@@ -1380,6 +1391,13 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi
transition: opacity var(--transition-fast), background var(--transition-fast);
}
.chat-message-edit-action--inline {
width: calc(var(--space-lg) * 1.5);
height: calc(var(--space-lg) * 1.5);
min-width: calc(var(--space-lg) * 1.5);
min-height: calc(var(--space-lg) * 1.5);
}
.chat-message-edit-action:hover {
opacity: 1;
background: color-mix(in srgb, var(--accent-text) 25%, transparent);
@@ -1415,11 +1433,15 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi
}
@media (max-width: 768px) {
.chat-message-edit-action {
width: calc(var(--space-lg) * 2.25);
height: calc(var(--space-lg) * 2.25);
min-width: calc(var(--space-lg) * 2.25);
min-height: calc(var(--space-lg) * 2.25);
.chat-message-time-row {
align-items: center;
}
.chat-message-edit-action--inline {
width: calc(var(--space-lg) * 1.75);
height: calc(var(--space-lg) * 1.75);
min-width: calc(var(--space-lg) * 1.75);
min-height: calc(var(--space-lg) * 1.75);
}
.chat-message-edit-textarea {
@@ -1485,13 +1507,15 @@ CLI-agent sessions, or while streaming) — see StandardChatSurface.tsx `showEdi
}
.chat-message-time {
font-size: 11px;
font-size: var(--font-size-xs);
color: var(--text-dim);
margin-top: 4px;
margin-top: var(--space-2xs);
}
.chat-message-thinking {
margin-top: 6px;
flex: 1 1 auto;
min-width: 0;
margin: 0;
}
.chat-message-thinking summary {
@@ -2504,10 +2528,18 @@ Queued-message banners stack above the composer input with a capped scroll area,
opacity: 1;
}
.chat-message-thinking-row {
gap: var(--space-xs);
}
.chat-message-scroll-to-top-action {
opacity: 1;
}
.chat-message-scroll-to-top-action--hidden {
display: none;
}
.chat-tool-calls-group-summary,
.chat-tool-call summary {
flex-wrap: nowrap;

View File

@@ -704,6 +704,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const chatThreadRef = useRef<HTMLDivElement | null>(null);
const clippedMessageFrameRef = useRef<number | null>(null);
const [topClippedMessageIds, setTopClippedMessageIds] = useState<Set<string>>(() => new Set());
// FN-5365: mirror QuickChat's mid-dismiss suppress gate so transient
// visualViewport shrink samples do not jerk the chat thread/composer.
const suppressVvShrinkRef = useRef(false);
@@ -978,6 +980,36 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
return container.querySelector<HTMLElement>(`.chat-message[data-message-id="${messageId.replace(/"/g, "\\\"")}"]`);
}, []);
const updateTopClippedMessages = useCallback(() => {
const messagesContainer = messagesContainerRef.current;
if (!messagesContainer) return;
const containerTop = messagesContainer.getBoundingClientRect().top;
const nextIds = new Set<string>();
messagesContainer.querySelectorAll<HTMLElement>(".chat-message--assistant:not(.chat-message--failure)[data-message-id]").forEach((element) => {
const messageId = element.getAttribute("data-message-id");
if (!messageId) return;
if (element.getBoundingClientRect().top < containerTop) {
nextIds.add(messageId);
}
});
setTopClippedMessageIds((previousIds) => {
if (previousIds.size === nextIds.size && Array.from(previousIds).every((id) => nextIds.has(id))) {
return previousIds;
}
return nextIds;
});
}, []);
const scheduleTopClippedMessageUpdate = useCallback(() => {
if (!messagesContainerRef.current || clippedMessageFrameRef.current !== null) return;
clippedMessageFrameRef.current = window.requestAnimationFrame(() => {
clippedMessageFrameRef.current = null;
updateTopClippedMessages();
});
}, [updateTopClippedMessages]);
const captureScrollSnapshot = useCallback(() => {
const messagesContainer = messagesContainerRef.current;
const threadId = getActiveThreadId();
@@ -1012,7 +1044,8 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
setIsUserScrolling(!atBottom);
isUserScrollingRef.current = !atBottom;
captureScrollSnapshot();
}, [captureScrollSnapshot]);
scheduleTopClippedMessageUpdate();
}, [captureScrollSnapshot, scheduleTopClippedMessageUpdate]);
const anchorToBottom = useCallback((container: HTMLElement, options?: { force?: boolean }) => {
if (!container.isConnected) return;
@@ -1054,6 +1087,20 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
const activeThreadMessages = roomThreadActive ? rooms.messages : messages;
/*
FNXC:ChatMessageScrollToTop 2026-07-12-23:16:
ChatView owns the `.chat-messages` viewport, so it measures assistant message tops against the container's visible top on scroll/message changes and passes clipped membership down. The go-to-top control remains DOM-mounted by StandardChatSurface but becomes visually available only after the message's top has moved above this container edge.
*/
useLayoutEffect(() => {
scheduleTopClippedMessageUpdate();
return () => {
if (clippedMessageFrameRef.current !== null) {
window.cancelAnimationFrame(clippedMessageFrameRef.current);
clippedMessageFrameRef.current = null;
}
};
}, [activeThreadMessages, scheduleTopClippedMessageUpdate]);
useLayoutEffect(() => {
const messagesContainer = messagesContainerRef.current;
const threadId = getActiveThreadId();
@@ -2577,6 +2624,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
isTopClipped={topClippedMessageIds.has(message.id)}
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
onQuestionSubmit={handleQuestionSubmit}
@@ -2621,6 +2669,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
roomContext={null}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
onScrollToTop={handleScrollMessageToTop}
isTopClipped={topClippedMessageIds.has(message.id)}
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
onQuestionSubmit={handleQuestionSubmit}
@@ -3574,6 +3623,7 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
mentionAgentsByName={mentionAgentsByName}
roomContext={roomContext}
onScrollToTop={handleScrollMessageToTop}
isTopClipped={topClippedMessageIds.has(message.id)}
isAwaitingQuestionAnswer={false}
onQuestionSubmit={handleQuestionSubmit}
/>

View File

@@ -29,6 +29,11 @@ export interface StandardChatMessageItemProps {
roomContext?: StandardRoomContext | null;
copyAction?: ReactNode;
onScrollToTop?: (messageId: string) => void;
/**
* FNXC:ChatMessageScrollToTop 2026-07-12-23:09:
* ChatView owns scroll-container measurement and sets this when the message top is clipped above the visible container top. StandardChatSurface keeps eligible go-to-top controls mounted for tests/accessibility wiring but hides them until this state is true, and renders the control inline with the Thinking row instead of a standalone action line.
*/
isTopClipped?: boolean;
isAwaitingQuestionAnswer?: boolean;
submittedQuestionAnswer?: string;
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
@@ -351,6 +356,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
toolCallRenderer,
onEditMessage,
canEdit = false,
isTopClipped = false,
}: StandardChatMessageItemProps) {
const { t } = useTranslation("app");
const isAssistantMessage = message.role === "assistant";
@@ -360,7 +366,8 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
* Edit affordance is scoped strictly to user messages on surfaces that opt in via both
* `canEdit` and `onEditMessage`; absent either, `showEditAction` is false and nothing renders
* (no dead button, no empty shell) — e.g. assistant/system messages, Rooms, CLI-agent chat, or
* while a generation is streaming.
* while a generation is streaming. The compact pencil renders in the timestamp footer so user
* bubbles do not grow an extra action row above their time metadata.
*/
const showEditAction = isUserMessage && canEdit && Boolean(onEditMessage);
const [isEditing, setIsEditing] = useState(false);
@@ -451,6 +458,9 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
}
return renderStandardAssistantContent(message.content, forcePlain);
}, [failureInfo, forcePlain, isAssistantMessage, isEmptyAssistantMessage, message.content, t]);
const hasAssistantFooterRow = isAssistantMessage && !failureInfo && Boolean(message.thinkingOutput || copyAction || onScrollToTop);
const hasVisibleAssistantFooterContent = Boolean(message.thinkingOutput || copyAction || (onScrollToTop && isTopClipped));
const messageTime = <div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>;
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>}
@@ -480,12 +490,25 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
) : (
isAssistantMessage ? assistantBody : <div className="chat-message-content">{renderedUserContent}</div>
)}
{isAssistantMessage && !failureInfo && (copyAction || onScrollToTop) && <div className="chat-message-actions">{copyAction}{onScrollToTop && <button type="button" className="btn-icon chat-message-scroll-to-top-action" aria-label={t("chat.scrollMessageToTop", "Scroll message to top")} data-testid={`chat-message-scroll-to-top-${message.id}`} onClick={() => onScrollToTop(message.id)}><ArrowUpToLine size={14} /></button>}</div>}
{showEditAction && !isEditing && <div className="chat-message-actions chat-message-actions--user"><button type="button" className="btn-icon chat-message-edit-action" aria-label={t("chat.editMessage", "Edit message")} data-testid={`chat-message-edit-${message.id}`} onClick={startEditing}><Pencil size={14} /></button></div>}
{hasAssistantFooterRow && (
<div className={`chat-message-thinking-row${hasVisibleAssistantFooterContent ? "" : " chat-message-thinking-row--collapsed"}`}>
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
{(copyAction || onScrollToTop) && (
<div className="chat-message-actions">
{copyAction}
{onScrollToTop && <button type="button" className={`btn-icon chat-message-scroll-to-top-action${isTopClipped ? "" : " chat-message-scroll-to-top-action--hidden"}`} aria-label={t("chat.scrollMessageToTop", "Scroll message to top")} data-testid={`chat-message-scroll-to-top-${message.id}`} onClick={() => onScrollToTop(message.id)}><ArrowUpToLine size={14} /></button>}
</div>
)}
</div>
)}
{renderStandardToolCalls(message.toolCalls, t, { isAwaitingAnswer: isAwaitingQuestionAnswer, submittedAnswer: submittedQuestionAnswer, onQuestionSubmit, toolCallRenderer })}
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
{renderedAttachments}
<div className="chat-message-time">{formatRelativeTime(message.createdAt, t)}</div>
{isUserMessage ? (
<div className="chat-message-time-row">
{messageTime}
{showEditAction && !isEditing && <button type="button" className="btn-icon chat-message-edit-action chat-message-edit-action--inline" aria-label={t("chat.editMessage", "Edit message")} data-testid={`chat-message-edit-${message.id}`} onClick={startEditing}><Pencil size={14} /></button>}
</div>
) : messageTime}
</div>
);
});

View File

@@ -9,6 +9,7 @@ editMessageAndResend wiring.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react";
import { ChatView } from "../ChatView";
import { StandardChatMessageItem } from "../StandardChatSurface";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
@@ -155,7 +156,15 @@ describe("ChatView message edit affordance", () => {
await renderWithAct(<ChatView addToast={vi.fn()} />);
expect(screen.getByTestId("chat-message-edit-user-1")).toHaveAttribute("aria-label", "Edit message");
const editButton = screen.getByTestId("chat-message-edit-user-1");
const userMessage = screen.getByTestId("chat-message-user-1");
const timeRow = userMessage.querySelector(".chat-message-time-row");
expect(editButton).toHaveAttribute("aria-label", "Edit message");
expect(editButton).toHaveClass("chat-message-edit-action--inline");
expect(timeRow).toContainElement(userMessage.querySelector(".chat-message-time") as HTMLElement);
expect(timeRow).toContainElement(editButton);
expect(userMessage.querySelector(".chat-message-actions--user")).toBeNull();
expect(screen.queryByTestId("chat-message-edit-assistant-1")).toBeNull();
});
@@ -248,6 +257,29 @@ describe("ChatView message edit affordance", () => {
expect(screen.getByTestId("chat-message-user-1")).toHaveTextContent("hello");
});
it("renders inline edit without a go-to-top control for non-scroll-to-top consumers", () => {
rtlRender(
<StandardChatMessageItem
message={{ id: "planner-user-1", sessionId: "task-planner:FN-1", role: "user", content: "planner request", createdAt: "2026-04-08T00:00:00.000Z" }}
forcePlain={false}
agentName="Planner"
hideAssistantIdentity={false}
showAssistantModelTag={false}
activeModelTag={null}
activeModelProvider={null}
activeSessionId="task-planner:FN-1"
onEditMessage={vi.fn()}
canEdit={true}
/>,
);
const editButton = screen.getByTestId("chat-message-edit-planner-user-1");
const message = screen.getByTestId("chat-message-planner-user-1");
expect(editButton).toHaveClass("chat-message-edit-action--inline");
expect(message.querySelector(".chat-message-time-row")).toContainElement(editButton);
expect(message.querySelector("[data-testid^='chat-message-scroll-to-top-']")).toBeNull();
});
it("does not leave an empty edit-action shell for assistant messages", async () => {
mockUseChat.mockReturnValue(baseChatState({
messages: [

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react";
import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import { ChatView } from "../ChatView";
import * as useChatModule from "../../hooks/useChat";
import * as useChatRoomsModule from "../../hooks/useChatRooms";
@@ -44,6 +44,12 @@ async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
return result!;
}
async function flushAnimationFrame() {
await act(async () => {
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
});
}
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
@@ -204,6 +210,45 @@ describe("ChatView scroll-to-top message affordance", () => {
expect(container.scrollTo).toHaveBeenCalledWith({ top: 120, behavior: "auto" });
});
it("shows the affordance only when the direct-session assistant message top is clipped", async () => {
await setup({
messages: [
{ id: "assistant-ok", sessionId: activeSession.id, role: "assistant", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const target = screen.getByTestId("chat-message-assistant-ok") as HTMLDivElement;
const button = screen.getByTestId("chat-message-scroll-to-top-assistant-ok");
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect);
const targetRect = vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect);
fireEvent.scroll(container);
await flushAnimationFrame();
expect(button).toHaveClass("chat-message-scroll-to-top-action--hidden");
targetRect.mockReturnValue({ top: 80 } as DOMRect);
fireEvent.scroll(container);
await flushAnimationFrame();
await waitFor(() => expect(button).not.toHaveClass("chat-message-scroll-to-top-action--hidden"));
});
it("renders the affordance inline with the Thinking row when thinking output exists", async () => {
await setup({
messages: [
{ id: "assistant-thinking", sessionId: activeSession.id, role: "assistant", content: "hello", thinkingOutput: "reasoning", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
const message = screen.getByTestId("chat-message-assistant-thinking");
const row = message.querySelector(".chat-message-thinking-row");
const thinking = message.querySelector(".chat-message-thinking");
const button = screen.getByTestId("chat-message-scroll-to-top-assistant-thinking");
expect(row).toContainElement(thinking as HTMLElement);
expect(row).toContainElement(button);
});
it("renders the affordance for room assistant messages", async () => {
await setup(
{
@@ -223,6 +268,34 @@ describe("ChatView scroll-to-top message affordance", () => {
expect(screen.getByTestId("chat-message-scroll-to-top-room-assistant-1")).toBeInTheDocument();
});
it("shows the affordance only when a room assistant message top is clipped", async () => {
await setup(
{ sessions: [activeSession], activeSession },
{
messages: [
{ id: "room-assistant-1", roomId: roomA.id, role: "assistant", content: "Room response", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
],
},
{ chatRooms: true },
);
fireEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
const container = document.querySelector(".chat-messages") as HTMLDivElement;
const target = screen.getByTestId("chat-message-room-assistant-1") as HTMLDivElement;
const button = screen.getByTestId("chat-message-scroll-to-top-room-assistant-1");
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ top: 100 } as DOMRect);
const targetRect = vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 120 } as DOMRect);
fireEvent.scroll(container);
await flushAnimationFrame();
expect(button).toHaveClass("chat-message-scroll-to-top-action--hidden");
targetRect.mockReturnValue({ top: 90 } as DOMRect);
fireEvent.scroll(container);
await flushAnimationFrame();
await waitFor(() => expect(button).not.toHaveClass("chat-message-scroll-to-top-action--hidden"));
});
it("does not reset to top when a stale zero snapshot is captured while user is reading older messages", async () => {
const state: UseChatReturn = {
...defaultChatState,