diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index efc92ba..82feb24 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -6,22 +6,19 @@ import type { Connection, ConnectionCatalogItem, MessageBlock, + MessageReaction, Routine, } from "@rakazo/contracts"; -import { - AUTOPILOT_LIMITS, - canReactToThreadMessage, - MESSAGE_REACTIONS, - type MessageReaction, -} from "@rakazo/contracts"; -import type { AutopilotUntilChoice } from "@rakazo/core"; +import { AUTOPILOT_LIMITS, canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; +import type { AutopilotUntilChoice, ComposerMention, SlashActionId } from "@rakazo/core"; import { abortableDelay, attachmentsForThread, autopilotUntil, buildComposerMentionOptions, - type ComposerMention, + clearComposerDraft, cloudAgentHttpsUrl, + composerDraftThreadKey, deriveWorkspaceFocus, foldRunNarration, isApprovalAskBlock, @@ -32,13 +29,14 @@ import { liveScreenRunId, mentionChipKey, projectMessageReactions, + readComposerDraft, resolveComposerSendPlan, SLASH_ACTIONS, - type SlashActionId, selectedAskActionLabel, serializeComposerPrompt, truncateSlashDescription, userVisibleMessages, + writeComposerDraft, } from "@rakazo/core"; import * as Clipboard from "expo-clipboard"; import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router"; @@ -289,6 +287,8 @@ function Thread() { const mentionBotsAppliedGeneration = useRef(0); const readVisibleTarget = useRef(null); const threadKey = groupId ?? botId; + const draftKey = composerDraftThreadKey({ botId, groupId }); + const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]); const [threadScrollState, setThreadScrollState] = useState(() => scrollBehavior.current.state(), ); @@ -310,7 +310,7 @@ function Thread() { /** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */ const [screenRunId, setScreenRunId] = useState(null); const activeThreadId = useRef(undefined); - const [draft, setDraft] = useState(""); + const [draft, setDraft] = useState(() => initialDraft.text); const [mentionQuery, setMentionQuery] = useState(null); const [slashQuery, setSlashQuery] = useState(null); const [agentSkills, setAgentSkills] = useState([]); @@ -325,8 +325,12 @@ function Thread() { connectionId?: string; }> >([]); - const [selectedMentions, setSelectedMentions] = useState([]); - const [selectedSkill, setSelectedSkill] = useState(null); + const [selectedMentions, setSelectedMentions] = useState( + () => initialDraft.selectedMentions, + ); + const [selectedSkill, setSelectedSkill] = useState( + () => initialDraft.selectedSkill, + ); const [pendingAttachments, setPendingAttachments] = useState([]); const [replyTarget, setReplyTarget] = useState(null); const [attachmentNotice, setAttachmentNotice] = useState(null); @@ -1002,13 +1006,27 @@ function Thread() { ); }, [botId, groupId, messageId]); + const loadedDraftKeyRef = useRef(draftKey); + useLayoutEffect(() => { + if (loadedDraftKeyRef.current !== draftKey) { + loadedDraftKeyRef.current = draftKey; + const stored = readComposerDraft(draftKey); + setDraft(stored.text); + setSelectedSkill(stored.selectedSkill); + setSelectedMentions(stored.selectedMentions); + return; + } + writeComposerDraft(draftKey, { + text: draft, + selectedSkill, + selectedMentions, + }); + }, [draft, draftKey, selectedMentions, selectedSkill]); + useEffect(() => { setPendingAttachments((current) => attachmentsForThread(current, threadKey)); - setDraft(""); setMentionQuery(null); setSlashQuery(null); - setSelectedSkill(null); - setSelectedMentions([]); setReplyTarget(null); setAttachmentNotice(null); setError(null); @@ -1053,6 +1071,11 @@ function Thread() { function runSlashAction(action: SlashActionId) { setDraft(""); setSlashQuery(null); + writeComposerDraft(draftKey, { + text: "", + selectedSkill, + selectedMentions, + }); if (action === "chat-settings") { if (inGroup && groupId) { router.push({ pathname: "/group-settings", params: { groupId } }); @@ -1078,6 +1101,10 @@ function Thread() { const initialGroupTarget = groupId; if ((!initialBotTarget && !initialGroupTarget) || sending) return; const originThreadKey = initialGroupTarget ?? initialBotTarget; + const originDraftKey = composerDraftThreadKey({ + botId: initialBotTarget, + groupId: initialGroupTarget, + }); const attachments = attachmentsForThread(pendingAttachments, originThreadKey); const plan = resolveComposerSendPlan({ text: serializeComposerPromptText(), @@ -1111,6 +1138,7 @@ function Thread() { ); } const clearOriginComposer = () => { + clearComposerDraft(originDraftKey); setPendingAttachments((current) => current.filter((attachment) => attachment.threadKey !== originThreadKey), ); @@ -1313,6 +1341,10 @@ function Thread() { const targetGroupId = groupId; if (!targetBotId && !targetGroupId) return; const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }; + const targetDraftKey = composerDraftThreadKey({ + botId: targetBotId, + groupId: targetGroupId, + }); const until = autopilotUntil(choice, new Date()); void rpc("autopilot/start", { ...target, @@ -1320,8 +1352,12 @@ function Thread() { ...(until ? { until: until.toISOString() } : {}), }) .then(async () => { - setDraft(""); - if (isCurrentTarget(targetBotId, targetGroupId)) await refresh(); + const existing = readComposerDraft(targetDraftKey); + writeComposerDraft(targetDraftKey, { ...existing, text: "" }); + if (isCurrentTarget(targetBotId, targetGroupId)) { + setDraft(""); + await refresh(); + } }) .catch((err) => Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")), diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index a917c5d..45291fa 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -13,6 +13,7 @@ import type { ImportBotInput, MainConversationRef, Me, + MessageReaction, Routine, RunStatus, Space, @@ -22,16 +23,19 @@ import type { ThreadSnapshot, VoiceStatus, } from "@rakazo/contracts"; -import { - ATTACHMENT_MAX_COUNT, - type MessageReaction, - normalizeCreateBotProfile, -} from "@rakazo/contracts"; -import type { OptimisticMessage } from "@rakazo/core"; +import { ATTACHMENT_MAX_COUNT, normalizeCreateBotProfile } from "@rakazo/contracts"; +import type { + ComposerMention, + OptimisticMessage, + PaneState, + WorkspaceArtifact, + WorkspaceFocus, +} from "@rakazo/core"; import { applyWorkspaceFocus, attachmentsForThread, - type ComposerMention, + clearComposerDraft, + composerDraftThreadKey, cronFromPreset, deriveWorkspaceFocus, inferAttachmentMimeType, @@ -41,7 +45,6 @@ import { liveScreenRunId, matchVoiceByName, OptimisticInputQueue, - type PaneState, parseVoiceChatCommand, projectThreads, recordScreenPaneChoice, @@ -53,8 +56,6 @@ import { speechFromBlocks, unconfirmedOptimistic, userVisibleMessages, - type WorkspaceArtifact, - type WorkspaceFocus, } from "@rakazo/core"; import { BotAvatar, @@ -4057,6 +4058,10 @@ export function ShellPage() { {(active || activeGroup) && !showProjectTimeline ? ( setDeleteTarget(null)} onConfirm={async (deleteMemories) => { await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories }); + clearComposerDraft(composerDraftThreadKey({ botId: deleteTarget.id })); setDeleteTarget(null); setPanel(null); await refreshBots(true); @@ -4829,6 +4835,7 @@ export function ShellPage() { onCancel={() => setDeleteGroupTarget(null)} onConfirm={async () => { await rpc.groups.remove({ groupId: deleteGroupTarget.id }); + clearComposerDraft(composerDraftThreadKey({ groupId: deleteGroupTarget.id })); setDeleteGroupTarget(null); setPanel(null); await refreshBots(true); diff --git a/apps/web/src/pages/shell/composer.tsx b/apps/web/src/pages/shell/composer.tsx index 33c7379..3c7a725 100644 --- a/apps/web/src/pages/shell/composer.tsx +++ b/apps/web/src/pages/shell/composer.tsx @@ -5,11 +5,14 @@ import { ATTACHMENT_ALLOWED_MIME_TYPES } from "@rakazo/contracts"; import type { ComposerMention, SlashActionId } from "@rakazo/core"; import { clampMentionHighlightIndex, + clearComposerDraft, mentionChipKey, + readComposerDraft, resolveMentionPickerKey, SLASH_ACTIONS, serializeComposerPrompt, truncateSlashDescription, + writeComposerDraft, } from "@rakazo/core"; import { BotAvatar, @@ -48,6 +51,7 @@ const addButtonClass = "tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer"; export const Composer = memo(function Composer({ + draftKey, running, disabled, pendingAttachments, @@ -77,6 +81,7 @@ export const Composer = memo(function Composer({ onConnectModel, onAutopilot, }: { + draftKey?: string; running: boolean; disabled?: boolean; pendingAttachments: PendingAttachment[]; @@ -109,13 +114,37 @@ export const Composer = memo(function Composer({ onAutopilot?: (goal: string, until?: string) => Promise; }) { const { t } = useLingui(); - const [draft, setDraft] = useState(""); + const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]); + const [draft, setDraft] = useState(() => initialDraft.text); const [autopilotOpen, setAutopilotOpen] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [slashQuery, setSlashQuery] = useState(null); - const [selectedSkill, setSelectedSkill] = useState(null); - const [selectedMentions, setSelectedMentions] = useState([]); + const [selectedSkill, setSelectedSkill] = useState( + () => initialDraft.selectedSkill, + ); + const [selectedMentions, setSelectedMentions] = useState( + () => initialDraft.selectedMentions, + ); + const loadedDraftKeyRef = useRef(draftKey); + useLayoutEffect(() => { + if (loadedDraftKeyRef.current !== draftKey) { + loadedDraftKeyRef.current = draftKey; + const stored = readComposerDraft(draftKey); + setDraft(stored.text); + setSelectedSkill(stored.selectedSkill); + setSelectedMentions(stored.selectedMentions); + setMentionQuery(null); + setMentionHighlightIndex(0); + setSlashQuery(null); + return; + } + writeComposerDraft(draftKey, { + text: draft, + selectedSkill, + selectedMentions, + }); + }, [draft, draftKey, selectedMentions, selectedSkill]); const textareaRef = useRef(null); const runErrorRef = useRef(null); const submitFingerprint = useRef(null); @@ -265,6 +294,11 @@ export const Composer = memo(function Composer({ function runSlashAction(action: SlashActionId) { setDraft(""); setSlashQuery(null); + writeComposerDraft(draftKey, { + text: "", + selectedSkill, + selectedMentions, + }); onSlashAction?.(action); } @@ -331,6 +365,7 @@ export const Composer = memo(function Composer({ ); if (submitFingerprint.current === fingerprint) return; submitFingerprint.current = fingerprint; + clearComposerDraft(draftKey); setDraft(""); setMentionQuery(null); setMentionHighlightIndex(0); @@ -665,6 +700,11 @@ export const Composer = memo(function Composer({ onStart={async (goal, until) => { await onAutopilot(goal, until); setDraft(""); + writeComposerDraft(draftKey, { + text: "", + selectedSkill, + selectedMentions, + }); }} /> ) : null} diff --git a/packages/core/src/composer-drafts.test.ts b/packages/core/src/composer-drafts.test.ts new file mode 100644 index 0000000..8fdce7e --- /dev/null +++ b/packages/core/src/composer-drafts.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ComposerDraftStorage } from "./composer-drafts.js"; +import { + COMPOSER_DRAFT_LIMIT, + COMPOSER_DRAFT_STORAGE_KEY, + clearComposerDraft, + composerDraftThreadKey, + EMPTY_COMPOSER_DRAFT, + isComposerDraftEmpty, + readComposerDraft, + resetMemoryComposerDrafts, + writeComposerDraft, +} from "./composer-drafts.js"; + +function createFakeStorage(): ComposerDraftStorage & { map: Map } { + const map = new Map(); + return { + map, + getItem(key: string) { + return map.get(key) ?? null; + }, + setItem(key: string, value: string) { + map.set(key, value); + }, + removeItem(key: string) { + map.delete(key); + }, + }; +} + +describe("composer-drafts", () => { + beforeEach(() => { + resetMemoryComposerDrafts(); + }); + + it("derives thread keys for bot and group conversations", () => { + expect(composerDraftThreadKey({ botId: "bot-1" })).toBe("bot:bot-1"); + expect(composerDraftThreadKey({ groupId: "grp-1", botId: "bot-1" })).toBe("group:grp-1"); + expect(composerDraftThreadKey({ botId: " ", groupId: null })).toBeUndefined(); + }); + + it("detects empty vs non-empty drafts", () => { + expect(isComposerDraftEmpty(EMPTY_COMPOSER_DRAFT)).toBe(true); + expect( + isComposerDraftEmpty({ + text: "Weiter an der Aufgabe schreiben", + selectedSkill: null, + selectedMentions: [], + }), + ).toBe(false); + expect( + isComposerDraftEmpty({ + text: "", + selectedSkill: { + id: "skill-1", + name: "research", + description: "Research skill", + source: "user", + readOnly: false, + }, + selectedMentions: [], + }), + ).toBe(false); + }); + + it("persists and restores drafts across thread and page switches independently", () => { + const storage = createFakeStorage(); + const botKey = composerDraftThreadKey({ botId: "alpha" }); + const groupKey = composerDraftThreadKey({ groupId: "team-room" }); + + writeComposerDraft( + botKey, + { + text: "Entwurf im ersten Chat", + selectedSkill: { + id: "sk-1", + name: "summarize", + description: "Summarize notes", + source: "builtin", + readOnly: true, + }, + selectedMentions: [ + { + kind: "bot", + id: "helper-bot", + name: "Helper", + color: "#112233", + }, + ], + }, + storage, + ); + + writeComposerDraft( + groupKey, + { + text: "Andere Nachricht in der Gruppe", + selectedSkill: null, + selectedMentions: [], + }, + storage, + ); + + expect(readComposerDraft(botKey, storage)).toEqual({ + text: "Entwurf im ersten Chat", + selectedSkill: { + id: "sk-1", + name: "summarize", + description: "Summarize notes", + source: "builtin", + readOnly: true, + }, + selectedMentions: [ + { + kind: "bot", + id: "helper-bot", + name: "Helper", + color: "#112233", + }, + ], + }); + expect(readComposerDraft(groupKey, storage)).toEqual({ + text: "Andere Nachricht in der Gruppe", + selectedSkill: null, + selectedMentions: [], + }); + + clearComposerDraft(botKey, storage); + expect(readComposerDraft(botKey, storage)).toEqual(EMPTY_COMPOSER_DRAFT); + expect(readComposerDraft(groupKey, storage).text).toBe("Andere Nachricht in der Gruppe"); + + clearComposerDraft(groupKey, storage); + expect(storage.map.has(COMPOSER_DRAFT_STORAGE_KEY)).toBe(false); + }); + + it("evicts oldest drafts beyond COMPOSER_DRAFT_LIMIT", () => { + const storage = createFakeStorage(); + for (let index = 0; index < COMPOSER_DRAFT_LIMIT + 5; index += 1) { + writeComposerDraft( + `bot:b-${index}`, + { + text: `Draft ${index}`, + selectedSkill: null, + selectedMentions: [], + }, + storage, + ); + } + + expect(readComposerDraft("bot:b-0", storage)).toEqual(EMPTY_COMPOSER_DRAFT); + expect(readComposerDraft("bot:b-4", storage)).toEqual(EMPTY_COMPOSER_DRAFT); + expect(readComposerDraft("bot:b-5", storage).text).toBe("Draft 5"); + expect(readComposerDraft(`bot:b-${COMPOSER_DRAFT_LIMIT + 4}`, storage).text).toBe( + `Draft ${COMPOSER_DRAFT_LIMIT + 4}`, + ); + }); + + it("falls back to in-memory storage when persistent storage is unavailable", () => { + const key = composerDraftThreadKey({ botId: "mobile-bot" }); + writeComposerDraft( + key, + { + text: "Mobile Entwurf bleibt beim Tab-Wechsel erhalten", + selectedSkill: null, + selectedMentions: [], + }, + null, + ); + + expect(readComposerDraft(key, null).text).toBe( + "Mobile Entwurf bleibt beim Tab-Wechsel erhalten", + ); + clearComposerDraft(key, null); + expect(readComposerDraft(key, null)).toEqual(EMPTY_COMPOSER_DRAFT); + }); +}); diff --git a/packages/core/src/composer-drafts.ts b/packages/core/src/composer-drafts.ts new file mode 100644 index 0000000..2665d7f --- /dev/null +++ b/packages/core/src/composer-drafts.ts @@ -0,0 +1,244 @@ +import type { AgentSkillCatalogEntry } from "@rakazo/contracts"; +import type { ComposerMention, ComposerMentionKind } from "./composer-mentions.js"; +import { COMPOSER_MENTION_KINDS } from "./composer-mentions.js"; + +export const COMPOSER_DRAFT_STORAGE_KEY = "rakazo:composer-drafts:v1"; +export const COMPOSER_DRAFT_LIMIT = 50; + +export type ComposerDraftStorage = Pick; + +export type ComposerDraftState = { + text: string; + selectedSkill: AgentSkillCatalogEntry | null; + selectedMentions: ComposerMention[]; +}; + +export const EMPTY_COMPOSER_DRAFT: ComposerDraftState = { + text: "", + selectedSkill: null, + selectedMentions: [], +}; + +type StoredComposerDraftEntry = { + key: string; + text: string; + selectedSkill: AgentSkillCatalogEntry | null; + selectedMentions: ComposerMention[]; +}; + +const memoryDrafts = new Map(); + +function browserStorage(): ComposerDraftStorage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +function cloneDraft(draft: ComposerDraftState): ComposerDraftState { + return { + text: draft.text, + selectedSkill: draft.selectedSkill ? { ...draft.selectedSkill } : null, + selectedMentions: draft.selectedMentions.map((mention) => ({ ...mention })), + }; +} + +function isSkillSource(value: unknown): value is AgentSkillCatalogEntry["source"] { + return value === "user" || value === "builtin" || value === "plugin"; +} + +function sanitizeSkill(value: unknown): AgentSkillCatalogEntry | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + typeof record.id !== "string" || + !record.id || + typeof record.name !== "string" || + !record.name || + typeof record.description !== "string" || + !isSkillSource(record.source) || + typeof record.readOnly !== "boolean" + ) { + return null; + } + return { + id: record.id, + name: record.name, + description: record.description, + source: record.source, + readOnly: record.readOnly, + }; +} + +function isComposerMentionKind(value: unknown): value is ComposerMentionKind { + return typeof value === "string" && (COMPOSER_MENTION_KINDS as readonly string[]).includes(value); +} + +function sanitizeMention(value: unknown): ComposerMention | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + !isComposerMentionKind(record.kind) || + typeof record.id !== "string" || + !record.id || + typeof record.name !== "string" || + !record.name + ) { + return null; + } + const mention: ComposerMention = { + kind: record.kind, + id: record.id, + name: record.name, + }; + if (typeof record.subtitle === "string") mention.subtitle = record.subtitle; + if (typeof record.color === "string") mention.color = record.color; + if (typeof record.botId === "string") mention.botId = record.botId; + if (typeof record.connectionId === "string") mention.connectionId = record.connectionId; + if (record.authStatus === "connected" || record.authStatus === "needs_auth") { + mention.authStatus = record.authStatus; + } + return mention; +} + +function sanitizeDraft(draft: ComposerDraftState): ComposerDraftState { + const text = typeof draft.text === "string" ? draft.text : ""; + const selectedSkill = sanitizeSkill(draft.selectedSkill); + const selectedMentions = Array.isArray(draft.selectedMentions) + ? draft.selectedMentions + .map((item) => sanitizeMention(item)) + .filter((item): item is ComposerMention => item !== null) + : []; + return { text, selectedSkill, selectedMentions }; +} + +function readStoredEntries(storage: ComposerDraftStorage): StoredComposerDraftEntry[] { + try { + const raw = storage.getItem(COMPOSER_DRAFT_STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const entries: StoredComposerDraftEntry[] = []; + for (const item of parsed) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + const key = typeof record.key === "string" ? record.key.trim() : ""; + if (!key) continue; + const draft = sanitizeDraft({ + text: typeof record.text === "string" ? record.text : "", + selectedSkill: sanitizeSkill(record.selectedSkill), + selectedMentions: Array.isArray(record.selectedMentions) + ? (record.selectedMentions as ComposerMention[]) + : [], + }); + if (isComposerDraftEmpty(draft)) continue; + entries.push({ key, ...draft }); + } + return entries; + } catch { + return []; + } +} + +function setMemoryDraft(key: string, draft: ComposerDraftState): void { + memoryDrafts.delete(key); + if (isComposerDraftEmpty(draft)) return; + memoryDrafts.set(key, cloneDraft(draft)); + while (memoryDrafts.size > COMPOSER_DRAFT_LIMIT) { + const oldest = memoryDrafts.keys().next().value; + if (oldest === undefined) break; + memoryDrafts.delete(oldest); + } +} + +export function composerDraftThreadKey(target: { + botId?: string | null; + groupId?: string | null; +}): string | undefined { + const groupId = target.groupId?.trim(); + if (groupId) return `group:${groupId}`; + const botId = target.botId?.trim(); + if (botId) return `bot:${botId}`; + return undefined; +} + +export function isComposerDraftEmpty(draft: ComposerDraftState): boolean { + return ( + draft.text.length === 0 && draft.selectedSkill === null && draft.selectedMentions.length === 0 + ); +} + +export function resetMemoryComposerDrafts(): void { + memoryDrafts.clear(); +} + +export function readComposerDraft( + threadKey: string | null | undefined, + storage?: ComposerDraftStorage | null, +): ComposerDraftState { + const key = threadKey?.trim(); + if (!key) return cloneDraft(EMPTY_COMPOSER_DRAFT); + + const resolvedStorage = storage === undefined ? browserStorage() : storage; + if (resolvedStorage) { + const entries = readStoredEntries(resolvedStorage); + const match = entries.find((entry) => entry.key === key); + if (match) { + return cloneDraft({ + text: match.text, + selectedSkill: match.selectedSkill, + selectedMentions: match.selectedMentions, + }); + } + if (storage !== undefined) { + return cloneDraft(EMPTY_COMPOSER_DRAFT); + } + } + + const fromMemory = memoryDrafts.get(key); + return fromMemory ? cloneDraft(fromMemory) : cloneDraft(EMPTY_COMPOSER_DRAFT); +} + +export function writeComposerDraft( + threadKey: string | null | undefined, + draft: ComposerDraftState, + storage?: ComposerDraftStorage | null, +): void { + const key = threadKey?.trim(); + if (!key) return; + + const sanitized = sanitizeDraft(draft); + if (storage === undefined || storage === null) { + setMemoryDraft(key, sanitized); + } + + const resolvedStorage = storage === undefined ? browserStorage() : storage; + if (!resolvedStorage) return; + + try { + const remaining = readStoredEntries(resolvedStorage).filter((entry) => entry.key !== key); + if (isComposerDraftEmpty(sanitized)) { + if (remaining.length === 0) { + resolvedStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); + } else { + resolvedStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, JSON.stringify(remaining)); + } + return; + } + const nextEntries: StoredComposerDraftEntry[] = [ + { key, ...cloneDraft(sanitized) }, + ...remaining, + ].slice(0, COMPOSER_DRAFT_LIMIT); + resolvedStorage.setItem(COMPOSER_DRAFT_STORAGE_KEY, JSON.stringify(nextEntries)); + } catch { + // Keep in-memory draft behavior when persistent storage is unavailable or full. + } +} + +export function clearComposerDraft( + threadKey: string | null | undefined, + storage?: ComposerDraftStorage | null, +): void { + writeComposerDraft(threadKey, EMPTY_COMPOSER_DRAFT, storage); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0c8822d..07dab2a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,7 @@ export * from "./cards.js"; export * from "./cloud-agent.js"; export * from "./coach.js"; export * from "./compose-update.js"; +export * from "./composer-drafts.js"; export * from "./composer-mention-picker.js"; export * from "./composer-mentions.js"; export * from "./composer-slash.js";