diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index e4741baa8b1..5a79641930f 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -54,6 +54,7 @@ import { clearDraftEntry, getActiveDraftEntries, getAllDraftEntries, + getInboxDraftEntries, getSentDraftEntries, initDraftStore, loadDraftEntry, @@ -96,7 +97,10 @@ function makeDraft(overrides = {}) { createdAt: now, updatedAt: now, pendingImeta: [], + mentionRefs: [], spoileredAttachmentUrls: [], + status: "active", + entryKind: "draft", ...overrides, }; } @@ -623,6 +627,7 @@ function makeFullDraft(overrides = {}) { pendingImeta: [], spoileredAttachmentUrls: [], status: "active", + entryKind: "draft", ...overrides, }; } @@ -1405,3 +1410,207 @@ test("no_relay_legacy_caller_form_still_reads_writes_v1_key", () => { assert.ok(raw["chan-new"], "new draft must be in v1 key"); assert.equal(raw["chan-new"].content, "New no-relay"); }); + +const AGENT_REF = { + displayName: "Jitter", + pubkey: "agent-pubkey-1", + isAgent: true, +}; +const HUMAN_REF = { + displayName: "Alice", + pubkey: "human-pubkey-1", + isAgent: false, +}; + +function makePrefill(overrides = {}) { + return makeDraft({ + content: "@Jitter ", + selectionStart: 8, + selectionEnd: 8, + channelId: "chan-prefill", + entryKind: "agent-prefill", + mentionRefs: [AGENT_REF], + ...overrides, + }); +} + +function seedRawDrafts(pubkey, drafts) { + localStorage.setItem(`buzz-drafts.v1:${pubkey}`, JSON.stringify(drafts)); + clearAllDrafts(); + initDraftStore(pubkey); +} + +test("entryKind round-trips both kinds across restart", () => { + const pubkey = "pubkey-entrykind-roundtrip"; + setup(pubkey); + saveDraftEntry("chan-draft", makeDraft({ content: "authored" })); + saveDraftEntry("chan-prefill", makePrefill()); + + clearAllDrafts(); + initDraftStore(pubkey); + assert.equal(loadDraftEntry("chan-draft")?.entryKind, "draft"); + assert.equal(loadDraftEntry("chan-prefill")?.entryKind, "agent-prefill"); +}); + +test("missing and unknown entryKind values normalize to draft", () => { + const pubkey = "pubkey-entrykind-legacy"; + setup(pubkey); + const { entryKind, ...legacy } = makeDraft({ content: "legacy" }); + seedRawDrafts(pubkey, { + legacy, + unknown: makeDraft({ content: "unknown", entryKind: "bogus" }), + }); + assert.equal(loadDraftEntry("legacy")?.entryKind, "draft"); + assert.equal(loadDraftEntry("unknown")?.entryKind, "draft"); +}); + +test("invalid agent-prefill metadata fails visible as draft", () => { + const pubkey = "pubkey-entrykind-invalid-prefill"; + setup(pubkey); + seedRawDrafts(pubkey, { + empty: makePrefill({ content: " " }), + media: makePrefill({ pendingImeta: [IMG_A] }), + spoiler: makePrefill({ spoileredAttachmentUrls: [IMG_A.url] }), + noRefs: makePrefill({ mentionRefs: [] }), + humanRef: makePrefill({ mentionRefs: [AGENT_REF, HUMAN_REF] }), + authoredText: makePrefill({ content: "@Jitter please investigate" }), + unknownMention: makePrefill({ content: "@SomeoneElse " }), + missingMention: makePrefill({ + content: "@Jitter ", + mentionRefs: [ + AGENT_REF, + { displayName: "Vogue", pubkey: "agent-vogue", isAgent: true }, + ], + }), + }); + for (const key of [ + "empty", + "media", + "spoiler", + "noRefs", + "humanRef", + "authoredText", + "unknownMention", + "missingMention", + ]) { + assert.equal(loadDraftEntry(key)?.entryKind, "draft", key); + } +}); + +test("save boundary also normalizes invalid agent-prefill metadata", () => { + setup("pubkey-entrykind-save-boundary"); + saveDraftEntry("missing-kind", { + ...makeDraft({ content: "authored" }), + entryKind: undefined, + }); + saveDraftEntry("media", makePrefill({ pendingImeta: [IMG_A] })); + assert.equal(loadDraftEntry("missing-kind")?.entryKind, "draft"); + assert.equal(loadDraftEntry("media")?.entryKind, "draft"); +}); + +test("persistDraftEntry defaults to draft and accepts valid prefill", () => { + setup("pubkey-entrykind-persist"); + persistDraftEntry("draft", "hello", "draft", [], []); + persistDraftEntry( + "prefill", + "@Jitter ", + "prefill", + [], + [], + [AGENT_REF], + "agent-prefill", + ); + assert.equal(loadDraftEntry("draft")?.entryKind, "draft"); + assert.equal(loadDraftEntry("prefill")?.entryKind, "agent-prefill"); +}); + +test("entryKind transition resets createdAt while same kind preserves it", () => { + setup("pubkey-entrykind-createdat"); + const oldCreatedAt = "2025-01-01T00:00:00.000Z"; + saveDraftEntry( + "chan-a", + makePrefill({ createdAt: oldCreatedAt, updatedAt: oldCreatedAt }), + ); + persistDraftEntry( + "chan-a", + "@Jitter ", + "chan-a", + [], + [], + [AGENT_REF], + "agent-prefill", + ); + assert.equal(loadDraftEntry("chan-a")?.createdAt, oldCreatedAt); + persistDraftEntry("chan-a", "authored", "chan-a", [], []); + assert.notEqual(loadDraftEntry("chan-a")?.createdAt, oldCreatedAt); +}); + +test("Inbox selector excludes prefills but keeps text and media drafts", () => { + setup("pubkey-entrykind-inbox"); + saveDraftEntry("prefill", makePrefill()); + saveDraftEntry("text", makeDraft({ content: "authored" })); + saveDraftEntry("media", makeDraft({ content: "", pendingImeta: [IMG_A] })); + assert.deepEqual( + getInboxDraftEntries() + .map(({ key }) => key) + .sort(), + ["media", "text"], + ); + assert.equal(loadDraftEntry("prefill")?.entryKind, "agent-prefill"); +}); + +test("prefill retention cannot evict real drafts", () => { + setup("pubkey-entrykind-retention-isolation"); + for (let i = 0; i < 100; i++) { + const timestamp = new Date(1_000_000 + i * 1000).toISOString(); + saveDraftEntry( + `draft-${i}`, + makeDraft({ createdAt: timestamp, updatedAt: timestamp }), + ); + } + for (let i = 0; i < 201; i++) { + const timestamp = new Date(2_000_000 + i * 1000).toISOString(); + saveDraftEntry( + `prefill-${i}`, + makePrefill({ createdAt: timestamp, updatedAt: timestamp }), + ); + } + assert.equal(loadDraftEntry("prefill-0"), undefined); + for (let i = 0; i < 100; i++) { + assert.ok(loadDraftEntry(`draft-${i}`), `draft-${i}`); + } +}); + +test("rename collision distinguishes records that differ only by entryKind", () => { + setup("pubkey-entrykind-rename"); + const timestamp = "2026-01-01T00:00:00.000Z"; + const shared = { + channelId: "chan-a", + content: "@Jitter ", + createdAt: timestamp, + updatedAt: timestamp, + mentionRefs: [AGENT_REF], + }; + saveDraftEntry("draft", makeDraft({ ...shared })); + saveDraftEntry("prefill", makePrefill({ ...shared })); + assert.equal(renameDraftEntry("prefill", "draft"), "collision"); + assert.ok(loadDraftEntry("draft")); + assert.ok(loadDraftEntry("prefill")); +}); + +test("markDraftSentEntry includes entryKind in exact-snapshot clearing", () => { + setup("pubkey-entrykind-send-clear"); + persistDraftEntry( + "chan-a", + "@Jitter ", + "chan-a", + [], + [], + [AGENT_REF], + "agent-prefill", + ); + markDraftSentEntry("chan-a", "@Jitter ", "chan-a", [], [], "draft"); + assert.equal(loadDraftEntry("chan-a")?.entryKind, "agent-prefill"); + markDraftSentEntry("chan-a", "@Jitter ", "chan-a", [], [], "agent-prefill"); + assert.equal(loadDraftEntry("chan-a"), undefined); +}); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index a3e0fcf9197..f74c7a273cb 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -46,7 +46,11 @@ export type DraftMentionRef = { isAgent: boolean; }; +export type DraftEntryKind = "draft" | "agent-prefill"; + export type DraftState = { + /** Current purpose of this stored composer snapshot. */ + entryKind: DraftEntryKind; content: string; selectionStart: number; selectionEnd: number; @@ -83,6 +87,7 @@ type StoredDrafts = Record; const DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v2"; const LEGACY_DRAFT_STORE_KEY_PREFIX = "buzz-drafts.v1"; const MAX_DRAFTS = 100; +const MAX_AGENT_PREFILLS = 200; /** * Canonicalize a relay URL for use as a storage key scope. @@ -253,9 +258,65 @@ function isValidDraftState(v: unknown): v is DraftState { } else if (d.status !== "active") { return false; } + (d as DraftState).entryKind = normalizeDraftEntryKind(d); return true; } +function normalizeDraftEntryKind(draft: Partial): DraftEntryKind { + if (draft.entryKind !== "agent-prefill") return "draft"; + const mentionRefs = draft.mentionRefs; + const isValidAgentPrefill = + typeof draft.content === "string" && + draft.content.trim().length > 0 && + Array.isArray(draft.pendingImeta) && + draft.pendingImeta.length === 0 && + Array.isArray(draft.spoileredAttachmentUrls) && + draft.spoileredAttachmentUrls.length === 0 && + Array.isArray(mentionRefs) && + mentionRefs.length > 0 && + mentionRefs.every( + (ref) => + typeof ref === "object" && + ref !== null && + typeof ref.displayName === "string" && + ref.displayName.trim().length > 0 && + typeof ref.pubkey === "string" && + ref.pubkey.trim().length > 0 && + ref.isAgent === true, + ) && + isAgentMentionOnlyContent(draft.content, mentionRefs); + return isValidAgentPrefill ? "agent-prefill" : "draft"; +} + +function isAgentMentionOnlyContent( + content: string, + mentionRefs: DraftMentionRef[], +): boolean { + const remainingMentions = mentionRefs + .map((ref) => `@${ref.displayName.trim()}`) + .sort((a, b) => b.length - a.length); + let offset = 0; + let matchedMentions = 0; + + while (offset < content.length) { + while (/\s/.test(content[offset] ?? "")) offset += 1; + if (offset >= content.length) break; + + const matchIndex = remainingMentions.findIndex((mention) => { + if (!content.startsWith(mention, offset)) return false; + const nextCharacter = content[offset + mention.length]; + return nextCharacter === undefined || /\s/.test(nextCharacter); + }); + if (matchIndex === -1) return false; + + offset += remainingMentions[matchIndex].length; + remainingMentions.splice(matchIndex, 1); + matchedMentions += 1; + } + + return matchedMentions > 0 && remainingMentions.length === 0; +} + function flushStore(map: Map): boolean { if (!currentPubkey) return false; const obj: StoredDrafts = {}; @@ -265,18 +326,19 @@ function flushStore(map: Map): boolean { return setLocalStorageItemWithRecovery(storageKey(), JSON.stringify(obj)); } -/** - * Evict the least-recently-updated entry until the map is within `MAX_DRAFTS`. - */ +/** Evict oldest entries independently within each composer-state class. */ function evictOldest(map: Map): void { - if (map.size <= MAX_DRAFTS) return; - // Sort ascending by updatedAt; evict oldest until within cap. - const sorted = [...map.entries()].sort((a, b) => - a[1].updatedAt.localeCompare(b[1].updatedAt), - ); - const excess = map.size - MAX_DRAFTS; - for (let i = 0; i < excess; i++) { - map.delete(sorted[i][0]); + const limits = [ + ["draft", MAX_DRAFTS], + ["agent-prefill", MAX_AGENT_PREFILLS], + ] as const; + for (const [entryKind, limit] of limits) { + const entries = [...map.entries()] + .filter(([, draft]) => draft.entryKind === entryKind) + .sort((a, b) => a[1].updatedAt.localeCompare(b[1].updatedAt)); + for (const [key] of entries.slice(0, Math.max(0, entries.length - limit))) { + map.delete(key); + } } } @@ -291,7 +353,10 @@ export function saveDraftEntry(draftKey: string, draft: DraftState): void { return; } const map = readStore(); - map.set(draftKey, draft); + map.set(draftKey, { + ...draft, + entryKind: normalizeDraftEntryKind(draft), + }); evictOldest(map); flushStore(map); notifySubscribers(); @@ -331,6 +396,7 @@ function draftStatesEqual(a: DraftState, b: DraftState): boolean { a.createdAt !== b.createdAt || a.updatedAt !== b.updatedAt || a.status !== b.status || + a.entryKind !== b.entryKind || a.pendingImeta.length !== b.pendingImeta.length || (a.mentionRefs?.length ?? 0) !== (b.mentionRefs?.length ?? 0) || a.spoileredAttachmentUrls.length !== b.spoileredAttachmentUrls.length @@ -438,18 +504,30 @@ export function persistDraftEntry( pendingImeta: ImetaMedia[], spoileredAttachmentUrls: string[], mentionRefs: DraftMentionRef[] = [], + entryKind: DraftEntryKind = "draft", ): void { const hasContent = content.trim().length > 0 || pendingImeta.length > 0; if (hasContent) { const map = readStore(); const existing = map.get(draftKey); const now = new Date().toISOString(); + const normalizedEntryKind = normalizeDraftEntryKind({ + content, + entryKind, + mentionRefs, + pendingImeta, + spoileredAttachmentUrls, + }); saveDraftEntry(draftKey, { content, + entryKind: normalizedEntryKind, selectionEnd: content.length, selectionStart: content.length, channelId, - createdAt: existing?.createdAt ?? now, + createdAt: + existing?.entryKind === normalizedEntryKind + ? (existing.createdAt ?? now) + : now, updatedAt: now, pendingImeta, mentionRefs, @@ -485,6 +563,22 @@ export function getActiveDraftEntries(): Array<{ return getAllDraftEntries().filter((e) => e.draft.status === "active"); } +/** + * Returns the drafts the Inbox should surface: active, user-authored entries + * that carry text or an attachment. Automatic agent prefills remain loadable + * by their destination composers but never appear in the Inbox or its count. + */ +export function getInboxDraftEntries(): Array<{ + key: string; + draft: DraftState; +}> { + return getActiveDraftEntries().filter( + ({ draft }) => + draft.entryKind === "draft" && + (draft.content.trim().length > 0 || draft.pendingImeta.length > 0), + ); +} + /** * Returns only sent drafts, sorted most-recently-updated first. * Returns empty — sent records are dropped on read. Kept for test assertions. @@ -509,6 +603,7 @@ export function markDraftSentEntry( channelId: string, pendingImeta: ImetaMedia[], spoileredAttachmentUrls: string[], + entryKind: DraftEntryKind = "draft", ): void { const draft = loadDraftEntry(draftKey); // A background upload can finish after the user has started the next draft @@ -519,7 +614,8 @@ export function markDraftSentEntry( draft.channelId === channelId && JSON.stringify(draft.pendingImeta) === JSON.stringify(pendingImeta) && JSON.stringify(draft.spoileredAttachmentUrls) === - JSON.stringify(spoileredAttachmentUrls) + JSON.stringify(spoileredAttachmentUrls) && + draft.entryKind === entryKind ) { clearDraftEntry(draftKey); } @@ -564,6 +660,7 @@ export function useDrafts() { pendingImeta: ImetaMedia[], spoileredAttachmentUrls: string[], mentionRefs: DraftMentionRef[] = [], + entryKind: DraftEntryKind = "draft", ) => persistDraftEntry( draftKey, @@ -572,6 +669,7 @@ export function useDrafts() { pendingImeta, spoileredAttachmentUrls, mentionRefs, + entryKind, ), [], ); @@ -589,6 +687,7 @@ export function useDrafts() { channelId: string, pendingImeta: ImetaMedia[], spoileredAttachmentUrls: string[], + entryKind: DraftEntryKind = "draft", ) => markDraftSentEntry( draftKey, @@ -596,6 +695,7 @@ export function useDrafts() { channelId, pendingImeta, spoileredAttachmentUrls, + entryKind, ), [], ); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 1a6ca1be4a7..ddcba0db6a2 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -686,11 +686,12 @@ export function useMentions( appendUniqueName(current, trimmedName), ); if (options?.isAgent) { - setSelectedAgentMentionNames((current) => { - const next = appendUniqueName(current, trimmedName); - selectedAgentMentionNamesRef.current = next; - return next; - }); + const next = appendUniqueName( + selectedAgentMentionNamesRef.current, + trimmedName, + ); + selectedAgentMentionNamesRef.current = next; + setSelectedAgentMentionNames(next); } }, [], diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index fa9644fa61b..7654374063d 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -74,6 +74,8 @@ export type AutocompleteEdit = { insertText: string; /** Keep the current selection mapped through this edit instead of moving it to the insertion. */ preserveSelection?: boolean; + /** Suppress authored-update observers for automatic composer restoration. */ + preventUpdate?: boolean; /** * When set, the replaced range becomes a CustomEmojiNode for this * shortcode (followed by `insertText`, which carries the trailing space) @@ -777,6 +779,7 @@ export function useRichTextEditor({ text: string, customEmojiShortcode?: string, preserveSelection = false, + preventUpdate = false, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -799,6 +802,7 @@ export function useRichTextEditor({ // after it. const afterNode = tr.mapping.map(toPM); if (text) tr = tr.insertText(text, afterNode); + if (preventUpdate) tr.setMeta("preventUpdate", true); const cursorPM = afterNode + (text ? text.length : 0); tr = tr.setSelection(TextSelection.create(tr.doc, cursorPM)); editor.view.dispatch(tr); @@ -809,6 +813,7 @@ export function useRichTextEditor({ } const tr = editor.state.tr.insertText(text, fromPM, toPM); + if (preventUpdate) tr.setMeta("preventUpdate", true); if (preserveSelection) { tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping)); } else { diff --git a/desktop/src/features/messages/ui/DraftsPanel.tsx b/desktop/src/features/messages/ui/DraftsPanel.tsx index 5a506a0a831..7b42b7be24e 100644 --- a/desktop/src/features/messages/ui/DraftsPanel.tsx +++ b/desktop/src/features/messages/ui/DraftsPanel.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { - getActiveDraftEntries, + getInboxDraftEntries, renameDraftEntry, useDraftsSnapshot, type DraftState, @@ -106,12 +106,6 @@ export function getThreadRootId(draftKey: string): string | null { return id.length > 0 ? id : null; } -function isVisibleDraft(entry: DraftListEntry): boolean { - const content = entry.draft.content.trim(); - const attachmentCount = entry.draft.pendingImeta.length; - return content.length > 0 || attachmentCount > 0; -} - export function getDraftPreview(draft: DraftState): string { const content = draft.content.trim(); if (content.length > 0) { @@ -468,7 +462,7 @@ function DraftRow({ * and root-status map. A draft is excluded from the count when its thread root * is definitively deleted (`"deleted"` status). * - * @param activeDrafts Active draft entries from `getActiveDraftEntries()`. + * @param activeDrafts User-authored entries from `getInboxDraftEntries()`. * @param rootStatusMap Root-status map from `useDraftRootStatus()`. * @returns Count of active drafts whose root is NOT deleted. */ @@ -505,7 +499,7 @@ export function useActiveDraftCount( ): number { // Re-render on every draft write via useDraftsSnapshot. useDraftsSnapshot(); - const activeDrafts = getActiveDraftEntries().filter(isVisibleDraft); + const activeDrafts = getInboxDraftEntries(); return deriveActiveDraftCount(activeDrafts, rootStatusMap); } @@ -517,7 +511,7 @@ export function useDraftViewItems(enabled: boolean): DraftViewItem[] { const channelsQuery = useChannelsQuery(); useDraftsSnapshot(); - const drafts = getActiveDraftEntries().filter(isVisibleDraft); + const drafts = getInboxDraftEntries(); const threadRootIds = React.useMemo(() => { const ids = new Set(); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index e77e2e0d553..4d01f5a472d 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -5,6 +5,7 @@ import { type ChannelSuggestion, } from "@/features/messages/lib/useChannelLinks"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; +import type { DraftEntryKind } from "@/features/messages/lib/useDrafts"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { @@ -63,6 +64,7 @@ import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents"; import { useComposerContentState } from "./useComposerContentState"; import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; +import { useAgentPrefillDraftBridge } from "./useAgentPrefillDraftBridge"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; @@ -141,6 +143,7 @@ function MessageComposerImpl({ effectiveDraftKeyRef.current = effectiveDraftKey; const preEditSnapshotRef = React.useRef<{ content: string; + entryKind: DraftEntryKind; pendingImeta: ImetaMedia[]; queuedAttachments: ReturnType["queuedAttachments"]; spoileredAttachmentUrls: Set; @@ -170,7 +173,13 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - const { trackAuthoredContent } = useDraftPersistLifecycle({ + const { + getEntryKind, + persistAgentPrefill, + resetToAgentPrefill, + restoreEntryKind, + trackAuthoredContent, + } = useDraftPersistLifecycle({ effectiveDraftKey, channelId, loadDraft: drafts.loadDraft, @@ -178,6 +187,7 @@ function MessageComposerImpl({ getMentionRefs: mentions.getDraftMentionRefs, restoreMentionRefs: mentions.restoreDraftMentionRefs, livePendingImeta: media.pendingImeta, + liveQueuedAttachmentCount: media.queuedAttachments.length, setPendingImeta: media.setPendingImeta, getQueuedAttachments: () => media.queuedAttachmentsRef.current, saveQueuedAttachmentsForDraft, @@ -313,25 +323,17 @@ function MessageComposerImpl({ onPulse: addressPulse.pulseOne, onTurnOff: () => setKeepMentionedAgentsPinned(false), }); - const restoreAddressedAgentMentionsRef = React.useRef< - ( - pubkeys?: readonly string[], - allowedUnpinnedPubkeys?: readonly string[], - ) => string - >(() => ""); - const restoreAddressedAgentMentionsFrameRef = React.useRef( - null, - ); - const channelIdRef = React.useRef(channelId); - channelIdRef.current = channelId; - React.useEffect( - () => () => { - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - }, - [], - ); + const agentPrefillDraft = useAgentPrefillDraftBridge({ + channelId, + contentRef, + getEntryKind, + keepMentionedAgentsPinned, + pendingImetaRef: media.pendingImetaRef, + persistAgentPrefill, + queuedAttachmentsRef: media.queuedAttachmentsRef, + resetToAgentPrefill, + setComposerContent, + }); const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -341,23 +343,12 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, - onAddressedAgentsComposerCleared: (pubkeys) => - restoreAddressedAgentMentionsRef.current(pubkeys), + onAddressedAgentsComposerCleared: + agentPrefillDraft.onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed: addressPulse.shakeMany, - onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; - const sentChannelId = channelId; - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame( - () => { - restoreAddressedAgentMentionsFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; - restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys); - }, - ); - }, + onDraftEntryKindRestored: restoreEntryKind, + onAddressedAgentsSendSucceeded: + agentPrefillDraft.onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText, @@ -381,6 +372,7 @@ function MessageComposerImpl({ if (editTarget) { preEditSnapshotRef.current = { content: syncComposerContentFromEditor(), + entryKind: getEntryKind(), pendingImeta: [...media.pendingImetaRef.current], queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), @@ -404,6 +396,7 @@ function MessageComposerImpl({ } else if (preEditSnapshotRef.current !== null) { const { content: restoredContent, + entryKind: restoredEntryKind, pendingImeta: restoredImeta, queuedAttachments: restoredQueuedAttachments, spoileredAttachmentUrls: restoredSpoileredAttachmentUrls, @@ -413,6 +406,7 @@ function MessageComposerImpl({ restoredContent ? richText.setContent(restoredContent) : richText.clearContent(); + restoreEntryKind(restoredEntryKind); media.setPendingImeta(restoredImeta); media.restoreQueuedAttachments(restoredQueuedAttachments); setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); @@ -436,6 +430,7 @@ function MessageComposerImpl({ edit.insertText, edit.customEmojiShortcode, edit.preserveSelection, + edit.preventUpdate, ); }, [richText.replacePlainTextRange], @@ -458,6 +453,7 @@ function MessageComposerImpl({ promoteExplicitlyAddressedAgents({ pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }), + onAgentPrefillChanged: agentPrefillDraft.onAgentPrefillChanged, onAutoPinAgentMention: (suggestion) => { promoteMentionedAgents({ pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], @@ -466,8 +462,10 @@ function MessageComposerImpl({ onPulseAddressLock: addressPulse.pulseOne, profiles, richText, + shouldKeepAgentPrefill: agentPrefillDraft.shouldKeepAgentPrefill, }); - restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; + agentPrefillDraft.restoreAddressedAgentMentionsRef.current = + restoreAddressedAgentMentions; syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { @@ -636,6 +634,7 @@ function MessageComposerImpl({ recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, + entryKind: hasMedia ? "draft" : getEntryKind(), }); } finally { isSubmitLockedRef.current = false; @@ -648,6 +647,7 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + getEntryKind, getLiveLinkPreviewCandidates, getReadyLinkPreviewTags, media.clearQueuedAttachments, diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index e5f08b6ebdc..3fc0501f5ad 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -193,6 +193,7 @@ function installDOMShim() { } }; globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); } installDOMShim(); @@ -233,6 +234,7 @@ import { act } from "react"; // Production hook under test — owns the restore effect, cleanup, and the // synchronous ref write that is the StrictMode fix. import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; +import { useAgentPrefillDraftBridge } from "./useAgentPrefillDraftBridge.ts"; // Real storage functions — the test uses them, not a replica. import { @@ -881,3 +883,354 @@ test("discarding_a_draft_drops_its_retained_local_files", () => { assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); }); + +const AGENT_PREFILL_REFS = [ + { displayName: "Jitter", pubkey: "agent-jitter", isAgent: true }, +]; + +test("strictmode_lifecycle_restores_and_repersists_agent_prefill_kind", async () => { + const draftKey = "chan-prefill-strictmode"; + setupStore("pubkey-prefill-strictmode"); + persistDraftEntry( + draftKey, + "@Jitter ", + draftKey, + [], + [], + AGENT_PREFILL_REFS, + "agent-prefill", + ); + + let editorContent = ""; + let activeRefs = []; + let getEntryKind; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ getEntryKind } = useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => activeRefs, + restoreMentionRefs: (refs) => { + activeRefs = [...refs]; + }, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(getEntryKind(), "agent-prefill"); + assert.equal(editorContent, "@Jitter "); + await handle.unmount(); + assert.equal(loadDraftEntry(draftKey)?.entryKind, "agent-prefill"); +}); + +test("automatic_prefill_persists_immediately_without_waiting_for_cleanup", async () => { + const draftKey = "chan-prefill-immediate"; + setupStore("pubkey-prefill-immediate"); + + let persistAgentPrefill; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ persistAgentPrefill } = useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => AGENT_PREFILL_REFS, + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: () => {}, + clearContent: () => {}, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => "@Jitter ", + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + persistAgentPrefill("@Jitter "); + assert.equal(loadDraftEntry(draftKey)?.entryKind, "agent-prefill"); + assert.equal(loadDraftEntry(draftKey)?.content, "@Jitter "); + await handle.unmount(); +}); + +test("authored_edit_promotes_prefill_monotonically_even_if_text_returns", async () => { + const draftKey = "chan-prefill-authored"; + setupStore("pubkey-prefill-authored"); + persistDraftEntry( + draftKey, + "@Jitter ", + draftKey, + [], + [], + AGENT_PREFILL_REFS, + "agent-prefill", + ); + + let editorContent = ""; + let activeRefs = []; + let getEntryKind; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ getEntryKind, trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => activeRefs, + restoreMentionRefs: (refs) => { + activeRefs = [...refs]; + }, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + editorContent = "@Jitter hello"; + trackAuthoredContent(editorContent); + assert.equal(getEntryKind(), "draft"); + editorContent = "@Jitter "; + trackAuthoredContent(editorContent); + assert.equal(getEntryKind(), "draft"); + await handle.unmount(); + assert.equal(loadDraftEntry(draftKey)?.entryKind, "draft"); +}); + +test("retained_attachment_promotes_an_agent_prefill_to_draft", async () => { + const draftKey = "chan-prefill-media"; + setupStore("pubkey-prefill-media"); + persistDraftEntry( + draftKey, + "@Jitter ", + draftKey, + [], + [], + AGENT_PREFILL_REFS, + "agent-prefill", + ); + + let editorContent = ""; + let activeRefs = []; + let pendingImeta = []; + let getEntryKind; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ getEntryKind } = useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => activeRefs, + restoreMentionRefs: (refs) => { + activeRefs = [...refs]; + }, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + pendingImeta = [IMG_A]; + await handle.rerender(); + assert.equal(getEntryKind(), "draft"); + await handle.unmount(); + assert.equal(loadDraftEntry(draftKey)?.entryKind, "draft"); + assert.deepEqual(loadDraftEntry(draftKey)?.pendingImeta, [IMG_A]); +}); + +test("failed_send_restores_the_captured_entry_kind", async () => { + const draftKey = "chan-send-kind-recovery"; + setupStore("pubkey-send-kind-recovery"); + persistDraftEntry(draftKey, "authored message", draftKey, [], []); + + let editorContent = ""; + let getEntryKind; + let resetToAgentPrefill; + let restoreEntryKind; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ getEntryKind, resetToAgentPrefill, restoreEntryKind } = + useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + const submittedKind = getEntryKind(); + editorContent = "@Jitter "; + resetToAgentPrefill(); + assert.equal(getEntryKind(), "agent-prefill"); + + editorContent = "authored message"; + restoreEntryKind(submittedKind); + await handle.unmount(); + assert.equal(loadDraftEntry(draftKey)?.entryKind, "draft"); + assert.equal(loadDraftEntry(draftKey)?.content, "authored message"); +}); + +function mountAgentPrefillBridge(overrides = {}) { + let bridge; + const contentRef = { current: "@Jitter " }; + const persisted = []; + const rendered = []; + let entryKind = "agent-prefill"; + let resetCount = 0; + function HarnessComposer() { + bridge = useAgentPrefillDraftBridge({ + channelId: "chan-prefill-bridge", + contentRef, + getEntryKind: () => entryKind, + keepMentionedAgentsPinned: false, + pendingImetaRef: { current: [] }, + persistAgentPrefill: (content) => persisted.push(content), + queuedAttachmentsRef: { current: [] }, + resetToAgentPrefill: () => { + entryKind = "agent-prefill"; + resetCount += 1; + }, + setComposerContent: (content) => rendered.push(content), + ...overrides, + }); + return null; + } + return { + HarnessComposer, + contentRef, + persisted, + rendered, + get bridge() { + return bridge; + }, + get entryKind() { + return entryKind; + }, + set entryKind(value) { + entryKind = value; + }, + get resetCount() { + return resetCount; + }, + }; +} + +test("agent_prefill_bridge_classifies_the_optimistic_clear", async () => { + const harness = mountAgentPrefillBridge(); + const handle = await mountStrictMode(harness.HarnessComposer); + harness.bridge.restoreAddressedAgentMentionsRef.current = () => "@Jitter "; + + let restored; + await act(async () => { + restored = harness.bridge.onAddressedAgentsComposerCleared([ + "agent-jitter", + ]); + }); + + assert.equal(restored, "@Jitter "); + assert.equal(harness.entryKind, "agent-prefill"); + assert.equal(harness.resetCount, 1); + assert.deepEqual(harness.rendered, ["@Jitter "]); + await handle.unmount(); +}); + +test("agent_prefill_bridge_persists_the_post_send_prefill_immediately", async () => { + const harness = mountAgentPrefillBridge(); + const handle = await mountStrictMode(harness.HarnessComposer); + + await act(async () => { + harness.bridge.onAddressedAgentsSendSucceeded(["agent-jitter"], []); + }); + + assert.deepEqual(harness.persisted, ["@Jitter "]); + await handle.unmount(); +}); + +test("agent_prefill_bridge_never_persists_a_delayed_restore_over_authored_text", async () => { + let scheduledFrame = null; + const originalRequestAnimationFrame = globalThis.requestAnimationFrame; + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame; + globalThis.requestAnimationFrame = (callback) => { + scheduledFrame = callback; + return 1; + }; + globalThis.cancelAnimationFrame = () => { + scheduledFrame = null; + }; + const harness = mountAgentPrefillBridge({ + keepMentionedAgentsPinned: true, + }); + const handle = await mountStrictMode(harness.HarnessComposer); + harness.bridge.restoreAddressedAgentMentionsRef.current = () => + "@Jitter authored"; + + await act(async () => { + harness.bridge.onAddressedAgentsSendSucceeded( + ["agent-jitter"], + ["agent-jitter"], + ); + }); + harness.entryKind = "draft"; + harness.contentRef.current = "authored"; + await act(async () => scheduledFrame()); + + assert.equal(harness.contentRef.current, "@Jitter authored"); + assert.deepEqual(harness.persisted, ["@Jitter "]); + await handle.unmount(); + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + globalThis.cancelAnimationFrame = originalCancelAnimationFrame; +}); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 95e0af9e06b..07a51d0f45b 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -632,3 +632,126 @@ test("an addressed agent keeps its resolved name while mention state clears duri assert.equal(result.current.lockedAgents[0].displayName, "Agent Ada"); }); + +test("always-addressing an empty composer classifies the inserted mention as a prefill", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + let text = ""; + const persistedPrefills = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => { + text = `${edit.insertText}${text}`; + }, + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAgentPrefillChanged: (content) => persistedPrefills.push(content), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text, cursor: text.length }), + }, + shouldKeepAgentPrefill: (currentContent) => currentContent.length === 0, + }), + ); + + act(() => + result.current.toggleAlwaysAddressAgent({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }), + ); + + assert.equal(text, "@Agent Ada "); + assert.deepEqual(persistedPrefills, ["@Agent Ada "]); +}); + +test("always-addressing never downgrades existing authored content", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + let text = "authored"; + const persistedPrefills = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => { + text = `${edit.insertText}${text}`; + }, + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAgentPrefillChanged: (content) => persistedPrefills.push(content), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text, cursor: text.length }), + }, + shouldKeepAgentPrefill: (currentContent) => currentContent.length === 0, + }), + ); + + act(() => + result.current.toggleAlwaysAddressAgent({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }), + ); + + assert.equal(text, "@Agent Ada authored"); + assert.deepEqual(persistedPrefills, []); +}); + +test("post-send address restoration suppresses the authored update path", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const edits = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => edits.push(edit), + audience: { pubkeys: ["agent-pubkey"] }, + audienceScope: "channel-scope", + mentions: { + getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, + }, + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + }, + }), + ); + + let restored; + act(() => { + restored = result.current.restoreAddressedAgentMentions(["agent-pubkey"]); + }); + + assert.equal(restored, "@Agent Ada "); + assert.deepEqual(edits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + preserveSelection: true, + preventUpdate: true, + }, + ]); +}); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 17883900ecb..1b885ef718f 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -58,20 +58,24 @@ export function useAgentAddressLockPicker({ audienceScope, mentions, onAddressAgentMention, + onAgentPrefillChanged, onAutoPinAgentMention, onPulseAddressLock, profiles, richText, + shouldKeepAgentPrefill, }: { applyAutocompleteEdit: (edit: AutocompleteEdit) => void; audience: ReturnType; audienceScope: string | null; mentions: UseMentionsResult; onAddressAgentMention?: (suggestion: MentionSuggestion) => void; + onAgentPrefillChanged?: (content: string) => void; onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; + shouldKeepAgentPrefill?: (currentContent: string) => boolean; }) { const lockedAgentPubkeys = React.useMemo( () => new Set(audience.pubkeys), @@ -165,12 +169,19 @@ export function useAgentAddressLockPicker({ const normalized = normalizePubkey(pubkey); if (!audienceScope || !normalized) return; const { text } = richText.getPlainTextAndCursor(); + const keepAgentPrefill = shouldKeepAgentPrefill?.(text) ?? false; const matchingDisplayNames = mentions .getDraftMentionRefs(text) .filter((ref) => normalizePubkey(ref.pubkey) === normalized) .map((ref) => ref.displayName); for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { - applyAutocompleteEdit(edit); + applyAutocompleteEdit( + keepAgentPrefill ? { ...edit, preventUpdate: true } : edit, + ); + } + const nextContent = richText.getPlainTextAndCursor().text; + if (keepAgentPrefill) { + onAgentPrefillChanged?.(nextContent); } removeAddressedAgent(normalized); }, @@ -178,14 +189,17 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audienceScope, mentions.getDraftMentionRefs, + onAgentPrefillChanged, removeAddressedAgent, richText.getPlainTextAndCursor, + shouldKeepAgentPrefill, ], ); const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; + let keepAgentPrefill = false; if (lockedAgentPubkeys.has(pubkey)) { removeAddressedAgentMentions(pubkey); @@ -198,12 +212,14 @@ export function useAgentAddressLockPicker({ isAgent: true, }); const { text } = richText.getPlainTextAndCursor(); + keepAgentPrefill = shouldKeepAgentPrefill?.(text) ?? false; if (getMentionOffsets(text, suggestion.displayName).length === 0) { applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, insertText: `@${suggestion.displayName} `, preserveSelection: true, + ...(keepAgentPrefill ? { preventUpdate: true } : {}), }); } trackMentionAddressedAgent(pubkey); @@ -235,6 +251,10 @@ export function useAgentAddressLockPicker({ }); mentions.openMentionPicker(queryStart, "preserve"); } + const nextContent = richText.getPlainTextAndCursor().text; + if (keepAgentPrefill) { + onAgentPrefillChanged?.(nextContent); + } }, [ applyAutocompleteEdit, @@ -247,9 +267,11 @@ export function useAgentAddressLockPicker({ mentions.openMentionPicker, mentions.registerMentionPubkey, onAddressAgentMention, + onAgentPrefillChanged, onPulseAddressLock, removeAddressedAgentMentions, richText.getPlainTextAndCursor, + shouldKeepAgentPrefill, trackMentionAddressedAgent, ], ); @@ -361,6 +383,7 @@ export function useAgentAddressLockPicker({ replaceToOffset: 0, insertText: insertedText, preserveSelection: true, + preventUpdate: true, }); return `${insertedText}${text}`; }, diff --git a/desktop/src/features/messages/ui/useAgentPrefillDraftBridge.ts b/desktop/src/features/messages/ui/useAgentPrefillDraftBridge.ts new file mode 100644 index 00000000000..fafcbda96f9 --- /dev/null +++ b/desktop/src/features/messages/ui/useAgentPrefillDraftBridge.ts @@ -0,0 +1,118 @@ +import * as React from "react"; + +import type { DraftEntryKind } from "@/features/messages/lib/useDrafts"; + +type RestoreAddressedAgentMentions = ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys?: readonly string[], +) => string; + +export function useAgentPrefillDraftBridge({ + channelId, + contentRef, + getEntryKind, + keepMentionedAgentsPinned, + pendingImetaRef, + persistAgentPrefill, + queuedAttachmentsRef, + resetToAgentPrefill, + setComposerContent, +}: { + channelId: string | null; + contentRef: React.MutableRefObject; + getEntryKind: () => DraftEntryKind; + keepMentionedAgentsPinned: boolean; + pendingImetaRef: React.MutableRefObject; + persistAgentPrefill: (content: string) => void; + queuedAttachmentsRef: React.MutableRefObject; + resetToAgentPrefill: () => void; + setComposerContent: (content: string) => void; +}) { + const restoreAddressedAgentMentionsRef = + React.useRef(() => ""); + const restoreFrameRef = React.useRef(null); + const channelIdRef = React.useRef(channelId); + channelIdRef.current = channelId; + + React.useEffect( + () => () => { + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + }, + [], + ); + + const onAddressedAgentsComposerCleared = React.useCallback( + (pubkeys: readonly string[]) => { + const content = restoreAddressedAgentMentionsRef.current(pubkeys); + setComposerContent(content); + resetToAgentPrefill(); + return content; + }, + [resetToAgentPrefill, setComposerContent], + ); + + const onAddressedAgentsSendSucceeded = React.useCallback( + (pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => { + if ( + getEntryKind() === "agent-prefill" && + contentRef.current.trim().length > 0 + ) { + persistAgentPrefill(contentRef.current); + } + if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; + + const sentChannelId = channelId; + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + restoreFrameRef.current = requestAnimationFrame(() => { + restoreFrameRef.current = null; + if (channelIdRef.current !== sentChannelId) return; + const shouldPersistPrefill = getEntryKind() === "agent-prefill"; + const content = restoreAddressedAgentMentionsRef.current( + pubkeys, + newlyPinnedPubkeys, + ); + contentRef.current = content; + setComposerContent(content); + if (shouldPersistPrefill) persistAgentPrefill(content); + }); + }, + [ + channelId, + contentRef, + getEntryKind, + keepMentionedAgentsPinned, + persistAgentPrefill, + setComposerContent, + ], + ); + + const onAgentPrefillChanged = React.useCallback( + (content: string) => { + contentRef.current = content; + setComposerContent(content); + persistAgentPrefill(content); + }, + [contentRef, persistAgentPrefill, setComposerContent], + ); + + const shouldKeepAgentPrefill = React.useCallback( + (currentContent: string) => + getEntryKind() === "agent-prefill" || + (currentContent.trim().length === 0 && + pendingImetaRef.current.length === 0 && + queuedAttachmentsRef.current.length === 0), + [getEntryKind, pendingImetaRef, queuedAttachmentsRef], + ); + + return { + onAddressedAgentsComposerCleared, + onAddressedAgentsSendSucceeded, + onAgentPrefillChanged, + restoreAddressedAgentMentionsRef, + shouldKeepAgentPrefill, + }; +} diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 694bf6a2a55..667cf0862a0 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -4,6 +4,7 @@ import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { getDraftStoreScope, + type DraftEntryKind, type DraftMentionRef, type DraftState, } from "@/features/messages/lib/useDrafts"; @@ -21,6 +22,7 @@ type UseDraftPersistLifecycleParams = { pendingImeta: ImetaMedia[], spoileredAttachmentUrls: string[], mentionRefs: DraftMentionRef[], + entryKind?: DraftEntryKind, ) => void; /** Snapshot selected mention identities still present in current content. */ getMentionRefs: (content: string) => DraftMentionRef[]; @@ -28,6 +30,8 @@ type UseDraftPersistLifecycleParams = { restoreMentionRefs: (refs: readonly DraftMentionRef[]) => void; /** Live `pendingImeta` from React state — used for render-time ref sync. */ livePendingImeta: ImetaMedia[]; + /** Live local-file count; any retained attachment makes this a real draft. */ + liveQueuedAttachmentCount?: number; /** Async setter for pendingImeta — called after the synchronous snapshot. */ setPendingImeta: (imeta: ImetaMedia[]) => void; /** Snapshot the local files owned by the outgoing draft key. */ @@ -68,6 +72,13 @@ type UseDraftPersistLifecycleResult = { * later non-empty editor update supersedes it. */ trackAuthoredContent: (content: string) => void; + /** Begin a fresh automatic address-only composer snapshot. */ + resetToAgentPrefill: () => void; + /** Persist an automatic prefill, or clear it after its last mention is removed. */ + persistAgentPrefill: (content: string) => void; + /** Restore the exact classification captured before an attempted send. */ + restoreEntryKind: (entryKind: DraftEntryKind) => void; + getEntryKind: () => DraftEntryKind; }; const authoritativelyClearedDraftKeys = new Set(); @@ -109,6 +120,7 @@ export function useDraftPersistLifecycle({ getMentionRefs, restoreMentionRefs, livePendingImeta, + liveQueuedAttachmentCount = 0, setPendingImeta, getQueuedAttachments, saveQueuedAttachmentsForDraft, @@ -123,6 +135,7 @@ export function useDraftPersistLifecycle({ }: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { const pendingImetaForPersistRef = React.useRef([]); const emptyContentIsAuthoritativeRef = React.useRef(false); + const entryKindRef = React.useRef("draft"); const isRestoringContentRef = React.useRef(false); const restoredQueuedAttachmentsRef = React.useRef( [], @@ -133,6 +146,9 @@ export function useDraftPersistLifecycle({ // Render-time update: keep the ref in sync with committed state so the // cleanup always reads the latest value during normal mounted operation. pendingImetaForPersistRef.current = livePendingImeta; + if (livePendingImeta.length > 0 || liveQueuedAttachmentCount > 0) { + entryKindRef.current = "draft"; + } // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useLayoutEffect(() => { @@ -161,6 +177,7 @@ export function useDraftPersistLifecycle({ const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; isRestoringContentRef.current = true; + entryKindRef.current = saved?.entryKind ?? "draft"; if (saved) { const restoredContent = wasAuthoritativelyCleared ? "" : saved.content; setContent(restoredContent); @@ -200,6 +217,10 @@ export function useDraftPersistLifecycle({ [...pendingImetaForPersistRef.current], [...spoileredAttachmentUrlsRef.current], getMentionRefs(content), + pendingImetaForPersistRef.current.length > 0 || + queuedAttachments.length > 0 + ? "draft" + : entryKindRef.current, ); } }; @@ -208,6 +229,7 @@ export function useDraftPersistLifecycle({ const trackAuthoredContent = React.useCallback( (content: string) => { if (!effectiveDraftKey || isRestoringContentRef.current) return; + entryKindRef.current = "draft"; const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); if (content.length > 0) { authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); @@ -223,10 +245,67 @@ export function useDraftPersistLifecycle({ [...pendingImetaForPersistRef.current], [...spoileredAttachmentUrlsRef.current], [], + "draft", ); }, [channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef], ); - return { trackAuthoredContent }; + const resetToAgentPrefill = React.useCallback(() => { + entryKindRef.current = "agent-prefill"; + if (effectiveDraftKey) { + authoritativelyClearedDraftKeys.delete(scopedDraftKey(effectiveDraftKey)); + emptyContentIsAuthoritativeRef.current = false; + } + }, [effectiveDraftKey]); + + const persistAgentPrefill = React.useCallback( + (content: string) => { + if (!effectiveDraftKey) return; + if (content.trim().length === 0) { + entryKindRef.current = "draft"; + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + [], + "draft", + ); + return; + } + resetToAgentPrefill(); + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + getMentionRefs(content), + "agent-prefill", + ); + }, + [ + channelId, + effectiveDraftKey, + getMentionRefs, + persistDraft, + resetToAgentPrefill, + spoileredAttachmentUrlsRef, + ], + ); + + const restoreEntryKind = React.useCallback((entryKind: DraftEntryKind) => { + entryKindRef.current = entryKind; + }, []); + + const getEntryKind = React.useCallback(() => entryKindRef.current, []); + return { + trackAuthoredContent, + resetToAgentPrefill, + persistAgentPrefill, + restoreEntryKind, + getEntryKind, + }; } diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index ab7d8c2f4d6..4589bc4b939 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -5,7 +5,10 @@ import { } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { PreparedBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; -import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import type { + DraftEntryKind, + DraftMentionRef, +} from "@/features/messages/lib/useDrafts"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; @@ -33,6 +36,7 @@ export type PendingNonMemberMentionSend = { sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; savedMentionRefs: DraftMentionRef[]; + savedEntryKind: DraftEntryKind; }; export type SendMessageWithMentionFlowInput = { @@ -47,6 +51,7 @@ export type SendMessageWithMentionFlowInput = { recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; + entryKind: DraftEntryKind; }; export async function resolvePreviewTags( diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 484b4512070..42ffbde92b1 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -57,6 +57,7 @@ export function useMentionSendFlow({ onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, onAddressedAgentsSendSucceeded, + onDraftEntryKindRestored, onSendRef, richText, setContent, @@ -343,6 +344,7 @@ export function useMentionSendFlow({ draft.savedImeta, [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, + draft.savedEntryKind, ); saveQueuedAttachmentsForDraft( draft.recoveryDraftKey, @@ -360,7 +362,8 @@ export function useMentionSendFlow({ JSON.stringify(existing.pendingImeta) !== JSON.stringify(draft.savedImeta) || JSON.stringify(existing.spoileredAttachmentUrls) !== - JSON.stringify([...draft.savedSpoileredAttachmentUrls])) + JSON.stringify([...draft.savedSpoileredAttachmentUrls]) || + existing.entryKind !== draft.savedEntryKind) ) { return; } @@ -371,6 +374,7 @@ export function useMentionSendFlow({ draft.savedImeta, [...draft.savedSpoileredAttachmentUrls], draft.savedMentionRefs, + draft.savedEntryKind, ); }; let composerCleared = false; @@ -405,6 +409,7 @@ export function useMentionSendFlow({ setContent(draft.savedContent); contentRef.current = draft.savedContent; richText.setContent(draft.savedContent); + onDraftEntryKindRestored?.(draft.savedEntryKind); setPendingImeta(draft.savedImeta); restoreQueuedAttachments(draft.queuedAttachments); mentions.restoreDraftMentionRefs(draft.savedMentionRefs); @@ -562,6 +567,16 @@ export function useMentionSendFlow({ const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), ); + if (draft.sentDraftKey) { + drafts.markDraftSent( + draft.sentDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.sentDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedEntryKind, + ); + } if ( draft.capturedChannelId === channelIdRef.current || channelIdRef.current === null @@ -576,15 +591,6 @@ export function useMentionSendFlow({ newlyPinnedPubkeys, ); } - if (draft.sentDraftKey) { - drafts.markDraftSent( - draft.sentDraftKey, - draft.savedContent, - draft.capturedChannelId ?? draft.sentDraftKey, - draft.savedImeta, - [...draft.savedSpoileredAttachmentUrls], - ); - } }; if (preparedUpload) { let settleUpload!: () => void; @@ -652,6 +658,7 @@ export function useMentionSendFlow({ onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, onAddressedAgentsSendSucceeded, + onDraftEntryKindRestored, onPrepareSendChannel, onSendRef, richText.setContent, @@ -677,6 +684,7 @@ export function useMentionSendFlow({ recoveryDraftKey, spoileredAttachmentUrls = new Set(), trimmed, + entryKind, }: SendMessageWithMentionFlowInput) => { if (isMentionSendPendingRef.current) { return; @@ -799,6 +807,7 @@ export function useMentionSendFlow({ sentDraftKey, recoveryDraftKey, savedMentionRefs, + savedEntryKind: entryKind, }; if (promptNonMemberPubkeys.length > 0) { setNonMemberPromptError(null); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts index fe16ba2f63a..f8a2516af8e 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -7,7 +7,10 @@ import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLi import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; +import type { + DraftEntryKind, + UseDraftsResult, +} from "@/features/messages/lib/useDrafts"; export type UseMentionSendFlowOptions = { channelId: string | null; @@ -21,6 +24,7 @@ export type UseMentionSendFlowOptions = { onPrepareSendChannel?: (pubkeys?: string[]) => Promise; onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string; onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onDraftEntryKindRestored?: (entryKind: DraftEntryKind) => void; onAddressedAgentsSendSucceeded?: ( pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[], diff --git a/desktop/src/features/projects/ui/useProjectDiscussInChannel.ts b/desktop/src/features/projects/ui/useProjectDiscussInChannel.ts index a5931e70cb8..0e7b3ebd24c 100644 --- a/desktop/src/features/projects/ui/useProjectDiscussInChannel.ts +++ b/desktop/src/features/projects/ui/useProjectDiscussInChannel.ts @@ -25,6 +25,7 @@ export function useProjectDiscussInChannel(items: ProjectSelectionItem[]) { saveDraftEntry(channelId, { channelId, content, + entryKind: "draft", createdAt: existing?.createdAt ?? now, mentionRefs: existing?.mentionRefs ?? [], pendingImeta: existing?.pendingImeta ?? [], diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 0b957992e2b..c7636c721e2 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -136,6 +136,28 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) { }, content); } +async function readStoredDraftEntryKind(page: Page, channelId: string) { + return page.evaluate((targetChannelId) => { + for (let index = 0; index < window.localStorage.length; index += 1) { + const key = window.localStorage.key(index); + if (!key?.startsWith("buzz-drafts.")) continue; + const raw = window.localStorage.getItem(key); + if (!raw) continue; + try { + const drafts = JSON.parse(raw) as Record< + string, + { channelId?: string; entryKind?: string } + >; + const draft = Object.values(drafts).find( + (candidate) => candidate.channelId === targetChannelId, + ); + if (draft) return draft.entryKind ?? null; + } catch {} + } + return null; + }, channelId); +} + async function emitMockMessage( page: Page, content: string, @@ -285,6 +307,76 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); +test("automatic agent prefill survives navigation and reload without becoming an Inbox draft", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + await automaticallyMention(composer, "Morgarita"); + await expect(composer.getByTestId("message-input")).toHaveText("@Morgarita "); + await expect + .poll(() => readStoredDraftEntryKind(page, CHANNEL_ID)) + .toBe("agent-prefill"); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("home-inbox")).toBeVisible(); + expect(await readStoredDraftEntryKind(page, CHANNEL_ID)).toBe( + "agent-prefill", + ); + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Drafts" }).click(); + await expect(page.getByTestId("home-inbox-drafts")).toBeVisible(); + await expect(page.locator("[data-testid^='home-draft-item-']")).toHaveCount( + 0, + ); + await expect(page.getByTestId("inbox-draft-badge-option")).toHaveCount(0); + + await openGeneral(page); + await expect(channelComposer(page).getByTestId("message-input")).toHaveText( + "@Morgarita ", + ); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(channelComposer(page).getByTestId("message-input")).toHaveText( + "@Morgarita ", + ); +}); + +test("cancelling a message edit restores the automatic prefill classification", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + await expect + .poll(() => readStoredDraftEntryKind(page, CHANNEL_ID)) + .toBe("agent-prefill"); + + const editableRow = page.locator(`[data-message-id="${THREAD_ROOT_ID}"]`); + await editableRow.hover(); + await editableRow.getByRole("button", { name: "More actions" }).click(); + await page.getByTestId(`edit-message-${THREAD_ROOT_ID}`).click(); + await expect(composer.getByTestId("edit-target")).toBeVisible(); + + await composer.getByRole("button", { name: "Cancel edit" }).click(); + await expect(input).toHaveText("@Morgarita "); + await expect + .poll(() => readStoredDraftEntryKind(page, CHANNEL_ID)) + .toBe("agent-prefill"); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("inbox-filter-trigger").click(); + await page.getByRole("menuitemradio", { name: "Drafts" }).click(); + await expect(page.locator("[data-testid^='home-draft-item-']")).toHaveCount( + 0, + ); +}); + test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page);