Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ function MessageComposerImpl({
media.queuedAttachmentsRef.current.length === 0;
const ownsDropZone = mediaController === undefined;
const backgroundUpload = useBackgroundMediaUpload();
const hydrationRef =
React.useRef<ReturnType<typeof usePersistentAgentMentionHydration>>(null);
useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
Expand All @@ -189,6 +191,7 @@ function MessageComposerImpl({
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
draftContentResolverRef: hydrationRef,
});
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
Expand Down Expand Up @@ -273,7 +276,7 @@ function MessageComposerImpl({
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
persistentMentionHydrationRef.current?.reconcile(text);
hydrationRef.current?.reconcile(text);
if (text.trim().length > 0) {
notifyTyping();
}
Expand All @@ -298,10 +301,7 @@ function MessageComposerImpl({
richText,
});
const persistentAudience = persistentMentionHydration.audience;
const persistentMentionHydrationRef = React.useRef(
persistentMentionHydration,
);
persistentMentionHydrationRef.current = persistentMentionHydration;
hydrationRef.current = persistentMentionHydration;
const mentionSendFlow = useMentionSendFlow({
channelId,
channelLinks,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ function installDOMShim() {
}
};
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
}

installDOMShim();
Expand Down Expand Up @@ -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 { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration.ts";

// Real storage functions — the test uses them, not a replica.
import {
Expand All @@ -246,6 +248,11 @@ import {
saveQueuedAttachmentsForDraft,
takeQueuedAttachmentsForDraft,
} from "../lib/backgroundMediaUploadStore.ts";
import {
getPersistentAgentAudienceScope,
setPersistentAgentAudience,
setPersistentAgentAudienceEnabled,
} from "../lib/persistentAgentAudience.ts";

// ── Helpers ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -291,6 +298,142 @@ async function mountStrictMode(Comp) {
};
}

const AUDIENCE_OWNER_PUBKEY = "1".repeat(64);
const AGENT_ADA = {
displayName: "Agent Ada",
pubkey: "a".repeat(64),
};
const AGENT_BEA = {
displayName: "Agent Bea",
pubkey: "b".repeat(64),
};

function createPersistentAudienceDraftHarness({ agents, initialRoute }) {
setupStore(AUDIENCE_OWNER_PUBKEY);
setPersistentAgentAudienceEnabled(false);
setPersistentAgentAudienceEnabled(true);

let route = initialRoute;
let editorContent = "";
let setPendingImetaFromTest = () => {};
const spoileredRef = { current: new Set() };
const hydrationRef = { current: null };
const agentByPubkey = new Map(agents.map((agent) => [agent.pubkey, agent]));

const scopeFor = (candidate) => {
const scope = getPersistentAgentAudienceScope({
ownerPubkey: AUDIENCE_OWNER_PUBKEY,
channelId: candidate.channelId,
threadRootId: candidate.threadRootId,
});
assert.ok(scope);
return scope;
};
const seedRouteAudience = (candidate) => {
setPersistentAgentAudience(scopeFor(candidate), candidate.agentPubkeys);
};
seedRouteAudience(route);

const extractMentionPubkeys = (content) =>
agents
.filter((agent) => content.includes(`@${agent.displayName}`))
.map((agent) => agent.pubkey);
const mentions = {
cancelMentionAutocomplete: () => {},
clearMentions: () => {},
extractMentionPubkeys,
getMentionDisplayName: (pubkey) =>
agentByPubkey.get(pubkey)?.displayName ?? null,
insertResolvedMention: ({
replaceFromOffset,
replaceToOffset,
displayName,
}) => ({
replaceFromOffset,
replaceToOffset,
insertText: `@${displayName} `,
}),
registerMentionPubkey: () => {},
};
const richText = {
getMarkdown: () => editorContent,
getPlainTextAndCursor: () => ({
cursor: editorContent.length,
text: editorContent,
}),
replacePlainTextRange: (from, to, text) => {
editorContent =
editorContent.slice(0, from) + text + editorContent.slice(to);
},
};

function HarnessComposer() {
const [pendingImeta, setPendingImeta] = React.useState([]);
setPendingImetaFromTest = setPendingImeta;

useDraftPersistLifecycle({
effectiveDraftKey: route.draftKey,
channelId: route.channelId,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: (content) =>
extractMentionPubkeys(content).map((pubkey) => ({
displayName: agentByPubkey.get(pubkey).displayName,
pubkey,
isAgent: true,
})),
restoreMentionRefs: () => {},
livePendingImeta: pendingImeta,
setPendingImeta,
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
draftContentResolverRef: hydrationRef,
});

hydrationRef.current = usePersistentAgentMentionHydration({
audienceScope: scopeFor(route),
hydrationKey: route.draftKey,
isEditing: false,
mentions,
richText,
});
return null;
}

return {
HarnessComposer,
addAttachment: async (attachment) => {
await act(async () => {
setPendingImetaFromTest([attachment]);
});
},
appendEditorContent: (content) => {
editorContent += content;
},
getEditorContent: () => editorContent,
resolvePostSendContent: (pubkeys) => {
editorContent = hydrationRef.current.resolvePostSendContent(pubkeys);
},
switchRoute: (nextRoute) => {
seedRouteAudience(nextRoute);
route = nextRoute;
},
};
}

async function flushPersistentAudienceHydration() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}

// ── Tests ─────────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -662,3 +805,156 @@ test("discarding_a_draft_drops_its_retained_local_files", () => {

assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []);
});

test("persistent audience hydration alone does not create a thread draft", async (t) => {
t.after(() => setPersistentAgentAudienceEnabled(false));
const route = {
agentPubkeys: [AGENT_ADA.pubkey],
channelId: "channel-audience-only",
draftKey: "thread:audience-only",
threadRootId: "audience-only",
};
const harness = createPersistentAudienceDraftHarness({
agents: [AGENT_ADA],
initialRoute: route,
});

const handle = await mountStrictMode(harness.HarnessComposer);
await flushPersistentAudienceHydration();
assert.equal(
harness.getEditorContent(),
`@${AGENT_ADA.displayName} `,
"precondition: opening the thread hydrates its saved agent audience",
);

await handle.unmount();
assert.equal(
loadDraftEntry(route.draftKey),
undefined,
"preselected agent recipients are not authored draft content",
);

const authoredHandle = await mountStrictMode(harness.HarnessComposer);
await flushPersistentAudienceHydration();
harness.appendEditorContent("please investigate");
await authoredHandle.unmount();
assert.equal(
loadDraftEntry(route.draftKey)?.content,
`@${AGENT_ADA.displayName} please investigate`,
"user-authored content still persists with its preselected recipient",
);
});

test("persistent audience hydration preserves an existing mention-only draft", async (t) => {
t.after(() => setPersistentAgentAudienceEnabled(false));
const route = {
agentPubkeys: [AGENT_ADA.pubkey],
channelId: "channel-existing-mention",
draftKey: "thread:existing-mention",
threadRootId: "existing-mention",
};
const harness = createPersistentAudienceDraftHarness({
agents: [AGENT_ADA],
initialRoute: route,
});
const savedContent = `@${AGENT_ADA.displayName} `;
persistDraftEntry(
route.draftKey,
savedContent,
route.channelId,
[],
[],
[{ ...AGENT_ADA, isAgent: true }],
);

const handle = await mountStrictMode(harness.HarnessComposer);
await flushPersistentAudienceHydration();
await handle.unmount();

assert.equal(
loadDraftEntry(route.draftKey)?.content,
savedContent,
"restored authored content is never mistaken for programmatic hydration",
);
});

