diff --git a/apps/web/package.json b/apps/web/package.json index f15565711..dd6d60075 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,7 +39,6 @@ "@reactive-resume/fonts": "workspace:*", "@reactive-resume/import": "workspace:*", "@reactive-resume/pdf": "workspace:*", - "@reactive-resume/resume": "workspace:*", "@reactive-resume/schema": "workspace:*", "@reactive-resume/ui": "workspace:*", "@reactive-resume/utils": "workspace:*", diff --git a/apps/web/src/features/resume/builder/draft.test.ts b/apps/web/src/features/resume/builder/draft.test.ts new file mode 100644 index 000000000..52a18142e --- /dev/null +++ b/apps/web/src/features/resume/builder/draft.test.ts @@ -0,0 +1,340 @@ +// @vitest-environment happy-dom + +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { Resume } from "./draft"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { useBuilderResumeUpdateSubscription, useResumeStore, useResumeUpdateSubscription } from "./draft"; + +const orpcMocks = vi.hoisted(() => ({ + getResumeById: vi.fn(), + patchResume: vi.fn(), + streamSubscribe: vi.fn(), + updateResume: vi.fn(), +})); + +const consumeEventIteratorMock = vi.hoisted(() => vi.fn()); + +const queryClientMock = vi.hoisted(() => ({ + setQueryData: vi.fn(), +})); + +const routerParamsMock = vi.hoisted(() => ({ + value: {} as { resumeId?: string }, +})); + +const toastMocks = vi.hoisted(() => ({ + dismiss: vi.fn(), + error: vi.fn(() => "sync-error-toast"), +})); + +vi.mock("@orpc/client", () => ({ + consumeEventIterator: consumeEventIteratorMock, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => queryClientMock, +})); + +vi.mock("@tanstack/react-router", () => ({ + useParams: () => routerParamsMock.value, +})); + +vi.mock("@/libs/orpc/client", () => ({ + orpc: { + resume: { + getById: { + call: orpcMocks.getResumeById, + queryOptions: ({ input }: { input: { id: string } }) => ({ + queryKey: ["resume", "getById", input.id], + }), + }, + patch: { + call: orpcMocks.patchResume, + }, + update: { + call: orpcMocks.updateResume, + }, + }, + }, + streamClient: { + resume: { + updates: { + subscribe: orpcMocks.streamSubscribe, + }, + }, + }, +})); + +vi.mock("sonner", () => ({ + toast: toastMocks, +})); + +function cloneResumeData(data: ResumeData): ResumeData { + return structuredClone(data); +} + +function makeResume(id: string): Resume { + return { + id, + name: "Resume", + slug: id, + tags: [], + data: cloneResumeData(defaultResumeData), + isLocked: false, + isPublic: false, + hasPassword: false, + updatedAt: new Date("2026-05-26T12:00:00.000Z"), + }; +} + +function withBasicsName(resume: Resume, name: string): Resume { + return { + ...resume, + data: { + ...resume.data, + basics: { + ...resume.data.basics, + name, + }, + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("builder resume autosave", () => { + beforeEach(() => { + vi.useFakeTimers(); + orpcMocks.getResumeById.mockReset(); + orpcMocks.patchResume.mockReset(); + orpcMocks.streamSubscribe.mockReset(); + orpcMocks.updateResume.mockReset(); + consumeEventIteratorMock.mockReset(); + queryClientMock.setQueryData.mockClear(); + routerParamsMock.value = {}; + i18n.loadAndActivate({ locale: "en-US", messages: {} }); + toastMocks.dismiss.mockClear(); + toastMocks.error.mockClear(); + useResumeStore.getState().reset(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + useResumeStore.getState().reset(); + }); + + it("coalesces rapid local edits into one full-data update", async () => { + const initial = makeResume("resume-rapid"); + const updated = withBasicsName(initial, "Latest Name"); + orpcMocks.updateResume.mockResolvedValue(updated); + useResumeStore.getState().initialize(initial); + + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "First Name"; + }); + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "Latest Name"; + }); + + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1); + expect(orpcMocks.updateResume).toHaveBeenCalledWith( + { id: initial.id, data: updated.data }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(orpcMocks.patchResume).not.toHaveBeenCalled(); + }); + + it("saves the latest pending snapshot after an in-flight save resolves", async () => { + const initial = makeResume("resume-in-flight"); + const first = withBasicsName(initial, "First Name"); + const latest = withBasicsName(initial, "Latest Name"); + let resolveFirst!: (resume: Resume) => void; + + orpcMocks.updateResume + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce(latest); + + useResumeStore.getState().initialize(initial); + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "First Name"; + }); + + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "Latest Name"; + }); + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1); + + resolveFirst(first); + await flushMicrotasks(); + + expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2); + expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({ id: initial.id, data: first.data }); + expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data }); + expect(orpcMocks.patchResume).not.toHaveBeenCalled(); + }); + + it("does not run a stale debounced save after immediately saving an edit made during an in-flight save", async () => { + const initial = makeResume("resume-stale-timer"); + const first = withBasicsName(initial, "First Name"); + const latest = withBasicsName(initial, "Latest Name"); + let resolveFirst!: (resume: Resume) => void; + + orpcMocks.updateResume + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue(latest); + + useResumeStore.getState().initialize(initial); + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "First Name"; + }); + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "Latest Name"; + }); + + resolveFirst(first); + await flushMicrotasks(); + expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2); + expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data }); + }); + + it("keeps the latest draft data and shows a persistent toast when saving fails", async () => { + const initial = makeResume("resume-failure"); + orpcMocks.updateResume.mockRejectedValue(new Error("network down")); + useResumeStore.getState().initialize(initial); + + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "Unsaved Name"; + }); + + vi.advanceTimersByTime(500); + await flushMicrotasks(); + + expect(useResumeStore.getState().resume?.data.basics.name).toBe("Unsaved Name"); + expect(toastMocks.error).toHaveBeenCalledWith( + "Your latest changes could not be saved.", + expect.objectContaining({ duration: Number.POSITIVE_INFINITY }), + ); + expect(orpcMocks.patchResume).not.toHaveBeenCalled(); + }); +}); + +describe("resume update stream subscription", () => { + beforeEach(() => { + vi.useFakeTimers(); + orpcMocks.streamSubscribe.mockReset(); + consumeEventIteratorMock.mockReset(); + orpcMocks.getResumeById.mockReset(); + queryClientMock.setQueryData.mockClear(); + routerParamsMock.value = {}; + i18n.loadAndActivate({ locale: "en-US", messages: {} }); + useResumeStore.getState().reset(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + useResumeStore.getState().reset(); + }); + + it("subscribes by explicit resume id and calls the provided update handler", async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + const onUpdate = vi.fn().mockResolvedValue(undefined); + consumeEventIteratorMock.mockReturnValue(cancel); + + const { unmount } = renderHook(() => + useResumeUpdateSubscription({ + resumeId: "resume-stream", + onUpdate, + }), + ); + + expect(orpcMocks.streamSubscribe).toHaveBeenCalledWith({ id: "resume-stream" }); + const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise } | undefined; + expect(handlers).toBeDefined(); + + await act(async () => { + await handlers?.onEvent(); + }); + + expect(onUpdate).toHaveBeenCalledTimes(1); + + unmount(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("replaces the builder draft from the server when there are no pending local edits", async () => { + const initial = makeResume("resume-clean"); + const remote = withBasicsName(initial, "Remote Name"); + const cancel = vi.fn().mockResolvedValue(undefined); + consumeEventIteratorMock.mockReturnValue(cancel); + orpcMocks.getResumeById.mockResolvedValue(remote); + routerParamsMock.value = { resumeId: initial.id }; + useResumeStore.getState().initialize(initial); + + renderHook(() => useBuilderResumeUpdateSubscription()); + const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise } | undefined; + + await act(async () => { + await handlers?.onEvent(); + }); + + expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote); + expect(useResumeStore.getState().resume?.data.basics.name).toBe("Remote Name"); + }); + + it("does not overwrite pending local builder edits when a remote update arrives", async () => { + const initial = makeResume("resume-pending"); + const remote = withBasicsName(initial, "Remote Name"); + const cancel = vi.fn().mockResolvedValue(undefined); + consumeEventIteratorMock.mockReturnValue(cancel); + orpcMocks.getResumeById.mockResolvedValue(remote); + routerParamsMock.value = { resumeId: initial.id }; + useResumeStore.getState().initialize(initial); + useResumeStore.getState().updateResumeData((draft) => { + draft.basics.name = "Local Name"; + }); + + renderHook(() => useBuilderResumeUpdateSubscription()); + const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as { onEvent: () => Promise } | undefined; + + await act(async () => { + await handlers?.onEvent(); + }); + + expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote); + expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name"); + }); +}); diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index 016bf198b..a0ef9b54d 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -10,7 +10,6 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { immer } from "zustand/middleware/immer"; import { create } from "zustand/react"; -import { applyResumePatches, createResumePatches } from "@reactive-resume/resume/patch"; import { orpc, streamClient } from "@/libs/orpc/client"; export type Resume = { @@ -46,13 +45,20 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions; type Runtime = { abortController: AbortController; queryClient?: QueryClient; - baselineData?: ResumeData; hasPendingLocalChanges: boolean; + isSaving: boolean; + pendingResume?: Resume; syncErrorToastId?: string | number; - syncResume: ReturnType Promise>>; + syncResume: ReturnType void>>; beforeUnloadHandler?: () => void; }; +type ResumeUpdateSubscriptionOptions = { + resumeId?: string; + onUpdate: () => Promise | void; + onError?: (error: unknown) => void; +}; + const SAVE_DEBOUNCE_MS = 500; const runtimes = new Map(); @@ -66,66 +72,86 @@ function cloneResumeData(data: ResumeData): ResumeData { return structuredClone(data); } +function cloneResume(resume: Resume): Resume { + return { ...resume, data: cloneResumeData(resume.data) }; +} + function createResumeUpdateEventIterator(resumeId: string) { return streamClient.resume.updates.subscribe({ id: resumeId }); } function setRuntimeBaseline(resume: Resume) { const runtime = getRuntime(resume.id); - runtime.baselineData = cloneResumeData(resume.data); runtime.hasPendingLocalChanges = false; + runtime.pendingResume = undefined; +} + +async function flushResumeSave(id: string) { + const runtime = runtimes.get(id); + if (!runtime || runtime.isSaving || !runtime.pendingResume) return; + + const submitted = runtime.pendingResume; + const submittedData = cloneResumeData(submitted.data); + runtime.pendingResume = undefined; + runtime.isSaving = true; + + try { + const updated = (await orpc.resume.update.call( + { id: submitted.id, data: submittedData }, + { signal: runtime.abortController.signal }, + )) as Resume; + + runtime.queryClient?.setQueryData(getResumeQueryKey(submitted.id), updated); + + const currentResume = useResumeStore.getState().resume; + const currentDataStillMatchesSubmission = + currentResume?.id === submitted.id && isEqual(currentResume.data, submittedData); + + if (currentDataStillMatchesSubmission && !runtime.pendingResume) { + runtime.hasPendingLocalChanges = false; + useResumeStore.getState().replaceResumeFromServer(updated); + } else { + runtime.hasPendingLocalChanges = true; + useResumeStore.getState().mergeResumeMetadata(updated); + + if (!runtime.pendingResume && currentResume?.id === submitted.id && !isEqual(currentResume.data, submittedData)) { + runtime.syncResume.cancel(); + runtime.pendingResume = cloneResume(currentResume); + } + } + + if (runtime.syncErrorToastId !== undefined) { + toast.dismiss(runtime.syncErrorToastId); + runtime.syncErrorToastId = undefined; + } + } catch (error: unknown) { + if (error instanceof DOMException && error.name === "AbortError") return; + + runtime.pendingResume ??= submitted; + runtime.hasPendingLocalChanges = true; + runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, { + id: runtime.syncErrorToastId, + duration: Number.POSITIVE_INFINITY, + }); + } finally { + runtime.isSaving = false; + if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id); + } +} + +function queueResumeSave(resume: Resume) { + const runtime = getRuntime(resume.id); + runtime.pendingResume = cloneResume(resume); + runtime.hasPendingLocalChanges = true; + void flushResumeSave(resume.id); } function createRuntime(): Runtime { const abortController = new AbortController(); const syncResume = debounce( - async (resume: Resume) => { - const runtime = runtimes.get(resume.id); - if (!runtime) return; - - const baselineData = runtime.baselineData ?? cloneResumeData(resume.data); - const operations = createResumePatches(baselineData, resume.data); - - if (operations.length === 0) { - runtime.hasPendingLocalChanges = false; - return; - } - - const submittedData = cloneResumeData(resume.data); - - try { - const updated = (await orpc.resume.patch.call( - { id: resume.id, operations }, - { signal: abortController.signal }, - )) as Resume; - - runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated); - runtime.baselineData = cloneResumeData(updated.data); - - const currentResume = useResumeStore.getState().resume; - const currentDataStillMatchesSubmission = - currentResume?.id === resume.id && isEqual(currentResume.data, submittedData); - - if (currentDataStillMatchesSubmission) { - runtime.hasPendingLocalChanges = false; - useResumeStore.getState().replaceResumeFromServer(updated); - } else { - runtime.hasPendingLocalChanges = true; - useResumeStore.getState().mergeResumeMetadata(updated); - syncCurrentResume(resume.id); - } - - if (runtime.syncErrorToastId === undefined) return; - toast.dismiss(runtime.syncErrorToastId); - runtime.syncErrorToastId = undefined; - } catch (error: unknown) { - if (error instanceof DOMException && error.name === "AbortError") return; - runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, { - id: runtime.syncErrorToastId, - duration: Number.POSITIVE_INFINITY, - }); - } + (resume: Resume) => { + queueResumeSave(resume); }, SAVE_DEBOUNCE_MS, { signal: abortController.signal }, @@ -134,6 +160,7 @@ function createRuntime(): Runtime { const runtime: Runtime = { abortController, hasPendingLocalChanges: false, + isSaving: false, syncResume, }; @@ -330,56 +357,26 @@ export function useUpdateResumeData() { ); } -export function useResumeUpdateSubscription() { - const queryClient = useQueryClient(); - const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer); - const params = useParams({ strict: false }) as { resumeId?: string }; - const resumeId = params.resumeId; +export function useResumeUpdateSubscription({ resumeId, onUpdate, onError }: ResumeUpdateSubscriptionOptions) { const [_retryNonce, setRetryNonce] = useState(0); useEffect(() => { if (!resumeId) return; - bindRuntimeQueryClient(resumeId, queryClient); - let didCancel = false; let retryTimer: number | undefined; const cancel = consumeEventIterator(createResumeUpdateEventIterator(resumeId), { onEvent: async () => { try { - const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume; - - if (hasPendingLocalChanges(resumeId)) { - const runtime = getRuntime(resumeId); - const currentResume = useResumeStore.getState().resume; - const baselineData = runtime.baselineData ?? currentResume?.data; - - if (currentResume && baselineData) { - const localOperations = createResumePatches(baselineData, currentResume.data); - const mergedData = applyResumePatches(resume.data, localOperations); - - runtime.baselineData = cloneResumeData(resume.data); - runtime.hasPendingLocalChanges = localOperations.length > 0; - queryClient.setQueryData(getResumeQueryKey(resumeId), resume); - useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData }); - syncCurrentResume(resumeId); - } else { - runtime.baselineData = cloneResumeData(resume.data); - useResumeStore.getState().mergeResumeMetadata(resume); - } - return; - } - - queryClient.setQueryData(getResumeQueryKey(resumeId), resume); - replaceResumeFromServer(resume); + await onUpdate(); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; - console.warn("Failed to refresh resume after update event:", error); + onError?.(error); } }, onError: (error) => { if (didCancel) return; - console.warn("Resume update stream failed, reconnecting:", error); + onError?.(error); retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500); }, }); @@ -389,7 +386,36 @@ export function useResumeUpdateSubscription() { if (retryTimer) window.clearTimeout(retryTimer); void cancel().catch(() => {}); }; + }, [onError, onUpdate, resumeId]); +} + +export function useBuilderResumeUpdateSubscription() { + const queryClient = useQueryClient(); + const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer); + const params = useParams({ strict: false }) as { resumeId?: string }; + const resumeId = params.resumeId; + + const onUpdate = useCallback(async () => { + if (!resumeId) return; + + bindRuntimeQueryClient(resumeId, queryClient); + const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume; + + queryClient.setQueryData(getResumeQueryKey(resumeId), resume); + + if (hasPendingLocalChanges(resumeId)) { + useResumeStore.getState().mergeResumeMetadata(resume); + return; + } + + replaceResumeFromServer(resume); }, [queryClient, replaceResumeFromServer, resumeId]); + + const onError = useCallback((error: unknown) => { + console.warn("Resume update stream failed, reconnecting:", error); + }, []); + + useResumeUpdateSubscription({ resumeId, onUpdate, onError }); } export function useResumeCleanup() { diff --git a/apps/web/src/routes/agent/$threadId.tsx b/apps/web/src/routes/agent/$threadId.tsx index 200116937..25045d8cb 100644 --- a/apps/web/src/routes/agent/$threadId.tsx +++ b/apps/web/src/routes/agent/$threadId.tsx @@ -57,6 +57,7 @@ import { getOrpcErrorMessage } from "@/libs/error-message"; import { client, orpc, streamClient } from "@/libs/orpc/client"; import { AgentThreadSidebar } from "./-components/thread-sidebar"; import { attachmentIdsFromTransportBody, buildAgentChatSubmission } from "./-helpers/chat-attachments"; +import { useAgentResumeUpdateSubscription } from "./-hooks/use-agent-resume-updates"; type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"]; type AgentAction = AgentThreadDetail["actions"][number]; @@ -1218,6 +1219,7 @@ function RouteComponent() { const [isThreadsCollapsed, setIsThreadsCollapsed] = useState(false); const [isResumeCollapsed, setIsResumeCollapsed] = useState(false); const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } })); + useAgentResumeUpdateSubscription({ resumeId: data?.resume?.id, threadId }); const toggleThreadsPanel = useCallback(() => { const panel = threadsPanelRef.current; diff --git a/apps/web/src/routes/agent/-hooks/use-agent-resume-updates.test.tsx b/apps/web/src/routes/agent/-hooks/use-agent-resume-updates.test.tsx new file mode 100644 index 000000000..5e6f5a78d --- /dev/null +++ b/apps/web/src/routes/agent/-hooks/use-agent-resume-updates.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom + +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentResumeUpdateSubscription } from "./use-agent-resume-updates"; + +const subscriptionMock = vi.hoisted(() => vi.fn()); + +const queryClientMock = vi.hoisted(() => ({ + invalidateQueries: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => queryClientMock, +})); + +vi.mock("@/features/resume/builder/draft", () => ({ + useResumeUpdateSubscription: subscriptionMock, +})); + +vi.mock("@/libs/orpc/client", () => ({ + orpc: { + agent: { + threads: { + get: { + queryKey: ({ input }: { input: { id: string } }) => ["agent", "threads", "get", input.id], + }, + list: { + queryKey: () => ["agent", "threads", "list"], + }, + }, + }, + }, +})); + +describe("useAgentResumeUpdateSubscription", () => { + beforeEach(() => { + subscriptionMock.mockReset(); + queryClientMock.invalidateQueries.mockClear(); + }); + + it("invalidates the active thread and thread list when the working resume changes", async () => { + renderHook(() => + useAgentResumeUpdateSubscription({ + resumeId: "resume-1", + threadId: "thread-1", + }), + ); + + const options = subscriptionMock.mock.calls[0]?.[0] as + | { resumeId?: string; onUpdate: () => Promise } + | undefined; + expect(options?.resumeId).toBe("resume-1"); + + await options?.onUpdate(); + + expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["agent", "threads", "list"], + }); + expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["agent", "threads", "get", "thread-1"], + }); + }); +}); diff --git a/apps/web/src/routes/agent/-hooks/use-agent-resume-updates.ts b/apps/web/src/routes/agent/-hooks/use-agent-resume-updates.ts new file mode 100644 index 000000000..93907734e --- /dev/null +++ b/apps/web/src/routes/agent/-hooks/use-agent-resume-updates.ts @@ -0,0 +1,28 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useResumeUpdateSubscription } from "@/features/resume/builder/draft"; +import { orpc } from "@/libs/orpc/client"; + +type UseAgentResumeUpdateSubscriptionInput = { + resumeId?: string; + threadId: string; +}; + +export function useAgentResumeUpdateSubscription({ resumeId, threadId }: UseAgentResumeUpdateSubscriptionInput) { + const queryClient = useQueryClient(); + + const onUpdate = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: orpc.agent.threads.get.queryKey({ input: { id: threadId } }) }), + ]); + }, [queryClient, threadId]); + + useResumeUpdateSubscription({ + resumeId, + onUpdate, + onError: useCallback((error: unknown) => { + console.warn("Agent resume update stream failed, reconnecting:", error); + }, []), + }); +} diff --git a/apps/web/src/routes/builder/$resumeId/route.tsx b/apps/web/src/routes/builder/$resumeId/route.tsx index 75ae8d107..db25497f6 100644 --- a/apps/web/src/routes/builder/$resumeId/route.tsx +++ b/apps/web/src/routes/builder/$resumeId/route.tsx @@ -8,11 +8,11 @@ import { useEffect, useRef } from "react"; import { usePanelRef } from "react-resizable-panels"; import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable"; import { + useBuilderResumeUpdateSubscription, useInitializeResumeStore, useMergeResumeMetadata, useResumeCleanup, useResumeStore, - useResumeUpdateSubscription, } from "@/features/resume/builder/draft"; import { useIsMobile } from "@/hooks/use-mobile"; import { orpc } from "@/libs/orpc/client"; @@ -62,7 +62,7 @@ function RouteComponent() { const isInitialized = isReady && initializedResumeId === resumeId; useResumeCleanup(); - useResumeUpdateSubscription(); + useBuilderResumeUpdateSubscription(); useEffect(() => { if (isInitialized) return; diff --git a/knip.json b/knip.json index 810211b2a..9536246fd 100644 --- a/knip.json +++ b/knip.json @@ -26,7 +26,6 @@ "@better-auth/infra", "@better-auth/passkey", "@orpc/experimental-ratelimit", - "@react-pdf/renderer", "@sindresorhus/slugify", "@t3-oss/env-core", "@uiw/color-convert", diff --git a/packages/utils/src/rate-limit.test.ts b/packages/utils/src/rate-limit.test.ts index 2fd190e8a..2525f5450 100644 --- a/packages/utils/src/rate-limit.test.ts +++ b/packages/utils/src/rate-limit.test.ts @@ -91,7 +91,7 @@ describe("rateLimitConfig", () => { }); it("provides reasonable mutation throughput for resume edits", () => { - expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 60, window: 60 * 1000 }); + expect(rateLimitConfig.orpc.resumeMutations).toEqual({ maxRequests: 300, window: 60 * 1000 }); }); it("limits storage uploads more strictly than deletes", () => { diff --git a/packages/utils/src/rate-limit.ts b/packages/utils/src/rate-limit.ts index 58c1766c9..ef8b46caa 100644 --- a/packages/utils/src/rate-limit.ts +++ b/packages/utils/src/rate-limit.ts @@ -45,6 +45,6 @@ export const rateLimitConfig = { jobsTestConnection: { maxRequests: 10, window: 60 * 1000 }, storageUpload: { maxRequests: 20, window: 60 * 1000 }, storageDelete: { maxRequests: 30, window: 60 * 1000 }, - resumeMutations: { maxRequests: 60, window: 60 * 1000 }, + resumeMutations: { maxRequests: 300, window: 60 * 1000 }, }, } as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9e57faaa..a4e4ed15f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,9 +336,6 @@ importers: '@reactive-resume/pdf': specifier: workspace:* version: link:../../packages/pdf - '@reactive-resume/resume': - specifier: workspace:* - version: link:../../packages/resume '@reactive-resume/schema': specifier: workspace:* version: link:../../packages/schema