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:
parent
34fe57a024
commit
4e9a78db58
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue