diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx new file mode 100644 index 00000000000..45a46334e88 --- /dev/null +++ b/ui/src/components/TaskChatThread.test.tsx @@ -0,0 +1,72 @@ +// @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"; + +/** + * TaskChatThread pulls in live-transcript polling, the sidebar/mobile context, + * the plan-document query, and the rich MarkdownEditor. For a draft + * pass-through test we only need the composer to mount, so each heavy + * dependency is stubbed and the editor is reduced to a node that echoes its + * `value` — which is the restored draft once TaskChatComposer wires draftKey in. + */ +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 to the composer so a stored draft restores on mount", () => { + localStorage.setItem("task-chat-draft:issue-1", "half-written thought"); + + render( + {}} + draftKey="task-chat-draft:issue-1" + />, + ); + + const editor = container.querySelector('[data-testid="mock-editor"]'); + expect(editor?.textContent).toBe("half-written thought"); + }); +}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index a0575b3ba01..ebc9ad58a86 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -107,6 +107,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { threadHeader, workModeChanges, issueBrief, + draftKey, } = props; const linkedRunMetaById = useMemo(() => { @@ -473,6 +474,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 3304877979a..d2123595419 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -6,6 +6,7 @@ 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); @@ -528,4 +530,87 @@ describe("TaskChatComposer", () => { const trigger = container.querySelector('[data-testid="task-chat-composer-assignee"]'); expect(trigger?.textContent).toContain("Sam"); }); + + describe("draft persistence (draftKey)", () => { + const KEY = "task-chat-draft:issue-1"; + + it("restores a saved draft on mount and posts it verbatim", async () => { + localStorage.setItem(KEY, "unsent draft"); + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + + expect(editable().textContent).toBe("unsent draft"); + expect(sendButton().disabled).toBe(false); + + pressKey("Enter", { metaKey: true }); + await flushAsync(); + expect(onAdd).toHaveBeenCalledWith("unsent draft", undefined, undefined); + }); + + it("saves the body to localStorage after the debounce window", () => { + vi.useFakeTimers(); + try { + render(); + + typeText("work in progress"); + // Debounced: nothing persisted until the timer elapses. + expect(localStorage.getItem(KEY)).toBeNull(); + + vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); + expect(localStorage.getItem(KEY)).toBe("work in progress"); + } finally { + vi.useRealTimers(); + } + }); + + it("clears the draft key on a successful send (not via the empty-body debounce)", async () => { + localStorage.setItem(KEY, "queued message"); + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + + // Send the restored draft. Real timers, no advance — so the 800ms + // empty-body save can't be what removed the key; only the explicit + // clearDraft on success could have. + pressKey("Enter", { metaKey: true }); + await flushAsync(); + + expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it("keeps the draft when the send fails", async () => { + const onAdd = vi.fn().mockRejectedValue(new Error("network down")); + render(); + + typeText("do not lose this"); + pressKey("Enter", { metaKey: true }); + await flushAsync(); // the rejected submit restores the body + + // The restored body re-fires the debounced save; wait it out. + await new Promise((resolve) => setTimeout(resolve, DRAFT_DEBOUNCE_MS + 50)); + expect(onAdd).toHaveBeenCalled(); + expect(localStorage.getItem(KEY)).toBe("do not lose this"); + }); + + it("never touches localStorage when draftKey is absent", () => { + vi.useFakeTimers(); + const getItem = vi.spyOn(Storage.prototype, "getItem"); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + const removeItem = vi.spyOn(Storage.prototype, "removeItem"); + try { + render(); + typeText("no key, no persistence"); + vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS); + + expect(getItem).not.toHaveBeenCalled(); + expect(setItem).not.toHaveBeenCalled(); + expect(removeItem).not.toHaveBeenCalled(); + } finally { + getItem.mockRestore(); + setItem.mockRestore(); + removeItem.mockRestore(); + vi.useRealTimers(); + } + }); + }); }); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index baeffdf4cb7..0b3c52b70de 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,12 @@ interface TaskChatComposerProps { issueStatus?: string; /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ mobile?: boolean; + /** + * localStorage key for per-task draft persistence (mirrors the default + * composer). When set, the body is restored on mount / key change, saved + * debounced on change, and cleared on a successful send. Absent → no draft. + */ + draftKey?: string; } /** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */ @@ -146,6 +154,7 @@ export function TaskChatComposer({ currentAssigneeValue = "", issueStatus, mobile = false, + draftKey, }: TaskChatComposerProps) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -156,6 +165,31 @@ export function TaskChatComposer({ const editorRef = useRef(null); const bodyRef = useRef(body); bodyRef.current = body; + const draftTimer = useRef | null>(null); + + // Restore the saved draft on mount / when the task (key) changes. Absent + // draftKey leaves the composer untouched (no localStorage access). + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + // Debounced save on every body change. A successful submit clears the key + // explicitly (see submit) rather than relying on this empty-body save, so a + // send-then-navigate within the debounce window can't resurrect the message. + 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); + }; + }, []); const modeMeta = workModeMetaFor(pendingMode); const canAcceptFiles = Boolean(onAttachImage || onImageUpload); @@ -291,10 +325,11 @@ export function TaskChatComposer({ await onWorkModeChange(pendingMode); } await onAdd(fullBody, reopen, reassignment); + if (draftKey) clearDraft(draftKey); setAttachments([]); setPendingAssignee(null); } catch { - setBody(trimmed); // restore on failure (chips stay for retry) + setBody(trimmed); // restore on failure (chips stay + the draft save effect re-fires) } finally { setSubmitting(false); } diff --git a/ui/src/lib/composer-draft.ts b/ui/src/lib/composer-draft.ts new file mode 100644 index 00000000000..52a00e7aaab --- /dev/null +++ b/ui/src/lib/composer-draft.ts @@ -0,0 +1,41 @@ +/** + * Per-task composer draft persistence, shared by the chat composers. + * + * Mirrors the behavior of the duplicated copies in IssueChatThread / + * CommentThread (see IssueChatThread.tsx ~line 3552): draft text is kept in + * localStorage under the caller-provided key, all access is try/catch-wrapped + * so a disabled/full store never throws into React, and saving an empty (or + * whitespace-only) body removes the key rather than storing "". Only the body + * text persists — no attachments, mode chip, or assignee. + */ + +/** Debounce before a keystroke lands in localStorage (matches the legacy copies). */ +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. + } +}