feat(ui): persist composer draft in experimental TaskChatComposer

Chat-style task composers now keep unsent body text per task, matching the
default composer. Adds a shared ui/src/lib/composer-draft.ts (loadDraft /
saveDraft / clearDraft + DRAFT_DEBOUNCE_MS) and wires an optional draftKey
through TaskChatThread into TaskChatComposer: restore on mount/key change,
debounced save on change, explicit clear on successful send (so a
send-then-navigate within the debounce window can't resurrect the sent
message), and preserved draft on failed send.

Body text only — no attachments/mode/assignee, no server sync. The legacy
helper copies in CommentThread/IssueChatThread are intentionally left in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-08-07 16:57:53 -07:00
1 parent 6f3723de7e
commit c0a31ccf7c
5 files changed
+236 -1

No files matched your search

+72
View File
@@ -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<unknown>,
) {
useImperativeHandle(ref, () => ({ insertMarkdown: () => {}, focus: () => {} }));
return <div data-testid="mock-editor">{value}</div>;
}),
}));
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(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
draftKey="task-chat-draft:issue-1"
/>,
);
const editor = container.querySelector('[data-testid="mock-editor"]');
expect(editor?.textContent).toBe("half-written thought");
});
});
+2
View File
@@ -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}
</div>
@@ -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(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={KEY} />);
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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={KEY} />);
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(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={KEY} />);
// 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(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={KEY} />);
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(<TaskChatComposer onAdd={vi.fn().mockResolvedValue(undefined)} workMode="standard" />);
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();
}
});
});
});
@@ -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<MarkdownEditorRef>(null);
const bodyRef = useRef(body);
bodyRef.current = body;
const draftTimer = useRef<ReturnType<typeof setTimeout> | 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);
}
+41
View File
@@ -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.
}
}