Stack queued chat sends above the composer and flush them in order. - Persist pending chat sends as per-session FIFO arrays with legacy single-message restore fallback. - Render stacked queued previews above the direct and quick chat composer with per-item dismissal. - Flush one queued message after each completed or safely verified idle generation, preserving reconnect guards. - Extend chat hook and composer tests for multi-message queuing, restore, dismissal, and FIFO sends. - Document the queued stack behavior and add a published package changeset. Files changed: .changeset/fn-7137-stack-queued-chat-messages.md | 7 + docs/dashboard-guide.md | 4 +- packages/dashboard/app/components/ChatView.css | 15 +- packages/dashboard/app/components/ChatView.tsx | 38 ++-- .../__tests__/ChatView.autosize.test.tsx | 2 +- .../__tests__/ChatView.cli-mount.test.tsx | 2 +- .../__tests__/ChatView.core-interactions.test.tsx | 27 ++- .../__tests__/ChatView.default-model-icon.test.tsx | 2 +- .../components/__tests__/ChatView.draft.test.tsx | 2 +- .../__tests__/ChatView.hash-mention.test.tsx | 2 +- .../__tests__/ChatView.mobile-render.test.tsx | 2 +- .../components/__tests__/ChatView.rooms.test.tsx | 2 +- .../__tests__/ChatView.scroll-to-top.test.tsx | 2 +- .../__tests__/ChatView.swipe-back.test.tsx | 2 +- .../components/__tests__/ChatView.test-harness.tsx | 2 +- .../dashboard/app/hooks/__tests__/useChat.test.ts | 247 +++++++++++++++++---- .../app/hooks/chatPendingMessageStorage.ts | 38 +++- packages/dashboard/app/hooks/useChat.ts | 125 ++++++++--- 18 files changed, 390 insertions(+), 131 deletions(-) Fusion-Task-Id: FN-7137 Fusion-Task-Lineage: 65dd5ed7-a8db-447b-9a50-ce73ea7b597f Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
// ChatView CLI-backed mount test (CLI Agent Executor, U12 completion).
|
|
//
|
|
// Asserts ChatView delegates the message-pane + composer region to
|
|
// <CliChatSurface> when the active chat session carries a `cliExecutorAdapterId`,
|
|
// and falls back to the normal provider composer for a regular session.
|
|
//
|
|
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) because
|
|
// CliChatSurface renders it under the hood.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, fireEvent, render as rtlRender, screen } from "@testing-library/react";
|
|
import { ChatView } from "../ChatView";
|
|
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";
|
|
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
|
|
|
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: [] }),
|
|
};
|
|
});
|
|
|
|
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: "sess-1",
|
|
agentId: "agent-1",
|
|
status: "active",
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function chatState(session: ChatSessionInfo): UseChatReturn {
|
|
return {
|
|
sessions: [session],
|
|
activeSession: session,
|
|
sessionsLoading: false,
|
|
messages: [],
|
|
messagesLoading: false,
|
|
isStreaming: false,
|
|
streamingText: "",
|
|
streamingThinking: "",
|
|
streamingToolCalls: [],
|
|
selectSession: vi.fn(),
|
|
createSession: vi.fn(),
|
|
archiveSession: vi.fn(),
|
|
deleteSession: vi.fn(),
|
|
sendMessage: 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(),
|
|
};
|
|
}
|
|
|
|
const defaultRoomsState: UseChatRoomsResult = {
|
|
rooms: [],
|
|
roomsLoading: false,
|
|
roomsError: null,
|
|
activeRoom: null,
|
|
activeRoomMembers: [],
|
|
messages: [],
|
|
messagesLoading: false,
|
|
selectRoom: vi.fn(),
|
|
createRoom: vi.fn(),
|
|
deleteRoom: vi.fn(),
|
|
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
|
|
refreshRooms: vi.fn(),
|
|
};
|
|
|
|
describe("ChatView CLI-backed session mount", () => {
|
|
beforeEach(() => {
|
|
_resetInitialViewportHeight();
|
|
vi.clearAllMocks();
|
|
mockUseChatRooms.mockReturnValue(defaultRoomsState);
|
|
});
|
|
|
|
it("renders CliChatSurface (transcript/terminal toggle) for a cli-backed session", async () => {
|
|
mockUseChat.mockReturnValue(
|
|
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
|
|
);
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
|
|
// CliChatSurface renders the transcript/terminal toggle tablist.
|
|
expect(screen.getByRole("tab", { name: /transcript/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("tab", { name: /terminal/i })).toBeInTheDocument();
|
|
// The standard provider send button is NOT rendered as a top-level composer
|
|
// affordance for the cli surface's default (transcript) view it wraps the
|
|
// existing composer, but the distinguishing CLI toggle is present.
|
|
});
|
|
|
|
it("attaches the terminal to the native cli session id linkage", async () => {
|
|
mockUseChat.mockReturnValue(
|
|
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
|
|
);
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
// Switch to the terminal tab to mount SessionTerminal.
|
|
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
|
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe("cli-native-1");
|
|
});
|
|
|
|
it("generic-tier cli session renders terminal-only (no toggle)", async () => {
|
|
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: "generic" })));
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
expect(screen.getByTestId("session-terminal")).toBeInTheDocument();
|
|
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
|
|
});
|
|
|
|
it("renders the normal provider composer for a regular (non-cli) session", async () => {
|
|
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: null })));
|
|
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
|
// Normal composer present, CLI toggle absent.
|
|
expect(screen.getByPlaceholderText("Type a message...")).toBeInTheDocument();
|
|
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
|
|
expect(screen.queryByTestId("session-terminal")).toBeNull();
|
|
});
|
|
});
|