feat: persist unsent composer drafts across chat and page switches

This commit is contained in:
cynarAI 2026-10-19 12:56:59 +02:00
parent be657f6e3c
commit f820c75632
6 changed files with 534 additions and 30 deletions

View File

@ -6,22 +6,19 @@ import type {
Connection, Connection,
ConnectionCatalogItem, ConnectionCatalogItem,
MessageBlock, MessageBlock,
MessageReaction,
Routine, Routine,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { import { AUTOPILOT_LIMITS, canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
AUTOPILOT_LIMITS, import type { AutopilotUntilChoice, ComposerMention, SlashActionId } from "@rakazo/core";
canReactToThreadMessage,
MESSAGE_REACTIONS,
type MessageReaction,
} from "@rakazo/contracts";
import type { AutopilotUntilChoice } from "@rakazo/core";
import { import {
abortableDelay, abortableDelay,
attachmentsForThread, attachmentsForThread,
autopilotUntil, autopilotUntil,
buildComposerMentionOptions, buildComposerMentionOptions,
type ComposerMention, clearComposerDraft,
cloudAgentHttpsUrl, cloudAgentHttpsUrl,
composerDraftThreadKey,
deriveWorkspaceFocus, deriveWorkspaceFocus,
foldRunNarration, foldRunNarration,
isApprovalAskBlock, isApprovalAskBlock,
@ -32,13 +29,14 @@ import {
liveScreenRunId, liveScreenRunId,
mentionChipKey, mentionChipKey,
projectMessageReactions, projectMessageReactions,
readComposerDraft,
resolveComposerSendPlan, resolveComposerSendPlan,
SLASH_ACTIONS, SLASH_ACTIONS,
type SlashActionId,
selectedAskActionLabel, selectedAskActionLabel,
serializeComposerPrompt, serializeComposerPrompt,
truncateSlashDescription, truncateSlashDescription,
userVisibleMessages, userVisibleMessages,
writeComposerDraft,
} from "@rakazo/core"; } from "@rakazo/core";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router"; import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from "expo-router";
@ -289,6 +287,8 @@ function Thread() {
const mentionBotsAppliedGeneration = useRef(0); const mentionBotsAppliedGeneration = useRef(0);
const readVisibleTarget = useRef<string | null>(null); const readVisibleTarget = useRef<string | null>(null);
const threadKey = groupId ?? botId; const threadKey = groupId ?? botId;
const draftKey = composerDraftThreadKey({ botId, groupId });
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() => const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() =>
scrollBehavior.current.state(), 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. */ /** Run that last pointed at the bot's screen, so the live screen is offered only during real screen work. */
const [screenRunId, setScreenRunId] = useState<string | null>(null); const [screenRunId, setScreenRunId] = useState<string | null>(null);
const activeThreadId = useRef<string | undefined>(undefined); const activeThreadId = useRef<string | undefined>(undefined);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState(() => initialDraft.text);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState<string | null>(null); const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]); const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
@ -325,8 +325,12 @@ function Thread() {
connectionId?: string; connectionId?: string;
}> }>
>([]); >([]);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]); const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null); () => initialDraft.selectedMentions,
);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
() => initialDraft.selectedSkill,
);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]); const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null); const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null); const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
@ -1002,13 +1006,27 @@ function Thread() {
); );
}, [botId, groupId, messageId]); }, [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(() => { useEffect(() => {
setPendingAttachments((current) => attachmentsForThread(current, threadKey)); setPendingAttachments((current) => attachmentsForThread(current, threadKey));
setDraft("");
setMentionQuery(null); setMentionQuery(null);
setSlashQuery(null); setSlashQuery(null);
setSelectedSkill(null);
setSelectedMentions([]);
setReplyTarget(null); setReplyTarget(null);
setAttachmentNotice(null); setAttachmentNotice(null);
setError(null); setError(null);
@ -1053,6 +1071,11 @@ function Thread() {
function runSlashAction(action: SlashActionId) { function runSlashAction(action: SlashActionId) {
setDraft(""); setDraft("");
setSlashQuery(null); setSlashQuery(null);
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
if (action === "chat-settings") { if (action === "chat-settings") {
if (inGroup && groupId) { if (inGroup && groupId) {
router.push({ pathname: "/group-settings", params: { groupId } }); router.push({ pathname: "/group-settings", params: { groupId } });
@ -1078,6 +1101,10 @@ function Thread() {
const initialGroupTarget = groupId; const initialGroupTarget = groupId;
if ((!initialBotTarget && !initialGroupTarget) || sending) return; if ((!initialBotTarget && !initialGroupTarget) || sending) return;
const originThreadKey = initialGroupTarget ?? initialBotTarget; const originThreadKey = initialGroupTarget ?? initialBotTarget;
const originDraftKey = composerDraftThreadKey({
botId: initialBotTarget,
groupId: initialGroupTarget,
});
const attachments = attachmentsForThread(pendingAttachments, originThreadKey); const attachments = attachmentsForThread(pendingAttachments, originThreadKey);
const plan = resolveComposerSendPlan({ const plan = resolveComposerSendPlan({
text: serializeComposerPromptText(), text: serializeComposerPromptText(),
@ -1111,6 +1138,7 @@ function Thread() {
); );
} }
const clearOriginComposer = () => { const clearOriginComposer = () => {
clearComposerDraft(originDraftKey);
setPendingAttachments((current) => setPendingAttachments((current) =>
current.filter((attachment) => attachment.threadKey !== originThreadKey), current.filter((attachment) => attachment.threadKey !== originThreadKey),
); );
@ -1313,6 +1341,10 @@ function Thread() {
const targetGroupId = groupId; const targetGroupId = groupId;
if (!targetBotId && !targetGroupId) return; if (!targetBotId && !targetGroupId) return;
const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! }; const target = targetGroupId ? { groupId: targetGroupId } : { botId: targetBotId! };
const targetDraftKey = composerDraftThreadKey({
botId: targetBotId,
groupId: targetGroupId,
});
const until = autopilotUntil(choice, new Date()); const until = autopilotUntil(choice, new Date());
void rpc("autopilot/start", { void rpc("autopilot/start", {
...target, ...target,
@ -1320,8 +1352,12 @@ function Thread() {
...(until ? { until: until.toISOString() } : {}), ...(until ? { until: until.toISOString() } : {}),
}) })
.then(async () => { .then(async () => {
setDraft(""); const existing = readComposerDraft(targetDraftKey);
if (isCurrentTarget(targetBotId, targetGroupId)) await refresh(); writeComposerDraft(targetDraftKey, { ...existing, text: "" });
if (isCurrentTarget(targetBotId, targetGroupId)) {
setDraft("");
await refresh();
}
}) })
.catch((err) => .catch((err) =>
Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")), Alert.alert(t("Autopilot"), err instanceof Error ? err.message : t("Try again.")),

View File

@ -13,6 +13,7 @@ import type {
ImportBotInput, ImportBotInput,
MainConversationRef, MainConversationRef,
Me, Me,
MessageReaction,
Routine, Routine,
RunStatus, RunStatus,
Space, Space,
@ -22,16 +23,19 @@ import type {
ThreadSnapshot, ThreadSnapshot,
VoiceStatus, VoiceStatus,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { import { ATTACHMENT_MAX_COUNT, normalizeCreateBotProfile } from "@rakazo/contracts";
ATTACHMENT_MAX_COUNT, import type {
type MessageReaction, ComposerMention,
normalizeCreateBotProfile, OptimisticMessage,
} from "@rakazo/contracts"; PaneState,
import type { OptimisticMessage } from "@rakazo/core"; WorkspaceArtifact,
WorkspaceFocus,
} from "@rakazo/core";
import { import {
applyWorkspaceFocus, applyWorkspaceFocus,
attachmentsForThread, attachmentsForThread,
type ComposerMention, clearComposerDraft,
composerDraftThreadKey,
cronFromPreset, cronFromPreset,
deriveWorkspaceFocus, deriveWorkspaceFocus,
inferAttachmentMimeType, inferAttachmentMimeType,
@ -41,7 +45,6 @@ import {
liveScreenRunId, liveScreenRunId,
matchVoiceByName, matchVoiceByName,
OptimisticInputQueue, OptimisticInputQueue,
type PaneState,
parseVoiceChatCommand, parseVoiceChatCommand,
projectThreads, projectThreads,
recordScreenPaneChoice, recordScreenPaneChoice,
@ -53,8 +56,6 @@ import {
speechFromBlocks, speechFromBlocks,
unconfirmedOptimistic, unconfirmedOptimistic,
userVisibleMessages, userVisibleMessages,
type WorkspaceArtifact,
type WorkspaceFocus,
} from "@rakazo/core"; } from "@rakazo/core";
import { import {
BotAvatar, BotAvatar,
@ -4057,6 +4058,10 @@ export function ShellPage() {
{(active || activeGroup) && !showProjectTimeline ? ( {(active || activeGroup) && !showProjectTimeline ? (
<Composer <Composer
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`} key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
draftKey={composerDraftThreadKey({
botId: inGroup ? undefined : active?.id,
groupId: inGroup ? groupId : undefined,
})}
running={composerRunning} running={composerRunning}
disabled={Boolean(recordingSkill)} disabled={Boolean(recordingSkill)}
pendingAttachments={activePendingAttachments} pendingAttachments={activePendingAttachments}
@ -4815,6 +4820,7 @@ export function ShellPage() {
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
onConfirm={async (deleteMemories) => { onConfirm={async (deleteMemories) => {
await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories }); await rpc.bots.remove({ botId: deleteTarget.id, deleteMemories });
clearComposerDraft(composerDraftThreadKey({ botId: deleteTarget.id }));
setDeleteTarget(null); setDeleteTarget(null);
setPanel(null); setPanel(null);
await refreshBots(true); await refreshBots(true);
@ -4829,6 +4835,7 @@ export function ShellPage() {
onCancel={() => setDeleteGroupTarget(null)} onCancel={() => setDeleteGroupTarget(null)}
onConfirm={async () => { onConfirm={async () => {
await rpc.groups.remove({ groupId: deleteGroupTarget.id }); await rpc.groups.remove({ groupId: deleteGroupTarget.id });
clearComposerDraft(composerDraftThreadKey({ groupId: deleteGroupTarget.id }));
setDeleteGroupTarget(null); setDeleteGroupTarget(null);
setPanel(null); setPanel(null);
await refreshBots(true); await refreshBots(true);

View File

@ -5,11 +5,14 @@ import { ATTACHMENT_ALLOWED_MIME_TYPES } from "@rakazo/contracts";
import type { ComposerMention, SlashActionId } from "@rakazo/core"; import type { ComposerMention, SlashActionId } from "@rakazo/core";
import { import {
clampMentionHighlightIndex, clampMentionHighlightIndex,
clearComposerDraft,
mentionChipKey, mentionChipKey,
readComposerDraft,
resolveMentionPickerKey, resolveMentionPickerKey,
SLASH_ACTIONS, SLASH_ACTIONS,
serializeComposerPrompt, serializeComposerPrompt,
truncateSlashDescription, truncateSlashDescription,
writeComposerDraft,
} from "@rakazo/core"; } from "@rakazo/core";
import { import {
BotAvatar, 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"; "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({ export const Composer = memo(function Composer({
draftKey,
running, running,
disabled, disabled,
pendingAttachments, pendingAttachments,
@ -77,6 +81,7 @@ export const Composer = memo(function Composer({
onConnectModel, onConnectModel,
onAutopilot, onAutopilot,
}: { }: {
draftKey?: string;
running: boolean; running: boolean;
disabled?: boolean; disabled?: boolean;
pendingAttachments: PendingAttachment[]; pendingAttachments: PendingAttachment[];
@ -109,13 +114,37 @@ export const Composer = memo(function Composer({
onAutopilot?: (goal: string, until?: string) => Promise<void>; onAutopilot?: (goal: string, until?: string) => Promise<void>;
}) { }) {
const { t } = useLingui(); 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 [autopilotOpen, setAutopilotOpen] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [slashQuery, setSlashQuery] = useState<string | null>(null); const [slashQuery, setSlashQuery] = useState<string | null>(null);
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null); const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]); () => initialDraft.selectedSkill,
);
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
() => 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<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const runErrorRef = useRef<HTMLDivElement>(null); const runErrorRef = useRef<HTMLDivElement>(null);
const submitFingerprint = useRef<string | null>(null); const submitFingerprint = useRef<string | null>(null);
@ -265,6 +294,11 @@ export const Composer = memo(function Composer({
function runSlashAction(action: SlashActionId) { function runSlashAction(action: SlashActionId) {
setDraft(""); setDraft("");
setSlashQuery(null); setSlashQuery(null);
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
onSlashAction?.(action); onSlashAction?.(action);
} }
@ -331,6 +365,7 @@ export const Composer = memo(function Composer({
); );
if (submitFingerprint.current === fingerprint) return; if (submitFingerprint.current === fingerprint) return;
submitFingerprint.current = fingerprint; submitFingerprint.current = fingerprint;
clearComposerDraft(draftKey);
setDraft(""); setDraft("");
setMentionQuery(null); setMentionQuery(null);
setMentionHighlightIndex(0); setMentionHighlightIndex(0);
@ -665,6 +700,11 @@ export const Composer = memo(function Composer({
onStart={async (goal, until) => { onStart={async (goal, until) => {
await onAutopilot(goal, until); await onAutopilot(goal, until);
setDraft(""); setDraft("");
writeComposerDraft(draftKey, {
text: "",
selectedSkill,
selectedMentions,
});
}} }}
/> />
) : null} ) : null}

View File

@ -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<string, string> } {
const map = new Map<string, string>();
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);
});
});

View File

@ -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<Storage, "getItem" | "setItem" | "removeItem">;
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<string, ComposerDraftState>();
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
}

View File

@ -14,6 +14,7 @@ export * from "./cards.js";
export * from "./cloud-agent.js"; export * from "./cloud-agent.js";
export * from "./coach.js"; export * from "./coach.js";
export * from "./compose-update.js"; export * from "./compose-update.js";
export * from "./composer-drafts.js";
export * from "./composer-mention-picker.js"; export * from "./composer-mention-picker.js";
export * from "./composer-mentions.js"; export * from "./composer-mentions.js";
export * from "./composer-slash.js"; export * from "./composer-slash.js";