diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx new file mode 100644 index 0000000000..86581ff2a6 --- /dev/null +++ b/ui/src/components/TaskChatThread.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import type { ReactElement } from "react"; +import { forwardRef, useImperativeHandle, type ForwardedRef } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskChatThread } from "./TaskChatThread"; + +vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({ + useLiveRunTranscripts: () => ({ transcriptByRun: new Map() }), +})); +vi.mock("@/context/SidebarContext", () => ({ + useSidebar: () => ({ isMobile: false }), +})); +vi.mock("@/hooks/useIssuePlanDocument", () => ({ + useIssuePlanDocument: () => ({ data: null }), +})); +vi.mock("@/components/MarkdownEditor", () => ({ + MarkdownEditor: forwardRef(function MockMarkdownEditor( + { value }: { value: string }, + ref: ForwardedRef, + ) { + useImperativeHandle(ref, () => ({ insertMarkdown: () => {}, focus: () => {} })); + return
{value}
; + }), +})); + +let container: HTMLDivElement; +let root: Root | null = null; + +beforeEach(() => { + localStorage.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + localStorage.clear(); +}); + +function render(ui: ReactElement) { + flushSync(() => root!.render(ui)); +} + +describe("TaskChatThread draft pass-through", () => { + it("forwards draftKey so the composer restores a task's saved draft", () => { + localStorage.setItem("task-chat-draft:issue-1", "half-written thought"); + + render( + {}} + draftKey="task-chat-draft:issue-1" + />, + ); + + expect(container.querySelector('[data-testid="mock-editor"]')?.textContent) + .toBe("half-written thought"); + }); +}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 27bfa3fb34..769a8754d9 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -116,6 +116,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { feedbackDataSharingPreference = "prompt", feedbackTermsUrl = null, onVote, + draftKey, } = props; const linkedRunMetaById = useMemo(() => { @@ -527,6 +528,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { currentAssigneeValue={currentAssigneeValue} issueStatus={issueStatus} mobile={isMobile} + draftKey={draftKey} /> {footer} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 215580b088..044f049f03 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1,11 +1,12 @@ // @vitest-environment jsdom -import type { ReactElement } from "react"; +import { StrictMode, type ReactElement } from "react"; import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildAgentMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { TaskChatComposer } from "./TaskChatComposer"; +import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft"; /** * MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor @@ -149,6 +150,7 @@ let root: Root | null = null; let originalRangeRect: typeof Range.prototype.getBoundingClientRect; beforeEach(() => { + localStorage.clear(); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -252,6 +254,7 @@ describe("TaskChatComposer", () => { pressKey("Enter", { metaKey: true }); await flushAsync(); + await flushAsync(); expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined); expect(editable().textContent).toBe(""); @@ -585,4 +588,146 @@ describe("TaskChatComposer", () => { const trigger = container.querySelector('[data-testid="task-chat-composer-assignee"]'); expect(trigger?.textContent).toContain("Sam"); }); + + describe("draft persistence", () => { + const draftKey = "task-chat-draft:issue-1"; + + it("restores a saved draft on mount", () => { + localStorage.setItem(draftKey, "unsent draft"); + + render(); + + expect(editable().textContent).toBe("unsent draft"); + expect(sendButton().disabled).toBe(false); + }); + + it("preserves a restored draft through the StrictMode effect probe", () => { + localStorage.setItem(draftKey, "still here"); + + render( + + + , + ); + + expect(editable().textContent).toBe("still here"); + expect(localStorage.getItem(draftKey)).toBe("still here"); + }); + + it("saves after the debounce and flushes a pending value on unmount", () => { + vi.useFakeTimers(); + try { + render(); + typeText("work in progress"); + expect(localStorage.getItem(draftKey)).toBeNull(); + + vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); + expect(localStorage.getItem(draftKey)).toBe("work in progress"); + + typeText("save before leaving"); + flushSync(() => root?.unmount()); + root = null; + expect(localStorage.getItem(draftKey)).toBe("save before leaving"); + } finally { + vi.useRealTimers(); + } + }); + + it("flushes a pending value before page unload", () => { + vi.useFakeTimers(); + try { + render(); + typeText("save before reload"); + + window.dispatchEvent(new Event("beforeunload")); + + expect(localStorage.getItem(draftKey)).toBe("save before reload"); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the saved draft only after a successful send", async () => { + localStorage.setItem(draftKey, "queued message"); + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + + pressKey("Enter", { metaKey: true }); + await flushAsync(); + + expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined); + expect(localStorage.getItem(draftKey)).toBeNull(); + }); + + it("keeps text entered while an earlier send is pending", async () => { + let resolveSend!: () => void; + const onAdd = vi.fn().mockReturnValue(new Promise((resolve) => { + resolveSend = resolve; + })); + render(); + typeText("first message"); + + pressKey("Enter", { metaKey: true }); + await flushAsync(); + typeText("next message"); + resolveSend(); + await flushAsync(); + await flushAsync(); + + expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined); + expect(editable().textContent).toBe("next message"); + expect(localStorage.getItem(draftKey)).toBe("next message"); + }); + + it("keeps an attachment added while an earlier send is pending", async () => { + let resolveSend!: () => void; + const onAdd = vi.fn().mockReturnValue(new Promise((resolve) => { + resolveSend = resolve; + })); + const onAttachImage = vi.fn().mockResolvedValue({ + contentPath: "/attachments/next.txt", + originalFilename: "next.txt", + }); + render( + , + ); + typeText("first message"); + + pressKey("Enter", { metaKey: true }); + await flushAsync(); + pasteFiles([new File(["next"], "next.txt", { type: "text/plain" })]); + await flushAsync(); + resolveSend(); + await flushAsync(); + await flushAsync(); + + expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined); + expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')?.textContent) + .toContain("next.txt"); + }); + + it("keeps the body and saved draft when sending fails", async () => { + vi.useFakeTimers(); + try { + const onAdd = vi.fn().mockRejectedValue(new Error("network down")); + render(); + typeText("do not lose this"); + vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); + vi.useRealTimers(); + + pressKey("Enter", { metaKey: true }); + await flushAsync(); + + expect(editable().textContent).toBe("do not lose this"); + expect(localStorage.getItem(draftKey)).toBe("do not lose this"); + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 5c7cca635b..03056cf020 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -1,4 +1,5 @@ import { + useEffect, useRef, useState, type ChangeEvent, @@ -6,6 +7,7 @@ import { type CSSProperties, } from "react"; import { cn } from "@/lib/utils"; +import { DRAFT_DEBOUNCE_MS, clearDraft, loadDraft, saveDraft } from "@/lib/composer-draft"; import { ArrowUp, Check, ChevronDown, Loader2, Plus, X } from "lucide-react"; import { DropdownMenu, @@ -55,6 +57,8 @@ interface TaskChatComposerProps { issueStatus?: string; /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ mobile?: boolean; + /** Storage key used to restore, persist, and clear this task's text draft. */ + draftKey?: string; } /** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */ @@ -146,16 +150,49 @@ export function TaskChatComposer({ currentAssigneeValue = "", issueStatus, mobile = false, + draftKey, }: TaskChatComposerProps) { - const [body, setBody] = useState(""); + const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : "")); const [submitting, setSubmitting] = useState(false); const [pendingMode, setPendingMode] = useState(workMode); const [pendingAssignee, setPendingAssignee] = useState(null); const [attachments, setAttachments] = useState([]); + const attachmentsRef = useRef(attachments); + attachmentsRef.current = attachments; + const pendingAssigneeRef = useRef(pendingAssignee); + pendingAssigneeRef.current = pendingAssignee; const fileInputRef = useRef(null); const editorRef = useRef(null); const bodyRef = useRef(body); bodyRef.current = body; + const draftTimer = useRef | null>(null); + + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + if (draftKey) saveDraft(draftKey, bodyRef.current); + }; + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + const flushDraft = () => saveDraft(draftKey, bodyRef.current); + window.addEventListener("beforeunload", flushDraft); + return () => window.removeEventListener("beforeunload", flushDraft); + }, [draftKey]); const modeMeta = workModeMetaFor(pendingMode); const canAcceptFiles = Boolean(onAttachImage || onImageUpload); @@ -278,7 +315,10 @@ export function TaskChatComposer({ const uploadFailed = attachments.some((item) => item.status === "error"); async function submit() { - const trimmed = bodyRef.current.trim(); + const submittedBody = bodyRef.current; + const submittedAttachments = attachmentsRef.current; + const submittedAssignee = pendingAssigneeRef.current; + const trimmed = submittedBody.trim(); if ( (!trimmed && attachedRefs.length === 0) || uploadPending || @@ -297,16 +337,32 @@ export function TaskChatComposer({ const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; setSubmitting(true); - setBody(""); try { if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } await onAdd(fullBody, reopen, reassignment); - setAttachments([]); - setPendingAssignee(null); + if (bodyRef.current === submittedBody) { + bodyRef.current = ""; + if (draftTimer.current) { + clearTimeout(draftTimer.current); + draftTimer.current = null; + } + if (draftKey) clearDraft(draftKey); + setBody(""); + } else if (draftKey) { + // The editor stays writable while the request is pending. Preserve + // text entered after this submission started as the next draft. + saveDraft(draftKey, bodyRef.current); + } + if (attachmentsRef.current === submittedAttachments) { + setAttachments([]); + } + if (pendingAssigneeRef.current === submittedAssignee) { + setPendingAssignee(null); + } } catch { - setBody(trimmed); // restore on failure (chips stay for retry) + // Keep the body and its draft available for retry. } finally { setSubmitting(false); } diff --git a/ui/src/lib/composer-draft.ts b/ui/src/lib/composer-draft.ts new file mode 100644 index 0000000000..400007ccac --- /dev/null +++ b/ui/src/lib/composer-draft.ts @@ -0,0 +1,38 @@ +/** + * Per-task composer draft persistence, shared by the chat composers. + * + * Draft text is kept in localStorage under the caller-provided key. All + * access is guarded so disabled or full storage never throws into React. + * Empty drafts remove the key, and only text is persisted. + */ + +/** Debounce before a keystroke lands in localStorage. */ +export const DRAFT_DEBOUNCE_MS = 800; + +export function loadDraft(draftKey: string): string { + try { + return localStorage.getItem(draftKey) ?? ""; + } catch { + return ""; + } +} + +export function saveDraft(draftKey: string, value: string) { + try { + if (value.trim()) { + localStorage.setItem(draftKey, value); + } else { + localStorage.removeItem(draftKey); + } + } catch { + // Ignore localStorage failures. + } +} + +export function clearDraft(draftKey: string) { + try { + localStorage.removeItem(draftKey); + } catch { + // Ignore localStorage failures. + } +}