feat: persist unsent composer drafts across chat and page switches
This commit is contained in:
parent
be657f6e3c
commit
f820c75632
@ -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<string | null>(null);
|
||||
const threadKey = groupId ?? botId;
|
||||
const draftKey = composerDraftThreadKey({ botId, groupId });
|
||||
const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]);
|
||||
const [threadScrollState, setThreadScrollState] = useState<ThreadScrollState>(() =>
|
||||
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<string | null>(null);
|
||||
const activeThreadId = useRef<string | undefined>(undefined);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [draft, setDraft] = useState(() => initialDraft.text);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
||||
const [agentSkills, setAgentSkills] = useState<AgentSkillCatalogEntry[]>([]);
|
||||
@ -325,8 +325,12 @@ function Thread() {
|
||||
connectionId?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
|
||||
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
|
||||
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>(
|
||||
() => initialDraft.selectedMentions,
|
||||
);
|
||||
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
|
||||
() => initialDraft.selectedSkill,
|
||||
);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [replyTarget, setReplyTarget] = useState<MobileMessage | null>(null);
|
||||
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(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.")),
|
||||
|
||||
@ -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 ? (
|
||||
<Composer
|
||||
key={inGroup ? `group:${groupId}` : `bot:${active?.id}`}
|
||||
draftKey={composerDraftThreadKey({
|
||||
botId: inGroup ? undefined : active?.id,
|
||||
groupId: inGroup ? groupId : undefined,
|
||||
})}
|
||||
running={composerRunning}
|
||||
disabled={Boolean(recordingSkill)}
|
||||
pendingAttachments={activePendingAttachments}
|
||||
@ -4815,6 +4820,7 @@ export function ShellPage() {
|
||||
onCancel={() => 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);
|
||||
|
||||
@ -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<void>;
|
||||
}) {
|
||||
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<string | null>(null);
|
||||
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
|
||||
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
||||
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(null);
|
||||
const [selectedMentions, setSelectedMentions] = useState<ComposerMention[]>([]);
|
||||
const [selectedSkill, setSelectedSkill] = useState<AgentSkillCatalogEntry | null>(
|
||||
() => 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 runErrorRef = useRef<HTMLDivElement>(null);
|
||||
const submitFingerprint = useRef<string | null>(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}
|
||||
|
||||
176
packages/core/src/composer-drafts.test.ts
Normal file
176
packages/core/src/composer-drafts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
244
packages/core/src/composer-drafts.ts
Normal file
244
packages/core/src/composer-drafts.ts
Normal 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);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user