mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
1 parent
6f3723de7e
commit
c0a31ccf7c
5 files changed
+236
-1
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user