mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-30 09:56:05 +00:00
feat(frontend): restore per-thread composer drafts (#4282)
This commit is contained in:
parent
474a0fd6b7
commit
39cc26ddfc
@ -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.
|
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.
|
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.
|
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
|
5. TanStack Query manages server state; localStorage stores user settings
|
||||||
6. Components subscribe to thread state and render updates
|
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.
|
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.
|
`/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}
|
isWelcomeMode={isWelcomeMode}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
draftThreadId={isNewThread ? "new" : threadId}
|
||||||
|
draftAgentName={agent_name}
|
||||||
autoFocus={isWelcomeMode}
|
autoFocus={isWelcomeMode}
|
||||||
status={
|
status={
|
||||||
thread.error
|
thread.error
|
||||||
|
|||||||
@ -378,6 +378,7 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
isWelcomeMode={isWelcomeMode}
|
isWelcomeMode={isWelcomeMode}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
draftThreadId={isNewThread ? "new" : threadId}
|
||||||
autoFocus={isWelcomeMode}
|
autoFocus={isWelcomeMode}
|
||||||
status={
|
status={
|
||||||
thread.error
|
thread.error
|
||||||
|
|||||||
@ -23,9 +23,11 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
@ -67,6 +69,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { fetch } from "@/core/api/fetcher";
|
import { fetch } from "@/core/api/fetcher";
|
||||||
|
import { useAuth } from "@/core/auth/AuthProvider";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { polishInputDraft } from "@/core/input-polish/api";
|
import { polishInputDraft } from "@/core/input-polish/api";
|
||||||
@ -81,6 +84,15 @@ import { useSkills } from "@/core/skills/hooks";
|
|||||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||||
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
||||||
import { compactThreadContext } from "@/core/threads/api";
|
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 { threadTokenUsageQueryKey } from "@/core/threads/token-usage";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
import {
|
import {
|
||||||
@ -145,6 +157,8 @@ import { Tooltip } from "./tooltip";
|
|||||||
|
|
||||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||||
|
|
||||||
|
const COMPOSER_DRAFT_SAVE_DELAY_MS = 300;
|
||||||
|
|
||||||
function focusContentEditableEnd(element: HTMLElement | null) {
|
function focusContentEditableEnd(element: HTMLElement | null) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
@ -273,6 +287,8 @@ export function InputBox({
|
|||||||
extraHeader,
|
extraHeader,
|
||||||
isWelcomeMode,
|
isWelcomeMode,
|
||||||
threadId,
|
threadId,
|
||||||
|
draftThreadId = threadId,
|
||||||
|
draftAgentName,
|
||||||
initialValue,
|
initialValue,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
onFollowupsVisibilityChange,
|
onFollowupsVisibilityChange,
|
||||||
@ -299,6 +315,8 @@ export function InputBox({
|
|||||||
*/
|
*/
|
||||||
isWelcomeMode?: boolean;
|
isWelcomeMode?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
draftThreadId?: string;
|
||||||
|
draftAgentName?: string | null;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
onContextChange?: (
|
onContextChange?: (
|
||||||
context: Omit<
|
context: Omit<
|
||||||
@ -322,12 +340,14 @@ export function InputBox({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
|
const { user } = useAuth();
|
||||||
const { thread, isMock } = useThread();
|
const { thread, isMock } = useThread();
|
||||||
const { attachments, textInput } = usePromptInputController();
|
const { attachments, textInput } = usePromptInputController();
|
||||||
|
const setTextInput = textInput.setInput;
|
||||||
const sidecar = useMaybeSidecar();
|
const sidecar = useMaybeSidecar();
|
||||||
const attachmentParts = attachments.files;
|
const attachmentParts = attachments.files;
|
||||||
const removeAttachment = attachments.remove;
|
const removeAttachment = attachments.remove;
|
||||||
const { skills } = useSkills();
|
const { skills, isLoading: skillsLoading } = useSkills();
|
||||||
const { data: uploadLimits } = useUploadLimits(threadId);
|
const { data: uploadLimits } = useUploadLimits(threadId);
|
||||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
@ -355,6 +375,13 @@ export function InputBox({
|
|||||||
>(null);
|
>(null);
|
||||||
const promptHistoryIndexRef = useRef<number | null>(null);
|
const promptHistoryIndexRef = useRef<number | null>(null);
|
||||||
const promptHistoryDraftRef = useRef("");
|
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 [followups, setFollowups] = useState<string[]>([]);
|
||||||
const { data: suggestionsConfig } = useSuggestionsConfig();
|
const { data: suggestionsConfig } = useSuggestionsConfig();
|
||||||
@ -372,6 +399,7 @@ export function InputBox({
|
|||||||
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
const [skillSuggestionIndex, setSkillSuggestionIndex] = useState(0);
|
||||||
const [selectedSlashSkill, setSelectedSlashSkill] =
|
const [selectedSlashSkill, setSelectedSlashSkill] =
|
||||||
useState<SlashSuggestion | null>(null);
|
useState<SlashSuggestion | null>(null);
|
||||||
|
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
||||||
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
const [dismissedSkillSuggestionValue, setDismissedSkillSuggestionValue] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
const lastGeneratedForAiIdRef = useRef<string | null>(null);
|
||||||
@ -550,6 +578,83 @@ export function InputBox({
|
|||||||
[selectedModel],
|
[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 promptHistory = useMemo(() => {
|
||||||
const history: string[] = [];
|
const history: string[] = [];
|
||||||
for (const message of thread.messages) {
|
for (const message of thread.messages) {
|
||||||
@ -575,12 +680,97 @@ export function InputBox({
|
|||||||
return history;
|
return history;
|
||||||
}, [thread.messages]);
|
}, [thread.messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
promptHistoryIndexRef.current = null;
|
promptHistoryIndexRef.current = null;
|
||||||
promptHistoryDraftRef.current = "";
|
promptHistoryDraftRef.current = "";
|
||||||
|
setTextInput("");
|
||||||
setSelectedSlashSkill(null);
|
setSelectedSlashSkill(null);
|
||||||
setInputPolishUndo(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(() => {
|
useEffect(() => {
|
||||||
const goalRequestState = goalRequestStateRef.current;
|
const goalRequestState = goalRequestStateRef.current;
|
||||||
@ -875,22 +1065,30 @@ export function InputBox({
|
|||||||
const quotes = sidecar?.conversationQuotes ?? [];
|
const quotes = sidecar?.conversationQuotes ?? [];
|
||||||
const quoteIds = quotes.map((quote) => quote.id);
|
const quoteIds = quotes.map((quote) => quote.id);
|
||||||
const quoteContexts = quotes.map((quote) => quote.context);
|
const quoteContexts = quotes.map((quote) => quote.context);
|
||||||
const submitOptions: InputBoxSubmitOptions | undefined = quotes.length
|
pendingDraftSubmissionKeyRef.current = draftKey;
|
||||||
? {
|
const submitOptions: InputBoxSubmitOptions = {
|
||||||
additionalKwargs: buildReferenceMessageMetadata(quoteContexts),
|
...(quotes.length
|
||||||
additionalInputMessages: [
|
? {
|
||||||
buildHiddenConversationQuoteMessage({
|
additionalKwargs: buildReferenceMessageMetadata(quoteContexts),
|
||||||
contexts: 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);
|
// 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);
|
const submit = () => onSubmit?.(message, submitOptions);
|
||||||
|
|
||||||
// Guard against submitting before the initial model auto-selection
|
// Guard against submitting before the initial model auto-selection
|
||||||
@ -915,6 +1113,8 @@ export function InputBox({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
context,
|
context,
|
||||||
|
draftKey,
|
||||||
|
invalidateDraftSaveTimer,
|
||||||
onContextChange,
|
onContextChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
reportUploadLimitViolations,
|
reportUploadLimitViolations,
|
||||||
@ -1550,15 +1750,29 @@ export function InputBox({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePromptTextareaChange = useCallback(() => {
|
const handlePromptTextareaChange = useCallback(
|
||||||
if (voiceListening) {
|
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
abortVoiceInput();
|
if (voiceListening) {
|
||||||
}
|
abortVoiceInput();
|
||||||
abortInputPolishRequest();
|
}
|
||||||
setInputPolishUndo(null);
|
abortInputPolishRequest();
|
||||||
promptHistoryIndexRef.current = null;
|
setInputPolishUndo(null);
|
||||||
promptHistoryDraftRef.current = "";
|
promptHistoryIndexRef.current = null;
|
||||||
}, [abortInputPolishRequest, abortVoiceInput, voiceListening]);
|
promptHistoryDraftRef.current = "";
|
||||||
|
scheduleDraftSave({
|
||||||
|
text: event.currentTarget.value,
|
||||||
|
skillName:
|
||||||
|
selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
abortInputPolishRequest,
|
||||||
|
abortVoiceInput,
|
||||||
|
scheduleDraftSave,
|
||||||
|
selectedSlashSkill,
|
||||||
|
voiceListening,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const updateInlineSkillTextInput = useCallback(
|
const updateInlineSkillTextInput = useCallback(
|
||||||
(element: HTMLElement) => {
|
(element: HTMLElement) => {
|
||||||
@ -1567,9 +1781,21 @@ export function InputBox({
|
|||||||
}
|
}
|
||||||
promptHistoryIndexRef.current = null;
|
promptHistoryIndexRef.current = null;
|
||||||
promptHistoryDraftRef.current = "";
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
130
frontend/src/core/threads/composer-draft.ts
Normal file
130
frontend/src/core/threads/composer-draft.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -12,6 +12,11 @@ const MOCK_AGENTS = [
|
|||||||
description: "A test agent for E2E tests",
|
description: "A test agent for E2E tests",
|
||||||
system_prompt: "You are a test agent.",
|
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", () => {
|
test.describe("Agent chat", () => {
|
||||||
@ -36,6 +41,25 @@ test.describe("Agent chat", () => {
|
|||||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
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 }) => {
|
test("agent chat page shows agent badge", async ({ page }) => {
|
||||||
mockLangGraphAPI(page, { agents: MOCK_AGENTS });
|
mockLangGraphAPI(page, { agents: MOCK_AGENTS });
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,25 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
|
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.describe("Chat workspace", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
mockLangGraphAPI(page);
|
mockLangGraphAPI(page);
|
||||||
@ -25,6 +44,204 @@ test.describe("Chat workspace", () => {
|
|||||||
await expect(textarea).toHaveValue("Hello, DeerFlow!");
|
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 }) => {
|
test("polishes draft input before sending", async ({ page }) => {
|
||||||
let polishRequest: { text?: string; model_name?: string } | undefined;
|
let polishRequest: { text?: string; model_name?: string } | undefined;
|
||||||
let submittedText: string | undefined;
|
let submittedText: string | undefined;
|
||||||
|
|||||||
172
frontend/tests/unit/core/threads/composer-draft.test.ts
Normal file
172
frontend/tests/unit/core/threads/composer-draft.test.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user