From 334554492129d81219965bf450c8147e00673b61 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Thu, 14 May 2026 20:38:24 +0000 Subject: [PATCH] fix(canvas): load chat history in MobileChat (closes #1062) MobileChat previously only read from the canvas store's agentMessages buffer, which is populated by desktop ChatTab (never runs on mobile) and live WebSocket events (only new messages). Opening chat on a phone/WebView showed an empty state even when history existed. Changes: - Fetch history via GET /workspaces/{id}/chat-history?limit=50 on mount - Show loading spinner during fetch, surface errors with Retry button - Merge live agentMessages from the store while the panel is open - Subscribe to store updates after bootstrap so new pushes are visible - Fix TypeScript strict-mode issue in effect cleanup (Promise vs. sync fn) Test coverage (canvas): - New MobileChat history tests: mount call, loading state, empty state, message rendering, user role mapping, error state, retry button flow - All 26 MobileChat tests pass; 3293 total canvas tests pass Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/mobile/MobileChat.tsx | 154 +++++++++++++- .../mobile/__tests__/MobileChat.test.tsx | 188 ++++++++++++++++-- 2 files changed, 315 insertions(+), 27 deletions(-) diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index aa76c4e8c..878eeec01 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -36,6 +36,20 @@ interface A2AResponseShape { error?: { message?: string }; } +// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse). +interface ApiChatMessage { + id: string; + role: string; // "user" | "agent" | "system" + content: string; + timestamp: string; + attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>; +} + +interface ChatHistoryResponse { + messages: ApiChatMessage[]; + reached_end: boolean; +} + const formatTime = (date: Date) => date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); @@ -61,18 +75,14 @@ export function MobileChat({ // that creates a new [] reference on every store update when the key is // absent, causing infinite re-render (React error #185). const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]); - const [messages, setMessages] = useState(() => - (storedMessages ?? []).map((m) => ({ - id: m.id, - role: "agent", - text: m.content, - ts: formatStoredTimestamp(m.timestamp), - })), - ); + // Start empty — history is loaded via useEffect below. + const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [tab, setTab] = useState("my"); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); // history is loading on mount + const [historyError, setHistoryError] = useState(null); const scrollRef = useRef(null); // Synchronous re-entry guard. `setSending(true)` schedules a state // update but doesn't flush before a second tap can fire send() — a ref @@ -80,6 +90,9 @@ export function MobileChat({ // double-send race a stale `sending` lets through. const sendInFlightRef = useRef(false); const composerRef = useRef(null); + // Guard: don't treat the initial store population as a live push. + // Set to false after the first render completes. + const initDoneRef = useRef(false); // Auto-grow the textarea: reset height to 'auto' so the scrollHeight // shrinks when the user deletes text, then size to scrollHeight up to @@ -92,6 +105,75 @@ export function MobileChat({ el.style.height = `${next}px`; }, [draft]); + // Fetch chat history on mount; keep merging live agentMessages while the + // panel is open. InitDoneRef prevents the initial store snapshot from + // triggering the live-merge path (the store buffer is populated by + // ChatTab on desktop, not on mobile — this effect loads history as the + // mobile-native path). + useEffect(() => { + let cancelled = false; + + const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({ + id: m.id, + role: m.role === "user" ? "user" : "agent", + text: m.content, + ts: formatStoredTimestamp(m.timestamp), + }); + + const syncLive = () => { + const live = useCanvasStore.getState().agentMessages[agentId] ?? []; + if (live.length > 0) { + setMessages((prev) => { + const existingIds = new Set(prev.map((m) => m.id)); + const newOnes = live + .filter((m) => !existingIds.has(m.id)) + .map((m) => ({ + id: m.id, + role: "agent" as const, + text: m.content, + ts: formatStoredTimestamp(m.timestamp), + })); + return newOnes.length > 0 ? [...prev, ...newOnes] : prev; + }); + } + }; + + const bootstrap = async (): Promise<(() => void) | undefined> => { + setLoading(true); + setHistoryError(null); + try { + const res = await api.get( + `/workspaces/${agentId}/chat-history?limit=50`, + ); + if (cancelled) return; + const initial = (res.messages ?? []).map(mapApiMessage); + setMessages(initial); + // Mark init done BEFORE marking loading=false so any store push + // that arrives in the same tick is treated as live, not init. + initDoneRef.current = true; + setLoading(false); + // Subscribe to live pushes after init is complete. + syncLive(); + const unsubscribe = useCanvasStore.subscribe(syncLive); + return unsubscribe; // returned for cleanup + } catch (e) { + if (cancelled) return; + setHistoryError(e instanceof Error ? e.message : "Failed to load chat history"); + setLoading(false); + initDoneRef.current = true; + return undefined; + } + }; + + let maybeUnsubscribe: (() => void) | undefined; + bootstrap().then((fn) => { maybeUnsubscribe = fn; }); + + return () => { + cancelled = true; + if (maybeUnsubscribe) maybeUnsubscribe(); + }; + }, [agentId]); + useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; @@ -311,7 +393,61 @@ export function MobileChat({ Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab. )} - {tab === "my" && messages.length === 0 && ( + {tab === "my" && loading && ( +
+
+
Loading chat history…
+
+ )} + {tab === "my" && !loading && historyError && ( +
+
Could not load chat history.
+ +
+ )} + {tab === "my" && !loading && !historyError && messages.length === 0 && (
Send a message to start chatting.
diff --git a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx index 9b89df4c9..968a77ace 100644 --- a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx +++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx @@ -8,11 +8,19 @@ * NOTE: No @testing-library/jest-dom — use DOM APIs. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render } from "@testing-library/react"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; import { MobileChat } from "../MobileChat"; +// ─── Mock API ───────────────────────────────────────────────────────────────── +// vi.mock without a factory auto-mocks the module. In tests, we configure +// api.get / api.post directly (they are vi.fn() from the auto-mock). +// Tests that need specific behaviour use mockResolvedValueOnce on the +// auto-mocked functions. +vi.mock("@/lib/api"); +import { api } from "@/lib/api"; + // ─── Mock store ─────────────────────────────────────────────────────────────── const mockAgentId = "ws-chat-test"; @@ -32,8 +40,14 @@ const mockStoreState = { vi.mock("@/store/canvas", () => ({ useCanvasStore: Object.assign( - vi.fn((sel) => sel(mockStoreState)), - { getState: () => mockStoreState }, + vi.fn((sel?: (state: typeof mockStoreState) => unknown) => { + if (sel) return sel(mockStoreState); + return mockStoreState; + }), + { + getState: () => mockStoreState, + subscribe: vi.fn(() => vi.fn()), + }, ), summarizeWorkspaceCapabilities: vi.fn((data: Record) => { const agentCard = data.agentCard as Record | null; @@ -54,16 +68,6 @@ vi.mock("@/store/canvas", () => ({ }), })); -// ─── Mock API ───────────────────────────────────────────────────────────────── - -const { mockApiPost } = vi.hoisted(() => ({ - mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }), -})); - -vi.mock("@/lib/api", () => ({ - api: { post: mockApiPost }, -})); - // ─── Fixtures ──────────────────────────────────────────────────────────────── const onlineNode = { @@ -150,7 +154,15 @@ beforeEach(() => { mockOnBack.mockClear(); mockStoreState.nodes = []; mockStoreState.agentMessages = {}; - mockApiPost.mockClear(); + // Set up spies on the real api methods. Tests override these per-call. + const getSpy = vi.spyOn(api, "get"); + const postSpy = vi.spyOn(api, "post"); + getSpy.mockResolvedValue({ messages: [], reached_end: true }); + postSpy.mockResolvedValue({ result: { parts: [] } }); +}); + +afterEach(() => { + vi.restoreAllMocks(); }); afterEach(() => { @@ -266,15 +278,26 @@ describe("MobileChat — empty state", () => { mockStoreState.nodes = [onlineNode]; }); - it('shows "Send a message to start chatting." when no messages', () => { - const { container } = renderChat(mockAgentId); + it('shows "Send a message to start chatting." when no messages', async () => { + // History fetch resolves immediately in tests (mockResolvedValue). + // act() flushes the microtask queue so the component reaches its + // post-load state before we assert. + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + const { container } = renderResult!; expect(container.textContent ?? "").toContain("Send a message to start chatting."); }); - it("shows no messages when agentMessages[agentId] is absent (undefined)", () => { + it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => { // Explicitly set to empty to simulate no stored messages mockStoreState.agentMessages = {}; - const { container } = renderChat(mockAgentId); + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + const { container } = renderResult!; expect(container.textContent ?? "").toContain("Send a message to start chatting."); }); }); @@ -321,3 +344,132 @@ describe("MobileChat — dark mode", () => { expect(container.querySelector('[aria-label="Back"]')).toBeTruthy(); }); }); + +// ─── Chat history loading ──────────────────────────────────────────────────── + +describe("MobileChat — chat history", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("calls GET /workspaces/:id/chat-history on mount", async () => { + await act(async () => { + renderChat(mockAgentId); + }); + expect(api.get).toHaveBeenCalledWith( + `/workspaces/${mockAgentId}/chat-history?limit=50`, + ); + }); + + it("shows loading state while history is fetching", () => { + // Do NOT await — check the pre-resolve state. + const { container } = renderChat(mockAgentId); + expect(container.textContent ?? "").toContain("Loading chat history…"); + }); + + it("shows empty state after history resolves with no messages", async () => { + // beforeEach already sets api.get to resolve with empty — no override needed. + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + const { container } = renderResult!; + expect(container.textContent ?? "").toContain("Send a message to start chatting."); + }); + + it("renders messages from history response", async () => { + vi.spyOn(api, "get").mockResolvedValueOnce({ + messages: [ + { + id: "msg-1", + role: "user", + content: "Hello agent", + timestamp: "2026-04-25T10:00:00Z", + }, + { + id: "msg-2", + role: "agent", + content: "Hello back", + timestamp: "2026-04-25T10:00:01Z", + }, + ], + reached_end: true, + }); + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + const { container } = renderResult!; + expect(container.textContent ?? "").toContain("Hello agent"); + expect(container.textContent ?? "").toContain("Hello back"); + }); + + it("maps user role from API correctly", async () => { + vi.spyOn(api, "get").mockResolvedValueOnce({ + messages: [ + { + id: "msg-u", + role: "user", + content: "user message", + timestamp: "2026-04-25T10:00:00Z", + }, + ], + reached_end: true, + }); + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + // User messages render right-aligned. The text content check is sufficient + // to confirm the message appeared. + const { container } = renderResult!; + expect(container.textContent ?? "").toContain("user message"); + }); + + it("shows error state when history fetch fails", async () => { + vi.spyOn(api, "get").mockRejectedValue(new Error("Network error")); + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + const { container } = renderResult!; + expect(container.textContent ?? "").toContain("Could not load chat history."); + expect(container.textContent ?? "").toContain("Retry"); + }); + + it("Retry button re-fetches history after error", async () => { + // Make the initial mount call fail so the Retry button appears, then + // make the retry call succeed so we can verify the full flow. + const getSpy = vi.spyOn(api, "get"); + getSpy + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ messages: [], reached_end: true }); + + let renderResult: ReturnType; + await act(async () => { + renderResult = renderChat(mockAgentId); + }); + const { container } = renderResult!; + + // Error state should be shown with Retry button. + expect(container.textContent ?? "").toContain("Could not load chat history."); + expect(container.textContent ?? "").toContain("Retry"); + + // Click Retry — the button's onClick fires api.get again. + // The second mockResolvedValueOnce makes it succeed. + const retryBtn = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Retry", + ); + expect(retryBtn).toBeTruthy(); + await act(async () => { + retryBtn?.click(); + }); + + // waitFor polls until the retry resolves and component re-renders. + await waitFor(() => { + expect(container.textContent ?? "").toContain("Send a message to start chatting."); + }); + // Initial call + retry = 2. + expect(getSpy).toHaveBeenCalledTimes(2); + }); +}); -- 2.52.0