mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
feat(frontend): restore per-thread composer drafts (#4282)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user