fix(desktop): scope Bestie handoffs to their workspace

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-08-26 21:02:47 -04:00
committed by Fizz
parent 367b532bdb
commit 2e5cfa1cdd
9 changed files with 359 additions and 11 deletions

View File

@@ -453,6 +453,8 @@ export function useSendMessageMutation(
sentFromThreadRootId?: string | null;
sentFromThreadRootExcerpt?: string | null;
transport?: "auto" | "http";
expectedRelayUrl?: string;
expectedSignerPubkey?: string;
},
MessageQueryContext | undefined
>({
@@ -467,6 +469,8 @@ export function useSendMessageMutation(
sentFromThreadRootId,
sentFromThreadRootExcerpt,
transport = "auto",
expectedRelayUrl,
expectedSignerPubkey,
}) => {
// Prefer a channel captured by the caller at compose time. Otherwise,
// resolve a captured id from the shared channel cache so navigation
@@ -493,6 +497,11 @@ export function useSendMessageMutation(
if (!identity) {
throw new Error("No identity available for sending messages.");
}
if (Boolean(expectedRelayUrl) !== Boolean(expectedSignerPubkey)) {
throw new Error(
"Scoped message sends require both a relay and signer identity.",
);
}
// `mediaTags` arrives as the merged outgoing tag set (imeta + NIP-30
// emoji). Split it so each kind goes to its own validated Tauri arg —
@@ -531,7 +540,8 @@ export function useSendMessageMutation(
parentEventId ||
imetaTags.length > 0 ||
emojiTags.length > 0 ||
linkPreviewTags.length > 0
linkPreviewTags.length > 0 ||
expectedRelayUrl
) {
const cachedMessages =
queryClient.getQueryData<RelayEvent[]>(
@@ -559,8 +569,8 @@ export function useSendMessageMutation(
mentionTags,
linkPreviewTags,
sentFromThreadTag,
undefined,
undefined,
expectedRelayUrl,
expectedSignerPubkey,
suppliedRootEventId,
);

View File

@@ -55,10 +55,13 @@ import {
getActiveDraftEntries,
getAllDraftEntries,
getSentDraftEntries,
getDraftStoreScope,
initDraftStore,
loadDraftEntry,
loadDraftEntryForScope,
markDraftSentEntry,
persistDraftEntry,
persistDraftEntryForScope,
renameDraftEntry,
saveDraftEntry,
subscribeToStore,
@@ -1045,6 +1048,35 @@ test("cross_relay_isolation_via_initDraftStore_without_clearAllDrafts", () => {
assert.notEqual(v2a, v2b, "buckets must differ");
});
test("captured scope recovers an in-flight draft after the active relay changes", () => {
installFreshLocalStorage();
clearAllDrafts();
const pk = "pubkey-in-flight";
initDraftStore(pk, "wss://relay-a.example.com");
const capturedScope = getDraftStoreScope();
initDraftStore(pk, "wss://relay-b.example.com");
persistDraftEntryForScope(
capturedScope,
"bestie-share:event",
"Keep this in relay A",
"bestie-share:event",
[],
[],
);
assert.equal(loadDraftEntry("bestie-share:event"), undefined);
assert.equal(
loadDraftEntryForScope(capturedScope, "bestie-share:event")?.content,
"Keep this in relay A",
);
initDraftStore(pk, "wss://relay-a.example.com");
assert.equal(
loadDraftEntry("bestie-share:event")?.content,
"Keep this in relay A",
);
});
test("legacy_migration_v1_entries_readable_after_scoped_init_v1_key_removed", () => {
const storage = installFreshLocalStorage();
clearAllDrafts();

View File

@@ -118,6 +118,39 @@ export function getDraftStoreScope(): string {
return storageKey();
}
function readExplicitStore(scope: string): Map<string, DraftState> {
if (scope === storageKey()) return readStore();
const map = new Map<string, DraftState>();
const raw = localStorage.getItem(scope);
if (!raw) return map;
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
) {
for (const [key, value] of Object.entries(parsed as StoredDrafts)) {
if (!key.startsWith("sent:") && isValidDraftState(value)) {
map.set(key, value);
}
}
}
} catch {
// Match readStore's fail-open corruption behavior without mutating an
// inactive workspace while its owner is away.
}
return map;
}
function flushExplicitStore(
scope: string,
map: Map<string, DraftState>,
): boolean {
const value = Object.fromEntries(map);
return setLocalStorageItemWithRecovery(scope, JSON.stringify(value));
}
function legacyStorageKey(): string {
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
}
@@ -301,6 +334,14 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined {
return readStore().get(draftKey);
}
/** Read a draft from the workspace scope captured before an async send. */
export function loadDraftEntryForScope(
scope: string,
draftKey: string,
): DraftState | undefined {
return readExplicitStore(scope).get(draftKey);
}
export function deleteDraftEntry(draftKey: string): void {
discardQueuedAttachmentsForDraft(draftKey);
clearDraftEntry(draftKey);
@@ -461,6 +502,55 @@ export function persistDraftEntry(
}
}
/**
* Recover an async send into the exact workspace that owned it, even when the
* active relay/identity changed while publication was in flight.
*/
export function persistDraftEntryForScope(
scope: string,
draftKey: string,
content: string,
channelId: string,
pendingImeta: ImetaMedia[],
spoileredAttachmentUrls: string[],
mentionRefs: DraftMentionRef[] = [],
): void {
if (scope === storageKey()) {
persistDraftEntry(
draftKey,
content,
channelId,
pendingImeta,
spoileredAttachmentUrls,
mentionRefs,
);
return;
}
const map = readExplicitStore(scope);
const hasContent = content.trim().length > 0 || pendingImeta.length > 0;
if (!hasContent) {
map.delete(draftKey);
flushExplicitStore(scope, map);
return;
}
const existing = map.get(draftKey);
const now = new Date().toISOString();
map.set(draftKey, {
content,
selectionEnd: content.length,
selectionStart: content.length,
channelId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
pendingImeta,
mentionRefs,
spoileredAttachmentUrls,
status: "active",
});
evictOldest(map);
flushExplicitStore(scope, map);
}
/**
* Returns all drafts sorted most-recently-updated first.
* Used by the Drafts inbox panel (Phase 2).

View File

@@ -50,6 +50,19 @@ export function BestieMessagePanel({
mentionPubkeys: string[],
mediaTags?: string[][],
) => {
// The action can outlive its rendered community while opening the DM and
// preparing optimistic state. Capture both ownership dimensions before
// the first await and require the backend to reject publication if either
// one changes in flight.
const expectedRelayUrl = activeCommunity?.relayUrl;
const expectedSignerPubkey = identityQuery.data?.pubkey;
if (!expectedRelayUrl || !expectedSignerPubkey) {
const error = new Error(
"Bestie messages require an active community and signing identity.",
);
toast.error(`Couldn't send to ${bestie.name}`);
throw error;
}
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
@@ -64,14 +77,17 @@ export function BestieMessagePanel({
try {
const dm = await openDmMutation.mutateAsync({
pubkeys: [bestie.pubkey],
expectedRelayUrl: activeCommunity?.relayUrl,
expectedSignerPubkey: identityQuery.data?.pubkey,
expectedRelayUrl,
expectedSignerPubkey,
});
await sendMessageMutation.mutateAsync({
content,
expectedRelayUrl,
expectedSignerPubkey,
mediaTags,
mentionPubkeys,
targetChannel: dm,
transport: "http",
});
onClose();
toast.success(`Sent to ${bestie.name}`);

View File

@@ -232,12 +232,16 @@ 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 {
markRecoveredDraftRestorable,
useDraftPersistLifecycle,
} from "./useDraftPersistSnapshot.ts";
// Real storage functions — the test uses them, not a replica.
import {
clearAllDrafts,
deleteDraftEntry,
getDraftStoreScope,
initDraftStore,
loadDraftEntry,
persistDraftEntry,
@@ -639,6 +643,53 @@ test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async (
await remounted.unmount();
});
test("draft_lifecycle_restores_an_async_send_recovery_after_optimistic_clear", async () => {
const DRAFT_KEY = "chan-send-recovery";
const ORIGINAL = "recover this failed send";
setupStore("pubkey-send-recovery");
persistDraftEntry(DRAFT_KEY, ORIGINAL, DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
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);
assert.equal(editorContent, ORIGINAL);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
persistDraftEntry(DRAFT_KEY, ORIGINAL, DRAFT_KEY, [], []);
markRecoveredDraftRestorable(getDraftStoreScope(), DRAFT_KEY);
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, ORIGINAL);
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-image";
setupStore("pubkey-clear-caption-image");

View File

@@ -76,6 +76,17 @@ function scopedDraftKey(draftKey: string): string {
return `${getDraftStoreScope()}:${draftKey}`;
}
/**
* Make an asynchronously recovered draft visible again after the optimistic
* send path marked its previous empty editor state as authoritative.
*/
export function markRecoveredDraftRestorable(
scope: string,
draftKey: string,
): void {
authoritativelyClearedDraftKeys.delete(`${scope}:${draftKey}`);
}
/**
* Owns the draft-persist lifecycle for `MessageComposer`.
*

View File

@@ -32,6 +32,7 @@ export type PendingNonMemberMentionSend = {
savedSpoileredAttachmentUrls: Set<string>;
sentDraftKey: string | null | undefined;
recoveryDraftKey: string | null | undefined;
recoveryDraftStoreScope: string;
savedMentionRefs: DraftMentionRef[];
};

View File

@@ -24,7 +24,13 @@ import {
buildOutgoingMessage,
type ImetaMedia,
} from "@/features/messages/lib/imetaMediaMarkdown";
import {
getDraftStoreScope,
loadDraftEntryForScope,
persistDraftEntryForScope,
} from "@/features/messages/lib/useDrafts";
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
import { markRecoveredDraftRestorable } from "./useDraftPersistSnapshot";
import { invokeTauri } from "@/shared/api/tauri";
import type { AcpRuntime, ManagedAgent } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
@@ -335,8 +341,9 @@ export function useMentionSendFlow({
? prepareBackgroundMediaUpload(draft.queuedAttachments)
: null;
const persistPreflightDraft = () => {
if (isSendCancelled() || !draft.recoveryDraftKey) return;
drafts.persistDraft(
if (!draft.recoveryDraftKey) return;
persistDraftEntryForScope(
draft.recoveryDraftStoreScope,
draft.recoveryDraftKey,
draft.savedContent,
draft.capturedChannelId ?? draft.recoveryDraftKey,
@@ -344,14 +351,21 @@ export function useMentionSendFlow({
[...draft.savedSpoileredAttachmentUrls],
draft.savedMentionRefs,
);
markRecoveredDraftRestorable(
draft.recoveryDraftStoreScope,
draft.recoveryDraftKey,
);
saveQueuedAttachmentsForDraft(
draft.recoveryDraftKey,
draft.queuedAttachments,
);
};
const persistCanceledDraft = () => {
if (isSendCancelled() || !draft.recoveryDraftKey) return;
const existing = drafts.loadDraft(draft.recoveryDraftKey);
if (!draft.recoveryDraftKey) return;
const existing = loadDraftEntryForScope(
draft.recoveryDraftStoreScope,
draft.recoveryDraftKey,
);
if (
existing &&
(existing.content !== draft.savedContent ||
@@ -364,7 +378,8 @@ export function useMentionSendFlow({
) {
return;
}
drafts.persistDraft(
persistDraftEntryForScope(
draft.recoveryDraftStoreScope,
draft.recoveryDraftKey,
draft.savedContent,
draft.capturedChannelId ?? draft.recoveryDraftKey,
@@ -372,6 +387,10 @@ export function useMentionSendFlow({
[...draft.savedSpoileredAttachmentUrls],
draft.savedMentionRefs,
);
markRecoveredDraftRestorable(
draft.recoveryDraftStoreScope,
draft.recoveryDraftKey,
);
};
let composerCleared = false;
let optimisticComposerContent = "";
@@ -681,6 +700,7 @@ export function useMentionSendFlow({
if (isMentionSendPendingRef.current) {
return;
}
const recoveryDraftStoreScope = getDraftStoreScope();
isMentionSendPendingRef.current = true;
setIsMentionSendPending(true);
const isSendCancelled = () =>
@@ -798,6 +818,7 @@ export function useMentionSendFlow({
savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
sentDraftKey,
recoveryDraftKey,
recoveryDraftStoreScope,
savedMentionRefs,
};
if (promptNonMemberPubkeys.length > 0) {

View File

@@ -28,6 +28,10 @@ const bestie = {
relayUrl: RELAY_A,
status: "running" as const,
};
const bestieInCommunityB = {
...bestie,
relayUrl: COMMUNITY_B.relayUrl,
};
async function seedCommunities(
page: import("@playwright/test").Page,
@@ -444,3 +448,115 @@ test("a message can be handed to Bestie with an optional note", async ({
);
await expect(sentMessage).toContainText("Open original message");
});
test("a Bestie handoff fails closed across a community switch and preserves its draft", async ({
page,
}) => {
await installMockBridge(
page,
{
managedAgents: [bestie, bestieInCommunityB],
sendMessageDelayMs: 1_000,
},
{ skipCommunitySeed: true },
);
await seedCommunities(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
const messageId = await page.evaluate(() => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is unavailable.");
return emit({
channelName: "general",
content: "Keep this handoff in its original community",
id: "e".repeat(64),
}).id;
});
const draft = "This note must stay in Alpha.";
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await row.getByTestId(`send-to-bestie-${messageId}`).click();
const popover = page.getByTestId(`bestie-popover-${messageId}`);
const editor = popover.locator('[contenteditable="true"]');
await editor.fill(draft);
await popover.getByRole("button", { name: "Send" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
window.__BUZZ_E2E_COMMAND_LOG__?.findLast(
(entry) => entry.command === "send_channel_message",
)?.payload,
),
)
.toMatchObject({
content: expect.stringContaining(draft),
expectedRelayUrl: RELAY_A,
expectedSignerPubkey: OWNER_PUBKEY,
});
await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click();
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-active-community-id"),
),
)
.toBe(COMMUNITY_B.id);
await page.waitForTimeout(1_150);
// The two fixtures intentionally resolve to the same participant-derived
// DM id. An unscoped send would therefore appear in both tenant views.
await page
.getByTestId("sidebar-channel-content")
.getByRole("button", { name: /Bestie/ })
.first()
.click();
await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
await expect(page.getByTestId("message-timeline")).not.toContainText(draft);
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
await expect
.poll(() =>
page.evaluate(() =>
window.localStorage.getItem("buzz-active-community-id"),
),
)
.toBe(COMMUNITY_A.id);
await page
.getByTestId("sidebar-channel-content")
.getByRole("button", { name: /Bestie/ })
.first()
.click();
await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
await expect(page.getByTestId("message-timeline")).not.toContainText(draft);
await page.getByTestId("channel-general").click();
await page.evaluate((id) => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message emitter is unavailable.");
emit({
channelName: "general",
content: "Keep this handoff in its original community",
id,
});
}, messageId);
const restoredRow = page.locator(`[data-message-id="${messageId}"]`);
await restoredRow.hover();
await restoredRow.getByTestId(`send-to-bestie-${messageId}`).click();
const restoredPopover = page.getByTestId(`bestie-popover-${messageId}`);
await expect(restoredPopover.locator('[contenteditable="true"]')).toHaveText(
draft,
);
await page.evaluate(() => {
if (window.__BUZZ_E2E__?.mock) {
window.__BUZZ_E2E__.mock.sendMessageDelayMs = 0;
}
});
await restoredPopover.getByRole("button", { name: "Send" }).click();
await expect(restoredPopover).toBeHidden();
await page.getByTestId("open-bestie-dm").click();
await expect(page.getByTestId("message-timeline")).toContainText(draft);
});