From 2d1b3b4e3d8f2acb42bf4f1f7199e7f9dcdfac88 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 09:05:37 -0500 Subject: [PATCH] fix(ui): stabilize issue chat runtime adapter --- .../hooks/usePaperclipIssueRuntime.test.tsx | 179 ++++++++++++++++++ ui/src/hooks/usePaperclipIssueRuntime.ts | 33 +++- 2 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 ui/src/hooks/usePaperclipIssueRuntime.test.tsx diff --git a/ui/src/hooks/usePaperclipIssueRuntime.test.tsx b/ui/src/hooks/usePaperclipIssueRuntime.test.tsx new file mode 100644 index 00000000000..b3799034e1e --- /dev/null +++ b/ui/src/hooks/usePaperclipIssueRuntime.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppendMessage, ExternalStoreAdapter, ThreadMessage } from "@assistant-ui/react"; +import { usePaperclipIssueRuntime } from "./usePaperclipIssueRuntime"; + +const { useExternalStoreRuntimeMock } = vi.hoisted(() => ({ + useExternalStoreRuntimeMock: vi.fn(() => ({ kind: "runtime" })), +})); + +vi.mock("@assistant-ui/react", () => ({ + useExternalStoreRuntime: useExternalStoreRuntimeMock, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function HookHarness({ + messages, + isRunning, + onSend, + onCancel, +}: { + messages: readonly ThreadMessage[]; + isRunning: boolean; + onSend: (options: { body: string; reopen?: boolean; reassignment?: { assigneeAgentId: string | null; assigneeUserId: string | null } }) => Promise; + onCancel?: (() => Promise) | undefined; +}) { + usePaperclipIssueRuntime({ + messages, + isRunning, + onSend, + onCancel, + }); + return null; +} + +function createAppendMessage(body: string): AppendMessage { + return { + createdAt: new Date("2026-04-11T14:00:02.000Z"), + parentId: null, + role: "user", + sourceId: null, + content: [{ type: "text", text: body }], + metadata: { custom: {} }, + attachments: [], + runConfig: undefined, + }; +} + +function createUserMessage(id: string, text: string): ThreadMessage { + return { + id, + role: "user", + content: [{ type: "text", text }], + metadata: { custom: {} }, + attachments: [], + createdAt: new Date("2026-04-11T14:00:00.000Z"), + } as unknown as ThreadMessage; +} + +function createAssistantMessage(id: string, text: string): ThreadMessage { + return { + id, + role: "assistant", + content: [{ type: "text", text }], + metadata: { custom: {} }, + status: { type: "complete", reason: "stop" }, + createdAt: new Date("2026-04-11T14:00:01.000Z"), + } as unknown as ThreadMessage; +} + +describe("usePaperclipIssueRuntime", () => { + afterEach(() => { + useExternalStoreRuntimeMock.mockReset(); + }); + + it("keeps the external-store adapter stable across unrelated rerenders", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const messages: ThreadMessage[] = [createUserMessage("message-1", "hello")]; + const firstOnSend = vi.fn(async () => {}); + const secondOnSend = vi.fn(async () => {}); + + act(() => { + root.render( + , + ); + }); + + const runtimeCalls = useExternalStoreRuntimeMock.mock.calls as unknown as Array< + [ExternalStoreAdapter] + >; + expect(runtimeCalls.length).toBeGreaterThanOrEqual(1); + const firstAdapter = runtimeCalls[0]![0]; + expect(firstAdapter).toBeTruthy(); + + act(() => { + root.render( + , + ); + }); + + expect(runtimeCalls.length).toBeGreaterThanOrEqual(2); + const secondAdapter = runtimeCalls[1]![0]; + expect(secondAdapter).toBe(firstAdapter); + + await act(async () => { + await secondAdapter.onNew?.(createAppendMessage("latest callback")); + }); + + expect(firstOnSend).not.toHaveBeenCalled(); + expect(secondOnSend).toHaveBeenCalledWith({ + body: "latest callback", + reopen: undefined, + reassignment: undefined, + }); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("rebuilds the adapter when thread data changes", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onSend = vi.fn(async () => {}); + const firstMessages: ThreadMessage[] = [createUserMessage("message-1", "hello")]; + const secondMessages: ThreadMessage[] = [...firstMessages, createAssistantMessage("message-2", "world")]; + + act(() => { + root.render( + , + ); + }); + + const runtimeCalls = useExternalStoreRuntimeMock.mock.calls as unknown as Array< + [ExternalStoreAdapter] + >; + expect(runtimeCalls.length).toBeGreaterThanOrEqual(1); + const firstAdapter = runtimeCalls[0]![0]; + + act(() => { + root.render( + , + ); + }); + + expect(runtimeCalls.length).toBeGreaterThanOrEqual(2); + const secondAdapter = runtimeCalls[1]![0]; + expect(secondAdapter).not.toBe(firstAdapter); + + act(() => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/ui/src/hooks/usePaperclipIssueRuntime.ts b/ui/src/hooks/usePaperclipIssueRuntime.ts index 328a55447b1..6c47f10b122 100644 --- a/ui/src/hooks/usePaperclipIssueRuntime.ts +++ b/ui/src/hooks/usePaperclipIssueRuntime.ts @@ -1,4 +1,10 @@ -import { useExternalStoreRuntime, type ThreadMessage, type AppendMessage } from "@assistant-ui/react"; +import { useEffect, useMemo, useRef } from "react"; +import { + useExternalStoreRuntime, + type ThreadMessage, + type AppendMessage, + type ExternalStoreAdapter, +} from "@assistant-ui/react"; export interface PaperclipIssueRuntimeReassignment { assigneeAgentId: string | null; @@ -37,7 +43,18 @@ export function usePaperclipIssueRuntime({ onSend, onCancel, }: UsePaperclipIssueRuntimeOptions) { - return useExternalStoreRuntime({ + const onSendRef = useRef(onSend); + const onCancelRef = useRef(onCancel); + + useEffect(() => { + onSendRef.current = onSend; + }, [onSend]); + + useEffect(() => { + onCancelRef.current = onCancel; + }, [onCancel]); + + const adapter = useMemo>(() => ({ messages, isRunning, onNew: async (message) => { @@ -57,12 +74,18 @@ export function usePaperclipIssueRuntime({ } : undefined; - await onSend({ + await onSendRef.current({ body, reopen: custom?.reopen === true ? true : undefined, reassignment, }); }, - ...(onCancel ? { onCancel } : {}), - }); + ...(onCancel ? { + onCancel: async () => { + await onCancelRef.current?.(); + }, + } : {}), + }), [messages, isRunning, !!onCancel]); + + return useExternalStoreRuntime(adapter); }