Files
fusion/packages/dashboard/app/components/__tests__/ChatView.message-edit.test.tsx
gsxdsm e84fda936a 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>
2026-07-12 23:24:03 -07:00

296 lines
11 KiB
TypeScript

/*
FNXC:ChatMessageEdit 2026-07-07-09:00:
Covers the FN-7628 chat message edit affordance across the surfaces enumerated in
PROMPT.md: renders only for user messages in direct/model-loop chat, is absent for
assistant messages, CLI-agent-backed sessions, and Rooms, and is disabled while
streaming. Also covers the inline editor save/cancel interaction and the
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";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
Element.prototype.scrollIntoView = vi.fn();
vi.mock("../SessionTerminal", () => ({
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-terminal" data-session-id={sessionId}>
terminal
</div>
),
}));
vi.mock("../../hooks/useChat");
vi.mock("../../hooks/useChatRooms");
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
fetchAgents: vi.fn().mockResolvedValue([]),
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
fetchTasks: vi.fn().mockResolvedValue([]),
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
};
});
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
Pencil: (props: any) => <svg data-testid="icon-pencil" {...props} />,
};
});
async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
let result: ReturnType<typeof rtlRender> | undefined;
await act(async () => {
result = rtlRender(ui);
});
return result!;
}
const mockUseChat = vi.mocked(useChatModule.useChat);
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
function makeSession(overrides: Partial<ChatSessionInfo> = {}): ChatSessionInfo {
return {
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",
...overrides,
};
}
const roomA = {
id: "room-a",
name: "Room A",
slug: "room-a",
description: null,
projectId: "proj-123",
createdBy: "agent-1",
status: "active" as const,
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
};
function baseRoomsState(overrides: Partial<UseChatRoomsResult> = {}): UseChatRoomsResult {
return {
rooms: [roomA],
roomsLoading: false,
roomsError: null,
activeRoom: null,
activeRoomMembers: [],
messages: [],
messagesLoading: false,
selectRoom: vi.fn(),
createRoom: vi.fn(),
deleteRoom: vi.fn(),
sendRoomMessage: vi.fn(),
clearRoom: vi.fn(),
refreshRooms: vi.fn(),
...overrides,
};
}
function baseChatState(overrides: Partial<UseChatReturn> = {}): UseChatReturn {
const session = overrides.activeSession ?? makeSession();
return {
sessions: [session],
activeSession: session,
sessionsLoading: false,
messages: [],
messagesLoading: false,
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn(),
archiveSession: vi.fn(),
renameSession: vi.fn(),
setSessionThinkingLevel: vi.fn(),
deleteSession: vi.fn(),
sendMessage: vi.fn(),
editMessageAndResend: vi.fn(),
stopStreaming: vi.fn(),
pendingMessages: [],
clearPendingMessage: vi.fn(),
loadMoreMessages: vi.fn(),
hasMoreMessages: false,
searchQuery: "",
setSearchQuery: vi.fn(),
filteredSessions: [session],
refreshSessions: vi.fn(),
agentsMap: new Map(),
...overrides,
};
}
describe("ChatView message edit affordance", () => {
beforeEach(() => {
localStorage.clear();
mockUseChatRooms.mockReturnValue(baseRoomsState());
});
it("renders the edit affordance only on user messages in a direct chat session", async () => {
mockUseChat.mockReturnValue(baseChatState({
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "assistant-1", sessionId: "session-001", role: "assistant", content: "hi there", createdAt: "2026-04-08T00:00:01.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
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();
});
it("is absent for CLI-agent-backed sessions", async () => {
mockUseChat.mockReturnValue(baseChatState({
activeSession: makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" }),
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-message-edit-user-1")).toBeNull();
});
it("is absent for Rooms messages", async () => {
mockUseChat.mockReturnValue(baseChatState({ messages: [] }));
mockUseChatRooms.mockReturnValue(baseRoomsState({
activeRoom: roomA,
messages: [
{ id: "room-user-1", roomId: roomA.id, role: "user", content: "hey room", createdAt: "2026-04-08T00:00:00.000Z", senderAgentId: "agent-1", mentions: [] },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} experimentalFeatures={{ chatRooms: true }} />);
fireEvent.click(screen.getByTestId("chat-sidebar-scope-rooms"));
expect(screen.queryByTestId("chat-message-edit-room-user-1")).toBeNull();
});
it("is absent while a generation is streaming", async () => {
mockUseChat.mockReturnValue(baseChatState({
isStreaming: true,
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-message-edit-user-1")).toBeNull();
});
it("swaps to an inline editor and saves via editMessageAndResend", async () => {
const editMessageAndResend = vi.fn();
mockUseChat.mockReturnValue(baseChatState({
editMessageAndResend,
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-message-edit-user-1"));
const editor = screen.getByTestId("chat-message-edit-editor-user-1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
expect(textarea.value).toBe("hello");
fireEvent.change(textarea, { target: { value: "hello, edited" } });
fireEvent.click(screen.getByText("Save"));
expect(editMessageAndResend).toHaveBeenCalledWith("user-1", "hello, edited");
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
});
it("cancel restores the original content without calling editMessageAndResend", async () => {
const editMessageAndResend = vi.fn();
mockUseChat.mockReturnValue(baseChatState({
editMessageAndResend,
messages: [
{ id: "user-1", sessionId: "session-001", role: "user", content: "hello", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
fireEvent.click(screen.getByTestId("chat-message-edit-user-1"));
const editor = screen.getByTestId("chat-message-edit-editor-user-1");
const textarea = editor.querySelector("textarea") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "changed but cancelled" } });
fireEvent.click(screen.getByText("Cancel"));
expect(editMessageAndResend).not.toHaveBeenCalled();
expect(screen.queryByTestId("chat-message-edit-editor-user-1")).toBeNull();
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: [
{ id: "assistant-1", sessionId: "session-001", role: "assistant", content: "hi there", createdAt: "2026-04-08T00:00:00.000Z" },
],
}));
await renderWithAct(<ChatView addToast={vi.fn()} />);
const assistantMessage = screen.getByTestId("chat-message-assistant-1");
expect(assistantMessage.querySelector("[aria-label='Edit message']")).toBeNull();
});
});