feat(frontend): restore per-thread composer drafts (#4282)

This commit is contained in:
Ryker_Feng
2026-07-20 08:06:32 +08:00
committed by GitHub
parent 474a0fd6b7
commit 39cc26ddfc
9 changed files with 805 additions and 29 deletions
+2
View File
@@ -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.
+2
View File
@@ -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 <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` 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.
@@ -363,6 +363,8 @@ export default function AgentChatPage() {
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
draftThreadId={isNewThread ? "new" : threadId}
draftAgentName={agent_name}
autoFocus={isWelcomeMode}
status={
thread.error
@@ -378,6 +378,7 @@ export default function ChatPage() {
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
draftThreadId={isNewThread ? "new" : threadId}
autoFocus={isWelcomeMode}
status={
thread.error
+255 -29
View File
@@ -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<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -355,6 +375,13 @@ export function InputBox({
>(null);
const promptHistoryIndexRef = useRef<number | null>(null);
const promptHistoryDraftRef = useRef("");
const pendingDraftSubmissionKeyRef = useRef<string | null>(null);
const latestDraftRef = useRef<{
key: string;
draft: { text: string; skillName: string | null };
} | null>(null);
const draftSaveTimerRef = useRef<number | null>(null);
const draftSaveGenerationRef = useRef(0);
const [followups, setFollowups] = useState<string[]>([]);
const { data: suggestionsConfig } = useSuggestionsConfig();
@@ -372,6 +399,7 @@ export function InputBox({
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
const [selectedSlashSkill, setSelectedSlashSkill] =
useState<SlashSuggestion | null>(null);
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
useState<string | null>(null);
const lastGeneratedForAiIdRef = useRef<string | null>(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<HTMLTextAreaElement>) => {
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(() => {
+130
View File
@@ -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<string>,
): ComposerDraft {
if (!draft.skillName || enabledSkillNames.has(draft.skillName)) {
return draft;
}
return {
text: `/${draft.skillName}${draft.text ? ` ${draft.text}` : ""}`,
skillName: null,
};
}
+24
View File
@@ -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 });
+217
View File
@@ -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<void>((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;
@@ -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<string, string>();
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,
});
}
});
});