From 39cc26ddfc3cd84beb8ffb3dfe9b7581af61543f Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:06:32 +0800 Subject: [PATCH] feat(frontend): restore per-thread composer drafts (#4282) --- README.md | 2 + frontend/AGENTS.md | 2 + .../[agent_name]/chats/[thread_id]/page.tsx | 2 + .../app/workspace/chats/[thread_id]/page.tsx | 1 + .../src/components/workspace/input-box.tsx | 284 ++++++++++++++++-- frontend/src/core/threads/composer-draft.ts | 130 ++++++++ frontend/tests/e2e/agent-chat.spec.ts | 24 ++ frontend/tests/e2e/chat.spec.ts | 217 +++++++++++++ .../unit/core/threads/composer-draft.test.ts | 172 +++++++++++ 9 files changed, 805 insertions(+), 29 deletions(-) create mode 100644 frontend/src/core/threads/composer-draft.ts create mode 100644 frontend/tests/unit/core/threads/composer-draft.test.ts diff --git a/README.md b/README.md index 9765e8f64..81190fd94 100644 --- a/README.md +++ b/README.md @@ -676,6 +676,8 @@ Gateway-generated follow-up suggestions now normalize both plain-string model ou The Web UI composer can polish draft input before sending. The rewrite runs as a short Gateway LLM request using the `input_polish` model configuration, keeps slash skill prefixes such as `/data-analysis`, and only replaces the local draft after the user clicks the polish button; it does not create a thread run or persist a message. +Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include a selected slash skill when present, and are cleared once a send is accepted. Attachments and quoted conversation context are intentionally not persisted. + The Web UI composer also supports browser-based voice dictation when the browser exposes the Web Speech API. The microphone button transcribes speech into the local draft only; DeerFlow receives only the transcribed text, while audio handling is delegated to the browser or operating system speech-recognition service according to that environment's policy. Users can review or edit the text before sending. Interrupted first-turn runs still persist a fallback conversation title, so stopping a streaming response does not leave the thread as "Untitled" after refresh. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9c709e0d7..48b96a9a6 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -70,6 +70,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat 5. TanStack Query manages server state; localStorage stores user settings 6. Components subscribe to thread state and render updates +Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. + Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned. `/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal ` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal ` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until the next stream `values` update arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index d22c3be5d..c2d59ea13 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -363,6 +363,8 @@ export default function AgentChatPage() { )} isWelcomeMode={isWelcomeMode} threadId={threadId} + draftThreadId={isNewThread ? "new" : threadId} + draftAgentName={agent_name} autoFocus={isWelcomeMode} status={ thread.error diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 0281062bb..5e8130ac8 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -378,6 +378,7 @@ export default function ChatPage() { )} isWelcomeMode={isWelcomeMode} threadId={threadId} + draftThreadId={isNewThread ? "new" : threadId} autoFocus={isWelcomeMode} status={ thread.error diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index b5cf5eee4..b6ec06f5d 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -23,9 +23,11 @@ import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, + type ChangeEvent, type ComponentProps, type ClipboardEvent, type FormEvent, @@ -67,6 +69,7 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { fetch } from "@/core/api/fetcher"; +import { useAuth } from "@/core/auth/AuthProvider"; import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { polishInputDraft } from "@/core/input-polish/api"; @@ -81,6 +84,15 @@ import { useSkills } from "@/core/skills/hooks"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext, GoalState } from "@/core/threads"; import { compactThreadContext } from "@/core/threads/api"; +import { + buildComposerDraftKey, + clearComposerDraft, + getSessionComposerDraftStorage, + readComposerDraft, + resolveComposerDraft, + type ComposerDraft, + writeComposerDraft, +} from "@/core/threads/composer-draft"; import { threadTokenUsageQueryKey } from "@/core/threads/token-usage"; import { textOfMessage } from "@/core/threads/utils"; import { @@ -145,6 +157,8 @@ import { Tooltip } from "./tooltip"; type InputMode = "flash" | "thinking" | "pro" | "ultra"; +const COMPOSER_DRAFT_SAVE_DELAY_MS = 300; + function focusContentEditableEnd(element: HTMLElement | null) { if (!element) { return; @@ -273,6 +287,8 @@ export function InputBox({ extraHeader, isWelcomeMode, threadId, + draftThreadId = threadId, + draftAgentName, initialValue, onContextChange, onFollowupsVisibilityChange, @@ -299,6 +315,8 @@ export function InputBox({ */ isWelcomeMode?: boolean; threadId: string; + draftThreadId?: string; + draftAgentName?: string | null; initialValue?: string; onContextChange?: ( context: Omit< @@ -322,12 +340,14 @@ export function InputBox({ const searchParams = useSearchParams(); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); + const { user } = useAuth(); const { thread, isMock } = useThread(); const { attachments, textInput } = usePromptInputController(); + const setTextInput = textInput.setInput; const sidecar = useMaybeSidecar(); const attachmentParts = attachments.files; const removeAttachment = attachments.remove; - const { skills } = useSkills(); + const { skills, isLoading: skillsLoading } = useSkills(); const { data: uploadLimits } = useUploadLimits(threadId); const promptRootRef = useRef(null); const textareaRef = useRef(null); @@ -355,6 +375,13 @@ export function InputBox({ >(null); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); + const pendingDraftSubmissionKeyRef = useRef(null); + const latestDraftRef = useRef<{ + key: string; + draft: { text: string; skillName: string | null }; + } | null>(null); + const draftSaveTimerRef = useRef(null); + const draftSaveGenerationRef = useRef(0); const [followups, setFollowups] = useState([]); const { data: suggestionsConfig } = useSuggestionsConfig(); @@ -372,6 +399,7 @@ export function InputBox({ const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0); const [selectedSlashSkill, setSelectedSlashSkill] = useState(null); + const [hydratedDraftKey, setHydratedDraftKey] = useState(null); const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] = useState(null); const lastGeneratedForAiIdRef = useRef(null); @@ -550,6 +578,83 @@ export function InputBox({ [selectedModel], ); + const draftKey = useMemo( + () => + buildComposerDraftKey({ + userId: user?.id ?? "anonymous", + agentName: + draftAgentName ?? + (typeof context.agent_name === "string" ? context.agent_name : null), + threadId: draftThreadId, + }), + [context.agent_name, draftAgentName, draftThreadId, user?.id], + ); + const enabledSkillNames = useMemo( + () => + new Set( + skills.filter((skill) => skill.enabled).map((skill) => skill.name), + ), + [skills], + ); + const cancelDraftSaveTimer = useCallback(() => { + if (draftSaveTimerRef.current === null) { + return; + } + window.clearTimeout(draftSaveTimerRef.current); + draftSaveTimerRef.current = null; + }, []); + const invalidateDraftSaveTimer = useCallback(() => { + draftSaveGenerationRef.current += 1; + cancelDraftSaveTimer(); + }, [cancelDraftSaveTimer]); + const scheduleDraftSave = useCallback( + (draft: ComposerDraft, key = draftKey) => { + if ( + !draft.text && + !draft.skillName && + pendingDraftSubmissionKeyRef.current === key + ) { + return null; + } + if (draft.text || draft.skillName) { + pendingDraftSubmissionKeyRef.current = null; + } + + latestDraftRef.current = { key, draft }; + cancelDraftSaveTimer(); + draftSaveGenerationRef.current += 1; + const generation = draftSaveGenerationRef.current; + const timer = window.setTimeout(() => { + if ( + draftSaveGenerationRef.current !== generation || + draftSaveTimerRef.current !== timer + ) { + return; + } + draftSaveTimerRef.current = null; + writeComposerDraft(getSessionComposerDraftStorage(), key, draft); + }, COMPOSER_DRAFT_SAVE_DELAY_MS); + draftSaveTimerRef.current = timer; + return timer; + }, + [cancelDraftSaveTimer, draftKey], + ); + const flushLatestDraft = useCallback( + (expectedKey?: string) => { + const latest = latestDraftRef.current; + if (!latest || (expectedKey && latest.key !== expectedKey)) { + return; + } + cancelDraftSaveTimer(); + writeComposerDraft( + getSessionComposerDraftStorage(), + latest.key, + latest.draft, + ); + }, + [cancelDraftSaveTimer], + ); + const promptHistory = useMemo(() => { const history: string[] = []; for (const message of thread.messages) { @@ -575,12 +680,97 @@ export function InputBox({ return history; }, [thread.messages]); - useEffect(() => { + useLayoutEffect(() => { promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; + setTextInput(""); setSelectedSlashSkill(null); setInputPolishUndo(null); - }, [threadId]); + setHydratedDraftKey(null); + pendingDraftSubmissionKeyRef.current = null; + latestDraftRef.current = null; + invalidateDraftSaveTimer(); + return () => flushLatestDraft(draftKey); + }, [draftKey, flushLatestDraft, invalidateDraftSaveTimer, setTextInput]); + + useLayoutEffect(() => { + const handlePageHide = () => flushLatestDraft(); + window.addEventListener("pagehide", handlePageHide); + return () => window.removeEventListener("pagehide", handlePageHide); + }, [flushLatestDraft]); + + useEffect(() => { + if (skillsLoading || hydratedDraftKey === draftKey) { + return; + } + + const savedDraft = readComposerDraft( + getSessionComposerDraftStorage(), + draftKey, + ); + if (!savedDraft) { + if (!textInput.value && initialValue) { + setTextInput(initialValue); + } + setHydratedDraftKey(draftKey); + return; + } + + const resolvedDraft = resolveComposerDraft(savedDraft, enabledSkillNames); + setTextInput(resolvedDraft.text); + const restoredSkill = resolvedDraft.skillName + ? skills.find( + (skill) => skill.enabled && skill.name === resolvedDraft.skillName, + ) + : undefined; + setSelectedSlashSkill( + restoredSkill + ? { + name: restoredSkill.name, + description: restoredSkill.description, + kind: "skill", + } + : null, + ); + setHydratedDraftKey(draftKey); + }, [ + draftKey, + enabledSkillNames, + hydratedDraftKey, + initialValue, + setTextInput, + skills, + skillsLoading, + textInput.value, + ]); + + useEffect(() => { + if (hydratedDraftKey !== draftKey) { + return; + } + + const draft: ComposerDraft = { + text: textInput.value ?? "", + skillName: + selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + }; + const timer = scheduleDraftSave(draft, draftKey); + return () => { + if (timer === null) { + return; + } + window.clearTimeout(timer); + if (draftSaveTimerRef.current === timer) { + draftSaveTimerRef.current = null; + } + }; + }, [ + draftKey, + hydratedDraftKey, + scheduleDraftSave, + selectedSlashSkill, + textInput.value, + ]); useEffect(() => { const goalRequestState = goalRequestStateRef.current; @@ -875,22 +1065,30 @@ export function InputBox({ const quotes = sidecar?.conversationQuotes ?? []; const quoteIds = quotes.map((quote) => quote.id); const quoteContexts = quotes.map((quote) => quote.context); - const submitOptions: InputBoxSubmitOptions | undefined = quotes.length - ? { - additionalKwargs: buildReferenceMessageMetadata(quoteContexts), - additionalInputMessages: [ - buildHiddenConversationQuoteMessage({ - contexts: quoteContexts, - }), - ], - // Clear quotes only once the send genuinely proceeds. If the send - // is dropped by the in-flight guard, `onSent` never fires and the - // quotes stay attached so they aren't silently lost. - onSent: () => { - sidecar?.clearConversationQuotes(quoteIds); - }, + pendingDraftSubmissionKeyRef.current = draftKey; + const submitOptions: InputBoxSubmitOptions = { + ...(quotes.length + ? { + additionalKwargs: buildReferenceMessageMetadata(quoteContexts), + additionalInputMessages: [ + buildHiddenConversationQuoteMessage({ + contexts: quoteContexts, + }), + ], + } + : {}), + // Clear one-time state only once the send genuinely proceeds. If the + // send is dropped by the in-flight guard, `onSent` never fires. + onSent: () => { + if (pendingDraftSubmissionKeyRef.current === draftKey) { + pendingDraftSubmissionKeyRef.current = null; + latestDraftRef.current = null; + invalidateDraftSaveTimer(); + clearComposerDraft(getSessionComposerDraftStorage(), draftKey); } - : undefined; + sidecar?.clearConversationQuotes(quoteIds); + }, + }; const submit = () => onSubmit?.(message, submitOptions); // Guard against submitting before the initial model auto-selection @@ -915,6 +1113,8 @@ export function InputBox({ }, [ context, + draftKey, + invalidateDraftSaveTimer, onContextChange, onSubmit, reportUploadLimitViolations, @@ -1550,15 +1750,29 @@ export function InputBox({ ], ); - const handlePromptTextareaChange = useCallback(() => { - if (voiceListening) { - abortVoiceInput(); - } - abortInputPolishRequest(); - setInputPolishUndo(null); - promptHistoryIndexRef.current = null; - promptHistoryDraftRef.current = ""; - }, [abortInputPolishRequest, abortVoiceInput, voiceListening]); + const handlePromptTextareaChange = useCallback( + (event: ChangeEvent) => { + if (voiceListening) { + abortVoiceInput(); + } + abortInputPolishRequest(); + setInputPolishUndo(null); + promptHistoryIndexRef.current = null; + promptHistoryDraftRef.current = ""; + scheduleDraftSave({ + text: event.currentTarget.value, + skillName: + selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + }); + }, + [ + abortInputPolishRequest, + abortVoiceInput, + scheduleDraftSave, + selectedSlashSkill, + voiceListening, + ], + ); const updateInlineSkillTextInput = useCallback( (element: HTMLElement) => { @@ -1567,9 +1781,21 @@ export function InputBox({ } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; - textInput.setInput(element.textContent ?? ""); + const nextText = element.textContent ?? ""; + textInput.setInput(nextText); + scheduleDraftSave({ + text: nextText, + skillName: + selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + }); }, - [abortVoiceInput, textInput, voiceListening], + [ + abortVoiceInput, + scheduleDraftSave, + selectedSlashSkill, + textInput, + voiceListening, + ], ); useEffect(() => { diff --git a/frontend/src/core/threads/composer-draft.ts b/frontend/src/core/threads/composer-draft.ts new file mode 100644 index 000000000..1d71b9baa --- /dev/null +++ b/frontend/src/core/threads/composer-draft.ts @@ -0,0 +1,130 @@ +const COMPOSER_DRAFT_VERSION = 1; +const COMPOSER_DRAFT_PREFIX = "deerflow:composer-draft:v1"; + +export type ComposerDraft = { + text: string; + skillName: string | null; +}; + +export type ComposerDraftStorage = Pick< + Storage, + "getItem" | "setItem" | "removeItem" +>; + +export function getSessionComposerDraftStorage(): ComposerDraftStorage | null { + try { + if (typeof window === "undefined") { + return null; + } + return window.sessionStorage; + } catch { + return null; + } +} + +export function buildComposerDraftKey({ + userId, + agentName, + threadId, +}: { + userId: string; + agentName?: string | null; + threadId: string; +}) { + return [ + COMPOSER_DRAFT_PREFIX, + encodeURIComponent(userId ? userId : "anonymous"), + encodeURIComponent(agentName ?? "lead-agent"), + encodeURIComponent(threadId), + ].join(":"); +} + +export function readComposerDraft( + storage: ComposerDraftStorage | null | undefined, + key: string, +): ComposerDraft | null { + try { + if (!storage) { + return null; + } + const raw = storage.getItem(key); + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as { + version?: unknown; + text?: unknown; + skillName?: unknown; + }; + if ( + parsed.version !== COMPOSER_DRAFT_VERSION || + typeof parsed.text !== "string" || + !(parsed.skillName === null || typeof parsed.skillName === "string") + ) { + return null; + } + + return { + text: parsed.text, + skillName: parsed.skillName, + }; + } catch { + return null; + } +} + +export function writeComposerDraft( + storage: ComposerDraftStorage | null | undefined, + key: string, + draft: ComposerDraft, +) { + try { + if (!storage) { + return; + } + if (!draft.text && !draft.skillName) { + storage.removeItem(key); + return; + } + + storage.setItem( + key, + JSON.stringify({ + version: COMPOSER_DRAFT_VERSION, + text: draft.text, + skillName: draft.skillName, + }), + ); + } catch { + // Browser storage can be disabled or full; drafting must keep working. + } +} + +export function clearComposerDraft( + storage: ComposerDraftStorage | null | undefined, + key: string, +) { + try { + if (!storage) { + return; + } + storage.removeItem(key); + } catch { + // Browser storage can be disabled; sending must keep working. + } +} + +export function resolveComposerDraft( + draft: ComposerDraft, + enabledSkillNames: ReadonlySet, +): ComposerDraft { + if (!draft.skillName || enabledSkillNames.has(draft.skillName)) { + return draft; + } + + return { + text: `/${draft.skillName}${draft.text ? ` ${draft.text}` : ""}`, + skillName: null, + }; +} diff --git a/frontend/tests/e2e/agent-chat.spec.ts b/frontend/tests/e2e/agent-chat.spec.ts index e9fc29cb4..7d44d7518 100644 --- a/frontend/tests/e2e/agent-chat.spec.ts +++ b/frontend/tests/e2e/agent-chat.spec.ts @@ -12,6 +12,11 @@ const MOCK_AGENTS = [ description: "A test agent for E2E tests", system_prompt: "You are a test agent.", }, + { + name: "second-agent", + description: "Another test agent for E2E tests", + system_prompt: "You are another test agent.", + }, ]; test.describe("Agent chat", () => { @@ -36,6 +41,25 @@ test.describe("Agent chat", () => { await expect(textarea).toBeVisible({ timeout: 15_000 }); }); + test("keeps new-chat drafts isolated between agents", async ({ page }) => { + mockLangGraphAPI(page, { agents: MOCK_AGENTS }); + + await page.goto("/workspace/agents/test-agent/chats/new"); + const firstAgentInput = page.getByPlaceholder(/how can i assist you/i); + await expect(firstAgentInput).toBeVisible({ timeout: 15_000 }); + await firstAgentInput.fill("Draft for the first agent"); + + await page.goto("/workspace/agents/second-agent/chats/new"); + const secondAgentInput = page.getByPlaceholder(/how can i assist you/i); + await expect(secondAgentInput).toHaveValue(""); + await secondAgentInput.fill("Draft for the second agent"); + + await page.goto("/workspace/agents/test-agent/chats/new"); + await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue( + "Draft for the first agent", + ); + }); + test("agent chat page shows agent badge", async ({ page }) => { mockLangGraphAPI(page, { agents: MOCK_AGENTS }); diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 0e0d108c0..cde58cb11 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test"; import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api"; +function textFromMessageContent(content: unknown) { + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return undefined; + } + return content + .map((block) => + typeof block === "object" && + block !== null && + "text" in block && + typeof block.text === "string" + ? block.text + : "", + ) + .join(""); +} + test.describe("Chat workspace", () => { test.beforeEach(async ({ page }) => { mockLangGraphAPI(page); @@ -25,6 +44,204 @@ test.describe("Chat workspace", () => { await expect(textarea).toHaveValue("Hello, DeerFlow!"); }); + test("restores a draft after reload and clears it after sending", async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill("Keep this unfinished draft"); + + await page.reload(); + + const restoredTextarea = page.getByPlaceholder(/how can i assist you/i); + await expect(restoredTextarea).toHaveValue("Keep this unfinished draft"); + await restoredTextarea.press("Enter"); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + + await page.reload(); + await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue( + "", + ); + }); + + test("restores a repeated draft that matches the last sent prompt", async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill("Repeat this request"); + await textarea.press("Enter"); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + await expect(textarea).toHaveValue(""); + + await textarea.fill("Repeat this request"); + await expect + .poll(() => + page.evaluate(() => Object.values(window.sessionStorage).join("\n")), + ) + .toContain("Repeat this request"); + + await page.reload(); + await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue( + "Repeat this request", + ); + }); + + test("restores a selected slash skill draft after reload", async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill("/dat"); + await textarea.press("Enter"); + + await expect(page.getByText("/data-analysis")).toBeVisible(); + const skillInput = page.getByRole("textbox", { + name: /how can i assist you/i, + }); + await skillInput.fill("Analyze the latest results"); + await expect + .poll(() => + page.evaluate(() => Object.values(window.sessionStorage).join("\n")), + ) + .toContain("Analyze the latest results"); + + await page.reload(); + + await expect(page.getByText("/data-analysis")).toBeVisible(); + await expect( + page.getByRole("textbox", { + name: /how can i assist you/i, + }), + ).toHaveText("Analyze the latest results"); + }); + + test("continues without draft persistence when sessionStorage is blocked", async ({ + page, + }) => { + let submittedText: string | undefined; + await page.addInitScript(() => { + const realSessionStorage = window.sessionStorage; + Reflect.set(window, "__blockComposerDraftStorage", false); + Object.defineProperty(window, "sessionStorage", { + configurable: true, + get() { + if (Reflect.get(window, "__blockComposerDraftStorage") === true) { + throw new DOMException("Blocked", "SecurityError"); + } + return realSessionStorage; + }, + }); + }); + await page.route("**/runs/stream", (route) => { + const body = route.request().postDataJSON() as { + input?: { messages?: Array<{ content?: unknown }> }; + }; + const content = body.input?.messages?.at(-1)?.content; + submittedText = textFromMessageContent(content); + return handleRunStream(route); + }); + + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await page.evaluate(() => { + Reflect.set(window, "__blockComposerDraftStorage", true); + }); + await textarea.fill("Send while storage is blocked"); + await textarea.press("Enter"); + + await expect + .poll(() => submittedText, { timeout: 10_000 }) + .toBe("Send while storage is blocked"); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + }); + + test("does not rewrite an accepted attachment draft from a stale debounce", async ({ + page, + }) => { + let releaseUpload!: () => void; + const uploadHeld = new Promise((resolve) => { + releaseUpload = resolve; + }); + let submittedText: string | undefined; + + await page.route("**/api/threads/*/uploads", async (route) => { + await uploadHeld; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + success: true, + message: "Uploaded", + files: [ + { + filename: "notes.txt", + size: 12, + path: "notes.txt", + virtual_path: "/mnt/user-data/uploads/notes.txt", + artifact_url: "/api/threads/test/uploads/notes.txt", + extension: ".txt", + }, + ], + }), + }); + }); + await page.route("**/runs/stream", (route) => { + const body = route.request().postDataJSON() as { + input?: { messages?: Array<{ content?: unknown }> }; + }; + const content = body.input?.messages?.at(-1)?.content; + submittedText = textFromMessageContent(content); + return handleRunStream(route); + }); + + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await page.getByLabel("Upload files").setInputFiles({ + name: "notes.txt", + mimeType: "text/plain", + buffer: Buffer.from("fake notes"), + }); + await textarea.fill("Send this immediately"); + await textarea.press("Enter"); + + await page.waitForTimeout(500); + expect( + await page.evaluate(() => + Object.values(window.sessionStorage).join("\n"), + ), + ).not.toContain("Send this immediately"); + + releaseUpload(); + await expect + .poll(() => submittedText, { timeout: 10_000 }) + .toBe("Send this immediately"); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + + await page.reload(); + await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue( + "", + ); + }); + test("polishes draft input before sending", async ({ page }) => { let polishRequest: { text?: string; model_name?: string } | undefined; let submittedText: string | undefined; diff --git a/frontend/tests/unit/core/threads/composer-draft.test.ts b/frontend/tests/unit/core/threads/composer-draft.test.ts new file mode 100644 index 000000000..f360de020 --- /dev/null +++ b/frontend/tests/unit/core/threads/composer-draft.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@rstest/core"; + +import { + buildComposerDraftKey, + clearComposerDraft, + getSessionComposerDraftStorage, + readComposerDraft, + resolveComposerDraft, + writeComposerDraft, + type ComposerDraftStorage, +} from "@/core/threads/composer-draft"; + +class MemoryStorage implements ComposerDraftStorage { + readonly values = new Map(); + throwOnRead = false; + throwOnWrite = false; + + getItem(key: string) { + if (this.throwOnRead) { + throw new DOMException("Storage is unavailable"); + } + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + if (this.throwOnWrite) { + throw new DOMException("Storage quota exceeded"); + } + this.values.set(key, value); + } + + removeItem(key: string) { + if (this.throwOnWrite) { + throw new DOMException("Storage is unavailable"); + } + this.values.delete(key); + } +} + +describe("composer draft storage", () => { + it("isolates drafts by user, agent, and thread", () => { + const base = { + userId: "user:1", + agentName: "lead-agent", + threadId: "thread/1", + }; + + const key = buildComposerDraftKey(base); + + expect(key).not.toBe(buildComposerDraftKey({ ...base, userId: "user:2" })); + expect(key).not.toBe( + buildComposerDraftKey({ ...base, agentName: "reviewer" }), + ); + expect(key).not.toBe( + buildComposerDraftKey({ ...base, threadId: "thread/2" }), + ); + expect(key).toContain("user%3A1"); + expect(key).toContain("thread%2F1"); + }); + + it("round-trips text and a selected slash skill", () => { + const storage = new MemoryStorage(); + const key = "draft-key"; + const draft = { + text: "Summarize the uploaded report", + skillName: "data-analysis", + }; + + writeComposerDraft(storage, key, draft); + + expect(readComposerDraft(storage, key)).toEqual(draft); + }); + + it("removes empty drafts and explicitly cleared drafts", () => { + const storage = new MemoryStorage(); + const key = "draft-key"; + writeComposerDraft(storage, key, { text: "temporary", skillName: null }); + + writeComposerDraft(storage, key, { text: "", skillName: null }); + expect(storage.values.has(key)).toBe(false); + + writeComposerDraft(storage, key, { text: "temporary", skillName: null }); + clearComposerDraft(storage, key); + expect(storage.values.has(key)).toBe(false); + }); + + it("falls back to editable slash text when the saved skill is unavailable", () => { + expect( + resolveComposerDraft( + { + text: "Analyze the latest results", + skillName: "data-analysis", + }, + new Set(["data-analysis"]), + ), + ).toEqual({ + text: "Analyze the latest results", + skillName: "data-analysis", + }); + + expect( + resolveComposerDraft( + { + text: "Analyze the latest results", + skillName: "data-analysis", + }, + new Set(), + ), + ).toEqual({ + text: "/data-analysis Analyze the latest results", + skillName: null, + }); + }); + + it("ignores malformed payloads and unavailable storage", () => { + const storage = new MemoryStorage(); + storage.values.set("malformed", "{not-json"); + storage.values.set( + "wrong-version", + JSON.stringify({ version: 2, text: "future", skillName: null }), + ); + + expect(readComposerDraft(storage, "malformed")).toBeNull(); + expect(readComposerDraft(storage, "wrong-version")).toBeNull(); + + storage.throwOnRead = true; + expect(readComposerDraft(storage, "draft-key")).toBeNull(); + + storage.throwOnRead = false; + storage.throwOnWrite = true; + expect(() => + writeComposerDraft(storage, "draft-key", { + text: "keep typing", + skillName: null, + }), + ).not.toThrow(); + expect(() => clearComposerDraft(storage, "draft-key")).not.toThrow(); + }); + + it("treats missing storage as unavailable instead of throwing", () => { + expect(readComposerDraft(null, "draft-key")).toBeNull(); + expect(() => + writeComposerDraft(null, "draft-key", { + text: "keep typing", + skillName: null, + }), + ).not.toThrow(); + expect(() => clearComposerDraft(null, "draft-key")).not.toThrow(); + }); + + it("returns null when the browser sessionStorage getter is blocked", () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: Object.defineProperty({}, "sessionStorage", { + configurable: true, + get() { + throw new DOMException("Blocked", "SecurityError"); + }, + }), + }); + + try { + expect(getSessionComposerDraftStorage()).toBeNull(); + } finally { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); + } + }); +});