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,
|
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.")),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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 "./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";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user