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."), + ); }); });