fix(desktop): keep your draft editable when an @ mention cannot be sent (#7190)

🤖
## Summary
Fixes four ways the composer could lose your text or cursor while
working with @ mentions:

- **A failed send no longer wipes your draft.** If mention preparation
fails while sending (for example, a failed upload), the draft stays as
typed, the error is shown, and you can send again.
- **A closing menu can no longer steal your cursor.** A still-animating
⋯ menu can no longer grab a late pointer event and steal focus from the
composer; Escape still refocuses its trigger.
- **Delayed automatic mentions no longer overwrite your typing.** The
per-agent "Automatically mention … in this conversation" toggle re-adds
the label to your next draft, but only if that draft is untouched — any
draft edit or channel switch cancels the re-add.
- **Regenerated automatic mention labels leave no residue.** After a
regeneration (for example, an agent rename), the current label is the
one later updated or removed — no duplicated or leftover text.

Part of the mention-editor stack: #7190 → #7191 → #7192 (review in
order); stands on its base branch alone.

### Related issue
Continues the merged mention-editor work from #7124 and #7128. No
separate tracking issue for this slice.

### Testing
- Unit tests cover each fix: draft retention on failed sends, re-add
revocation on draft edits or channel switches, and regenerated-label
ownership.
- Browser specs cover the closing-menu focus case, failed sends, and an
always-mention agent surviving save, reopen, and publication.
- All PR checks pass on this head.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-09-11 12:51:23 -04:00
committed by GitHub
parent 802ba6170e
commit 65e814f728
19 changed files with 850 additions and 76 deletions

View File