test("persistent audience hydration keeps an attachment draft without mention text", async (t) => {
t.after(() => setPersistentAgentAudienceEnabled(false));
const route = {
agentPubkeys: [AGENT_ADA.pubkey],
channelId: "channel-audience-attachment",
draftKey: "thread:audience-attachment",
threadRootId: "audience-attachment",
};
const harness = createPersistentAudienceDraftHarness({
agents: [AGENT_ADA],
initialRoute: route,
});

const handle = await mountStrictMode(harness.HarnessComposer);
await flushPersistentAudienceHydration();
await harness.addAttachment(IMG_A);
await handle.unmount();

const saved = loadDraftEntry(route.draftKey);
assert.ok(saved, "the attachment keeps the draft alive");
assert.equal(saved.content, "", "hydrated recipients are not draft text");
assert.deepEqual(saved.pendingImeta, [IMG_A]);
});

test("persistent audience hydration does not leak ghost drafts across thread switches", async (t) => {
t.after(() => setPersistentAgentAudienceEnabled(false));
const routeA = {
agentPubkeys: [AGENT_ADA.pubkey],
channelId: "channel-switch",
draftKey: "thread:switch-a",
threadRootId: "switch-a",
};
const routeB = {
agentPubkeys: [AGENT_BEA.pubkey],
channelId: "channel-switch",
draftKey: "thread:switch-b",
threadRootId: "switch-b",
};
const harness = createPersistentAudienceDraftHarness({
agents: [AGENT_ADA, AGENT_BEA],
initialRoute: routeA,
});

const handle = await mountStrictMode(harness.HarnessComposer);
await flushPersistentAudienceHydration();
assert.equal(harness.getEditorContent(), `@${AGENT_ADA.displayName} `);

await act(async () => {
harness.switchRoute(routeB);
});
await handle.rerender();
await flushPersistentAudienceHydration();
assert.equal(loadDraftEntry(routeA.draftKey), undefined);
assert.equal(harness.getEditorContent(), `@${AGENT_BEA.displayName} `);

await handle.unmount();
assert.equal(loadDraftEntry(routeB.draftKey), undefined);
});

test("persistent audience post-send recipients do not become a draft", async (t) => {
t.after(() => setPersistentAgentAudienceEnabled(false));
const route = {
agentPubkeys: [AGENT_ADA.pubkey],
channelId: "channel-post-send",
draftKey: "thread:post-send",
threadRootId: "post-send",
};
const harness = createPersistentAudienceDraftHarness({
agents: [AGENT_ADA],
initialRoute: route,
});

const handle = await mountStrictMode(harness.HarnessComposer);
await flushPersistentAudienceHydration();
harness.resolvePostSendContent([AGENT_ADA.pubkey]);
assert.equal(harness.getEditorContent(), `@${AGENT_ADA.displayName} `);
await handle.unmount();

assert.equal(loadDraftEntry(route.draftKey), undefined);
});
11 changes: 10 additions & 1 deletion desktop/src/features/messages/ui/useDraftPersistSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ type UseDraftPersistLifecycleParams = {
* closure to capture the latest text before the effect fires.
*/
syncComposerContentFromEditor: () => string;
/** Resolver for editor-only state that should not become authored content. */
draftContentResolverRef?: React.RefObject<{
resolveDraftContentForPersistence: (content: string) => string;
} | null>;
};

/**
Expand Down Expand Up @@ -104,6 +108,7 @@ export function useDraftPersistLifecycle({
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
draftContentResolverRef,
}: UseDraftPersistLifecycleParams): void {
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
const restoredQueuedAttachmentsRef = React.useRef<QueuedMediaAttachment[]>(
Expand Down Expand Up @@ -160,7 +165,11 @@ export function useDraftPersistLifecycle({
if (queuedAttachments.length > 0) {
saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments);
}
const content = syncComposerContentFromEditor();
const editorContent = syncComposerContentFromEditor();
const content =
draftContentResolverRef?.current?.resolveDraftContentForPersistence(
editorContent,
) ?? editorContent;
persistDraft(
effectiveDraftKey,
content,
Expand Down
Loading