mirror of
https://github.com/block/buzz.git
synced 2026-11-15 05:41:14 +00:00
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:
@@ -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",
|
||||
|
||||
20
desktop/src/features/messages/ui/ComposerUploadError.tsx
Normal file
20
desktop/src/features/messages/ui/ComposerUploadError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(), "");
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
86
desktop/tests/e2e/message-edit-focus.spec.ts
Normal file
86
desktop/tests/e2e/message-edit-focus.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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 ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user