mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): persist agent addressing across composer messages (#6714)
**Category:** improvement **User Impact:** Agent addressing now stays visible, editable, and consistent across messages, so users can keep talking to the same agents without rebuilding the audience for every send. **Problem:** Agent addressing was transient and easy to lose track of. Inline mention text, persistent audience state, restored drafts, and sent-message rendering could drift apart, making it unclear who would receive the next message. **Solution:** The composer now treats addressed agents as a persistent audience represented by ordinary editable mention chips and a synchronized compact avatar tray. Eligible agent mentions can become persistent immediately, addressing survives sends without flashing empty, and removing or explicitly unpinning an agent updates that state without disturbing unrelated draft text. `Cmd+Shift+M` / `Ctrl+Shift+M` provides a fast way to toggle the current recipient or select the best eligible agent. - Promotes eligible inline agent mentions into persistent addressing when the preference is enabled; human mentions remain unchanged. - Keeps inline chips, restored drafts, persistent recipient state, and the avatar tray synchronized. - Clears an agent's persistent address when its final chip is removed, while explicit unpinning preserves unrelated draft text. - Restores persistent mentions optimistically during send and restores the full draft on failure, avoiding an empty-composer flash. - Deduplicates inline mentions and tag-backed address prefixes in sent messages. - Toggles the current addressed agent with `Cmd+Shift+M` / `Ctrl+Shift+M`, or selects the best eligible agent when none is addressed. - Ranks shortcut selection by recent explicit stream mentions, then active/member/runnable/stable fallbacks; DM participant fan-out does not affect recency. - Adds focused avatar feedback for entry/exit, send bounce, and failure shake, with reduced-motion and narrow-layout support. - Applies the same behavior to main and thread composers while preserving draft restoration, channel switching, and send-race correctness. ### Screenshots #### Light theme The compact tray keeps persistent recipients visible alongside editable mention chips.  #### Dark theme Persistent addressing remains clear and legible in the dark composer treatment.  ### Reproduction steps 1. Open a channel with an eligible agent and enable persistent agent addressing. 2. Insert an agent mention and confirm it appears as a normal editable chip while the compact avatar tray reflects the same persistent recipient. 3. Send several messages and confirm the agent remains addressed, the mention restores without an empty-composer flash, and sent messages do not duplicate the addressed agent. 4. Remove the agent's final mention chip and confirm its persistent address clears. Add unrelated draft text, explicitly unpin the agent, and confirm that text remains intact. 5. Press `Cmd+Shift+M` on macOS or `Ctrl+Shift+M` on Windows/Linux. Confirm it toggles the current addressed agent or, when none is selected, chooses the most relevant eligible agent. 6. Verify recent explicit channel/thread mentions influence shortcut selection, while DM participant fan-out does not. 7. Force a failed send and confirm the addressed avatars shake and the full draft is restored; confirm successful sends use the subtler bounce feedback. 8. Repeat in the thread composer, after switching channels, in dark theme, with reduced motion enabled, and at a narrow composer width. <details> <summary>File changes</summary> **desktop/src/features/channels/ui/ChannelPane.tsx** Feeds recent explicit agent mentions into the composer for contextual shortcut selection. **desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts** and tests Show tag-backed addressed agents only when they are not already represented by inline mention text. **desktop/src/features/messages/lib/mentionCandidates.ts**, **mentionRanking.ts**, **mentionSuggestionMapping.ts**, **recentMentionPubkeys.ts**, and tests Model agent eligibility and activity, derive explicit mention recency, and rank the best shortcut recipient. **desktop/src/features/messages/lib/useActiveAgentPubkeys.ts**, **useDefaultAgentSuggestion.ts**, and **useMentions.ts** Expose active-agent and recent-recipient context to mention consumers. **desktop/src/features/messages/ui/ComposerAddressControls.tsx** and tests Render the compact persistent-recipient tray with enter, exit, send, failure, layout, and reduced-motion states. **desktop/src/features/messages/ui/MessageComposer.tsx** and **MessageComposer.types.ts** Connect editable mention text, persistent addressing, shortcut selection, draft restoration, and send feedback. **desktop/src/features/messages/ui/MessageRow.tsx** Avoids duplicating addressed agents already rendered as inline mentions. **desktop/src/features/messages/ui/MessageThreadPanel.tsx** Brings the same recency and persistent-addressing behavior to thread composers. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**, **useAutoPinMentionedAgents.ts**, **useAlwaysAddressShortcut.ts**, **useMentionSendFlow.ts**, and tests Synchronize mention chips with persistent audience state, promote eligible mentions, handle shortcut toggling/default selection, and preserve recipients through send success or failure. **desktop/src/features/messages/ui/useComposerPasteHandler.ts** and **useMentionSendFlow.types.ts** Keep the composer implementation focused by extracting paste and send-flow boundaries. **desktop/src/shared/lib/keyboard-shortcuts.ts** Documents `Cmd+Shift+M` / `Ctrl+Shift+M` as the agent-address shortcut. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Exercises persistence, selection, removal, sends, failures, restored drafts, themes, reduced motion, and narrow layouts. </details> --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -371,7 +371,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
}),
|
||||
[activeChannel, currentPubkey, profiles],
|
||||
);
|
||||
|
||||
const handleWelcomeAddAgent = React.useCallback(() => {
|
||||
onAddAgent?.({
|
||||
beforeSend: () =>
|
||||
@@ -387,13 +386,14 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
|
||||
});
|
||||
const channelIntro = isHuddleTranscript ? null : standardChannelIntro;
|
||||
const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({
|
||||
activeChannel,
|
||||
isHuddleTranscript,
|
||||
messages,
|
||||
profiles,
|
||||
threadSummaries,
|
||||
});
|
||||
const { mainTimelineEntries, recentMentions, visibleMessages } =
|
||||
useChannelPaneMessages({
|
||||
activeChannel,
|
||||
isHuddleTranscript,
|
||||
messages,
|
||||
profiles,
|
||||
threadSummaries,
|
||||
});
|
||||
useRenderScopedReactionHydration({
|
||||
activeChannel,
|
||||
mainTimelineEntries,
|
||||
@@ -780,7 +780,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
: undefined
|
||||
}
|
||||
onSend={handleSendMessage}
|
||||
profiles={profiles}
|
||||
{...{ profiles, recentMentionPubkeys: recentMentions }}
|
||||
showBackgroundUploadProgress={false}
|
||||
placeholder={
|
||||
timeoutState.active
|
||||
@@ -889,7 +889,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onScrollTargetSettled={resolveScrollTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
profiles={profiles}
|
||||
{...{ profiles, recentMentionPubkeys: recentMentions }}
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
scrollTargetHighlights={!layoutScrollTargetId}
|
||||
scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/features/channels/ui/ChannelPane.helpers";
|
||||
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import { getRecentMentionPubkeys } from "@/features/messages/lib/recentMentionPubkeys";
|
||||
import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome";
|
||||
|
||||
type ChannelPaneMessagesOptions = Pick<
|
||||
@@ -46,5 +47,14 @@ export function useChannelPaneMessages({
|
||||
[isHuddleTranscript, profiles, threadSummaries, visibleMessages],
|
||||
);
|
||||
|
||||
return { mainTimelineEntries, visibleMessages };
|
||||
const recentMentionPubkeys = React.useMemo(
|
||||
() => getRecentMentionPubkeys(messages, activeChannel?.channelType),
|
||||
[activeChannel?.channelType, messages],
|
||||
);
|
||||
|
||||
return {
|
||||
mainTimelineEntries,
|
||||
recentMentions: recentMentionPubkeys,
|
||||
visibleMessages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getVisibleAgentAddressPubkeys } from "./getVisibleAgentAddressPubkeys.ts";
|
||||
|
||||
const DARIA = "a".repeat(64);
|
||||
const RIZZ = "b".repeat(64);
|
||||
|
||||
test("hides an address prefix already represented by an inline mention", () => {
|
||||
assert.deepEqual(
|
||||
getVisibleAgentAddressPubkeys("@Daria please review this", [DARIA], {
|
||||
daria: DARIA,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a tag-backed prefix when its inline mention was deleted", () => {
|
||||
assert.deepEqual(
|
||||
getVisibleAgentAddressPubkeys("please review this", [DARIA], {
|
||||
daria: DARIA,
|
||||
}),
|
||||
[DARIA],
|
||||
);
|
||||
});
|
||||
|
||||
test("filters only addressed agents that are present inline", () => {
|
||||
assert.deepEqual(
|
||||
getVisibleAgentAddressPubkeys(
|
||||
"@Daria please pair with someone",
|
||||
[DARIA, RIZZ],
|
||||
{
|
||||
daria: DARIA,
|
||||
rizz: RIZZ,
|
||||
},
|
||||
),
|
||||
[RIZZ],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/**
|
||||
* Keep tag-backed address recipients visible without repeating ordinary inline
|
||||
* mentions already present in the message body.
|
||||
*/
|
||||
export function getVisibleAgentAddressPubkeys(
|
||||
body: string,
|
||||
addressedPubkeys: readonly string[],
|
||||
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
|
||||
): string[] {
|
||||
const inlineMentionPubkeys = new Set(
|
||||
orderMentionPubkeysByText(body, mentionPubkeysByName, () => true),
|
||||
);
|
||||
|
||||
return addressedPubkeys.filter(
|
||||
(pubkey) => !inlineMentionPubkeys.has(normalizePubkey(pubkey)),
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ export type MentionCandidate = {
|
||||
secondaryLabel?: string | null;
|
||||
ownerPubkey?: string | null;
|
||||
isAgent: boolean;
|
||||
isActiveAgent?: boolean;
|
||||
isManagedAgent?: boolean;
|
||||
isGlobalSearchResult?: boolean;
|
||||
};
|
||||
|
||||
@@ -202,6 +202,7 @@ export function settleAutocompleteMentionInsert(
|
||||
editor: { storage: object },
|
||||
tr: Transaction,
|
||||
text: string,
|
||||
settleCaret = true,
|
||||
): void {
|
||||
const storage = mentionHighlightStorage(editor);
|
||||
const mentionInsert = /(?:^|[\s(])([@#])([^\s]+) $/.exec(text);
|
||||
@@ -222,7 +223,7 @@ export function settleAutocompleteMentionInsert(
|
||||
}
|
||||
}
|
||||
}
|
||||
tr.setMeta(mentionHighlightKey, true);
|
||||
if (settleCaret) tr.setMeta(mentionHighlightKey, true);
|
||||
}
|
||||
|
||||
export function syncMentionHighlightFromProps(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { rankMentionCandidates } from "./mentionRanking.ts";
|
||||
import {
|
||||
pickDefaultAgentCandidate,
|
||||
rankMentionCandidates,
|
||||
} from "./mentionRanking.ts";
|
||||
|
||||
const CHANNEL_BRAIN_PUBKEY = "1".repeat(64);
|
||||
const OTHER_BRAIN_PUBKEY = "2".repeat(64);
|
||||
@@ -139,3 +142,108 @@ test("rankMentionCandidates: owned teams rank with runnable personas", () => {
|
||||
["team", "identity"],
|
||||
);
|
||||
});
|
||||
|
||||
test("pickDefaultAgentCandidate: active agents outrank stopped channel members", () => {
|
||||
const stoppedMember = candidate({
|
||||
displayName: "Ada",
|
||||
isActiveAgent: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
const runningNonMember = candidate({
|
||||
displayName: "Bea",
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
pickDefaultAgentCandidate([stoppedMember, runningNonMember]),
|
||||
runningNonMember,
|
||||
);
|
||||
});
|
||||
|
||||
test("pickDefaultAgentCandidate: stable labels break ties instead of roster order", () => {
|
||||
const vogue = candidate({
|
||||
displayName: "Vogue",
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
const morgarita = candidate({
|
||||
displayName: "Morgarita",
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.equal(pickDefaultAgentCandidate([vogue, morgarita]), morgarita);
|
||||
assert.equal(pickDefaultAgentCandidate([morgarita, vogue]), morgarita);
|
||||
});
|
||||
|
||||
test("pickDefaultAgentCandidate: runnable personas break otherwise equal ties", () => {
|
||||
const plain = candidate({
|
||||
displayName: "Zulu",
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
const runnable = candidate({
|
||||
displayName: "Zulu 2",
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
personaId: "active-persona",
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
pickDefaultAgentCandidate([plain, runnable], new Set(["active-persona"])),
|
||||
runnable,
|
||||
);
|
||||
});
|
||||
|
||||
test("pickDefaultAgentCandidate: recent eligible mentions outrank the fallback ranking", () => {
|
||||
const stoppedRecentMember = candidate({
|
||||
displayName: "Ada",
|
||||
isActiveAgent: false,
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
const runningNonMember = candidate({
|
||||
displayName: "Bea",
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
pickDefaultAgentCandidate(
|
||||
[runningNonMember, stoppedRecentMember],
|
||||
new Set(),
|
||||
[CHANNEL_BRAIN_PUBKEY],
|
||||
),
|
||||
stoppedRecentMember,
|
||||
);
|
||||
});
|
||||
|
||||
test("pickDefaultAgentCandidate: skips recent pubkeys that are not eligible candidates", () => {
|
||||
const runningAgent = candidate({
|
||||
isActiveAgent: true,
|
||||
isAgent: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
pickDefaultAgentCandidate([runningAgent], new Set(), ["f".repeat(64)]),
|
||||
runningAgent,
|
||||
);
|
||||
});
|
||||
|
||||
test("pickDefaultAgentCandidate: returns null without an addressable agent", () => {
|
||||
assert.equal(pickDefaultAgentCandidate([]), null);
|
||||
assert.equal(pickDefaultAgentCandidate([candidate()]), null);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
export type MentionCandidateForRanking = {
|
||||
displayName: string | null;
|
||||
isAgent: boolean;
|
||||
isActiveAgent?: boolean;
|
||||
isMember: boolean;
|
||||
kind: "identity" | "persona" | "team";
|
||||
personaId?: string | null;
|
||||
@@ -51,6 +52,58 @@ function scoreMentionCandidateLabel(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickDefaultAgentCandidate<T extends MentionCandidateForRanking>(
|
||||
candidates: readonly T[],
|
||||
activePersonaIds: ReadonlySet<string> = new Set(),
|
||||
recentMentionPubkeys: readonly string[] = [],
|
||||
): T | null {
|
||||
const recentMentionRankByPubkey = new Map(
|
||||
recentMentionPubkeys.map((pubkey, index) => [
|
||||
normalizePubkey(pubkey),
|
||||
index,
|
||||
]),
|
||||
);
|
||||
return (
|
||||
candidates
|
||||
.filter((candidate) => candidate.isAgent && Boolean(candidate.pubkey))
|
||||
.sort((left, right) => {
|
||||
const leftRecentRank = left.pubkey
|
||||
? recentMentionRankByPubkey.get(normalizePubkey(left.pubkey))
|
||||
: undefined;
|
||||
const rightRecentRank = right.pubkey
|
||||
? recentMentionRankByPubkey.get(normalizePubkey(right.pubkey))
|
||||
: undefined;
|
||||
const recentDiff =
|
||||
(leftRecentRank ?? recentMentionPubkeys.length) -
|
||||
(rightRecentRank ?? recentMentionPubkeys.length);
|
||||
if (recentDiff !== 0) return recentDiff;
|
||||
const activeDiff =
|
||||
Number(right.isActiveAgent === true) -
|
||||
Number(left.isActiveAgent === true);
|
||||
if (activeDiff !== 0) return activeDiff;
|
||||
const memberDiff = Number(right.isMember) - Number(left.isMember);
|
||||
if (memberDiff !== 0) return memberDiff;
|
||||
const runnableDiff =
|
||||
Number(
|
||||
Boolean(right.personaId) &&
|
||||
activePersonaIds.has(right.personaId ?? ""),
|
||||
) -
|
||||
Number(
|
||||
Boolean(left.personaId) &&
|
||||
activePersonaIds.has(left.personaId ?? ""),
|
||||
);
|
||||
if (runnableDiff !== 0) return runnableDiff;
|
||||
const labelDiff = (left.displayName ?? "").localeCompare(
|
||||
right.displayName ?? "",
|
||||
undefined,
|
||||
{ sensitivity: "base" },
|
||||
);
|
||||
if (labelDiff !== 0) return labelDiff;
|
||||
return (left.pubkey ?? "").localeCompare(right.pubkey ?? "");
|
||||
})[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function rankMentionCandidates<T extends MentionCandidateForRanking>(
|
||||
candidates: readonly T[],
|
||||
query: string,
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { formatOwnerLabel } from "@/features/profile/lib/identity";
|
||||
import type { ChannelRole, ChannelType } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { TeamMentionMember } from "./mentionCandidates";
|
||||
import type { MentionCandidate, TeamMentionMember } from "./mentionCandidates";
|
||||
import { mentionCandidateLabel } from "./mentionCandidates";
|
||||
import { pickDefaultAgentCandidate } from "./mentionRanking";
|
||||
|
||||
export type MentionSuggestionCandidate = {
|
||||
kind: "identity" | "persona" | "team";
|
||||
@@ -74,3 +76,26 @@ export function mapMentionCandidateToSuggestion(opts: {
|
||||
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function pickDefaultAgentSuggestion(opts: {
|
||||
activePersonaIds: ReadonlySet<string>;
|
||||
agentProvenanceReady: boolean;
|
||||
candidates: readonly MentionCandidate[];
|
||||
channelType?: ChannelType | null;
|
||||
currentPubkey?: string | null;
|
||||
ownerProfiles?: UserProfileLookup;
|
||||
profiles?: UserProfileLookup;
|
||||
recentMentionPubkeys?: readonly string[];
|
||||
}): MentionSuggestion | null {
|
||||
const candidate = pickDefaultAgentCandidate(
|
||||
opts.candidates,
|
||||
opts.activePersonaIds,
|
||||
opts.recentMentionPubkeys,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
return mapMentionCandidateToSuggestion({
|
||||
...opts,
|
||||
candidate,
|
||||
label: mentionCandidateLabel(candidate),
|
||||
});
|
||||
}
|
||||
|
||||
114
desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs
Normal file
114
desktop/src/features/messages/lib/recentMentionPubkeys.test.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getRecentMentionPubkeys } from "./recentMentionPubkeys.ts";
|
||||
|
||||
const AUTHOR = "a".repeat(64);
|
||||
const OLDER = "1".repeat(64);
|
||||
const LATEST_FIRST = "2".repeat(64);
|
||||
const LATEST_LAST = "3".repeat(64);
|
||||
const REPLY_AUTHOR = "4".repeat(64);
|
||||
|
||||
function message(createdAt, tags) {
|
||||
return {
|
||||
id: String(createdAt),
|
||||
createdAt,
|
||||
pubkey: AUTHOR,
|
||||
author: "Author",
|
||||
time: "",
|
||||
body: "",
|
||||
depth: 0,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
test("returns loaded channel mentions newest-first and excludes structural author tags", () => {
|
||||
assert.deepEqual(
|
||||
getRecentMentionPubkeys([
|
||||
message(1, [
|
||||
["p", AUTHOR],
|
||||
["p", OLDER],
|
||||
]),
|
||||
message(2, [
|
||||
["p", AUTHOR],
|
||||
["p", LATEST_FIRST],
|
||||
["p", LATEST_LAST],
|
||||
]),
|
||||
]),
|
||||
[LATEST_LAST, LATEST_FIRST, OLDER],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a top-level mention when the event omits its structural author tag", () => {
|
||||
assert.deepEqual(
|
||||
getRecentMentionPubkeys([message(1, [["p", LATEST_FIRST]])]),
|
||||
[LATEST_FIRST],
|
||||
);
|
||||
});
|
||||
|
||||
test("filters desktop structural self-tags by identity", () => {
|
||||
const parent = message(1, []);
|
||||
assert.deepEqual(
|
||||
getRecentMentionPubkeys([
|
||||
parent,
|
||||
{
|
||||
...message(2, [
|
||||
["p", REPLY_AUTHOR],
|
||||
["p", LATEST_FIRST],
|
||||
]),
|
||||
id: "desktop-reply",
|
||||
parentId: parent.id,
|
||||
pubkey: REPLY_AUTHOR,
|
||||
},
|
||||
]),
|
||||
[LATEST_FIRST],
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps an sdk-shaped reply mention that matches the parent author", () => {
|
||||
const parent = message(1, []);
|
||||
assert.deepEqual(
|
||||
getRecentMentionPubkeys([
|
||||
parent,
|
||||
{
|
||||
...message(2, [["p", AUTHOR]]),
|
||||
id: "sdk-reply",
|
||||
parentId: parent.id,
|
||||
pubkey: REPLY_AUTHOR,
|
||||
},
|
||||
]),
|
||||
[AUTHOR],
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores DM participant fan-out tags", () => {
|
||||
assert.deepEqual(
|
||||
getRecentMentionPubkeys(
|
||||
[
|
||||
message(1, [
|
||||
["p", AUTHOR],
|
||||
["p", LATEST_FIRST],
|
||||
["p", LATEST_LAST],
|
||||
]),
|
||||
],
|
||||
"dm",
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("deduplicates repeated mentions at their newest position", () => {
|
||||
assert.deepEqual(
|
||||
getRecentMentionPubkeys([
|
||||
message(1, [
|
||||
["p", AUTHOR],
|
||||
["p", LATEST_FIRST],
|
||||
]),
|
||||
message(2, [
|
||||
["p", AUTHOR],
|
||||
["p", LATEST_FIRST],
|
||||
]),
|
||||
]),
|
||||
[LATEST_FIRST],
|
||||
);
|
||||
});
|
||||
41
desktop/src/features/messages/lib/recentMentionPubkeys.ts
Normal file
41
desktop/src/features/messages/lib/recentMentionPubkeys.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/**
|
||||
* Return explicitly addressed pubkeys from the loaded channel window, newest
|
||||
* first. DM `p` tags fan out to every participant and cannot distinguish inline
|
||||
* mentions, so DMs deliberately fall back to the non-recency ranking ladder.
|
||||
* Desktop-authored stream events may include the message author as a structural
|
||||
* `p` tag, while SDK-authored events omit it. Filter by author identity rather
|
||||
* than tag position so an SDK mention of a reply target remains eligible.
|
||||
*/
|
||||
export function getRecentMentionPubkeys(
|
||||
messages: readonly TimelineMessage[],
|
||||
channelType?: ChannelType | null,
|
||||
): string[] {
|
||||
if (channelType === "dm") return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const recent: string[] = [];
|
||||
|
||||
for (
|
||||
let messageIndex = messages.length - 1;
|
||||
messageIndex >= 0;
|
||||
messageIndex -= 1
|
||||
) {
|
||||
const message = messages[messageIndex];
|
||||
const authorPubkey = normalizePubkey(message.pubkey ?? "");
|
||||
const tags = message.tags ?? [];
|
||||
for (let tagIndex = tags.length - 1; tagIndex >= 0; tagIndex -= 1) {
|
||||
const tag = tags[tagIndex];
|
||||
if (tag[0] !== "p" || !tag[1]) continue;
|
||||
const pubkey = normalizePubkey(tag[1]);
|
||||
if (!pubkey || pubkey === authorPubkey || seen.has(pubkey)) continue;
|
||||
seen.add(pubkey);
|
||||
recent.push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
return recent;
|
||||
}
|
||||
24
desktop/src/features/messages/lib/useActiveAgentPubkeys.ts
Normal file
24
desktop/src/features/messages/lib/useActiveAgentPubkeys.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export function useActiveAgentPubkeys(
|
||||
managedAgents?: readonly ManagedAgent[],
|
||||
relayAgents?: readonly RelayAgent[],
|
||||
): ReadonlySet<string> {
|
||||
return React.useMemo(
|
||||
() =>
|
||||
new Set([
|
||||
...(managedAgents ?? [])
|
||||
.filter(
|
||||
(agent) =>
|
||||
agent.status === "running" || agent.status === "deployed",
|
||||
)
|
||||
.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
...(relayAgents ?? [])
|
||||
.filter((agent) => agent.status !== "offline")
|
||||
.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
]),
|
||||
[managedAgents, relayAgents],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import type { MentionCandidate } from "./mentionCandidates";
|
||||
import { pickDefaultAgentSuggestion } from "./mentionSuggestionMapping";
|
||||
|
||||
export function useDefaultAgentSuggestion({
|
||||
activePersonaIds,
|
||||
agentProvenanceReady,
|
||||
candidates,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
ownerProfiles,
|
||||
profiles,
|
||||
recentMentionPubkeys,
|
||||
}: {
|
||||
activePersonaIds: ReadonlySet<string>;
|
||||
agentProvenanceReady: boolean;
|
||||
candidates: readonly MentionCandidate[];
|
||||
channelType?: ChannelType | null;
|
||||
currentPubkey?: string | null;
|
||||
ownerProfiles?: UserProfileLookup;
|
||||
profiles?: UserProfileLookup;
|
||||
recentMentionPubkeys?: readonly string[];
|
||||
}): () => MentionSuggestion | null {
|
||||
return React.useCallback(
|
||||
() =>
|
||||
pickDefaultAgentSuggestion({
|
||||
activePersonaIds,
|
||||
agentProvenanceReady,
|
||||
candidates,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
ownerProfiles,
|
||||
profiles,
|
||||
recentMentionPubkeys,
|
||||
}),
|
||||
[
|
||||
activePersonaIds,
|
||||
agentProvenanceReady,
|
||||
candidates,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
ownerProfiles,
|
||||
profiles,
|
||||
recentMentionPubkeys,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys";
|
||||
import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion";
|
||||
import { flushMentionDebounce } from "./flushMentionDebounce";
|
||||
import { useAgentMentionRevalidation } from "./agentMentionRevalidation";
|
||||
import { extractMentionPubkeys } from "./extractMentionPubkeys";
|
||||
@@ -61,9 +63,12 @@ import {
|
||||
type MentionCandidate,
|
||||
mentionCandidateLabel,
|
||||
} from "./mentionCandidates";
|
||||
const MENTION_DEBOUNCE_MS = 120;
|
||||
const MENTION_SUGGESTION_LIMIT = 50;
|
||||
type UseMentionsOptions = { channelType?: ChannelType | null };
|
||||
const MENTION_DEBOUNCE_MS = 120,
|
||||
MENTION_SUGGESTION_LIMIT = 50;
|
||||
type UseMentionsOptions = {
|
||||
channelType?: ChannelType | null;
|
||||
recentMentionPubkeys?: readonly string[];
|
||||
};
|
||||
export function useMentions(
|
||||
channelId: string | null,
|
||||
externalMembers?: ChannelMember[],
|
||||
@@ -170,6 +175,10 @@ export function useMentions(
|
||||
),
|
||||
[relayAgentsQuery.data],
|
||||
);
|
||||
const activeAgentPubkeys = useActiveAgentPubkeys(
|
||||
managedAgentsQuery.data,
|
||||
relayAgentsQuery.data,
|
||||
);
|
||||
const sharedChannelIds = React.useMemo(
|
||||
() => getSharedChannelIds(channelsQuery.data),
|
||||
[channelsQuery.data],
|
||||
@@ -276,6 +285,7 @@ export function useMentions(
|
||||
? (candidate.displayName ?? current.displayName)
|
||||
: (current.displayName ?? candidate.displayName),
|
||||
isAgent: current.isAgent || candidate.isAgent,
|
||||
isActiveAgent: current.isActiveAgent || candidate.isActiveAgent,
|
||||
isMember: current.isMember || candidate.isMember,
|
||||
personaId: current.personaId ?? candidate.personaId,
|
||||
personaName: current.personaName ?? candidate.personaName ?? null,
|
||||
@@ -321,6 +331,7 @@ export function useMentions(
|
||||
member.role === "bot" ||
|
||||
managedAgentNamesByPubkey.has(pubkey) ||
|
||||
relayAgentNamesByPubkey.has(pubkey),
|
||||
isActiveAgent: activeAgentPubkeys.has(pubkey),
|
||||
ownerPubkey: profile?.ownerPubkey ?? null,
|
||||
personaName: personaNameByPubkey.get(pubkey) ?? null,
|
||||
role: member.role,
|
||||
@@ -342,6 +353,7 @@ export function useMentions(
|
||||
(activePersonaById.has(pubkey) ? pubkey : undefined),
|
||||
ownerPubkey: agent.ownerPubkey,
|
||||
isAgent: true,
|
||||
isActiveAgent: agent.status !== "offline",
|
||||
});
|
||||
}
|
||||
for (const agent of managedAgentsQuery.data ?? []) {
|
||||
@@ -351,6 +363,8 @@ export function useMentions(
|
||||
displayName: agent.name,
|
||||
isMember: false,
|
||||
isAgent: true,
|
||||
isActiveAgent:
|
||||
agent.status === "running" || agent.status === "deployed",
|
||||
isManagedAgent: true,
|
||||
personaId: agent.personaId ?? undefined,
|
||||
personaName:
|
||||
@@ -406,6 +420,7 @@ export function useMentions(
|
||||
);
|
||||
}, [
|
||||
activePersonaById,
|
||||
activeAgentPubkeys,
|
||||
activePersonas,
|
||||
userSearchResults,
|
||||
canSearchGlobalUsers,
|
||||
@@ -534,6 +549,16 @@ export function useMentions(
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
profiles,
|
||||
]);
|
||||
const getDefaultAgentSuggestion = useDefaultAgentSuggestion({
|
||||
activePersonaIds,
|
||||
agentProvenanceReady: agentDirectoriesReady,
|
||||
candidates: mentionCandidates,
|
||||
channelType: options?.channelType,
|
||||
currentPubkey,
|
||||
ownerProfiles: ownerProfilesQuery.data?.profiles,
|
||||
profiles,
|
||||
recentMentionPubkeys: options?.recentMentionPubkeys,
|
||||
});
|
||||
const fetchMoreSuggestions = React.useCallback(() => {
|
||||
if (userSearchQuery.hasNextPage && !userSearchQuery.isFetchingNextPage) {
|
||||
void userSearchQuery.fetchNextPage();
|
||||
@@ -938,6 +963,7 @@ export function useMentions(
|
||||
return {
|
||||
cancelMentionAutocomplete,
|
||||
clearMentions,
|
||||
getDefaultAgentSuggestion,
|
||||
extractMentionPersonas,
|
||||
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
|
||||
revalidateMentionPubkeys,
|
||||
|
||||
@@ -71,6 +71,8 @@ export type AutocompleteEdit = {
|
||||
replaceFromOffset: number;
|
||||
replaceToOffset: number;
|
||||
insertText: string;
|
||||
/** Keep the current selection mapped through this edit instead of moving it to the insertion. */
|
||||
preserveSelection?: boolean;
|
||||
/**
|
||||
* When set, the replaced range becomes a CustomEmojiNode for this
|
||||
* shortcode (followed by `insertText`, which carries the trailing space)
|
||||
@@ -824,6 +826,7 @@ export function useRichTextEditor({
|
||||
toOffset: number,
|
||||
text: string,
|
||||
customEmojiShortcode?: string,
|
||||
preserveSelection = false,
|
||||
) => {
|
||||
if (!editor) return;
|
||||
const projection = buildPlainTextProjection(editor.state.doc);
|
||||
@@ -856,19 +859,23 @@ export function useRichTextEditor({
|
||||
}
|
||||
|
||||
const tr = editor.state.tr.insertText(text, fromPM, toPM);
|
||||
// Place cursor at the end of the inserted text. We map `toPM` (the
|
||||
// right end of the replaced range) through the transaction's
|
||||
// mapping — that's the post-transaction position right after the
|
||||
// inserted text, valid even if mark normalisation shifted things.
|
||||
// (Mapping `fromPM + text.length` directly would be a pre-image
|
||||
// position that may not exist in the original doc, which throws
|
||||
// "Position N out of range".)
|
||||
const cursorPM = tr.mapping.map(toPM);
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPM));
|
||||
settleAutocompleteMentionInsert(editor, tr, text);
|
||||
if (preserveSelection) {
|
||||
tr.setSelection(editor.state.selection.map(tr.doc, tr.mapping));
|
||||
} else {
|
||||
// Place cursor at the end of the inserted text. We map `toPM` (the
|
||||
// right end of the replaced range) through the transaction's
|
||||
// mapping — that's the post-transaction position right after the
|
||||
// inserted text, valid even if mark normalisation shifted things.
|
||||
// (Mapping `fromPM + text.length` directly would be a pre-image
|
||||
// position that may not exist in the original doc, which throws
|
||||
// "Position N out of range".)
|
||||
const cursorPM = tr.mapping.map(toPM);
|
||||
tr.setSelection(TextSelection.create(tr.doc, cursorPM));
|
||||
}
|
||||
settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection);
|
||||
editor.view.dispatch(tr);
|
||||
editor.view.focus();
|
||||
reassertMentionCaretAfterFocus(editor.view);
|
||||
if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view);
|
||||
},
|
||||
[editor, customEmojiWiring.resolveUrl],
|
||||
);
|
||||
|
||||
@@ -78,9 +78,8 @@ test("mention control expands with automatically mentioned agents", async () =>
|
||||
assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/);
|
||||
assert.match(manage.parentElement?.className ?? "", /(?:^|\s)pl-2(?:\s|$)/);
|
||||
assert.match(
|
||||
view.getByRole("button", { name: "Manage automatic agent mentions" })
|
||||
.parentElement?.className ?? "",
|
||||
/(?:^|\s)pr-1(?:\s|$)/,
|
||||
manage.parentElement?.className ?? "",
|
||||
/(?:^|\s)pr-1\.5(?:\s|$)/,
|
||||
);
|
||||
assert.match(
|
||||
view.getByRole("button", { name: "Manage automatic agent mentions" })
|
||||
@@ -111,17 +110,21 @@ test("mention control expands with automatically mentioned agents", async () =>
|
||||
/scale\(0.8\)/,
|
||||
);
|
||||
}
|
||||
const remove = view.getByRole("button", {
|
||||
name: "Stop automatically mentioning Agent Ada",
|
||||
});
|
||||
const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey");
|
||||
assert.match(
|
||||
remove.querySelector("span.absolute")?.className ?? "",
|
||||
/group-hover\/address:opacity-100/,
|
||||
);
|
||||
fireEvent.click(remove);
|
||||
assert.deepEqual(removed, ["agent-pubkey"]);
|
||||
fireEvent.click(
|
||||
view.getByRole("button", { name: "Manage automatic agent mentions" }),
|
||||
view.rerender(renderButton([]));
|
||||
const exitingLocks = view.getByTestId("composer-address-locks");
|
||||
assert.match(exitingLocks.className, /(?:^|\s)overflow-hidden(?:\s|$)/);
|
||||
assert.match(
|
||||
view.getByRole("button", { name: "Mention someone" }).parentElement
|
||||
?.className ?? "",
|
||||
/(?:^|\s)pr-1\.5(?:\s|$)/,
|
||||
);
|
||||
fireEvent.click(view.getByRole("button", { name: "Mention someone" }));
|
||||
assert.equal(opened, 1);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export type ComposerAddressAgent = {
|
||||
@@ -58,7 +59,7 @@ function AddressedAgentAvatar({
|
||||
return (
|
||||
<motion.span
|
||||
animate={controls}
|
||||
className="relative block h-5 w-5 shrink-0"
|
||||
className="relative block h-4.5 w-4.5 shrink-0"
|
||||
data-pulse-version={pulseVersion}
|
||||
data-shake-version={shakeVersion}
|
||||
data-testid={`composer-address-lock-${agent.pubkey}`}
|
||||
@@ -66,7 +67,7 @@ function AddressedAgentAvatar({
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={agent.avatarUrl}
|
||||
className="h-5 w-5"
|
||||
className="h-4.5 w-4.5"
|
||||
displayName={agent.displayName}
|
||||
size="xs"
|
||||
testId="composer-address-lock-avatar"
|
||||
@@ -125,7 +126,10 @@ type AddressAgentsProps = {
|
||||
|
||||
export function ComposerMentionButton({
|
||||
agents,
|
||||
confirmationTitle,
|
||||
disabled,
|
||||
onConfirmationDismiss,
|
||||
onConfirmationTurnOff,
|
||||
onCaptureSelection,
|
||||
onOpen,
|
||||
onRemove,
|
||||
@@ -133,7 +137,10 @@ export function ComposerMentionButton({
|
||||
shakeVersionByPubkey = {},
|
||||
showAgents,
|
||||
}: AddressAgentsProps & {
|
||||
confirmationTitle?: string | null;
|
||||
disabled: boolean;
|
||||
onConfirmationDismiss?: () => void;
|
||||
onConfirmationTurnOff?: () => void;
|
||||
onCaptureSelection: () => void;
|
||||
onOpen: () => void;
|
||||
onRemove: (pubkey: string) => void;
|
||||
@@ -142,89 +149,161 @@ export function ComposerMentionButton({
|
||||
const visibleAgents = showAgents ? agents.slice(0, VISIBLE_AGENT_LIMIT) : [];
|
||||
const hiddenCount = showAgents ? agents.length - visibleAgents.length : 0;
|
||||
const hasAgents = visibleAgents.length > 0;
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [showActiveChrome, setShowActiveChrome] = React.useState(hasAgents);
|
||||
const newlyAddedAgentPubkeys = useNewlyAddedAgentPubkeys(visibleAgents);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasAgents) setShowActiveChrome(true);
|
||||
}, [hasAgents]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 min-w-8 items-center justify-center rounded-lg transition-colors",
|
||||
hasAgents
|
||||
? "gap-1.5 bg-primary/15 pl-2 pr-1 text-primary hover:bg-primary/25 hover:text-primary/90"
|
||||
: "text-foreground",
|
||||
)}
|
||||
<Popover
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onConfirmationDismiss?.();
|
||||
}}
|
||||
open={Boolean(confirmationTitle)}
|
||||
>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 min-w-8 items-center justify-center rounded-lg transition-colors",
|
||||
showActiveChrome
|
||||
? "gap-1.5 bg-primary/15 pl-2 pr-1.5 text-primary hover:bg-primary/25 hover:text-primary/90"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={
|
||||
hasAgents
|
||||
? "Manage automatic agent mentions"
|
||||
: "Mention someone"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
showActiveChrome
|
||||
? "-ml-2 w-6 rounded-l-lg rounded-r-sm pl-2"
|
||||
: "w-8 hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-mention-picker-trigger=""
|
||||
data-testid="message-insert-mention"
|
||||
disabled={disabled}
|
||||
onClick={onOpen}
|
||||
onMouseDown={onCaptureSelection}
|
||||
type="button"
|
||||
>
|
||||
<AtSign aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasAgents
|
||||
? "Manage automatic agent mentions"
|
||||
: "Mention someone"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
onExitComplete={() => {
|
||||
if (!hasAgents) setShowActiveChrome(false);
|
||||
}}
|
||||
>
|
||||
{hasAgents ? (
|
||||
<motion.span
|
||||
animate={{ opacity: 1, width: "auto" }}
|
||||
className="flex items-center gap-1 overflow-hidden"
|
||||
data-testid="composer-address-locks"
|
||||
exit={{ opacity: 0, width: 0 }}
|
||||
initial={false}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18, ease: "easeOut" }
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleAgents.map((agent) => (
|
||||
<Tooltip disableHoverableContent key={agent.pubkey}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.button
|
||||
aria-label={`Stop automatically mentioning ${agent.displayName}`}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="group/address relative rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
data-testid={`composer-address-lock-remove-${agent.pubkey}`}
|
||||
disabled={disabled}
|
||||
exit={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, scale: 0.8 }
|
||||
}
|
||||
initial={
|
||||
newlyAddedAgentPubkeys.has(agent.pubkey)
|
||||
? shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, scale: 0.8 }
|
||||
: false
|
||||
}
|
||||
layout={!shouldReduceMotion}
|
||||
onClick={() => onRemove(agent.pubkey)}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: addressEntryTransition
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<AddressedAgentAvatar
|
||||
agent={agent}
|
||||
pulseVersion={
|
||||
pulseVersionByPubkey[agent.pubkey] ?? 0
|
||||
}
|
||||
shakeVersion={
|
||||
shakeVersionByPubkey[agent.pubkey] ?? 0
|
||||
}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground/80 text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
|
||||
<X aria-hidden="true" className="h-3 w-3" />
|
||||
</span>
|
||||
</motion.button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Stop automatically mentioning {agent.displayName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<RemainingAgentCount count={hiddenCount} />
|
||||
</motion.span>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
{confirmationTitle ? (
|
||||
<PopoverContent
|
||||
align="center"
|
||||
aria-live="polite"
|
||||
className="flex max-w-[calc(100vw-2rem)] items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs"
|
||||
collisionPadding={8}
|
||||
data-testid="composer-auto-pin-confirmation"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
style={{ width: "max-content" }}
|
||||
>
|
||||
<span className="whitespace-nowrap">{confirmationTitle}</span>
|
||||
<button
|
||||
aria-label={
|
||||
hasAgents ? "Manage automatic agent mentions" : "Mention someone"
|
||||
}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
hasAgents
|
||||
? "-ml-2 w-6 rounded-l-lg rounded-r-sm pl-2"
|
||||
: "w-8 hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-mention-picker-trigger=""
|
||||
data-testid="message-insert-mention"
|
||||
disabled={disabled}
|
||||
onClick={onOpen}
|
||||
onMouseDown={onCaptureSelection}
|
||||
className="shrink-0 rounded-md px-1.5 py-1 font-medium text-primary outline-hidden hover:bg-primary/10 focus-visible:ring-1 focus-visible:ring-ring"
|
||||
onClick={onConfirmationTurnOff}
|
||||
type="button"
|
||||
>
|
||||
<AtSign aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
Turn off
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasAgents ? "Manage automatic agent mentions" : "Mention someone"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{hasAgents ? (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
data-testid="composer-address-locks"
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleAgents.map((agent) => (
|
||||
<Tooltip disableHoverableContent key={agent.pubkey}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.button
|
||||
aria-label={`Stop automatically mentioning ${agent.displayName}`}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="group/address relative rounded-full focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
data-testid={`composer-address-lock-remove-${agent.pubkey}`}
|
||||
disabled={disabled}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
initial={
|
||||
newlyAddedAgentPubkeys.has(agent.pubkey)
|
||||
? { opacity: 0, scale: 0.8 }
|
||||
: false
|
||||
}
|
||||
layout
|
||||
onClick={() => onRemove(agent.pubkey)}
|
||||
transition={addressEntryTransition}
|
||||
type="button"
|
||||
>
|
||||
<AddressedAgentAvatar
|
||||
agent={agent}
|
||||
pulseVersion={pulseVersionByPubkey[agent.pubkey] ?? 0}
|
||||
shakeVersion={shakeVersionByPubkey[agent.pubkey] ?? 0}
|
||||
/>
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full bg-foreground/80 text-background opacity-0 transition-opacity group-hover/address:opacity-100 group-focus-visible/address:opacity-100">
|
||||
<X aria-hidden="true" className="h-3 w-3" />
|
||||
</span>
|
||||
</motion.button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Stop automatically mentioning {agent.displayName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<RemainingAgentCount count={hiddenCount} />
|
||||
</span>
|
||||
</PopoverContent>
|
||||
) : null}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useChannelLinks,
|
||||
type ChannelSuggestion,
|
||||
} from "@/features/messages/lib/useChannelLinks";
|
||||
import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard";
|
||||
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
|
||||
import { useDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey";
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
} from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import {
|
||||
getPersistentAgentAudienceRevision,
|
||||
getPersistentAgentAudienceScope,
|
||||
usePersistentAgentAudience,
|
||||
} from "@/features/messages/lib/persistentAgentAudience";
|
||||
@@ -38,10 +36,6 @@ import {
|
||||
useKeepMentionedAgentsPinned,
|
||||
} from "@/features/messages/lib/autoPinMentionedAgentsPreference";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import {
|
||||
hasMentionClipboardHtml,
|
||||
normalizeMentionClipboardHtml,
|
||||
} from "@/features/messages/lib/normalizeMentionClipboard";
|
||||
import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode";
|
||||
import {
|
||||
type AutocompleteEdit,
|
||||
@@ -51,7 +45,6 @@ import {
|
||||
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
|
||||
import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposerSpoilerParticles";
|
||||
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
|
||||
@@ -68,6 +61,7 @@ import { useAlwaysAddressShortcut } from "./useAlwaysAddressShortcut";
|
||||
import { useComposerMentionPicker } from "./useComposerMentionPicker";
|
||||
import { useAutoPinMentionedAgents } from "./useAutoPinMentionedAgents";
|
||||
import { useComposerContentState } from "./useComposerContentState";
|
||||
import { useComposerPasteHandler } from "./useComposerPasteHandler";
|
||||
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
|
||||
import { submitMessageEdit } from "./submitMessageEdit";
|
||||
import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore";
|
||||
@@ -98,6 +92,7 @@ function MessageComposerImpl({
|
||||
onSend,
|
||||
placeholder,
|
||||
profiles,
|
||||
recentMentionPubkeys,
|
||||
replyTarget = null,
|
||||
mediaController,
|
||||
showBackgroundUploadProgress = true,
|
||||
@@ -122,8 +117,6 @@ function MessageComposerImpl({
|
||||
} = useComposerLinkPreviews(previewContent, editTarget == null);
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
|
||||
const [mentionOptionsOpenRequest, setMentionOptionsOpenRequest] =
|
||||
React.useState(0);
|
||||
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
|
||||
Set<string>
|
||||
>(() => new Set());
|
||||
@@ -154,6 +147,7 @@ function MessageComposerImpl({
|
||||
} | null>(null);
|
||||
const mentions = useMentions(channelId, undefined, profiles, {
|
||||
channelType,
|
||||
recentMentionPubkeys,
|
||||
});
|
||||
const channelLinks = useChannelLinks();
|
||||
const customEmoji = useCustomEmoji();
|
||||
@@ -213,12 +207,16 @@ function MessageComposerImpl({
|
||||
const isSendingRef = React.useRef(isSending);
|
||||
const isUploadingRef = React.useRef(media.isUploading);
|
||||
const isSubmitLockedRef = React.useRef(false);
|
||||
const [isSubmitLocked, setIsSubmitLocked] = React.useState(false);
|
||||
const onSendRef = React.useRef(onSend);
|
||||
const onEditSaveRef = React.useRef(onEditSave);
|
||||
const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
|
||||
const editTargetRef = React.useRef(editTarget);
|
||||
const extractMentionPubkeysRef = React.useRef(mentions.extractMentionPubkeys);
|
||||
const ownerPubkeyRef = React.useRef(ownerPubkey);
|
||||
const syncAddressedAgentsFromTextRef = React.useRef<(text: string) => void>(
|
||||
() => {},
|
||||
);
|
||||
disabledRef.current = disabled;
|
||||
isSendingRef.current = isSending;
|
||||
isUploadingRef.current = media.isUploading;
|
||||
@@ -276,6 +274,9 @@ function MessageComposerImpl({
|
||||
onUpdate: ({ cursor, linkPreviewContent, text }) => {
|
||||
setComposerContentFromText(text);
|
||||
setPreviewContent(linkPreviewContent);
|
||||
if (!isSubmitLockedRef.current && !editTargetRef.current) {
|
||||
syncAddressedAgentsFromTextRef.current(text);
|
||||
}
|
||||
mentions.updateMentionQuery(text, cursor);
|
||||
channelLinks.updateChannelQuery(text, cursor);
|
||||
emojiAutocomplete.updateEmojiQuery(text, cursor);
|
||||
@@ -297,14 +298,38 @@ function MessageComposerImpl({
|
||||
const persistentAudience = usePersistentAgentAudience(audienceScope);
|
||||
const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned();
|
||||
const addressPulse = useAddressMentionPulse();
|
||||
const openMentionOptionsRef = React.useRef<() => void>(() => {});
|
||||
const addInlineAgentMentionsToAudience = useAutoPinMentionedAgents({
|
||||
const {
|
||||
confirmationTitle: autoPinConfirmationTitle,
|
||||
dismissConfirmation: dismissAutoPinConfirmation,
|
||||
promoteExplicitlyAddressedAgents,
|
||||
promoteMentionedAgents,
|
||||
turnOffConfirmation: turnOffAutoPinConfirmation,
|
||||
} = useAutoPinMentionedAgents({
|
||||
audienceScope,
|
||||
enabled: keepMentionedAgentsPinned,
|
||||
getDisplayName: mentions.getMentionDisplayName,
|
||||
onOpenOptions: () => openMentionOptionsRef.current(),
|
||||
onPulse: addressPulse.pulseOne,
|
||||
onTurnOff: () => setKeepMentionedAgentsPinned(false),
|
||||
});
|
||||
const restoreAddressedAgentMentionsRef = React.useRef<
|
||||
(
|
||||
pubkeys?: readonly string[],
|
||||
allowedUnpinnedPubkeys?: readonly string[],
|
||||
) => string
|
||||
>(() => "");
|
||||
const restoreAddressedAgentMentionsFrameRef = React.useRef<number | null>(
|
||||
null,
|
||||
);
|
||||
const channelIdRef = React.useRef(channelId);
|
||||
channelIdRef.current = channelId;
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const mentionSendFlow = useMentionSendFlow({
|
||||
channelId,
|
||||
channelLinks,
|
||||
@@ -315,8 +340,23 @@ function MessageComposerImpl({
|
||||
emojiAutocomplete,
|
||||
mentions,
|
||||
onAddressedAgentsSendStarted: addressPulse.pulseMany,
|
||||
onAddressedAgentsComposerCleared: (pubkeys) =>
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys),
|
||||
onAddressedAgentsSendFailed: addressPulse.shakeMany,
|
||||
onInlineAgentMentionsSent: addInlineAgentMentionsToAudience,
|
||||
onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => {
|
||||
if (newlyPinnedPubkeys.length === 0) return;
|
||||
const sentChannelId = channelId;
|
||||
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
|
||||
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
|
||||
}
|
||||
restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame(
|
||||
() => {
|
||||
restoreAddressedAgentMentionsFrameRef.current = null;
|
||||
if (channelIdRef.current !== sentChannelId) return;
|
||||
restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys);
|
||||
},
|
||||
);
|
||||
},
|
||||
onPrepareSendChannel,
|
||||
onSendRef,
|
||||
richText,
|
||||
@@ -394,6 +434,7 @@ function MessageComposerImpl({
|
||||
edit.replaceToOffset,
|
||||
edit.insertText,
|
||||
edit.customEmojiShortcode,
|
||||
edit.preserveSelection,
|
||||
);
|
||||
},
|
||||
[richText.replacePlainTextRange],
|
||||
@@ -403,17 +444,30 @@ function MessageComposerImpl({
|
||||
lockedAgents,
|
||||
lockedAgentPubkeys,
|
||||
removeAddressedAgent,
|
||||
restoreAddressedAgentMentions,
|
||||
selectMentionSuggestion,
|
||||
syncAddressedAgentsFromText,
|
||||
toggleAlwaysAddressAgent,
|
||||
} = useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit,
|
||||
audience: persistentAudience,
|
||||
audienceScope,
|
||||
mentions,
|
||||
onAddressAgentMention: (suggestion) =>
|
||||
promoteExplicitlyAddressedAgents({
|
||||
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
|
||||
}),
|
||||
onAutoPinAgentMention: (suggestion) => {
|
||||
promoteMentionedAgents({
|
||||
pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [],
|
||||
});
|
||||
},
|
||||
onPulseAddressLock: addressPulse.pulseOne,
|
||||
profiles,
|
||||
richText,
|
||||
});
|
||||
restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions;
|
||||
syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText;
|
||||
const applyChannelInsert = React.useCallback(
|
||||
(suggestion: ChannelSuggestion) => {
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
@@ -481,15 +535,12 @@ function MessageComposerImpl({
|
||||
() => openMentionPicker(false),
|
||||
[openMentionPicker],
|
||||
);
|
||||
const openMentionOptions = React.useCallback(() => {
|
||||
openMentionSettings();
|
||||
setMentionOptionsOpenRequest((request) => request + 1);
|
||||
}, [openMentionSettings]);
|
||||
openMentionOptionsRef.current = openMentionOptions;
|
||||
const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({
|
||||
enabled: Boolean(audienceScope && editTarget == null),
|
||||
lockedAgent: lockedAgents[0],
|
||||
mentions,
|
||||
onOpenPicker: openMentionPicker,
|
||||
onSelect: selectMentionSuggestion,
|
||||
onToggle: toggleAlwaysAddressAgent,
|
||||
});
|
||||
const submitMessage = React.useCallback(async () => {
|
||||
@@ -561,6 +612,7 @@ function MessageComposerImpl({
|
||||
return;
|
||||
}
|
||||
isSubmitLockedRef.current = true;
|
||||
setIsSubmitLocked(true);
|
||||
onPreparingMentionSendChange?.(true);
|
||||
try {
|
||||
const preparedLinkPreviews = getReadyLinkPreviewTags().some(
|
||||
@@ -570,9 +622,6 @@ function MessageComposerImpl({
|
||||
: prepareBackgroundLinkPreviews(getLiveLinkPreviewCandidates());
|
||||
await mentionSendFlow.sendMessageWithMentionFlow({
|
||||
addressedAgentPubkeys: persistentAudience.pubkeys,
|
||||
audienceRevision: audienceScope
|
||||
? getPersistentAgentAudienceRevision(audienceScope)
|
||||
: 0,
|
||||
capturedChannelId: channelId,
|
||||
capturedThreadContext,
|
||||
pendingImeta: currentPendingImeta,
|
||||
@@ -589,6 +638,7 @@ function MessageComposerImpl({
|
||||
});
|
||||
} finally {
|
||||
isSubmitLockedRef.current = false;
|
||||
setIsSubmitLocked(false);
|
||||
onPreparingMentionSendChange?.(false);
|
||||
}
|
||||
}, [
|
||||
@@ -615,7 +665,6 @@ function MessageComposerImpl({
|
||||
syncComposerContentFromEditor,
|
||||
onCaptureSendContext,
|
||||
onPreparingMentionSendChange,
|
||||
audienceScope,
|
||||
persistentAudience.pubkeys,
|
||||
isEditSubmissionLocked,
|
||||
effectiveDraftKey,
|
||||
@@ -713,74 +762,12 @@ function MessageComposerImpl({
|
||||
onCancelEdit,
|
||||
],
|
||||
);
|
||||
// ── Media paste + ⌘K link shortcut via Tiptap editorProps ──────────
|
||||
const uploadFileRef = React.useRef(media.uploadFile);
|
||||
uploadFileRef.current = media.uploadFile;
|
||||
React.useEffect(() => {
|
||||
if (!richText.editor) return;
|
||||
richText.editor.setOptions({
|
||||
editorProps: {
|
||||
...richText.editor.options.editorProps,
|
||||
handlePaste: (_view, event) => {
|
||||
// --- File paste ---
|
||||
// Any actual file (image, video, document, …) pastes as an
|
||||
// attachment. String/text items have kind "string", so plain-text
|
||||
// and code-block paste fall through to the handlers below.
|
||||
const items = Array.from(event.clipboardData?.items ?? []);
|
||||
const mediaItem = items.find((item) => item.kind === "file");
|
||||
if (mediaItem) {
|
||||
const file = mediaItem.getAsFile();
|
||||
if (file) {
|
||||
void uploadFileRef.current(file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// --- Buzz code-block paste ---
|
||||
// The code block copy button writes a small Buzz marker alongside
|
||||
// plain text. Use it to paste back as a literal code block so Markdown
|
||||
// parsing cannot reshape indentation, fence markers, or headings.
|
||||
const codeBlockText = getBuzzCodeBlockClipboardText(
|
||||
event.clipboardData,
|
||||
);
|
||||
if (codeBlockText !== null) {
|
||||
event.preventDefault();
|
||||
richText.editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "codeBlock",
|
||||
content:
|
||||
codeBlockText.length > 0
|
||||
? [{ type: "text", text: codeBlockText }]
|
||||
: [],
|
||||
},
|
||||
{ type: "paragraph" },
|
||||
])
|
||||
.run();
|
||||
scrollComposerToBottom();
|
||||
return true;
|
||||
}
|
||||
// Restore Buzz snapshots before normal styled-HTML normalization.
|
||||
if (handleAgentSnapshotPaste(event, media.setPendingImeta))
|
||||
return true;
|
||||
// Strip mention/channel wrappers that Tiptap would misread as bold.
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
if (html && hasMentionClipboardHtml(html)) {
|
||||
const cleanHtml = normalizeMentionClipboardHtml(html);
|
||||
event.preventDefault();
|
||||
_view.pasteHTML(cleanHtml);
|
||||
return true;
|
||||
}
|
||||
const plainText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (plainText.includes("\n")) {
|
||||
scrollComposerToBottom();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [media.setPendingImeta, richText.editor, scrollComposerToBottom]);
|
||||
useComposerPasteHandler({
|
||||
editor: richText.editor,
|
||||
scrollToBottom: scrollComposerToBottom,
|
||||
setPendingImeta: media.setPendingImeta,
|
||||
uploadFile: media.uploadFile,
|
||||
});
|
||||
// ── Send button state ───────────────────────────────────────────────
|
||||
const sendDisabled =
|
||||
composerDisabled ||
|
||||
@@ -858,6 +845,7 @@ function MessageComposerImpl({
|
||||
layoutMode === "standalone" &&
|
||||
"backdrop-blur-md dark:backdrop-blur-xl",
|
||||
)}
|
||||
data-submit-locked={isSubmitLocked ? "true" : "false"}
|
||||
data-testid="message-composer"
|
||||
onDragEnter={ownsDropZone ? media.handleDragEnter : undefined}
|
||||
onDragLeave={ownsDropZone ? media.handleDragLeave : undefined}
|
||||
@@ -904,7 +892,6 @@ function MessageComposerImpl({
|
||||
? setKeepMentionedAgentsPinned
|
||||
: undefined
|
||||
}
|
||||
openOptionsRequest={mentionOptionsOpenRequest}
|
||||
onToggleAlwaysAddressAgent={
|
||||
audienceScope && editTarget == null
|
||||
? toggleAlwaysAddressAgent
|
||||
@@ -973,6 +960,7 @@ function MessageComposerImpl({
|
||||
</div>
|
||||
<ComposerDockToolbar
|
||||
addressedAgents={editTarget == null ? lockedAgents : []}
|
||||
autoPinConfirmationTitle={autoPinConfirmationTitle}
|
||||
layoutMode={layoutMode}
|
||||
composerDisabled={composerDisabled}
|
||||
editor={richText.editor}
|
||||
@@ -983,6 +971,8 @@ function MessageComposerImpl({
|
||||
isSending={isSending || mentionSendFlow.isPreparingMentionSend}
|
||||
isUploading={media.isUploading}
|
||||
onCaptureSelection={handleCaptureSelection}
|
||||
onAutoPinConfirmationDismiss={dismissAutoPinConfirmation}
|
||||
onAutoPinConfirmationTurnOff={turnOffAutoPinConfirmation}
|
||||
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
|
||||
onEmojiSelect={insertEmoji}
|
||||
onFormattingToggle={handleFormattingToggle}
|
||||
|
||||
@@ -95,6 +95,8 @@ export type MessageComposerProps = {
|
||||
) => Promise<void>;
|
||||
placeholder?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
/** Explicit mention pubkeys from the loaded channel window, newest first. */
|
||||
recentMentionPubkeys?: readonly string[];
|
||||
replyTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ const ignoreAddressRemoval = () => {};
|
||||
export const MessageComposerToolbar = React.memo(
|
||||
function MessageComposerToolbar({
|
||||
addressedAgents = NO_ADDRESSED_AGENTS,
|
||||
autoPinConfirmationTitle,
|
||||
composerDisabled,
|
||||
editor,
|
||||
extraActions,
|
||||
@@ -35,6 +36,8 @@ export const MessageComposerToolbar = React.memo(
|
||||
isSending,
|
||||
isUploading,
|
||||
onCaptureSelection,
|
||||
onAutoPinConfirmationDismiss,
|
||||
onAutoPinConfirmationTurnOff,
|
||||
onEmojiPickerOpenChange,
|
||||
onEmojiSelect,
|
||||
onFormattingToggle,
|
||||
@@ -47,6 +50,7 @@ export const MessageComposerToolbar = React.memo(
|
||||
shakeVersionByPubkey,
|
||||
}: {
|
||||
addressedAgents?: readonly ComposerAddressAgent[];
|
||||
autoPinConfirmationTitle?: string | null;
|
||||
composerDisabled: boolean;
|
||||
editor: Editor | null;
|
||||
extraActions?: React.ReactNode;
|
||||
@@ -56,6 +60,8 @@ export const MessageComposerToolbar = React.memo(
|
||||
isSending: boolean;
|
||||
isUploading: boolean;
|
||||
onCaptureSelection: () => void;
|
||||
onAutoPinConfirmationDismiss?: () => void;
|
||||
onAutoPinConfirmationTurnOff?: () => void;
|
||||
onEmojiPickerOpenChange: (open: boolean) => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onFormattingToggle: (pressed: boolean) => void;
|
||||
@@ -175,7 +181,10 @@ export const MessageComposerToolbar = React.memo(
|
||||
>
|
||||
<ComposerMentionButton
|
||||
agents={addressedAgents}
|
||||
confirmationTitle={autoPinConfirmationTitle}
|
||||
disabled={composerDisabled}
|
||||
onConfirmationDismiss={onAutoPinConfirmationDismiss}
|
||||
onConfirmationTurnOff={onAutoPinConfirmationTurnOff}
|
||||
onCaptureSelection={onCaptureSelection}
|
||||
onOpen={onOpenMentionPicker}
|
||||
onRemove={onRemoveAddressedAgent}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import {
|
||||
depthGuideActionsEqual,
|
||||
numberArrayEqual,
|
||||
@@ -59,6 +58,7 @@ import { SentFromThreadLine } from "./SentFromThreadLine";
|
||||
import { WaveMessageAttachment } from "./WaveMessageAttachment";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs";
|
||||
import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys";
|
||||
import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
@@ -280,10 +280,12 @@ export const MessageRow = React.memo(
|
||||
return Object.keys(values).length > 0 ? values : undefined;
|
||||
}, [isKnownAgentPubkey, mentionPubkeysByName]);
|
||||
const addressedAgentPubkeys = React.useMemo(() => {
|
||||
return getAgentAddressMentionPubkeys(message.tags).filter(
|
||||
isKnownAgentPubkey,
|
||||
return getVisibleAgentAddressPubkeys(
|
||||
message.body,
|
||||
getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey),
|
||||
mentionPubkeysByName,
|
||||
);
|
||||
}, [isKnownAgentPubkey, message.tags]);
|
||||
}, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]);
|
||||
const agentAddressPrefix =
|
||||
addressedAgentPubkeys.length > 0 ? (
|
||||
<MessageAgentAddressPrefix
|
||||
|
||||
@@ -106,6 +106,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
profiles?: UserProfileLookup;
|
||||
recentMentionPubkeys?: readonly string[];
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
scrollTargetId: string | null;
|
||||
threadHead: TimelineMessage | null;
|
||||
@@ -184,6 +185,7 @@ export function MessageThreadPanel({
|
||||
onToggleReaction,
|
||||
onUnfollowThread,
|
||||
profiles,
|
||||
recentMentionPubkeys,
|
||||
replyTargetMessage,
|
||||
scrollTargetId,
|
||||
scrollTargetHighlights = true,
|
||||
@@ -855,6 +857,7 @@ export function MessageThreadPanel({
|
||||
: `Reply in thread to ${threadHead.author}`
|
||||
}
|
||||
profiles={profiles}
|
||||
recentMentionPubkeys={recentMentionPubkeys}
|
||||
replyTarget={composerReplyTarget}
|
||||
typingParentEventId={threadHead.id}
|
||||
typingRootEventId={threadHead.rootId}
|
||||
|
||||
@@ -44,13 +44,13 @@ test("agent picker preference skips people", async () => {
|
||||
assert.equal(view.result.current.mentionSelectedIndex, 1);
|
||||
});
|
||||
|
||||
test("primary+Shift+Enter opens the picker or toggles in place", async () => {
|
||||
test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAlwaysAddressShortcut } = await import(
|
||||
"./useAlwaysAddressShortcut.ts"
|
||||
);
|
||||
const { isMacPlatform } = await import("@/shared/lib/platform");
|
||||
const opened = [];
|
||||
const selected = [];
|
||||
const toggled = [];
|
||||
const suggestion = {
|
||||
displayName: "Agent Ada",
|
||||
@@ -59,8 +59,9 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => {
|
||||
};
|
||||
const createEvent = () => ({
|
||||
altKey: false,
|
||||
code: "KeyM",
|
||||
ctrlKey: !isMacPlatform(),
|
||||
key: "Enter",
|
||||
key: "M",
|
||||
metaKey: isMacPlatform(),
|
||||
preventDefault() {},
|
||||
repeat: false,
|
||||
@@ -71,24 +72,123 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => {
|
||||
useAlwaysAddressShortcut({
|
||||
enabled: true,
|
||||
mentions: {
|
||||
getDefaultAgentSuggestion: () => suggestion,
|
||||
isMentionOpen,
|
||||
mentionSelectedIndex: 0,
|
||||
suggestions: [suggestion],
|
||||
},
|
||||
onOpenPicker: (insertTrigger) => opened.push(insertTrigger),
|
||||
onOpenPicker: () => {},
|
||||
onSelect: (value) => selected.push(value),
|
||||
onToggle: (value) => toggled.push(value),
|
||||
}),
|
||||
{ initialProps: { isMentionOpen: false } },
|
||||
);
|
||||
|
||||
act(() => assert.equal(view.result.current(createEvent()), true));
|
||||
assert.deepEqual(opened, [false]);
|
||||
assert.deepEqual(toggled, []);
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, []);
|
||||
|
||||
view.rerender({ isMentionOpen: true });
|
||||
act(() => assert.equal(view.result.current(createEvent()), true));
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, [suggestion]);
|
||||
|
||||
act(() => assert.equal(view.result.current(createEvent()), true));
|
||||
assert.deepEqual(toggled, [suggestion, suggestion]);
|
||||
assert.deepEqual(toggled, [suggestion]);
|
||||
assert.deepEqual(selected, [suggestion, suggestion]);
|
||||
});
|
||||
|
||||
test("primary+Shift+M removes the current locked agent before choosing a new default", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAlwaysAddressShortcut } = await import(
|
||||
"./useAlwaysAddressShortcut.ts"
|
||||
);
|
||||
const { isMacPlatform } = await import("@/shared/lib/platform");
|
||||
const lockedAgent = {
|
||||
avatarUrl: null,
|
||||
displayName: "Agent Ada",
|
||||
pubkey: "agent-a",
|
||||
};
|
||||
const defaultAgent = {
|
||||
displayName: "Agent Bea",
|
||||
isAgent: true,
|
||||
pubkey: "agent-b",
|
||||
};
|
||||
const toggled = [];
|
||||
const { result } = renderHook(() =>
|
||||
useAlwaysAddressShortcut({
|
||||
enabled: true,
|
||||
lockedAgent,
|
||||
mentions: {
|
||||
getDefaultAgentSuggestion: () => defaultAgent,
|
||||
isMentionOpen: false,
|
||||
mentionSelectedIndex: 0,
|
||||
suggestions: [],
|
||||
},
|
||||
onOpenPicker: () => {},
|
||||
onSelect: () => {},
|
||||
onToggle: (value) => toggled.push(value),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
assert.equal(
|
||||
result.current({
|
||||
altKey: false,
|
||||
code: "KeyM",
|
||||
ctrlKey: !isMacPlatform(),
|
||||
key: "m",
|
||||
metaKey: isMacPlatform(),
|
||||
preventDefault() {},
|
||||
repeat: false,
|
||||
shiftKey: true,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
assert.deepEqual(toggled, [{ ...lockedAgent, isAgent: true }]);
|
||||
});
|
||||
|
||||
test("primary+Shift+M opens the picker when no default agent is ready", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAlwaysAddressShortcut } = await import(
|
||||
"./useAlwaysAddressShortcut.ts"
|
||||
);
|
||||
const { isMacPlatform } = await import("@/shared/lib/platform");
|
||||
let opened = 0;
|
||||
const { result } = renderHook(() =>
|
||||
useAlwaysAddressShortcut({
|
||||
enabled: true,
|
||||
mentions: {
|
||||
getDefaultAgentSuggestion: () => null,
|
||||
isMentionOpen: false,
|
||||
mentionSelectedIndex: 0,
|
||||
suggestions: [],
|
||||
},
|
||||
onOpenPicker: () => {
|
||||
opened += 1;
|
||||
},
|
||||
onSelect: () => {},
|
||||
onToggle: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
assert.equal(
|
||||
result.current({
|
||||
altKey: false,
|
||||
code: "KeyM",
|
||||
ctrlKey: !isMacPlatform(),
|
||||
key: "m",
|
||||
metaKey: isMacPlatform(),
|
||||
preventDefault() {},
|
||||
repeat: false,
|
||||
shiftKey: true,
|
||||
}),
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(opened, 1);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ afterEach(async () => {
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => {
|
||||
test("always addressing an agent keeps autocomplete open, inserts the chip, adds the lock, and pulses", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
@@ -32,7 +32,7 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul
|
||||
const addedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
let cancelCount = 0;
|
||||
const text = "Ask @Agent Ada later @";
|
||||
const text = "@";
|
||||
const mentions = {
|
||||
cancelMentionAutocomplete: () => {
|
||||
cancelCount += 1;
|
||||
@@ -45,6 +45,9 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul
|
||||
},
|
||||
],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
isInlineMentionSelection: () => false,
|
||||
isMentionOpen: true,
|
||||
registerMentionPubkey: () => {},
|
||||
mentionStartIndex: text.lastIndexOf("@"),
|
||||
};
|
||||
const audience = {
|
||||
@@ -73,7 +76,14 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: "@Agent Ada ",
|
||||
preserveSelection: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.deepEqual(addedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]);
|
||||
@@ -83,6 +93,49 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul
|
||||
);
|
||||
});
|
||||
|
||||
test("always addressing a new agent delegates the first add for immediate confirmation", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const addressedSuggestions = [];
|
||||
const addedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
const suggestion = {
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: () => {},
|
||||
audience: {
|
||||
pubkeys: [],
|
||||
addPubkey: (pubkey) => addedPubkeys.push(pubkey),
|
||||
},
|
||||
audienceScope: "channel-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
isInlineMentionSelection: () => false,
|
||||
isMentionOpen: false,
|
||||
registerMentionPubkey: () => {},
|
||||
},
|
||||
onAddressAgentMention: (value) => addressedSuggestions.push(value),
|
||||
onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey),
|
||||
richText: {
|
||||
getPlainTextAndCursor: () => ({ text: "@Agent Ada ", cursor: 11 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.toggleAlwaysAddressAgent(suggestion));
|
||||
|
||||
assert.deepEqual(addressedSuggestions, [suggestion]);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
});
|
||||
|
||||
test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
@@ -105,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock",
|
||||
},
|
||||
],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
registerMentionPubkey: () => {},
|
||||
mentionStartIndex: text.lastIndexOf("@"),
|
||||
};
|
||||
const audience = {
|
||||
@@ -136,7 +190,13 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock",
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 4,
|
||||
replaceToOffset: 15,
|
||||
insertText: "",
|
||||
},
|
||||
]);
|
||||
assert.equal(cancelCount, 0);
|
||||
assert.deepEqual(removedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
@@ -158,10 +218,13 @@ test("selecting an already addressed agent from the explicit picker pulses its b
|
||||
cancelMentionAutocomplete: () => {},
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
registerMentionPubkey: () => {},
|
||||
isInlineMentionSelection: () => false,
|
||||
insertMention: () => {
|
||||
throw new Error("an already addressed agent must not be inserted");
|
||||
},
|
||||
insertMention: () => ({
|
||||
replaceFromOffset: 5,
|
||||
replaceToOffset: 5,
|
||||
insertText: "@Agent Ada ",
|
||||
}),
|
||||
mentionStartIndex: 5,
|
||||
};
|
||||
const audience = {
|
||||
@@ -190,16 +253,23 @@ test("selecting an already addressed agent from the explicit picker pulses its b
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 5,
|
||||
replaceToOffset: 5,
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]);
|
||||
});
|
||||
|
||||
test("selecting an agent from a typed query leaves the inline mention for send", async () => {
|
||||
test("selecting an agent from a typed query immediately auto-addresses it", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const autoPinnedSuggestions = [];
|
||||
const appliedEdits = [];
|
||||
const addedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
@@ -207,6 +277,7 @@ test("selecting an agent from a typed query leaves the inline mention for send",
|
||||
cancelMentionAutocomplete: () => {},
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
registerMentionPubkey: () => {},
|
||||
isInlineMentionSelection: () => true,
|
||||
insertMention: () => ({
|
||||
replaceFromOffset: 5,
|
||||
@@ -224,6 +295,206 @@ test("selecting an agent from a typed query leaves the inline mention for send",
|
||||
// makes the editor text/cursor insufficient to re-detect the typed query.
|
||||
getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }),
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience,
|
||||
audienceScope: "channel-scope",
|
||||
mentions,
|
||||
onAutoPinAgentMention: (suggestion) =>
|
||||
autoPinnedSuggestions.push(suggestion),
|
||||
onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey),
|
||||
richText,
|
||||
}),
|
||||
);
|
||||
|
||||
const suggestion = {
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
};
|
||||
act(() => result.current.selectMentionSuggestion(suggestion));
|
||||
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 5,
|
||||
replaceToOffset: 6,
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(autoPinnedSuggestions, [suggestion]);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
assert.equal(result.current.announcement, "");
|
||||
});
|
||||
|
||||
test("selecting a human mention never changes automatic addressing", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const autoPinnedSuggestions = [];
|
||||
const appliedEdits = [];
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience: { pubkeys: [], addPubkey: () => {} },
|
||||
audienceScope: "channel-scope",
|
||||
mentions: {
|
||||
getMentionDisplayName: () => "Alice",
|
||||
insertMention: () => ({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 3,
|
||||
insertText: "@Alice ",
|
||||
}),
|
||||
},
|
||||
onAutoPinAgentMention: (suggestion) =>
|
||||
autoPinnedSuggestions.push(suggestion),
|
||||
onPulseAddressLock: () => {},
|
||||
richText: {
|
||||
getPlainTextAndCursor: () => ({ text: "@Al", cursor: 3 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.selectMentionSuggestion({
|
||||
pubkey: "human-pubkey",
|
||||
displayName: "Alice",
|
||||
isAgent: false,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 3,
|
||||
insertText: "@Alice ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(autoPinnedSuggestions, []);
|
||||
});
|
||||
|
||||
test("removing the last agent chip clears its automatic address", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const removedPubkeys = [];
|
||||
const mentionRefsByText = {
|
||||
"@Agent Ada first @Agent Ada second": [
|
||||
{ displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true },
|
||||
{ displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true },
|
||||
],
|
||||
"@Agent Ada second": [
|
||||
{ displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true },
|
||||
],
|
||||
"": [],
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: () => {},
|
||||
audience: {
|
||||
pubkeys: ["agent-pubkey", "existing-lock"],
|
||||
removePubkey: (pubkey) => removedPubkeys.push(pubkey),
|
||||
},
|
||||
audienceScope: "channel-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: (text) => mentionRefsByText[text] ?? [],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) },
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.trackMentionAddressedAgent("agent-pubkey"));
|
||||
act(() =>
|
||||
result.current.syncAddressedAgentsFromText(
|
||||
"@Agent Ada first @Agent Ada second",
|
||||
),
|
||||
);
|
||||
act(() => result.current.syncAddressedAgentsFromText("@Agent Ada second"));
|
||||
assert.deepEqual(removedPubkeys, []);
|
||||
|
||||
act(() => result.current.syncAddressedAgentsFromText(""));
|
||||
assert.deepEqual(removedPubkeys, ["agent-pubkey"]);
|
||||
});
|
||||
|
||||
test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const removedPubkeys = [];
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: () => {},
|
||||
audience: {
|
||||
pubkeys: ["existing-lock"],
|
||||
removePubkey: (pubkey) => removedPubkeys.push(pubkey),
|
||||
},
|
||||
audienceScope: "channel-scope",
|
||||
mentions: {
|
||||
getDraftMentionRefs: (text) => {
|
||||
if (text === "@Alice @Existing Agent") {
|
||||
return [
|
||||
{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false },
|
||||
{
|
||||
displayName: "Existing Agent",
|
||||
pubkey: "existing-lock",
|
||||
isAgent: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return text
|
||||
? [{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false }]
|
||||
: [];
|
||||
},
|
||||
getMentionDisplayName: () => "Existing Agent",
|
||||
},
|
||||
onPulseAddressLock: () => {},
|
||||
richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) },
|
||||
}),
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"),
|
||||
);
|
||||
act(() => result.current.syncAddressedAgentsFromText("@Alice"));
|
||||
assert.deepEqual(removedPubkeys, ["existing-lock"]);
|
||||
act(() => result.current.syncAddressedAgentsFromText(""));
|
||||
assert.deepEqual(removedPubkeys, ["existing-lock"]);
|
||||
});
|
||||
|
||||
test("selecting an agent from the explicit picker auto-addresses it", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const addedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
const mentions = {
|
||||
cancelMentionAutocomplete: () => {},
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
registerMentionPubkey: () => {},
|
||||
isInlineMentionSelection: () => false,
|
||||
insertMention: () => ({
|
||||
replaceFromOffset: 5,
|
||||
replaceToOffset: 5,
|
||||
insertText: "@Agent Ada ",
|
||||
}),
|
||||
mentionStartIndex: 5,
|
||||
};
|
||||
const audience = {
|
||||
pubkeys: [],
|
||||
addPubkey: (pubkey) => addedPubkeys.push(pubkey),
|
||||
};
|
||||
const richText = {
|
||||
getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }),
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
@@ -246,60 +517,10 @@ test("selecting an agent from a typed query leaves the inline mention for send",
|
||||
assert.deepEqual(appliedEdits, [
|
||||
{
|
||||
replaceFromOffset: 5,
|
||||
replaceToOffset: 6,
|
||||
replaceToOffset: 5,
|
||||
insertText: "@Agent Ada ",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(addedPubkeys, []);
|
||||
assert.deepEqual(pulsedPubkeys, []);
|
||||
assert.equal(result.current.announcement, "");
|
||||
});
|
||||
|
||||
test("selecting an agent from the explicit picker auto-addresses it", async () => {
|
||||
const { act, renderHook } = await import("@testing-library/react");
|
||||
const { useAgentAddressLockPicker } = await import(
|
||||
"./useAgentAddressLockPicker.ts"
|
||||
);
|
||||
const appliedEdits = [];
|
||||
const addedPubkeys = [];
|
||||
const pulsedPubkeys = [];
|
||||
const mentions = {
|
||||
cancelMentionAutocomplete: () => {},
|
||||
getDraftMentionRefs: () => [],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
isInlineMentionSelection: () => false,
|
||||
insertMention: () => {
|
||||
throw new Error("explicit picker selections must become addressing");
|
||||
},
|
||||
mentionStartIndex: 5,
|
||||
};
|
||||
const audience = {
|
||||
pubkeys: [],
|
||||
addPubkey: (pubkey) => addedPubkeys.push(pubkey),
|
||||
};
|
||||
const richText = {
|
||||
getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }),
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit: (edit) => appliedEdits.push(edit),
|
||||
audience,
|
||||
audienceScope: "channel-scope",
|
||||
mentions,
|
||||
onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey),
|
||||
richText,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.selectMentionSuggestion({
|
||||
pubkey: "agent-pubkey",
|
||||
displayName: "Agent Ada",
|
||||
isAgent: true,
|
||||
});
|
||||
});
|
||||
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
assert.deepEqual(addedPubkeys, ["agent-pubkey"]);
|
||||
assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]);
|
||||
assert.equal(
|
||||
@@ -319,8 +540,11 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
const pulsedPubkeys = [];
|
||||
const mentions = {
|
||||
cancelMentionAutocomplete: () => {},
|
||||
getDraftMentionRefs: () => [],
|
||||
getDraftMentionRefs: () => [
|
||||
{ displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true },
|
||||
],
|
||||
getMentionDisplayName: () => "Agent Ada",
|
||||
registerMentionPubkey: () => {},
|
||||
isInlineMentionSelection: () => false,
|
||||
insertMention: () => ({
|
||||
replaceFromOffset: 0,
|
||||
@@ -330,7 +554,10 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
mentionStartIndex: 0,
|
||||
};
|
||||
const richText = {
|
||||
getPlainTextAndCursor: () => ({ text: "", cursor: 0 }),
|
||||
getPlainTextAndCursor: () => ({
|
||||
text: "@Agent Ada keep this authored text",
|
||||
cursor: 35,
|
||||
}),
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ pubkeys }) =>
|
||||
@@ -350,6 +577,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn
|
||||
);
|
||||
|
||||
act(() => result.current.removeAddressedAgent("AGENT-PUBKEY"));
|
||||
assert.deepEqual(appliedEdits, []);
|
||||
rerender({ pubkeys: [] });
|
||||
act(() => {
|
||||
result.current.selectMentionSuggestion({
|
||||
|
||||
@@ -16,8 +16,7 @@ import type { MentionSuggestion } from "./MentionAutocomplete";
|
||||
function buildMentionRemovalEdits(
|
||||
text: string,
|
||||
displayNames: readonly string[],
|
||||
queryStart: number,
|
||||
cursor: number,
|
||||
queryRange?: { start: number; end: number },
|
||||
): AutocompleteEdit[] {
|
||||
const ranges = displayNames.flatMap((displayName) =>
|
||||
getMentionOffsets(text, displayName).map((start) => {
|
||||
@@ -26,10 +25,12 @@ function buildMentionRemovalEdits(
|
||||
return { start, end };
|
||||
}),
|
||||
);
|
||||
ranges.push({
|
||||
start: Math.max(0, Math.min(queryStart, text.length)),
|
||||
end: Math.max(0, Math.min(cursor, text.length)),
|
||||
});
|
||||
if (queryRange) {
|
||||
ranges.push({
|
||||
start: Math.max(0, Math.min(queryRange.start, text.length)),
|
||||
end: Math.max(0, Math.min(queryRange.end, text.length)),
|
||||
});
|
||||
}
|
||||
|
||||
const merged = ranges
|
||||
.filter(({ start, end }) => start < end)
|
||||
@@ -56,6 +57,8 @@ export function useAgentAddressLockPicker({
|
||||
audience,
|
||||
audienceScope,
|
||||
mentions,
|
||||
onAddressAgentMention,
|
||||
onAutoPinAgentMention,
|
||||
onPulseAddressLock,
|
||||
profiles,
|
||||
richText,
|
||||
@@ -64,6 +67,8 @@ export function useAgentAddressLockPicker({
|
||||
audience: ReturnType<typeof usePersistentAgentAudience>;
|
||||
audienceScope: string | null;
|
||||
mentions: UseMentionsResult;
|
||||
onAddressAgentMention?: (suggestion: MentionSuggestion) => void;
|
||||
onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void;
|
||||
onPulseAddressLock: (pubkey: string) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
richText: UseRichTextEditorResult;
|
||||
@@ -79,6 +84,12 @@ export function useAgentAddressLockPicker({
|
||||
unpinnedAgentPubkeysRef.current.clear();
|
||||
}
|
||||
const lockedAgentNamesRef = React.useRef(new Map<string, string>());
|
||||
const visibleAgentMentionPubkeysRef = React.useRef(new Set<string>());
|
||||
const mentionSyncScopeRef = React.useRef(audienceScope);
|
||||
if (mentionSyncScopeRef.current !== audienceScope) {
|
||||
mentionSyncScopeRef.current = audienceScope;
|
||||
visibleAgentMentionPubkeysRef.current.clear();
|
||||
}
|
||||
const [announcement, setAnnouncement] = React.useState("");
|
||||
const lockedAgents = React.useMemo<ComposerAddressAgent[]>(
|
||||
() =>
|
||||
@@ -104,41 +115,42 @@ export function useAgentAddressLockPicker({
|
||||
}),
|
||||
[audience.pubkeys, mentions.getMentionDisplayName, profiles],
|
||||
);
|
||||
const consumeAddressSuggestion = React.useCallback(
|
||||
(
|
||||
suggestion: MentionSuggestion,
|
||||
{ removeInlineMentions }: { removeInlineMentions: boolean },
|
||||
): string | null => {
|
||||
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
|
||||
if (!audienceScope || !pubkey || !suggestion.isAgent) return null;
|
||||
|
||||
const { text, cursor } = richText.getPlainTextAndCursor();
|
||||
const matchingDisplayNames = removeInlineMentions
|
||||
? mentions
|
||||
.getDraftMentionRefs(text)
|
||||
.filter((ref) => normalizePubkey(ref.pubkey) === pubkey)
|
||||
.map((ref) => ref.displayName)
|
||||
: [];
|
||||
mentions.cancelMentionAutocomplete();
|
||||
for (const edit of buildMentionRemovalEdits(
|
||||
text,
|
||||
matchingDisplayNames,
|
||||
mentions.mentionStartIndex,
|
||||
cursor,
|
||||
)) {
|
||||
applyAutocompleteEdit(edit);
|
||||
const trackMentionAddressedAgent = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (audienceScope && normalized) {
|
||||
visibleAgentMentionPubkeysRef.current.add(normalized);
|
||||
}
|
||||
return pubkey;
|
||||
},
|
||||
[audienceScope],
|
||||
);
|
||||
const syncAddressedAgentsFromText = React.useCallback(
|
||||
(text: string) => {
|
||||
if (!audienceScope) return;
|
||||
const presentAgentPubkeys = new Set(
|
||||
mentions
|
||||
.getDraftMentionRefs(text)
|
||||
.filter((ref) => ref.isAgent)
|
||||
.map((ref) => normalizePubkey(ref.pubkey)),
|
||||
);
|
||||
for (const pubkey of visibleAgentMentionPubkeysRef.current) {
|
||||
if (
|
||||
!presentAgentPubkeys.has(pubkey) &&
|
||||
lockedAgentPubkeys.has(pubkey)
|
||||
) {
|
||||
audience.removePubkey(pubkey);
|
||||
}
|
||||
}
|
||||
visibleAgentMentionPubkeysRef.current = presentAgentPubkeys;
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audience.removePubkey,
|
||||
audienceScope,
|
||||
mentions.cancelMentionAutocomplete,
|
||||
lockedAgentPubkeys,
|
||||
mentions.getDraftMentionRefs,
|
||||
mentions.mentionStartIndex,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
|
||||
const removeAddressedAgent = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
@@ -148,24 +160,63 @@ export function useAgentAddressLockPicker({
|
||||
},
|
||||
[audience.removePubkey, audienceScope],
|
||||
);
|
||||
const removeAddressedAgentMentions = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
if (!audienceScope || !normalized) return;
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
const matchingDisplayNames = mentions
|
||||
.getDraftMentionRefs(text)
|
||||
.filter((ref) => normalizePubkey(ref.pubkey) === normalized)
|
||||
.map((ref) => ref.displayName);
|
||||
for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) {
|
||||
applyAutocompleteEdit(edit);
|
||||
}
|
||||
removeAddressedAgent(normalized);
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audienceScope,
|
||||
mentions.getDraftMentionRefs,
|
||||
removeAddressedAgent,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
const toggleAlwaysAddressAgent = React.useCallback(
|
||||
(suggestion: MentionSuggestion) => {
|
||||
const pubkey = normalizePubkey(suggestion.pubkey ?? "");
|
||||
if (!audienceScope || !pubkey || !suggestion.isAgent) return;
|
||||
|
||||
if (lockedAgentPubkeys.has(pubkey)) {
|
||||
removeAddressedAgent(pubkey);
|
||||
removeAddressedAgentMentions(pubkey);
|
||||
setAnnouncement(
|
||||
`Stopped automatically mentioning ${suggestion.displayName}`,
|
||||
);
|
||||
} else {
|
||||
unpinnedAgentPubkeysRef.current.delete(pubkey);
|
||||
audience.addPubkey(pubkey);
|
||||
onPulseAddressLock(pubkey);
|
||||
mentions.registerMentionPubkey(suggestion.displayName, pubkey, {
|
||||
isAgent: true,
|
||||
});
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
if (getMentionOffsets(text, suggestion.displayName).length === 0) {
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: `@${suggestion.displayName} `,
|
||||
preserveSelection: true,
|
||||
});
|
||||
}
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
if (onAddressAgentMention) {
|
||||
onAddressAgentMention(suggestion);
|
||||
} else {
|
||||
audience.addPubkey(pubkey);
|
||||
onPulseAddressLock(pubkey);
|
||||
}
|
||||
setAnnouncement(`Automatically mentioning ${suggestion.displayName}`);
|
||||
}
|
||||
|
||||
if (mentions.isMentionOpen) {
|
||||
if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) {
|
||||
const { text, cursor } = richText.getPlainTextAndCursor();
|
||||
const activeMention = detectPrefixQuery("@", text, cursor, [
|
||||
suggestion.displayName.toLowerCase(),
|
||||
@@ -190,12 +241,16 @@ export function useAgentAddressLockPicker({
|
||||
audience.addPubkey,
|
||||
audienceScope,
|
||||
lockedAgentPubkeys,
|
||||
mentions.isInlineMentionSelection,
|
||||
mentions.isMentionOpen,
|
||||
mentions.mentionStartIndex,
|
||||
mentions.openMentionPicker,
|
||||
mentions.registerMentionPubkey,
|
||||
onAddressAgentMention,
|
||||
onPulseAddressLock,
|
||||
removeAddressedAgent,
|
||||
removeAddressedAgentMentions,
|
||||
richText.getPlainTextAndCursor,
|
||||
trackMentionAddressedAgent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -209,15 +264,25 @@ export function useAgentAddressLockPicker({
|
||||
unpinnedAgentPubkeysRef.current.has(pubkey);
|
||||
if (mentions.isInlineMentionSelection() || wasUnpinned) {
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey);
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
onAutoPinAgentMention?.(suggestion);
|
||||
return;
|
||||
}
|
||||
|
||||
consumeAddressSuggestion(suggestion, { removeInlineMentions: false });
|
||||
applyAutocompleteEdit(mentions.insertMention(suggestion, cursor));
|
||||
if (!lockedAgentPubkeys.has(pubkey)) {
|
||||
audience.addPubkey(pubkey);
|
||||
trackMentionAddressedAgent(pubkey);
|
||||
if (onAddressAgentMention) {
|
||||
onAddressAgentMention(suggestion);
|
||||
} else {
|
||||
audience.addPubkey(pubkey);
|
||||
onPulseAddressLock(pubkey);
|
||||
}
|
||||
setAnnouncement(`Automatically mentioning ${suggestion.displayName}`);
|
||||
} else {
|
||||
onPulseAddressLock(pubkey);
|
||||
}
|
||||
onPulseAddressLock(pubkey);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,12 +293,84 @@ export function useAgentAddressLockPicker({
|
||||
applyAutocompleteEdit,
|
||||
audience.addPubkey,
|
||||
audienceScope,
|
||||
consumeAddressSuggestion,
|
||||
lockedAgentPubkeys,
|
||||
mentions.isInlineMentionSelection,
|
||||
mentions.insertMention,
|
||||
onAddressAgentMention,
|
||||
onAutoPinAgentMention,
|
||||
onPulseAddressLock,
|
||||
richText.getPlainTextAndCursor,
|
||||
trackMentionAddressedAgent,
|
||||
],
|
||||
);
|
||||
|
||||
const restoreAddressedAgentMentions = React.useCallback(
|
||||
(
|
||||
pubkeys?: readonly string[],
|
||||
allowedUnpinnedPubkeys: readonly string[] = [],
|
||||
) => {
|
||||
const restorePubkeys = pubkeys
|
||||
? new Set(pubkeys.map(normalizePubkey))
|
||||
: null;
|
||||
const allowedUnpinned = new Set(
|
||||
allowedUnpinnedPubkeys.map(normalizePubkey),
|
||||
);
|
||||
const currentAudiencePubkeys = new Set(
|
||||
audience.pubkeys.map(normalizePubkey),
|
||||
);
|
||||
const targetAgents = [...(restorePubkeys ?? currentAudiencePubkeys)]
|
||||
.filter(
|
||||
(pubkey) =>
|
||||
currentAudiencePubkeys.has(pubkey) || allowedUnpinned.has(pubkey),
|
||||
)
|
||||
.map((pubkey) => {
|
||||
const profile = profiles?.[pubkey];
|
||||
const displayName =
|
||||
profile?.displayName?.trim() ||
|
||||
profile?.name?.trim() ||
|
||||
profile?.nip05Handle?.trim() ||
|
||||
mentions.getMentionDisplayName(pubkey)?.trim() ||
|
||||
lockedAgentNamesRef.current.get(pubkey) ||
|
||||
truncatePubkey(pubkey);
|
||||
return { pubkey, displayName };
|
||||
});
|
||||
const { text } = richText.getPlainTextAndCursor();
|
||||
for (const agent of targetAgents) {
|
||||
if (getMentionOffsets(text, agent.displayName).length > 0) {
|
||||
visibleAgentMentionPubkeysRef.current.add(agent.pubkey);
|
||||
}
|
||||
}
|
||||
const missingAgents = targetAgents.filter(
|
||||
(agent) =>
|
||||
(!unpinnedAgentPubkeysRef.current.has(agent.pubkey) ||
|
||||
allowedUnpinned.has(agent.pubkey)) &&
|
||||
getMentionOffsets(text, agent.displayName).length === 0,
|
||||
);
|
||||
if (missingAgents.length === 0) return text;
|
||||
for (const agent of missingAgents) {
|
||||
mentions.registerMentionPubkey(agent.displayName, agent.pubkey, {
|
||||
isAgent: true,
|
||||
});
|
||||
visibleAgentMentionPubkeysRef.current.add(agent.pubkey);
|
||||
}
|
||||
const insertedText = `${missingAgents
|
||||
.map((agent) => `@${agent.displayName}`)
|
||||
.join(" ")} `;
|
||||
applyAutocompleteEdit({
|
||||
replaceFromOffset: 0,
|
||||
replaceToOffset: 0,
|
||||
insertText: insertedText,
|
||||
preserveSelection: true,
|
||||
});
|
||||
return `${insertedText}${text}`;
|
||||
},
|
||||
[
|
||||
applyAutocompleteEdit,
|
||||
audience.pubkeys,
|
||||
mentions.getMentionDisplayName,
|
||||
mentions.registerMentionPubkey,
|
||||
profiles,
|
||||
richText.getPlainTextAndCursor,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -242,7 +379,10 @@ export function useAgentAddressLockPicker({
|
||||
lockedAgents,
|
||||
lockedAgentPubkeys,
|
||||
removeAddressedAgent,
|
||||
restoreAddressedAgentMentions,
|
||||
selectMentionSuggestion,
|
||||
syncAddressedAgentsFromText,
|
||||
toggleAlwaysAddressAgent,
|
||||
trackMentionAddressedAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete";
|
||||
|
||||
export function useAlwaysAddressShortcut({
|
||||
enabled,
|
||||
lockedAgent,
|
||||
mentions,
|
||||
onOpenPicker,
|
||||
onSelect,
|
||||
onToggle,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
lockedAgent?: Pick<MentionSuggestion, "avatarUrl" | "displayName" | "pubkey">;
|
||||
mentions: UseMentionsResult;
|
||||
onOpenPicker: (insertTrigger?: boolean) => void;
|
||||
onSelect: (suggestion: MentionSuggestion) => void;
|
||||
onToggle: (suggestion: MentionSuggestion) => void;
|
||||
}) {
|
||||
const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions;
|
||||
const {
|
||||
getDefaultAgentSuggestion,
|
||||
isMentionOpen,
|
||||
mentionSelectedIndex,
|
||||
suggestions,
|
||||
} = mentions;
|
||||
return React.useCallback(
|
||||
(event: React.KeyboardEvent): boolean => {
|
||||
if (
|
||||
!enabled ||
|
||||
event.key !== "Enter" ||
|
||||
event.code !== "KeyM" ||
|
||||
!hasPrimaryShortcutModifier(event) ||
|
||||
event.altKey ||
|
||||
!event.shiftKey
|
||||
@@ -30,21 +39,30 @@ export function useAlwaysAddressShortcut({
|
||||
|
||||
event.preventDefault();
|
||||
if (event.repeat) return true;
|
||||
if (!isMentionOpen) {
|
||||
onOpenPicker(false);
|
||||
const suggestion = isMentionOpen
|
||||
? suggestions[mentionSelectedIndex]
|
||||
: lockedAgent
|
||||
? { ...lockedAgent, isAgent: true }
|
||||
: getDefaultAgentSuggestion();
|
||||
if (!suggestion?.isAgent || !suggestion.pubkey) {
|
||||
if (!isMentionOpen) onOpenPicker(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
const suggestion = suggestions[mentionSelectedIndex];
|
||||
if (!suggestion?.isAgent || !suggestion.pubkey) return true;
|
||||
onToggle(suggestion);
|
||||
if (isMentionOpen) {
|
||||
onSelect(suggestion);
|
||||
} else {
|
||||
onToggle(suggestion);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
getDefaultAgentSuggestion,
|
||||
isMentionOpen,
|
||||
lockedAgent,
|
||||
mentionSelectedIndex,
|
||||
onOpenPicker,
|
||||
onSelect,
|
||||
onToggle,
|
||||
suggestions,
|
||||
],
|
||||
|
||||
@@ -1,36 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getPersistentAgentAudienceRevision,
|
||||
promotePersistentAgentAudienceIfUnchanged,
|
||||
removePersistentAgentAudienceMembersIfUnchanged,
|
||||
} from "@/features/messages/lib/persistentAgentAudience";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
const CONFIRMATION_DURATION_MS = 4_000;
|
||||
|
||||
type Confirmation = {
|
||||
expectedRevision: number;
|
||||
pubkeys: readonly string[];
|
||||
scope: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
audienceScope: string | null;
|
||||
enabled: boolean;
|
||||
getDisplayName: (pubkey: string) => string | null | undefined;
|
||||
onOpenOptions: () => void;
|
||||
onPulse: (pubkey: string) => void;
|
||||
onTurnOff: () => void;
|
||||
};
|
||||
|
||||
export function useAutoPinMentionedAgents({
|
||||
audienceScope,
|
||||
enabled,
|
||||
getDisplayName,
|
||||
onOpenOptions,
|
||||
onPulse,
|
||||
onTurnOff,
|
||||
}: Options) {
|
||||
return React.useCallback(
|
||||
const [confirmation, setConfirmation] = React.useState<Confirmation | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!confirmation) return;
|
||||
const timeout = window.setTimeout(
|
||||
() => setConfirmation(null),
|
||||
CONFIRMATION_DURATION_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [confirmation]);
|
||||
|
||||
const promoteAgents = React.useCallback(
|
||||
({
|
||||
expectedRevision,
|
||||
expectedRevision = audienceScope
|
||||
? getPersistentAgentAudienceRevision(audienceScope)
|
||||
: 0,
|
||||
pubkeys,
|
||||
requirePreference,
|
||||
}: {
|
||||
expectedRevision: number;
|
||||
expectedRevision?: number;
|
||||
pubkeys: readonly string[];
|
||||
requirePreference: boolean;
|
||||
}) => {
|
||||
if (!audienceScope || !enabled) return;
|
||||
if (!audienceScope || (requirePreference && !enabled)) return;
|
||||
const normalizedPubkeys = [
|
||||
...new Set(pubkeys.map(normalizePubkey)),
|
||||
].filter(Boolean);
|
||||
@@ -52,23 +78,47 @@ export function useAutoPinMentionedAgents({
|
||||
: promotedPubkeys.length === 1
|
||||
? "Agent will be mentioned automatically"
|
||||
: `${promotedPubkeys.length} agents will be mentioned automatically`;
|
||||
toast.success(title, {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => {
|
||||
if (
|
||||
removePersistentAgentAudienceMembersIfUnchanged({
|
||||
expectedRevision: revision,
|
||||
pubkeys: promotedPubkeys,
|
||||
scope: audienceScope,
|
||||
})
|
||||
) {
|
||||
onOpenOptions();
|
||||
}
|
||||
},
|
||||
},
|
||||
setConfirmation({
|
||||
expectedRevision: revision,
|
||||
pubkeys: promotedPubkeys,
|
||||
scope: audienceScope,
|
||||
title,
|
||||
});
|
||||
},
|
||||
[audienceScope, enabled, getDisplayName, onOpenOptions, onPulse],
|
||||
[audienceScope, enabled, getDisplayName, onPulse],
|
||||
);
|
||||
const promoteMentionedAgents = React.useCallback(
|
||||
(promotion: { expectedRevision?: number; pubkeys: readonly string[] }) =>
|
||||
promoteAgents({ ...promotion, requirePreference: true }),
|
||||
[promoteAgents],
|
||||
);
|
||||
const promoteExplicitlyAddressedAgents = React.useCallback(
|
||||
(promotion: { expectedRevision?: number; pubkeys: readonly string[] }) =>
|
||||
promoteAgents({ ...promotion, requirePreference: false }),
|
||||
[promoteAgents],
|
||||
);
|
||||
|
||||
const dismissConfirmation = React.useCallback(
|
||||
() => setConfirmation(null),
|
||||
[],
|
||||
);
|
||||
const turnOffConfirmation = React.useCallback(() => {
|
||||
if (!confirmation) return;
|
||||
setConfirmation(null);
|
||||
removePersistentAgentAudienceMembersIfUnchanged({
|
||||
expectedRevision: confirmation.expectedRevision,
|
||||
pubkeys: confirmation.pubkeys,
|
||||
scope: confirmation.scope,
|
||||
});
|
||||
onTurnOff();
|
||||
}, [confirmation, onTurnOff]);
|
||||
|
||||
return {
|
||||
confirmationTitle:
|
||||
confirmation?.scope === audienceScope ? confirmation.title : null,
|
||||
dismissConfirmation,
|
||||
promoteExplicitlyAddressedAgents,
|
||||
promoteMentionedAgents,
|
||||
turnOffConfirmation,
|
||||
};
|
||||
}
|
||||
|
||||
73
desktop/src/features/messages/ui/useComposerPasteHandler.ts
Normal file
73
desktop/src/features/messages/ui/useComposerPasteHandler.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as React from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard";
|
||||
import type { BlobDescriptor } from "@/shared/api/tauri";
|
||||
import {
|
||||
hasMentionClipboardHtml,
|
||||
normalizeMentionClipboardHtml,
|
||||
} from "@/features/messages/lib/normalizeMentionClipboard";
|
||||
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
|
||||
|
||||
export function useComposerPasteHandler(options: {
|
||||
editor: Editor | null;
|
||||
scrollToBottom: () => void;
|
||||
setPendingImeta: (
|
||||
update: (current: BlobDescriptor[]) => BlobDescriptor[],
|
||||
) => void;
|
||||
uploadFile: (file: File) => Promise<unknown>;
|
||||
}) {
|
||||
const uploadFileRef = React.useRef(options.uploadFile);
|
||||
uploadFileRef.current = options.uploadFile;
|
||||
React.useEffect(() => {
|
||||
const editor = options.editor;
|
||||
if (!editor) return;
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
...editor.options.editorProps,
|
||||
handlePaste: (view, event) => {
|
||||
const mediaItem = Array.from(event.clipboardData?.items ?? []).find(
|
||||
(item) => item.kind === "file",
|
||||
);
|
||||
if (mediaItem) {
|
||||
const file = mediaItem.getAsFile();
|
||||
if (file) void uploadFileRef.current(file);
|
||||
return true;
|
||||
}
|
||||
const codeBlockText = getBuzzCodeBlockClipboardText(
|
||||
event.clipboardData,
|
||||
);
|
||||
if (codeBlockText !== null) {
|
||||
event.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "codeBlock",
|
||||
content:
|
||||
codeBlockText.length > 0
|
||||
? [{ type: "text", text: codeBlockText }]
|
||||
: [],
|
||||
},
|
||||
{ type: "paragraph" },
|
||||
])
|
||||
.run();
|
||||
options.scrollToBottom();
|
||||
return true;
|
||||
}
|
||||
if (handleAgentSnapshotPaste(event, options.setPendingImeta))
|
||||
return true;
|
||||
const html = event.clipboardData?.getData("text/html");
|
||||
if (html && hasMentionClipboardHtml(html)) {
|
||||
event.preventDefault();
|
||||
view.pasteHTML(normalizeMentionClipboardHtml(html));
|
||||
return true;
|
||||
}
|
||||
if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n"))
|
||||
options.scrollToBottom();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [options.editor, options.scrollToBottom, options.setPendingImeta]);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export { MENTION_REFERENCE_TAG };
|
||||
|
||||
export type PendingNonMemberMentionSend = {
|
||||
addressedAgentPubkeys: string[];
|
||||
audienceRevision: number;
|
||||
inlineAgentMentionPubkeys: string[];
|
||||
capturedChannelId: string | null;
|
||||
capturedThreadContext: {
|
||||
@@ -38,7 +37,6 @@ export type PendingNonMemberMentionSend = {
|
||||
|
||||
export type SendMessageWithMentionFlowInput = {
|
||||
addressedAgentPubkeys?: readonly string[];
|
||||
audienceRevision?: number;
|
||||
capturedChannelId: string | null;
|
||||
capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"];
|
||||
pendingImeta: ImetaMedia[];
|
||||
|
||||
@@ -17,21 +17,14 @@ import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgent
|
||||
import {
|
||||
prepareBackgroundMediaUpload,
|
||||
saveQueuedAttachmentsForDraft,
|
||||
type QueuedMediaAttachment,
|
||||
} from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import {
|
||||
buildOutgoingMessage,
|
||||
type ImetaMedia,
|
||||
} from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
|
||||
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
|
||||
import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types";
|
||||
import type { AcpRuntime, ManagedAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags";
|
||||
import {
|
||||
@@ -47,46 +40,8 @@ import {
|
||||
uniqueNormalizedPubkeys,
|
||||
} from "./useMentionSendFlow.helpers";
|
||||
import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs";
|
||||
type UseMentionSendFlowOptions = {
|
||||
channelId: string | null;
|
||||
channelLinks: Pick<UseChannelLinksResult, "clearChannels">;
|
||||
channelType: ChannelType | null;
|
||||
contentRef: React.MutableRefObject<string>;
|
||||
customEmoji: CustomEmoji[];
|
||||
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
|
||||
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
|
||||
mentions: UseMentionsResult;
|
||||
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
|
||||
onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void;
|
||||
onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void;
|
||||
onInlineAgentMentionsSent?: (promotion: {
|
||||
expectedRevision: number;
|
||||
pubkeys: readonly string[];
|
||||
}) => void;
|
||||
onSendRef: React.MutableRefObject<
|
||||
(
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
channelId?: string | null,
|
||||
threadContext?: {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
forceRest?: boolean,
|
||||
) => Promise<void>
|
||||
>;
|
||||
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
|
||||
setContent: (content: string) => void;
|
||||
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
|
||||
hasUnsavedMedia: () => boolean;
|
||||
clearQueuedAttachments: () => void;
|
||||
restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void;
|
||||
setSpoileredAttachmentUrls?: React.Dispatch<
|
||||
React.SetStateAction<Set<string>>
|
||||
>;
|
||||
};
|
||||
import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types";
|
||||
|
||||
export function useMentionSendFlow({
|
||||
channelId,
|
||||
channelLinks,
|
||||
@@ -98,8 +53,9 @@ export function useMentionSendFlow({
|
||||
mentions,
|
||||
onPrepareSendChannel,
|
||||
onAddressedAgentsSendStarted,
|
||||
onAddressedAgentsComposerCleared,
|
||||
onAddressedAgentsSendFailed,
|
||||
onInlineAgentMentionsSent,
|
||||
onAddressedAgentsSendSucceeded,
|
||||
onSendRef,
|
||||
richText,
|
||||
setContent,
|
||||
@@ -406,6 +362,7 @@ export function useMentionSendFlow({
|
||||
);
|
||||
};
|
||||
let composerCleared = false;
|
||||
let optimisticComposerContent = "";
|
||||
const restoreComposerAfterFailure = () => {
|
||||
if (!composerCleared) return;
|
||||
composerCleared = false;
|
||||
@@ -422,7 +379,7 @@ export function useMentionSendFlow({
|
||||
}
|
||||
const canRestoreCurrentComposer =
|
||||
canAnimateCurrentComposer &&
|
||||
contentRef.current.trim().length === 0 &&
|
||||
contentRef.current.trim() === optimisticComposerContent.trim() &&
|
||||
!hasUnsavedMedia();
|
||||
if (!canRestoreCurrentComposer && draft.recoveryDraftKey) {
|
||||
saveQueuedAttachmentsForDraft(
|
||||
@@ -451,6 +408,12 @@ export function useMentionSendFlow({
|
||||
onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys);
|
||||
}
|
||||
clearComposer();
|
||||
if (draft.addressedAgentPubkeys.length > 0) {
|
||||
optimisticComposerContent =
|
||||
onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ??
|
||||
"";
|
||||
contentRef.current = optimisticComposerContent;
|
||||
}
|
||||
composerCleared = true;
|
||||
}
|
||||
let uploadStarted = false;
|
||||
@@ -587,12 +550,23 @@ export function useMentionSendFlow({
|
||||
const sentMentionPubkeys = new Set(
|
||||
revalidatedMentionPubkeys.map(normalizePubkey),
|
||||
);
|
||||
onInlineAgentMentionsSent?.({
|
||||
expectedRevision: draft.audienceRevision,
|
||||
pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) =>
|
||||
sentMentionPubkeys.has(normalizePubkey(pubkey)),
|
||||
),
|
||||
});
|
||||
const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter(
|
||||
(pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)),
|
||||
);
|
||||
if (
|
||||
draft.capturedChannelId === channelIdRef.current ||
|
||||
channelIdRef.current === null
|
||||
) {
|
||||
onAddressedAgentsSendSucceeded?.(
|
||||
[
|
||||
...new Set([
|
||||
...draft.addressedAgentPubkeys,
|
||||
...newlyPinnedPubkeys,
|
||||
]),
|
||||
],
|
||||
newlyPinnedPubkeys,
|
||||
);
|
||||
}
|
||||
if (draft.sentDraftKey) {
|
||||
drafts.markDraftSent(
|
||||
draft.sentDraftKey,
|
||||
@@ -604,12 +578,18 @@ export function useMentionSendFlow({
|
||||
}
|
||||
};
|
||||
if (preparedUpload) {
|
||||
let settleUpload!: () => void;
|
||||
const uploadSettled = new Promise<void>((resolve) => {
|
||||
settleUpload = resolve;
|
||||
});
|
||||
uploadStarted = preparedUpload.start({
|
||||
onComplete: async (uploaded, signal) => {
|
||||
try {
|
||||
await finishSend(uploaded, signal);
|
||||
} catch {
|
||||
restoreComposerAfterFailure();
|
||||
} finally {
|
||||
settleUpload();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -617,14 +597,18 @@ export function useMentionSendFlow({
|
||||
toast.error(
|
||||
`Upload failed: ${getErrorMessage(error, "Unknown error")}`,
|
||||
);
|
||||
settleUpload();
|
||||
},
|
||||
onCancel: () => {
|
||||
restoreComposerAfterFailure();
|
||||
settleUpload();
|
||||
},
|
||||
});
|
||||
if (!uploadStarted) {
|
||||
settleUpload();
|
||||
return restoreComposerAfterFailure();
|
||||
}
|
||||
await uploadSettled;
|
||||
}
|
||||
if (!preparedUpload) {
|
||||
try {
|
||||
@@ -657,8 +641,9 @@ export function useMentionSendFlow({
|
||||
mentions.isAgentPubkey,
|
||||
mentions.revalidateMentionPubkeys,
|
||||
onAddressedAgentsSendStarted,
|
||||
onAddressedAgentsComposerCleared,
|
||||
onAddressedAgentsSendFailed,
|
||||
onInlineAgentMentionsSent,
|
||||
onAddressedAgentsSendSucceeded,
|
||||
onPrepareSendChannel,
|
||||
onSendRef,
|
||||
richText.setContent,
|
||||
@@ -674,7 +659,6 @@ export function useMentionSendFlow({
|
||||
const sendMessageWithMentionFlow = React.useCallback(
|
||||
async ({
|
||||
addressedAgentPubkeys = [],
|
||||
audienceRevision = 0,
|
||||
capturedChannelId,
|
||||
capturedThreadContext = null,
|
||||
pendingImeta,
|
||||
@@ -783,7 +767,6 @@ export function useMentionSendFlow({
|
||||
const savedMentionRefs = mentions.getDraftMentionRefs(trimmed);
|
||||
const pendingDraft: PendingNonMemberMentionSend = {
|
||||
addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys),
|
||||
audienceRevision,
|
||||
inlineAgentMentionPubkeys: uniqueNormalizedPubkeys(
|
||||
savedMentionRefs
|
||||
.filter((ref) => ref.isAgent)
|
||||
|
||||
52
desktop/src/features/messages/ui/useMentionSendFlow.types.ts
Normal file
52
desktop/src/features/messages/ui/useMentionSendFlow.types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type * as React from "react";
|
||||
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
|
||||
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
|
||||
|
||||
export type UseMentionSendFlowOptions = {
|
||||
channelId: string | null;
|
||||
channelLinks: Pick<UseChannelLinksResult, "clearChannels">;
|
||||
channelType: ChannelType | null;
|
||||
contentRef: React.MutableRefObject<string>;
|
||||
customEmoji: CustomEmoji[];
|
||||
drafts: Pick<UseDraftsResult, "loadDraft" | "markDraftSent" | "persistDraft">;
|
||||
emojiAutocomplete: Pick<UseEmojiAutocompleteResult, "clearEmojis">;
|
||||
mentions: UseMentionsResult;
|
||||
onPrepareSendChannel?: (pubkeys?: string[]) => Promise<string | null>;
|
||||
onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void;
|
||||
onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string;
|
||||
onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void;
|
||||
onAddressedAgentsSendSucceeded?: (
|
||||
pubkeys: readonly string[],
|
||||
newlyPinnedPubkeys: readonly string[],
|
||||
) => void;
|
||||
onSendRef: React.MutableRefObject<
|
||||
(
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
channelId?: string | null,
|
||||
threadContext?: {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
forceRest?: boolean,
|
||||
) => Promise<void>
|
||||
>;
|
||||
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
|
||||
setContent: (content: string) => void;
|
||||
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
|
||||
hasUnsavedMedia: () => boolean;
|
||||
clearQueuedAttachments: () => void;
|
||||
restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void;
|
||||
setSpoileredAttachmentUrls?: React.Dispatch<
|
||||
React.SetStateAction<Set<string>>
|
||||
>;
|
||||
};
|
||||
@@ -166,9 +166,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
|
||||
{
|
||||
id: "always-address-agent",
|
||||
label: "Always address agent",
|
||||
description: "Open the agent picker, or toggle the highlighted agent",
|
||||
keys: "⇧⌘↵",
|
||||
keysWindows: "Ctrl+Shift+Enter",
|
||||
description: "Address the default agent, or select the highlighted agent",
|
||||
keys: "⇧⌘M",
|
||||
keysWindows: "Ctrl+Shift+M",
|
||||
category: "Messages",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -430,7 +430,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "local"))
|
||||
.toEqual([managedPubkey]);
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input).toHaveText("@carl ");
|
||||
|
||||
await page.getByTestId(`composer-address-lock-${managedPubkey}`).click();
|
||||
await input.fill("@carl");
|
||||
@@ -720,7 +720,7 @@ test("defers agent mentions until DM members finish loading", async ({
|
||||
expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe(
|
||||
commandCount(baselineCommands, "add_channel_members"),
|
||||
);
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input).toHaveText("@alice ");
|
||||
await expect(threadPanel).toContainText("before members resolve");
|
||||
});
|
||||
|
||||
@@ -1290,7 +1290,8 @@ test("managed relay-profile agents with member roles use the agent address tray"
|
||||
await expect(dropdown.getByText("agent")).toBeVisible();
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input).toHaveText("@charlie ");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toBeVisible();
|
||||
@@ -2642,8 +2643,8 @@ test("selecting a managed non-member agent from a DM addresses it", async ({
|
||||
await expect(input.locator(".mention-chip")).toHaveCount(0);
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toBeEmpty();
|
||||
await expect(input.locator(".mention-chip")).toHaveCount(0);
|
||||
await expect(input).toHaveText("@charlie ");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie");
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
@@ -28,6 +28,9 @@ async function automaticallyMention(
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: `Automatically mention ${displayName}` })
|
||||
.click();
|
||||
await expect(composer.getByTestId("message-input")).toContainText(
|
||||
`@${displayName}`,
|
||||
);
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
}
|
||||
|
||||
@@ -54,7 +57,20 @@ function threadComposer(page: Page) {
|
||||
return page.getByTestId("thread-composer-overlay");
|
||||
}
|
||||
|
||||
async function pressPrimaryShift(page: Page, key: "Enter" | "M") {
|
||||
async function readComposerCaret(input: Locator) {
|
||||
return input.evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.anchorNode || !element.contains(selection.anchorNode)) {
|
||||
return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.setEnd(selection.anchorNode, selection.anchorOffset);
|
||||
return range.toString().length;
|
||||
});
|
||||
}
|
||||
|
||||
async function pressPrimaryShift(page: Page, key: "M") {
|
||||
const isMac = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform),
|
||||
);
|
||||
@@ -106,11 +122,38 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) {
|
||||
}, content);
|
||||
}
|
||||
|
||||
async function emitMockMessage(
|
||||
page: Page,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ body, mentions }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
mentionPubkeys: mentions,
|
||||
});
|
||||
},
|
||||
{ body: content, mentions: mentionPubkeys },
|
||||
);
|
||||
}
|
||||
|
||||
async function installAudienceFixtures(
|
||||
page: Page,
|
||||
options: {
|
||||
deferredComposerUploads?: boolean;
|
||||
sendMessageDelayMs?: number;
|
||||
sendMessageErrors?: string[];
|
||||
uploadDelayMs?: number;
|
||||
uploadDescriptors?: Array<{
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
type: string;
|
||||
uploaded: number;
|
||||
url: string;
|
||||
}>;
|
||||
usersBatchDelayMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
@@ -133,6 +176,80 @@ async function installAudienceFixtures(
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps a queued-attachment send locked through upload and send settlement", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page, {
|
||||
deferredComposerUploads: true,
|
||||
uploadDelayMs: 2_000,
|
||||
sendMessageDelayMs: 2_000,
|
||||
uploadDescriptors: [
|
||||
{
|
||||
filename: "delayed-video.mp4",
|
||||
sha256: "d".repeat(64),
|
||||
size: 16,
|
||||
type: "video/mp4",
|
||||
uploaded: 1,
|
||||
url: `https://mock.relay/media/${"d".repeat(64)}.mp4`,
|
||||
},
|
||||
],
|
||||
});
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const composerForm = composer.getByTestId("message-composer");
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("delayed upload");
|
||||
|
||||
const [chooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser"),
|
||||
composer.getByRole("button", { name: "Attach file" }).click(),
|
||||
]);
|
||||
await chooser.setFiles({
|
||||
buffer: Buffer.from("delayed video"),
|
||||
mimeType: "video/mp4",
|
||||
name: "delayed-video.mp4",
|
||||
});
|
||||
|
||||
const sendAttempts = () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.filter(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
).length ?? 0,
|
||||
);
|
||||
const sendAttemptsBefore = await sendAttempts();
|
||||
await input.press("Enter");
|
||||
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
|
||||
|
||||
// Observe the synchronous lock itself after upload has started. The exact
|
||||
// premature-release mutation (`void uploadSettled; await Promise.resolve()`)
|
||||
// has already cleared this attribute by this boundary, before any retryable
|
||||
// downstream command or restored-audience timing can obscure the defect.
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
|
||||
await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1);
|
||||
|
||||
// Command entry marks the independently delayed finishSend() window. Keep
|
||||
// the synchronous lock held and fence repeated submits until it settles.
|
||||
await expect(composer.getByTestId("composer-upload-progress")).toBeVisible();
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
await input.press("Enter");
|
||||
await input.press("Enter");
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "true");
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "delayed upload" })
|
||||
.last(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await expect(composerForm).toHaveAttribute("data-submit-locked", "false");
|
||||
});
|
||||
|
||||
test("automatically mentions multiple agents from the mention picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -154,9 +271,7 @@ test("automatically mentions multiple agents from the mention picker", async ({
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Tab keeps a manually selected agent as an inline mention", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Tab immediately selects a manually mentioned agent", async ({ page }) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
@@ -169,12 +284,21 @@ test("Tab keeps a manually selected agent as an inline mention", async ({
|
||||
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
const selectAllShortcut = await page.evaluate(() =>
|
||||
/mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A",
|
||||
);
|
||||
await input.press(selectAllShortcut);
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("primary+Shift+Enter opens the picker, then pins the highlighted agent", async ({
|
||||
test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
@@ -183,23 +307,65 @@ test("primary+Shift+Enter opens the picker, then pins the highlighted agent", as
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await pressPrimaryShift(page, "Enter");
|
||||
await pressPrimaryShift(page, "M");
|
||||
|
||||
const menu = composer.getByTestId("mention-autocomplete");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("alice");
|
||||
await expect(
|
||||
page.getByTestId("composer-auto-pin-confirmation"),
|
||||
).toContainText("alice will be mentioned automatically");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Stop automatically mentioning / }),
|
||||
).toHaveCount(1);
|
||||
|
||||
await input.fill("@Mor");
|
||||
await expect(menu.getByTestId(`mention-suggestion-${AGENT_A}`)).toHaveClass(
|
||||
await input.fill("@Vog");
|
||||
const menu = composer.getByTestId("mention-autocomplete");
|
||||
await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass(
|
||||
/(?:^|\s)bg-accent(?:\s|$)/,
|
||||
);
|
||||
await pressPrimaryShift(page, "Enter");
|
||||
await pressPrimaryShift(page, "M");
|
||||
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(input).toHaveText("@Vogue ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_B}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.getByRole("button", { name: /^Stop automatically mentioning / }),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("primary+Shift+M favors the most recently mentioned eligible agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
await emitMockMessage(page, "Please ask Vogue", [AGENT_B]);
|
||||
|
||||
const input = channelComposer(page).getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await input.press("ArrowLeft");
|
||||
await input.press("ArrowLeft");
|
||||
await expect.poll(() => readComposerCaret(input)).toBe(8);
|
||||
await pressPrimaryShift(page, "M");
|
||||
|
||||
await expect(input).toHaveText("@Vogue draft text");
|
||||
await expect.poll(() => readComposerCaret(input)).toBe(15);
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("@Vogue draft text");
|
||||
});
|
||||
|
||||
test("the mention button opens settings and can undo an address", async ({
|
||||
@@ -215,11 +381,11 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
name: "Manage automatic agent mentions",
|
||||
});
|
||||
|
||||
await input.fill("draft text");
|
||||
await input.type("draft text");
|
||||
await ingress.click();
|
||||
const menu = composer.getByTestId("mention-autocomplete");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await page.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
page.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
@@ -235,7 +401,7 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out");
|
||||
await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2);
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await ingress.click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute(
|
||||
@@ -247,7 +413,7 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
).toHaveCount(0);
|
||||
await ingress.click();
|
||||
await expect(menu).toHaveCount(0);
|
||||
await input.fill("");
|
||||
await expect(input).toHaveText("@Morgarita draft text");
|
||||
await ingress.click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-panel")).toHaveCount(0);
|
||||
@@ -260,10 +426,11 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await menu
|
||||
.getByRole("button", { name: "Stop automatically mentioning Morgarita" })
|
||||
.click();
|
||||
await expect(input).toHaveText("");
|
||||
await expect(input).toHaveText("draft text");
|
||||
await expect(
|
||||
composer.getByRole("button", { name: "Mention someone" }),
|
||||
).toBeVisible();
|
||||
await input.fill("");
|
||||
|
||||
await menu
|
||||
.getByRole("button", { name: "Mention Morgarita", exact: true })
|
||||
@@ -271,11 +438,11 @@ test("the mention button opens settings and can undo an address", async ({
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
|
||||
await input.type("later");
|
||||
await input.press("Enter");
|
||||
await expect(input).toHaveText("");
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later"))
|
||||
.toContain(AGENT_A);
|
||||
@@ -296,15 +463,41 @@ test("always-mentioned agents remain in the mention button while Enter-send reso
|
||||
const composer = threadComposer(page);
|
||||
await automaticallyMention(composer, "Morgarita");
|
||||
const input = composer.getByTestId("message-input");
|
||||
const send = composer.getByTestId("send-message");
|
||||
await input.evaluate((element) => {
|
||||
const snapshots = [element.textContent ?? ""];
|
||||
new MutationObserver(() =>
|
||||
snapshots.push(element.textContent ?? ""),
|
||||
).observe(element, { childList: true, characterData: true, subtree: true });
|
||||
(
|
||||
window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] }
|
||||
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots;
|
||||
});
|
||||
const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`);
|
||||
const initialPulseVersion = Number(
|
||||
await avatar.getAttribute("data-pulse-version"),
|
||||
);
|
||||
await input.fill("hello");
|
||||
await input.type("hello");
|
||||
await input.evaluate((element) => {
|
||||
const snapshots = (
|
||||
window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] }
|
||||
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__;
|
||||
snapshots?.splice(0, snapshots.length, element.textContent ?? "");
|
||||
});
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("", { timeout: 500 });
|
||||
await expect(input).toHaveText("@Morgarita ", { timeout: 500 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[];
|
||||
}
|
||||
).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [],
|
||||
),
|
||||
)
|
||||
.not.toContain("");
|
||||
await expect(avatar).toHaveAttribute(
|
||||
"data-pulse-version",
|
||||
String(initialPulseVersion + 1),
|
||||
@@ -316,10 +509,10 @@ test("always-mentioned agents remain in the mention button while Enter-send reso
|
||||
await expect(input).toBeFocused();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
|
||||
await expect(send).toBeDisabled();
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "hello"))
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello"))
|
||||
.toContain(AGENT_A);
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
|
||||
const sentRow = page
|
||||
.getByTestId("message-row")
|
||||
@@ -331,6 +524,18 @@ test("always-mentioned agents remain in the mention button while Enter-send reso
|
||||
);
|
||||
await expect(addressPrefix).toBeVisible();
|
||||
await expect(addressPrefix).toHaveText("Morgarita");
|
||||
|
||||
await input.type("follow up");
|
||||
await input.press("Enter");
|
||||
const inlineMentionRow = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "follow up" })
|
||||
.last();
|
||||
await expect(
|
||||
inlineMentionRow.locator("[data-mention].agent-mention-highlight", {
|
||||
hasText: "Morgarita",
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("a failed always-mentioned send shakes the composer avatar", async ({
|
||||
@@ -351,7 +556,7 @@ test("a failed always-mentioned send shakes the composer avatar", async ({
|
||||
);
|
||||
await expect(avatar).toHaveAttribute("data-shake-version", "0");
|
||||
|
||||
await input.fill("please retry");
|
||||
await input.type("please retry");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(avatar).toHaveAttribute(
|
||||
@@ -359,11 +564,11 @@ test("a failed always-mentioned send shakes the composer avatar", async ({
|
||||
String(initialPulseVersion + 1),
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await expect(input).toHaveText("please retry");
|
||||
await expect(input).toHaveText("@Morgarita please retry");
|
||||
await expect(avatar).toHaveAttribute("data-shake-version", "1");
|
||||
});
|
||||
|
||||
test("a manually mentioned agent becomes selected after the message sends", async ({
|
||||
test("a manually mentioned agent becomes selected immediately", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 });
|
||||
@@ -377,42 +582,74 @@ test("a manually mentioned agent becomes selected after the message sends", asyn
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
|
||||
const autoPinConfirmation = page.getByTestId(
|
||||
"composer-auto-pin-confirmation",
|
||||
);
|
||||
await expect(autoPinConfirmation).toContainText(
|
||||
"Morgarita will be mentioned automatically",
|
||||
);
|
||||
await expect(autoPinConfirmation).not.toContainText(
|
||||
"Future messages in this channel will include this agent.",
|
||||
);
|
||||
await expect(autoPinConfirmation).toHaveAttribute("data-side", "right");
|
||||
await expect(autoPinConfirmation.locator("span")).toHaveCSS(
|
||||
"white-space",
|
||||
"nowrap",
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.locator("[data-sonner-toast]")
|
||||
.filter({ hasText: "Morgarita will be mentioned automatically" }),
|
||||
).toHaveCount(0);
|
||||
const addressControlBox = await composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.locator("..")
|
||||
.boundingBox();
|
||||
const confirmationBox = await autoPinConfirmation.boundingBox();
|
||||
expect(addressControlBox).not.toBeNull();
|
||||
expect(confirmationBox).not.toBeNull();
|
||||
if (!addressControlBox || !confirmationBox) {
|
||||
throw new Error("Automatic mention confirmation is not laid out");
|
||||
}
|
||||
expect(confirmationBox.x).toBeGreaterThan(
|
||||
addressControlBox.x + addressControlBox.width,
|
||||
);
|
||||
const turnOffAction = autoPinConfirmation.getByRole("button", {
|
||||
name: "Turn off",
|
||||
});
|
||||
await expect(turnOffAction).toHaveRole("button");
|
||||
await expect(turnOffAction).toHaveText("Turn off");
|
||||
|
||||
await input.press("Escape");
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
|
||||
await input.type("hello");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveText("", { timeout: 500 });
|
||||
await expect(input.locator("[data-placeholder]").first()).toHaveAttribute(
|
||||
"data-placeholder",
|
||||
"Message #general",
|
||||
{ timeout: 500 },
|
||||
);
|
||||
await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 });
|
||||
await expect(input.locator("[data-placeholder]")).toHaveCount(0);
|
||||
await expect(input).toBeFocused();
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible({ timeout: 2_500 });
|
||||
const autoPinToast = page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "Morgarita will be mentioned automatically" });
|
||||
await expect(autoPinToast).not.toContainText(
|
||||
"Future messages in this channel will include this agent.",
|
||||
);
|
||||
const undoAction = autoPinToast.locator("[data-action]");
|
||||
await expect(undoAction).toHaveRole("button");
|
||||
await expect(undoAction).toHaveText("Undo");
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello"))
|
||||
.toContain(AGENT_A);
|
||||
|
||||
await input.fill("follow up");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await input.press("Enter");
|
||||
await expect
|
||||
.poll(() => readOutgoingMentionPubkeys(page, "follow up"))
|
||||
.toContain(AGENT_A);
|
||||
.not.toContain(AGENT_A);
|
||||
});
|
||||
|
||||
test("the auto-pin toast can undo and the picker can restore the agent", async ({
|
||||
test("the auto-pin popover can turn off automatic agent mentions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installAudienceFixtures(page);
|
||||
@@ -424,41 +661,32 @@ test("the auto-pin toast can undo and the picker can restore the agent", async (
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await input.press("Space");
|
||||
await input.type("undo me");
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
const autoPinToast = page
|
||||
.locator("[data-sonner-toast][data-removed='false']")
|
||||
.filter({ hasText: "Morgarita will be mentioned automatically" });
|
||||
await autoPinToast.locator("[data-action]").click();
|
||||
const autoPinConfirmation = page.getByTestId(
|
||||
"composer-auto-pin-confirmation",
|
||||
);
|
||||
await expect(autoPinConfirmation).toContainText(
|
||||
"Morgarita will be mentioned automatically",
|
||||
);
|
||||
await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click();
|
||||
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toHaveCount(0);
|
||||
await expect(autoPinConfirmation).toHaveCount(0);
|
||||
|
||||
await composer.getByTestId("message-insert-mention").click();
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
await composer.getByTestId("mention-options-trigger").click();
|
||||
await expect(
|
||||
composer.getByTestId("mention-keep-agents-pinned-toggle"),
|
||||
).toBeVisible();
|
||||
).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await input.press("Escape");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0);
|
||||
await composer.locator("[data-mention-picker-trigger]").click();
|
||||
await composer
|
||||
.getByTestId("mention-autocomplete")
|
||||
.getByRole("button", { name: "Mention Morgarita", exact: true })
|
||||
.click();
|
||||
await expect(input).toHaveText("");
|
||||
await expect(
|
||||
composer.getByTestId(`composer-address-lock-${AGENT_A}`),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel automatic mentions carry into threads and stay synchronized", async ({
|
||||
@@ -479,12 +707,36 @@ test("channel automatic mentions carry into threads and stay synchronized", asyn
|
||||
await expect(threadAutomaticMention).toBeVisible();
|
||||
|
||||
await threadComposer(page)
|
||||
.getByRole("button", { name: "Stop automatically mentioning Morgarita" })
|
||||
.getByTestId(`composer-address-lock-remove-${AGENT_A}`)
|
||||
.click();
|
||||
await expect(threadAutomaticMention).toHaveCount(0);
|
||||
await expect(channelAutomaticMention).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("reduced motion removes addressed agents without spatial animation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("@Mor");
|
||||
await expect(composer.getByTestId("mention-autocomplete")).toBeVisible();
|
||||
await input.press("Tab");
|
||||
const removeButton = composer.getByTestId(
|
||||
`composer-address-lock-remove-${AGENT_A}`,
|
||||
);
|
||||
await expect(removeButton).toBeVisible();
|
||||
await expect(removeButton).toHaveAttribute("style", /opacity: 1/);
|
||||
await expect(removeButton).toHaveCSS("transform", "none");
|
||||
|
||||
await removeButton.click();
|
||||
await expect(input).toHaveText("@Morgarita ");
|
||||
await expect(removeButton).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const theme of ["buzz", "buzz-dark"]) {
|
||||
test(`captures the mention-button placement in ${theme}`, async ({
|
||||
page,
|
||||
@@ -519,3 +771,45 @@ test("the mention-button placement fits the narrow composer", async ({
|
||||
await waitForAnimations(page);
|
||||
await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` });
|
||||
});
|
||||
|
||||
test("captures the lightweight auto-pin popover", async ({ page }) => {
|
||||
await seedTheme(page, "buzz-dark");
|
||||
await installAudienceFixtures(page);
|
||||
await openGeneral(page);
|
||||
|
||||
const composer = channelComposer(page);
|
||||
const input = composer.getByTestId("message-input");
|
||||
await input.fill("draft text");
|
||||
await pressPrimaryShift(page, "M");
|
||||
await expect(input).toHaveText("@alice draft text");
|
||||
|
||||
const addressControl = composer
|
||||
.getByTestId("composer-address-locks")
|
||||
.locator("..");
|
||||
const confirmation = page.getByTestId("composer-auto-pin-confirmation");
|
||||
await expect(confirmation).toContainText(
|
||||
"alice will be mentioned automatically",
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
|
||||
const addressBox = await addressControl.boundingBox();
|
||||
const confirmationBox = await confirmation.boundingBox();
|
||||
expect(addressBox).not.toBeNull();
|
||||
expect(confirmationBox).not.toBeNull();
|
||||
if (!addressBox || !confirmationBox) {
|
||||
throw new Error("Popover is not laid out");
|
||||
}
|
||||
|
||||
const left = addressBox.x - 14;
|
||||
const top = Math.min(addressBox.y, confirmationBox.y) - 14;
|
||||
const right = confirmationBox.x + confirmationBox.width + 14;
|
||||
const bottom =
|
||||
Math.max(
|
||||
addressBox.y + addressBox.height,
|
||||
confirmationBox.y + confirmationBox.height,
|
||||
) + 14;
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/auto-pin-popover-dark.png`,
|
||||
clip: { x: left, y: top, width: right - left, height: bottom - top },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,8 +84,9 @@ test("message with agent mention lands in compose-time channel despite mid-send
|
||||
await input.press("Enter");
|
||||
await page.keyboard.type(` ${MESSAGE_TEXT}`);
|
||||
|
||||
// Verify the agent is addressed before submitting.
|
||||
await expect(input).toHaveText(` ${MESSAGE_TEXT}`);
|
||||
// Verify the inline mention and persistent address are present before submitting.
|
||||
await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`);
|
||||
await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA");
|
||||
await expect(
|
||||
page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`),
|
||||
).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user