From 0cf2fa6297aaf2d5012fa12a44ffe8b6b8886cdd Mon Sep 17 00:00:00 2001 From: core-be Date: Thu, 14 May 2026 12:52:42 -0700 Subject: [PATCH] fix(canvas): load chat history in MobileChat 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). This meant opening chat on a phone / WebView showed an empty 'Send a message to start chatting' state even when history existed. - Load history via GET /workspaces/{id}/chat-history?limit=50 on mount - Consume live agentMessages from the store while the panel is open - Show loading spinner while fetching and surface errors - Update tests to mock api.get and consumeAgentMessages --- canvas/src/components/mobile/MobileChat.tsx | 101 ++++++++++++++---- .../mobile/__tests__/MobileChat.test.tsx | 29 +++-- 2 files changed, 104 insertions(+), 26 deletions(-) diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index a7078255b..c06b84ec4 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -5,7 +5,7 @@ // that the desktop ChatTab uses, but with a slimmer surface: no // attachments, no A2A topology overlay, no conversation tracing. -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; @@ -50,26 +50,13 @@ export function MobileChat({ }) { const p = usePalette(dark); const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId)); - // Bootstrap from the canvas store's per-workspace message buffer so the - // user sees their prior thread on entry. The store is updated by the - // socket → ChatTab flows the desktop runs; on mobile we read from the - // same buffer to keep state coherent across viewports. - // NOTE: selector returns undefined (stable) — do NOT use ?? [] here, - // 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), - })), - ); + const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [tab, setTab] = useState("my"); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + const [historyLoading, setHistoryLoading] = useState(true); + 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 @@ -95,6 +82,74 @@ export function MobileChat({ } }, [messages]); + // Load chat history on mount / agent switch. + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + setHistoryError(null); + try { + const resp = await api.get<{ + messages: Array<{ + id: string; + role: string; + content: string; + timestamp: string; + }>; + }>(`/workspaces/${agentId}/chat-history?limit=50`); + const loaded = (resp.messages ?? []).map((m) => ({ + id: m.id, + role: m.role as "user" | "agent" | "system", + text: m.content, + ts: formatStoredTimestamp(m.timestamp), + })); + setMessages(loaded); + } catch (e) { + setHistoryError(e instanceof Error ? e.message : "Failed to load history"); + } finally { + setHistoryLoading(false); + } + }, [agentId]); + + useEffect(() => { + let cancelled = false; + loadHistory().then(() => { + if (cancelled) return; + // Consume any agent messages that arrived while history was loading. + const consume = useCanvasStore.getState().consumeAgentMessages; + const msgs = consume(agentId); + if (msgs.length > 0) { + setMessages((prev) => [ + ...prev, + ...msgs.map((m) => ({ + id: m.id, + role: "agent" as const, + text: m.content, + ts: formatStoredTimestamp(m.timestamp), + })), + ]); + } + }); + return () => { cancelled = true; }; + }, [agentId, loadHistory]); + + // Consume live agent pushes while the panel is mounted. + const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[agentId]); + useEffect(() => { + if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return; + const consume = useCanvasStore.getState().consumeAgentMessages; + const msgs = consume(agentId); + if (msgs.length > 0) { + setMessages((prev) => [ + ...prev, + ...msgs.map((m) => ({ + id: m.id, + role: "agent" as const, + text: m.content, + ts: formatStoredTimestamp(m.timestamp), + })), + ]); + } + }, [pendingAgentMsgs, agentId]); + if (!node) { return (
)} - {tab === "my" && messages.length === 0 && ( + {tab === "my" && historyLoading && ( +
+ Loading chat history… +
+ )} + {tab === "my" && !historyLoading && historyError && messages.length === 0 && ( +
+ {historyError} +
+ )} + {tab === "my" && !historyLoading && !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..1cdf4db73 100644 --- a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx +++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx @@ -8,7 +8,7 @@ * 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 { cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; import { MobileChat } from "../MobileChat"; @@ -33,7 +33,12 @@ const mockStoreState = { vi.mock("@/store/canvas", () => ({ useCanvasStore: Object.assign( vi.fn((sel) => sel(mockStoreState)), - { getState: () => mockStoreState }, + { + getState: () => ({ + ...mockStoreState, + consumeAgentMessages: vi.fn(() => []), + }), + }, ), summarizeWorkspaceCapabilities: vi.fn((data: Record) => { const agentCard = data.agentCard as Record | null; @@ -60,8 +65,12 @@ const { mockApiPost } = vi.hoisted(() => ({ mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }), })); +const { mockApiGet } = vi.hoisted(() => ({ + mockApiGet: vi.fn().mockResolvedValue({ messages: [] }), +})); + vi.mock("@/lib/api", () => ({ - api: { post: mockApiPost }, + api: { get: mockApiGet, post: mockApiPost }, })); // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -148,6 +157,7 @@ function renderChat(agentId: string, dark = false) { beforeEach(() => { mockOnBack.mockClear(); + mockApiGet.mockClear(); mockStoreState.nodes = []; mockStoreState.agentMessages = {}; mockApiPost.mockClear(); @@ -266,16 +276,19 @@ describe("MobileChat — empty state", () => { mockStoreState.nodes = [onlineNode]; }); - it('shows "Send a message to start chatting." when no messages', () => { + it('shows "Send a message to start chatting." when no messages', async () => { const { container } = renderChat(mockAgentId); - expect(container.textContent ?? "").toContain("Send a message to start chatting."); + await waitFor(() => + expect(container.textContent ?? "").toContain("Send a message to start chatting."), + ); }); - it("shows no messages when agentMessages[agentId] is absent (undefined)", () => { - // Explicitly set to empty to simulate no stored messages + it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => { mockStoreState.agentMessages = {}; const { container } = renderChat(mockAgentId); - expect(container.textContent ?? "").toContain("Send a message to start chatting."); + await waitFor(() => + expect(container.textContent ?? "").toContain("Send a message to start chatting."), + ); }); }); -- 2.52.0