From cb436a6461b45996757d1f72886c99e5f784a0f1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 5 Mar 2026 16:07:18 -0500 Subject: [PATCH 1/2] fix(penpal): refresh document content on SSE reconnect The SSE reconnect handler only refreshed threads and agent status but not file content. When the tab was backgrounded (closing the EventSource) and then foregrounded, any edits made in the interim would not appear. Co-Authored-By: Claude Opus 4.6 --- apps/penpal/frontend/src/pages/FilePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/penpal/frontend/src/pages/FilePage.tsx b/apps/penpal/frontend/src/pages/FilePage.tsx index 5ef64bea..e0e261e4 100644 --- a/apps/penpal/frontend/src/pages/FilePage.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.tsx @@ -262,9 +262,10 @@ export default function FilePage() { [project, fetchThreads, fetchContent, fetchAgentStatus], ), useCallback(() => { + fetchContent(); fetchAgentStatus(); fetchThreads(); - }, [fetchAgentStatus, fetchThreads]), + }, [fetchContent, fetchAgentStatus, fetchThreads]), ); const handleComment = useCallback((anchor: Anchor, selectedText: string) => { From df3859d74b66702c1dfc471c686d2ea30bd48b7e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 5 Mar 2026 16:12:57 -0500 Subject: [PATCH 2/2] test(penpal): verify content refresh on SSE reconnect and files event Extends the existing reconnect test to assert getRawFile is called, and adds a new test for the SSE 'files' event triggering fetchContent. Co-Authored-By: Claude Opus 4.6 --- .../frontend/src/pages/FilePage.test.tsx | 31 ++++++++++++++++++- apps/penpal/frontend/src/pages/FilePage.tsx | 8 +++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/penpal/frontend/src/pages/FilePage.test.tsx b/apps/penpal/frontend/src/pages/FilePage.test.tsx index 0dfe82b9..d863d823 100644 --- a/apps/penpal/frontend/src/pages/FilePage.test.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.test.tsx @@ -150,7 +150,7 @@ describe('FilePage', () => { expect(api.startAgent).not.toHaveBeenCalled(); }); - it('refreshes agent status and threads on SSE reconnect', async () => { + it('refreshes content, agent status, and threads on SSE reconnect', async () => { vi.mocked(api.getAgentStatus).mockResolvedValue(agentNotRunning); renderFilePage(); @@ -159,6 +159,7 @@ describe('FilePage', () => { await waitFor(() => { expect(api.getAgentStatus).toHaveBeenCalledTimes(1); expect(api.getThreads).toHaveBeenCalledTimes(1); + expect(api.getRawFile).toHaveBeenCalledTimes(1); }); // Get the onReconnect callback (2nd argument to useSSE) @@ -169,13 +170,41 @@ describe('FilePage', () => { // Simulate SSE reconnect vi.mocked(api.getAgentStatus).mockClear(); vi.mocked(api.getThreads).mockClear(); + vi.mocked(api.getRawFile).mockClear(); act(() => { onReconnect!(); }); await waitFor(() => { + expect(api.getRawFile).toHaveBeenCalledTimes(1); expect(api.getAgentStatus).toHaveBeenCalledTimes(1); expect(api.getThreads).toHaveBeenCalledTimes(1); }); }); + + it('refreshes content on SSE files event', async () => { + vi.mocked(api.getAgentStatus).mockResolvedValue(agentNotRunning); + + renderFilePage(); + + // Wait for initial load + await waitFor(() => { + expect(api.getRawFile).toHaveBeenCalledTimes(1); + }); + + // Get the onEvent callback (1st argument to useSSE) + const useSSEMock = vi.mocked(useSSE); + const onEvent = useSSEMock.mock.calls[0]?.[0]; + expect(onEvent).toBeDefined(); + + // Simulate a 'files' SSE event for our project + vi.mocked(api.getRawFile).mockClear(); + act(() => { + onEvent!({ type: 'files', project: 'ws/proj' }); + }); + + await waitFor(() => { + expect(api.getRawFile).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/apps/penpal/frontend/src/pages/FilePage.tsx b/apps/penpal/frontend/src/pages/FilePage.tsx index e0e261e4..59a92242 100644 --- a/apps/penpal/frontend/src/pages/FilePage.tsx +++ b/apps/penpal/frontend/src/pages/FilePage.tsx @@ -122,14 +122,16 @@ export default function FilePage() { }, [location.pathname, projects]); // Fetch raw file content - const fetchContent = useCallback(async () => { + const fetchContent = useCallback(async (opts?: { silent?: boolean }) => { if (!project || !path) return; try { const content = await api.getRawFile(project, path); setRawMarkdown(content); setError(null); } catch (err) { - setError('Failed to load file'); + if (!opts?.silent) { + setError('Failed to load file'); + } console.error(err); } finally { setLoading(false); @@ -262,7 +264,7 @@ export default function FilePage() { [project, fetchThreads, fetchContent, fetchAgentStatus], ), useCallback(() => { - fetchContent(); + fetchContent({ silent: true }); fetchAgentStatus(); fetchThreads(); }, [fetchContent, fetchAgentStatus, fetchThreads]),