feat(ui): persist task chat composer drafts (#11076)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People send task instructions through the board task chat.
> - A page refresh or task switch can discard an unfinished message in
the redesigned composer.
> - The existing task chat already supplies a task-specific draft key.
> - The redesigned composer must use that key without changing
attachment or send behavior.
> - This pull request restores, saves, and clears text drafts in the
redesigned composer.
> - The benefit is that users can return to unfinished task messages
without losing their text.

## Linked Issues or Issue Description

Related prior work: #11070. This pull request extracts only the final
composer draft behavior from that larger draft.

**Subsystem affected**

ui/ — React + Vite board UI.

**Problem or motivation**

The redesigned task chat composer does not use the draft key that the
task thread already provides. A refresh, navigation, or unmount can lose
an unfinished message.

**Proposed solution**

Persist text drafts by task key in local storage. Restore a draft when
the composer mounts. Save changes after a short delay and flush pending
text during unload or unmount. Clear the draft only after a successful
send.

**Alternatives considered**

The composer could save on every keystroke. A short delay avoids
unnecessary synchronous storage writes. The feature could also stay in
the larger predecessor PR, but a focused PR is easier to review and
verify.

**Roadmap alignment**

This is a focused usability improvement for the task conversation
surface. It does not add or duplicate a roadmap capability.

## What Changed

- Added safe draft storage helpers for load, save, and clear operations.
- Connected the task-specific draft key to the redesigned task chat
composer.
- Preserved drafts across debounce windows, unmounts, page unloads,
failed sends, and React Strict Mode probes.
- Cleared drafts after successful sends without changing current
attachment safeguards.
- Added focused composer and thread integration tests.

## Verification

- `pnpm check:token-gates`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/task-chat/TaskChatComposer.test.tsx
src/components/TaskChatThread.test.tsx`

## Risks

- Local storage can be unavailable or full. The helpers catch storage
errors and keep the composer usable.
- Only text is persisted. Attachments, work mode, and assignee
selections remain session state.
- Draft keys remain task-scoped, so text does not cross task boundaries.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with model `gpt-5`. The context-window size is not exposed
in this environment. The model used agentic reasoning, tool use, code
execution, and test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
scotttong 2026-08-07 18:32:49 -07:00 committed by GitHub
parent 34fe57a024
commit 4e9a78db58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 313 additions and 7 deletions

View File

@ -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<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 so the composer restores a task's saved draft", () => {
localStorage.setItem("task-chat-draft:issue-1", "half-written thought");
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
draftKey="task-chat-draft:issue-1"
/>,
);
expect(container.querySelector('[data-testid="mock-editor"]')?.textContent)
.toBe("half-written thought");
});
});

View File

@ -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}
</div>

View File

@ -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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={draftKey} />);
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(
<StrictMode>
<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={draftKey} />
</StrictMode>,
);
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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={draftKey} />);
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(<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={draftKey} />);
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(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={draftKey} />);
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<void>((resolve) => {
resolveSend = resolve;
}));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={draftKey} />);
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<void>((resolve) => {
resolveSend = resolve;
}));
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/next.txt",
originalFilename: "next.txt",
});
render(
<TaskChatComposer
onAdd={onAdd}
workMode="standard"
draftKey={draftKey}
onAttachImage={onAttachImage}
/>,
);
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(<TaskChatComposer onAdd={onAdd} workMode="standard" draftKey={draftKey} />);
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();
}
});
});
});

View File

@ -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<IssueWorkMode>(workMode);
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const attachmentsRef = useRef(attachments);
attachmentsRef.current = attachments;
const pendingAssigneeRef = useRef(pendingAssignee);
pendingAssigneeRef.current = pendingAssignee;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const editorRef = useRef<MarkdownEditorRef>(null);
const bodyRef = useRef(body);
bodyRef.current = body;
const draftTimer = useRef<ReturnType<typeof setTimeout> | 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);
}

View File

@ -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.
}
}