@@ -77,6 +77,7 @@ export default defineConfig({
"**/remote-owned-mentions.spec.ts",
"**/mention-spacing.spec.ts",
"**/mention-recipients.spec.ts",
"**/message-edit-focus.spec.ts",
"**/team-mentions.spec.ts",
"**/persistent-agent-audience.spec.ts",
"**/relay-reconnect.spec.ts",

View File

@@ -0,0 +1,20 @@
import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
/** Display the upload failure without losing the composer's retry controls. */
export function ComposerUploadError({
uploadState,
onDismiss,
}: {
uploadState: ReturnType<typeof useMediaUpload>["uploadState"];
onDismiss: () => void;
}) {
if (uploadState.status !== "error") return null;
return (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {uploadState.message}
<button className="ml-2 underline" onClick={onDismiss} type="button">
Dismiss
</button>
</div>
);
}

View File

@@ -157,6 +157,9 @@ function MoreActionsMenu({
</Tooltip>
<DropdownMenuContent
align="end"
// Exit animation keeps Radix pointer-leave handlers mounted after
// editing takes focus. A closed menu must no longer accept focus.
inert={!open}
side="top"
sideOffset={6}
onCloseAutoFocus={(event) => {

View File

@@ -52,6 +52,7 @@ import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
import { focusMentionOptionsTrigger } from "./MentionAutocomplete";
import { MessageComposerAutocompletes } from "./MessageComposerAutocompletes";
import { ComposerDockToolbar } from "./ComposerDockToolbar";
import { ComposerUploadError } from "./ComposerUploadError";
import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill";
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
import { useComposerVoiceNote } from "./useComposerVoiceNote";
@@ -351,6 +352,8 @@ function MessageComposerImpl({
audiencePubkeys: persistentAudience.pubkeys,
channelId,
enabled: keepMentionedAgentsPinned,
getComposerRevision,
runComposerUpdate,
});
const mentionSendFlow = useMentionSendFlow({
getComposerRevision,
@@ -457,7 +460,7 @@ function MessageComposerImpl({
lockedAgents,
lockedAgentPubkeys,
removeAddressedAgent,
restoreAddressedAgentMentions,
restoreAddressedAgentMentions: restoreAgentMentions,
selectMentionSuggestion,
syncAddressedAgentsFromText,
toggleAlwaysAddressAgent,
@@ -483,11 +486,11 @@ function MessageComposerImpl({
richText,
});
addressedMentionRestore.restoreAddressedAgentMentionsRef.current =
restoreAddressedAgentMentions;
restoreAgentMentions;
React.useLayoutEffect(() => {
if (!audienceScope || editTarget != null) return;
restoreAddressedAgentMentions();
}, [audienceScope, editTarget, restoreAddressedAgentMentions]);
runComposerUpdate(() => restoreAgentMentions());
}, [audienceScope, editTarget, restoreAgentMentions, runComposerUpdate]);
syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText;
const applyChannelInsert = React.useCallback(
(suggestion: ChannelSuggestion) => {
@@ -897,18 +900,10 @@ function MessageComposerImpl({
onOptionsRevealComplete={completeMentionOptionsReveal}
onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent}
/>
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
<ComposerUploadError
uploadState={media.uploadState}
onDismiss={() => media.setUploadState({ status: "idle" })}
/>
{composerLinkPreviews}
<output
aria-live="polite"

View File

@@ -0,0 +1,180 @@
import assert from "node:assert/strict";
import { after, beforeEach, test } from "node:test";
import { JSDOM } from "jsdom";
import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore.ts";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts";
import {
claimDraftSend,
clearAllDrafts,
deleteDraftEntry,
getDraftAuthority,
initDraftStore,
loadDraftEntry,
persistDraftEntry,
recordDraftAuthoredContent,
} from "../lib/useDrafts.ts";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
});
const { act, cleanup, renderHook } = await import("@testing-library/react");
after(() => {
cleanup();
dom.window.close();
});
let frames;
let nextFrame = 0;
beforeEach(() => {
cleanup();
clearAllDrafts();
initDraftStore("author", "wss://restore.example");
frames = new Map();
// Keep cancelled callbacks invocable: authority must reject an already dequeued frame too.
globalThis.requestAnimationFrame = (cb) => {
frames.set(++nextFrame, cb);
return nextFrame;
};
globalThis.cancelAnimationFrame = () => {};
});
const A = "a".repeat(64);
const B = "b".repeat(64);
function mount() {
let text = "";
const writes = [];
let lifecycle;
const hook = renderHook(
({ key, channelId, enabled }) => {
lifecycle = useDraftPersistLifecycle({
effectiveDraftKey: key,
channelId,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (value) => {
text = value;
},
clearContent: () => {
text = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: { current: new Set() },
syncComposerContentFromEditor: () => text,
});
const restore = useAddressedAgentMentionRestore({
audiencePubkeys: [A, B],
channelId,
enabled,
getComposerRevision: lifecycle.getComposerRevision,
runComposerUpdate: lifecycle.runComposerUpdate,
});
restore.restoreAddressedAgentMentionsRef.current = (keys, allowed) => {
writes.push([keys, allowed]);
text = "@Agent Ada ";
lifecycle.trackAuthoredContent(text);
return text;
};
return restore;
},
{ initialProps: { key: "A", channelId: "channel", enabled: true } },
);
return {
...hook,
writes,
author: (value) =>
act(() => {
text = value;
lifecycle.trackAuthoredContent(value);
}),
schedule: (keys = [A]) => {
act(() => hook.result.current.onAddressedAgentsSendSucceeded(keys, keys));
return nextFrame;
},
release: (id) => act(() => frames.get(id)(0)),
visit: (key, enabled = true) =>
hook.rerender({ key, channelId: "channel", enabled }),
};
}
for (const [name, supersede] of [
["authored content", (h) => h.author("follow up")],
["authored empty", (h) => h.author("")],
["other visit authors empty", () => recordDraftAuthoredContent("A", "")],
["explicit deletion of absent value", () => deleteDraftEntry("A")],
["new send", () => claimDraftSend("A")],
[
"scope reset round trip",
() => {
initDraftStore("other");
initDraftStore("author", "wss://restore.example");
},
],
[
"same-channel draft visit round trip",
(h) => {
h.visit("B");
h.visit("A");
},
],
[
"disable then enable",
(h) => {
h.visit("A", false);
h.visit("A");
},
],
["unmount", (h) => h.unmount()],
]) {
test(`delayed automatic restore loses authority after ${name}`, () => {
const h = mount();
const frame = h.schedule();
supersede(h);
h.release(frame);
assert.deepEqual(h.writes, []);
});
}
test("no author restores exact captured keys without manufacturing authored intent", () => {
const h = mount();
const authority = getDraftAuthority("A");
const revision = authority.revision;
const frame = h.schedule([B]);
h.release(frame);
assert.deepEqual(h.writes, [[[B], [B]]]);
assert.equal(authority.revision, revision);
assert.equal(authority.authoredRevision, 0);
});
test("restore first then author, and unrelated key authoring, preserve authority", () => {
const h = mount();
const frame = h.schedule();
recordDraftAuthoredContent("B", "other");
h.release(frame);
h.author("");
assert.equal(h.writes.length, 1);
assert.equal(getDraftAuthority("A").emptyContentIsAuthoritative, true);
});
test("a replaced frame cannot restore old recipients or consume the current frame", () => {
const h = mount();
const old = h.schedule([A]);
const current = h.schedule([B]);
h.release(old);
assert.deepEqual(h.writes, []);
h.release(current);
assert.deepEqual(h.writes, [[[B], [B]]]);
});
test("synchronous automatic clear restoration is programmatic too", () => {
const h = mount();
const revision = getDraftAuthority("A").revision;
act(() => h.result.current.onAddressedAgentsComposerCleared([A]));
assert.equal(getDraftAuthority("A").revision, revision);
assert.deepEqual(h.writes, [[[A], undefined]]);
});

View File

@@ -9,30 +9,48 @@ export function useAddressedAgentMentionRestore({
audiencePubkeys,
channelId,
enabled,
getComposerRevision,
runComposerUpdate,
}: {
audiencePubkeys: readonly string[];
channelId: string | null;
enabled: boolean;
getComposerRevision: () => number;
runComposerUpdate: (update: () => void) => void;
}) {
const restoreAddressedAgentMentionsRef =
React.useRef<RestoreAddressedAgentMentions>(() => "");
const restoreFrameRef = React.useRef<number | null>(null);
const channelIdRef = React.useRef(channelId);
channelIdRef.current = channelId;
React.useEffect(
// biome-ignore lint/correctness/useExhaustiveDependencies: revoke pending writes on owner/setting transitions
React.useLayoutEffect(
() => () => {
if (restoreFrameRef.current !== null) {
cancelAnimationFrame(restoreFrameRef.current);
restoreFrameRef.current = null;
}
},
[],
// Accessor identity owns a draft visit, not just a channel (including A→B→A).
[channelId, enabled, getComposerRevision],
);
const onAddressedAgentsComposerCleared = React.useCallback(
(pubkeys: readonly string[]) =>
restoreAddressedAgentMentionsRef.current(pubkeys),
[],
(
pubkeys?: readonly string[],
allowedUnpinnedPubkeys?: readonly string[],
) => {
let content = "";
// All automatic restorations share the draft owner's programmatic boundary.
// They must not manufacture authored intent, especially authored emptiness.
runComposerUpdate(() => {
content = restoreAddressedAgentMentionsRef.current(
pubkeys,
allowedUnpinnedPubkeys,
);
});
return content;
},
[runComposerUpdate],
);
const onAddressedAgentsSendSucceeded = React.useCallback(
(pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => {
@@ -42,20 +60,26 @@ export function useAddressedAgentMentionRestore({
);
if (!enabled || confirmedPinnedPubkeys.length === 0) return;
const sentChannelId = channelId;
const revision = getComposerRevision();
if (restoreFrameRef.current !== null) {
cancelAnimationFrame(restoreFrameRef.current);
}
restoreFrameRef.current = requestAnimationFrame(() => {
const frame = requestAnimationFrame(() => {
if (restoreFrameRef.current !== frame) return;
restoreFrameRef.current = null;
if (channelIdRef.current !== sentChannelId) return;
restoreAddressedAgentMentionsRef.current(
pubkeys,
confirmedPinnedPubkeys,
);
// Recheck shared authority at execution, not only at send settlement.
// Authoring (even empty), deletion, reset and newer sends revoke it.
if (getComposerRevision() !== revision) return;
onAddressedAgentsComposerCleared(pubkeys, confirmedPinnedPubkeys);
});
restoreFrameRef.current = frame;
},
[audiencePubkeys, channelId, enabled],
[
audiencePubkeys,
enabled,
getComposerRevision,
onAddressedAgentsComposerCleared,
],
);
return {

View File

@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { after, beforeEach, test } from "node:test";
import { JSDOM } from "jsdom";
import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance.ts";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts";
import {
clearAllDrafts,
initDraftStore,
loadDraftEntry,
persistDraftEntry,
} from "../lib/useDrafts.ts";
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
localStorage: dom.window.localStorage,
IS_REACT_ACT_ENVIRONMENT: true,
});
const { act, cleanup, renderHook } = await import("@testing-library/react");
beforeEach(() => {
cleanup();
clearAllDrafts();
initDraftStore("author", "wss://provenance.example");
});
after(() => {
cleanup();
dom.window.close();
});
const A = "a".repeat(64);
const B = "b".repeat(64);
const fragment = (pubkey, label) => ({ pubkey, prefix: `@${label} ` });
function mount() {
let text = "";
const hook = renderHook(
({ key }) => {
const provenance = useImplicitAgentMentionProvenance(key);
const lifecycle = useDraftPersistLifecycle({
effectiveDraftKey: key,
channelId: key,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (value) => {
text = value;
},
clearContent: () => {
text = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: { current: new Set() },
syncComposerContentFromEditor: () => text,
getImplicitAgentMentionPrefix: provenance.getPrefix,
});
return { provenance, lifecycle };
},
{ initialProps: { key: "channel" } },
);
return {
...hook,
text: () => text,
visit: (key) => hook.rerender({ key }),
// Same boundary as picker insertion: record exact fragments before updating text.
generate: (fragments) =>
act(() => {
hook.result.current.lifecycle.runComposerUpdate(() => {
hook.result.current.provenance.add(fragments);
text = fragments.map(({ prefix }) => prefix).join("");
hook.result.current.lifecycle.trackAuthoredContent(text);
});
}),
author: (value) =>
act(() => {
text = value;
hook.result.current.lifecycle.trackAuthoredContent(value);
}),
};
}
for (const [oldLabel, newLabel] of [
[`Scout (${B})`, "Scout"],
["Scout", "Scout Jones"],
["Scout Jones", `Scout Jones (${B})`],
]) {
for (const body of ["", "authored body", `@${newLabel} authored duplicate`]) {
test(`${oldLabel} → ${newLabel}: persists only ${JSON.stringify(body)}`, () => {
const h = mount();
h.generate([fragment(B, oldLabel)]);
const revision = h.result.current.lifecycle.getComposerRevision();
h.generate([fragment(B, newLabel)]);
assert.equal(h.result.current.lifecycle.getComposerRevision(), revision);
if (body) h.author(`@${newLabel} ${body}`);
h.visit("other");
assert.equal(loadDraftEntry("channel")?.content ?? "", body);
h.visit("channel");
assert.equal(h.text(), body);
assert.equal(h.result.current.provenance.getPrefix(), `@${newLabel} `);
});
}
}
test("new insertion order and fragments replace known keys, preserving other keys", () => {
const h = mount();
const p = () => h.result.current.provenance;
act(() => p().add([fragment(A, "Scout"), fragment(B, `Scout (${B})`)]));
act(() => p().add([fragment(B, "Scout Jones")]));
assert.equal(p().getPrefix(), "@Scout Jones @Scout ");
act(() => p().add([fragment(A, "Scout"), fragment(B, "Scout Jones")]));
assert.equal(p().getPrefix(), "@Scout @Scout Jones ");
act(() => p().remove(A));
assert.equal(p().getPrefix(), "@Scout Jones ");
act(() => p().remove(B));
assert.equal(p().getPrefix(), "");
h.author("@Scout Jones authored after removal");
h.visit("other");
assert.equal(
loadDraftEntry("channel").content,
"@Scout Jones authored after removal",
);
});
test("changed generated label cannot override authored empty authority", () => {
const h = mount();
h.generate([fragment(B, `Scout (${B})`)]);
h.author("");
const revision = h.result.current.lifecycle.getComposerRevision();
h.generate([fragment(B, "Scout")]);
assert.equal(h.result.current.lifecycle.getComposerRevision(), revision);
h.visit("other");
assert.equal(loadDraftEntry("channel"), undefined);
h.visit("channel");
assert.equal(h.text(), "");
});
test("changed labels are draft scoped and absent keys do not capture provenance", () => {
const h = mount();
h.generate([fragment(B, `Scout (${B})`)]);
h.visit("other");
h.generate([fragment(B, "Scout")]);
h.visit("channel");
assert.equal(h.result.current.provenance.getPrefix(), `@Scout (${B}) `);
h.visit(null);
h.generate([fragment(B, "No Draft")]);
assert.equal(h.result.current.provenance.getPrefix(), "");
});

View File

@@ -24,14 +24,16 @@ export function useImplicitAgentMentionProvenance(
(insertedFragments: readonly GeneratedMention[]) => {
if (!effectiveDraftKey) return;
const fragments = byDraftRef.current.get(effectiveDraftKey) ?? [];
const knownPubkeys = new Set(
fragments.map((fragment) => fragment.pubkey),
const insertedPubkeys = new Set(
insertedFragments.map((fragment) => fragment.pubkey),
);
// Identity survives label resets, but generated text and prepend order do
// not. The latest insertion owns that key's exact fragment for stripping.
byDraftRef.current.set(effectiveDraftKey, [
...insertedFragments.filter(
(fragment) => !knownPubkeys.has(fragment.pubkey),
...insertedFragments,
...fragments.filter(
(fragment) => !insertedPubkeys.has(fragment.pubkey),
),
...fragments,
]);
trimMapToSize(byDraftRef.current, 200);
},

View File

@@ -0,0 +1,28 @@
import * as React from "react";
import { useAvailableAcpRuntimes } from "@/features/agents/hooks";
import type { AcpRuntime } from "@/shared/api/types";
/** Resolve available runtimes for persona mentions without refetching warm data. */
export function useMentionAvailableRuntimes() {
const availableRuntimesQuery = useAvailableAcpRuntimes();
const getAvailableRuntimes = React.useCallback(async (): Promise<
AcpRuntime[]
> => {
const cached = availableRuntimesQuery.data ?? [];
if (cached.length > 0 || !availableRuntimesQuery.isLoading) {
return cached;
}
const refetched = await availableRuntimesQuery.refetch();
return (refetched.data ?? []).filter(
(runtime): runtime is AcpRuntime =>
runtime.availability === "available" &&
runtime.command !== null &&
runtime.binaryPath !== null,
);
}, [
availableRuntimesQuery.data,
availableRuntimesQuery.isLoading,
availableRuntimesQuery.refetch,
]);
return getAvailableRuntimes;
}

View File

@@ -216,3 +216,35 @@ test("automatic addressed prefix is programmatic optimistic empty, not new autho
assert.equal(s.store.get("thread:a").content, TEXT);
assert.deepEqual(s.store.get("thread:a").mentionRefs, s.refs);
});
for (const extractor of [
"getDraftMentionRefs",
"extractMentionPubkeys",
"extractMentionPersonas",
]) {
test(`synchronous ${extractor} failure is visible and releases the send latch`, async () => {
const s = await setup({ lifecycle: true });
s.dismiss();
s.options.mentions[extractor] = () => {
throw new Error("Choose an exact recipient");
};
s.rerender();
const input = {
capturedChannelId: "general",
pendingImeta: [],
trimmed: TEXT,
recoveryDraftKey: "thread:a",
sentDraftKey: "thread:a",
};
for (let attempt = 0; attempt < 2; attempt++) {
await s.act(async () =>
s.result.current.sendMessageWithMentionFlow(input),
);
assert.equal(s.result.current.isPreparingMentionSend, false);
assert.equal(s.options.contentRef.current, TEXT);
assert.equal(s.events("error").length, attempt + 1);
assert.equal(s.events("error").at(-1)[1], "Choose an exact recipient");
assert.equal(s.events("SEND").length, 0);
}
});
}

View File

@@ -159,6 +159,10 @@ export async function setup({ lifecycle = false } = {}) {
AgentMentionAuthorizationError: class extends Error {},
},
};
stubs["./useMentionAvailableRuntimes"] = load(
"useMentionAvailableRuntimes",
stubs,
);
stubs["./useNonMemberInvite"] = load("useNonMemberInvite", stubs);
stubs["./useActivePreparedLinkPreviews"] = load(
"useActivePreparedLinkPreviews",

View File

@@ -4,7 +4,6 @@ import { toast } from "sonner";
import {
type CreateChannelManagedAgentInput,
useAttachManagedAgentToChannelMutation,
useAvailableAcpRuntimes,
useCreateChannelManagedAgentMutation,
useManagedAgentsQuery,
usePersonasQuery,
@@ -15,6 +14,7 @@ import { applyReusableAgentAccessPolicy } from "@/features/agents/channelAgents"
import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime";
import { useAddChannelMembersMutation } from "@/features/channels/hooks";
import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers";
import { useMentionAvailableRuntimes } from "./useMentionAvailableRuntimes";
import { useNonMemberInvite } from "./useNonMemberInvite";
import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError";
import {
@@ -27,7 +27,7 @@ import {
} from "@/features/messages/lib/imetaMediaMarkdown";
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
import { invokeTauri } from "@/shared/api/tauri";
import type { AcpRuntime, ManagedAgent } from "@/shared/api/types";
import type { ManagedAgent } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
import {
@@ -111,7 +111,6 @@ export function useMentionSendFlow({
useCreateChannelManagedAgentMutation(channelId);
const provisionPersonaAgentMutation =
useProvisionChannelManagedAgentMutation(channelId);
const availableRuntimesQuery = useAvailableAcpRuntimes();
const managedAgentsQuery = useManagedAgentsQuery();
const personasQuery = usePersonasQuery();
const startAgentMutation = useStartManagedAgentMutation();
@@ -127,25 +126,7 @@ export function useMentionSendFlow({
const getPersonas = React.useCallback(async () => {
return personasQuery.data ?? (await personasQuery.refetch()).data ?? [];
}, [personasQuery.data, personasQuery.refetch]);
const getAvailableRuntimes = React.useCallback(async (): Promise<
AcpRuntime[]
> => {
const cached = availableRuntimesQuery.data ?? [];
if (cached.length > 0 || !availableRuntimesQuery.isLoading) {
return cached;
}
const refetched = await availableRuntimesQuery.refetch();
return (refetched.data ?? []).filter(
(runtime): runtime is AcpRuntime =>
runtime.availability === "available" &&
runtime.command !== null &&
runtime.binaryPath !== null,
);
}, [
availableRuntimesQuery.data,
availableRuntimesQuery.isLoading,
availableRuntimesQuery.refetch,
]);
const getAvailableRuntimes = useMentionAvailableRuntimes();
const ensureManagedAgentMentionsReady = React.useCallback(
async (
mentionPubkeys: string[],
@@ -669,7 +650,11 @@ export function useMentionSendFlow({
await finishSend(uploaded, signal);
} catch (error) {
restoreComposerAfterFailure();
toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error));
toast.error(
error instanceof AgentMentionAuthorizationError
? error.message
: formatMessageSendError(error),
);
} finally {
settleUpload();
}
@@ -697,7 +682,11 @@ export function useMentionSendFlow({
await finishSend([]);
} catch (error) {
restoreComposerAfterFailure();
toast.error(error instanceof AgentMentionAuthorizationError ? error.message : formatMessageSendError(error));
toast.error(
error instanceof AgentMentionAuthorizationError
? error.message
: formatMessageSendError(error),
);
}
}
} catch (error) {
@@ -762,20 +751,22 @@ export function useMentionSendFlow({
}
isMentionSendPendingRef.current = true;
setIsMentionSendPending(true);
// Capture exact selections before any async preparation can navigate the
// reused editor to another draft (possibly with identical display text).
claimDraftSend(effectiveDraftKey);
const composerRevision = getComposerRevision();
const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice();
const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed);
const selectedPersonas = mentions.extractMentionPersonas(trimmed);
const isSendCancelled = () =>
preparedLinkPreviews?.signal.aborted === true;
let sendPromoted = false;
if (preparedLinkPreviews) {
activePreparedLinkPreviews.add(preparedLinkPreviews);
}
try {
// Capture exact selections before any async preparation can navigate the
// reused editor to another draft (possibly with identical display text).
// Ambiguous-name extraction can throw; it owns the same visible failure
// and pending-state cleanup as the asynchronous preparation below.
claimDraftSend(effectiveDraftKey);
const composerRevision = getComposerRevision();
const savedMentionRefs = mentions.getDraftMentionRefs(trimmed).slice();
const selectedMentionPubkeys = mentions.extractMentionPubkeys(trimmed);
const selectedPersonas = mentions.extractMentionPersonas(trimmed);
const isSendCancelled = () =>
preparedLinkPreviews?.signal.aborted === true;
if (preparedLinkPreviews) {
activePreparedLinkPreviews.add(preparedLinkPreviews);
}
if (isSendCancelled()) return;
const dmThreadAgentMentionErrorMessage = dmThreadAgentMentionError({
trimmed,

View File

@@ -97,6 +97,9 @@
outline: none;
min-height: 1lh;
padding-block: 0.0625rem;
/* Keep literal separators even when TipTap's injected style is absent.
Collapsed trailing spaces let native input replace a restored boundary. */
white-space: break-spaces;
}
.rich-text-composer .tiptap p {

View File

@@ -14,7 +14,6 @@ import {
type ParsedMessageLink,
} from "@/features/messages/lib/messageLink";
import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { parseEntityLink } from "@/shared/lib/entityLink";

View File

@@ -1,3 +1,4 @@
import type { Editor } from "@tiptap/core";
import { expect, test, type Page } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
@@ -330,3 +331,145 @@ test("editing to a longer typed member drops the original shorter reference", as
.toEqual({ references: [], notifying: [SECOND] });
await expect(row).toContainText("Scout Jones hello");
});
for (const name of ["Morgarita", "claude code", "Scout"]) {
test(`restored ${name} separator survives missing injected editor styles`, async ({
page,
}) => {
const keys = ["a".repeat(64), "b".repeat(64)];
const selected = name === "Scout" ? keys.slice(1) : keys.slice(0, 1);
await installMockBridge(page, {
managedAgents: keys.map((pubkey) => ({
pubkey,
name,
status: "running",
channelNames: ["general"],
})),
});
await page.goto("/");
await page.getByTestId("channel-general").click();
const composer = page.getByTestId("channel-composer-overlay");
const input = composer.getByTestId("message-input");
await composer.locator("[data-mention-picker-trigger]").click();
for (const key of name === "Scout" ? keys : selected) {
await composer.getByTestId(`mention-always-address-${key}`).click();
}
if (name === "Scout") {
await composer.getByTestId(`mention-always-address-${keys[0]}`).click();
}
await composer.locator("[data-mention-picker-trigger]").click();
const initialPrefix = `@${name}${name === "Scout" ? ` (${keys[1]})` : ""} `;
await expect(input).toHaveText(initialPrefix);
await input.pressSequentially("hello");
await input.press("Enter");
await expect
.poll(() => recipients(page, `${initialPrefix}hello`))
.toEqual([selected]);
// Send clears label registrations. The new draft can use the bare label,
// but it must still bind only the surviving identity (B for Scout).
const prefix = `@${name} `;
await expect(input).toHaveText(prefix);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
// Generated-only text must not become an authored draft on navigation.
expect(
await page.evaluate(() =>
Object.keys(localStorage)
.filter((key) => key.startsWith("buzz-drafts.v2:"))
.flatMap((key) =>
Object.values(JSON.parse(localStorage.getItem(key) ?? "{}")),
),
),
).toEqual([]);
await page.getByTestId("channel-general").click();
await expect(input).toHaveText(prefix);
// Check hydration before removing styles or typing: whitespace-normalized
// toHaveText alone cannot distinguish a lost separator.
await expect
.poll(() =>
input.evaluate((element) => {
const editor = (element as HTMLElement & { editor: Editor }).editor;
return [
editor.state.doc.textContent,
editor.state.selection.from,
editor.state.selection.to,
];
}),
)
.toEqual([prefix, prefix.length + 1, prefix.length + 1]);
await expect(input).toBeFocused();
await expect(input.locator(".agent-mention-highlight")).toHaveCount(
selected.length,
);
// The reproduced failure had no injected TipTap whitespace stylesheet.
// Remove only that transient dependency, not app CSS or editor selection.
await input.evaluate(() => {
document.querySelector("style[data-tiptap-style]")?.remove();
});
await expect
.poll(() =>
input.evaluate((element) => {
const editor = (element as HTMLElement & { editor: Editor }).editor;
return {
text: editor.state.doc.textContent,
from: editor.state.selection.from,
to: editor.state.selection.to,
};
}),
)
.toEqual({
text: prefix,
from: prefix.length + 1,
to: prefix.length + 1,
});
await input.pressSequentially("follow-up");
await expect(input).toHaveText(`${prefix}follow-up`);
await expect(input).toHaveCSS("white-space", "break-spaces");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(
selected.length,
);
await input.press("Enter");
await expect
.poll(() => recipients(page, `${prefix}follow-up`))
.toEqual([selected]);
// Inspect all kind-9 publications, not just a matching body: no extra A send.
await expect
.poll(() =>
page.evaluate(() => {
type Event = { kind: number; content: string; tags: string[][] };
const summarize = (events: Event[]) =>
events
.filter((event) => event.kind === 9)
.map((event) => ({
content: event.content,
p: event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1]),
}));
const wire = (window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter((entry) => entry.command === "plugin:websocket|send")
.flatMap((entry) => {
const data = (entry.payload as { message?: { data?: string } })
?.message?.data;
if (!data) return [];
const frame = JSON.parse(data);
return frame[0] === "EVENT" ? [frame[1]] : [];
});
return {
signed: summarize(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []),
wire: summarize(wire),
};
}),
)
.toEqual({
signed: [
{ content: `${initialPrefix}hello`, p: selected },
{ content: `${prefix}follow-up`, p: selected },
],
wire: [
{ content: `${initialPrefix}hello`, p: selected },
{ content: `${prefix}follow-up`, p: selected },
],
});
});
}

View File

@@ -549,7 +549,7 @@ test("relay-only shared agents emit an outbound mention tag when selected", asyn
await expect
.poll(() => readOutgoingMentionPubkeys(page, content))
.toContain(TEST_IDENTITIES.alice.pubkey);
.toEqual([TEST_IDENTITIES.alice.pubkey]);
});
test("typing an exact agent name and Space commits its chip and mention tag", async ({
@@ -580,7 +580,7 @@ test("typing an exact agent name and Space commits its chip and mention tag", as
await page.getByTestId("send-message").click();
await expect
.poll(() => readOutgoingMentionPubkeys(page, content))
.toContain(TEST_IDENTITIES.alice.pubkey);
.toEqual([TEST_IDENTITIES.alice.pubkey]);
});
test("Shift+Space leaves an exact agent name plain and emits no mention tag", async ({

View File

@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Hold exit presence, not composer readiness: a closed menu can still receive a
// queued pointer-leave while its CSS exit animation is mounted.
const holdExit = `
[role="menu"][data-state="closed"] { animation-play-state: paused !important; }
`;
test("closing message menu cannot reclaim editor focus on pointer leave", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.addStyleTag({ content: holdExit });
const input = page.getByTestId("message-input");
await input.fill("focus ownership");
await page.getByTestId("send-message").click();
const row = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await expect(row).toContainText("focus ownership");
await row.hover();
await row.getByRole("button", { name: "More actions" }).click();
const edit = page.getByRole("menuitem", { name: "Edit message" });
const editElement = await edit.elementHandle();
await edit.click();
await expect(input).toHaveText("focus ownership");
// A deliberate user click establishes focus independently of RAF timing.
await input.click();
await input.press("ControlOrMeta+End");
await expect(input).toBeFocused();
await page.keyboard.type(" edit");
await expect(input).toHaveText("focus ownership edit");
const closing = await editElement?.evaluate((element) => {
const menu = element.closest('[role="menu"]');
const state = menu?.getAttribute("data-state");
const connected = element.isConnected;
element.dispatchEvent(
new PointerEvent("pointerout", {
bubbles: true,
pointerType: "mouse",
relatedTarget: document.body,
}),
);
return { state, connected };
});
expect(closing).toEqual({ state: "closed", connected: true });
await expect(input).toBeFocused();
await page.keyboard.type("ed");
await expect(input).toHaveText("focus ownership edited");
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(() => {
const call = window.__BUZZ_E2E_COMMAND_LOG__
?.filter((entry) => entry.command === "edit_message")
.at(-1);
return (call?.payload as { input?: { content: string } })?.input
?.content;
}),
)
.toBe("focus ownership edited");
});
test("message menu Escape still restores its keyboard trigger", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
const row = page
.getByTestId("message-timeline")
.getByTestId("message-row")
.last();
await row.hover();
const trigger = row.getByRole("button", { name: "More actions" });
await trigger.focus();
await trigger.press("Enter");
await expect(page.getByRole("menu")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(trigger).toBeFocused();
});

View File

@@ -137,6 +137,76 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) {
}, content);
}
// Exercise the persistence/identity boundary after the duplicate-chip check.
async function assertAuthoredDuplicateIdentity(page: Page) {
await expect
.poll(() =>
page.evaluate((channelId) => {
for (const key of Object.keys(localStorage)) {
if (!key.startsWith("buzz-drafts.v2:")) continue;
const draft = JSON.parse(localStorage.getItem(key) ?? "{}")[
channelId
];
if (draft) return draft.mentionRefs;
}
return null;
}, CHANNEL_ID),
)
.toEqual([{ displayName: "Morgarita", pubkey: AGENT_A, isAgent: true }]);
expect(
await page.evaluate(() =>
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter(
(event) => event.kind === 9,
),
),
).toEqual([]);
await openGeneral(page);
const composer = channelComposer(page);
const input = composer.getByTestId("message-input");
await expect(input).toHaveText("@Morgarita authored duplicate");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
).toHaveCount(0);
await composer.getByTestId("send-message").click();
const expected = [{ content: "@Morgarita authored duplicate", p: [AGENT_A] }];
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
.filter((event) => event.kind === 9)
.map((event) => ({
content: event.content,
p: event.tags.filter((tag) => tag[0] === "p").map((tag) => tag[1]),
})),
),
)
.toEqual(expected);
await expect
.poll(() =>
page.evaluate(() => {
const events: { content: string; p: string[] }[] = [];
for (const entry of window.__BUZZ_E2E_COMMAND_LOG__ ?? []) {
if (entry.command !== "plugin:websocket|send") continue;
const data = (entry.payload as { message?: { data?: string } })
?.message?.data;
if (!data) continue;
const frame = JSON.parse(data);
if (frame[0] !== "EVENT" || frame[1]?.kind !== 9) continue;
events.push({
content: frame[1].content,
p: frame[1].tags
.filter((tag: string[]) => tag[0] === "p")
.map((tag: string[]) => tag[1]),
});
}
return events;
}),
)
.toEqual(expected);
}
async function emitMockMessage(
page: Page,
content: string,
@@ -1318,6 +1388,7 @@ test("an authored duplicate leading mention survives draft restoration", async (
}, CHANNEL_ID),
)
.toBe("@Morgarita authored duplicate");
await assertAuthoredDuplicateIdentity(page);
});
test("typed deletion preserves an identical authored mention in drafts", async ({
@@ -1469,6 +1540,12 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra
}, CHANNEL_ID),
)
.toBe("@Morgarita authored duplicate");
await openGeneral(page);
await expect(input).toHaveText("@Morgarita authored duplicate");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
await expect(
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
).toHaveCount(0);
});
test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({

View File

@@ -53,6 +53,15 @@ test("message with agent mention lands in compose-time channel despite mid-send
}) => {
const MESSAGE_TEXT = `send-binding-repro-${Date.now()}`;
// This case exercises a persistent address; ordinary Enter completion is
// one-time by default. Opt in before the bridge mounts the composer.
await page.addInitScript(() => {
window.localStorage.setItem(
"buzz.messages.keepMentionedAgentsPinned",
"true",
);
});
// Install bridge with:
// - a managed agent that is NOT in general (forces add_channel_members path)
// - a 500ms delay on add_channel_members to open the race window
@@ -81,7 +90,7 @@ test("message with agent mention lands in compose-time channel despite mid-send
await expect(botRow).toBeVisible();
await expect(botRow.getByText("not in channel")).toBeVisible();
// Select BotA from the autocomplete
await input.press("Enter");
await botRow.click();
await page.keyboard.type(` ${MESSAGE_TEXT}`);
// Verify the inline mention and persistent address are present before submitting.
@@ -127,6 +136,31 @@ test("message with agent mention lands in compose-time channel despite mid-send
MESSAGE_TEXT,
);
// The signed event must retain both the compose-time channel and exact key.
await expect
.poll(() =>
page.evaluate(
(text) =>
(window.__BUZZ_E2E_SIGNED_EVENTS__ ?? [])
.filter((event) => event.content.includes(text))
.map((event) => ({
channels: event.tags
.filter((tag) => tag[0] === "h")
.map((tag) => tag[1]),
recipients: event.tags
.filter((tag) => tag[0] === "p")
.map((tag) => tag[1]),
})),
MESSAGE_TEXT,
),
)
.toEqual([
{
channels: ["9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
recipients: [OUT_OF_CHANNEL_BOT_PUBKEY],
},
]);
// --- Assert message did NOT land in agents (switched-to channel) ---
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");