From dc03a2b9f03cf81f2e152a4a691f563f3e9a71db Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Wed, 23 Sep 2026 08:36:28 -0600 Subject: [PATCH 1/2] Restore composer recipient avatars beside the mention tool Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- docs/design-system-adoption.md | 5 +- docs/plugin-architecture.md | 12 ++- src/features/conversation/ComposerTools.tsx | 22 ++++- .../messages/MessageComposer.test.tsx | 94 ++++++++++++++++++- src/features/messages/MessageComposer.tsx | 88 ++++++++++++++++- src/features/messages/Messages.module.css | 41 ++++++++ tests/browser/mentions.spec.mjs | 77 ++++++++++++++- tests/fixtures/mentions.tsx | 14 ++- 8 files changed, 333 insertions(+), 20 deletions(-) diff --git a/docs/design-system-adoption.md b/docs/design-system-adoption.md index fd5dcbfca..36ec24bac 100644 --- a/docs/design-system-adoption.md +++ b/docs/design-system-adoption.md @@ -37,8 +37,9 @@ pill actions, consistent fields and shared states. They are disclosures, not application menus; their content and state remain local. - Avatars, previews, links, mentions and thread summaries retain their identity and navigation ownership. Composer mentions use inert shared InlineChip rendering; - editing or deleting the mention removes its notification intent. Shared appearance - does not move their data. + editing or deleting the mention removes its notification intent. Host-owned + recipient avatars beside the mention tool also allow clearing that intent without + changing authored text. Shared appearance does not move their data. - Panel marks its surface separately from interactive components. Native product and plugin content inside it can still receive host defaults. - Legacy utility names remain available through the host bridge for existing diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 79d88d846..5461e43f5 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -379,8 +379,9 @@ chooser UI belongs in tool plugins: `bundled/emoji` and `bundled/mentions` use t same `registerTool` contract. No page imports their implementations. Optional numeric `order` (default zero, lower first; ties by contribution key) keeps visual and keyboard order stable across asynchronous activation and re-enable. Mentions uses -`-10` to retain its position before default-order tools such as Emoji. The host -renders tools in that order without a separate selected-recipient row. +`-10` to retain its position before default-order tools such as Emoji. The host groups +negative-order tools with selected-recipient avatars, preserving DOM/keyboard order; +this is host layout, not a new plugin contract. Inline identity chips remain in the draft. Links, channel references, selected mentions and custom emoji render through shared message components directly in the editable draft. Display tokens retain the exact authored source; @@ -401,9 +402,10 @@ presentation, never recipient resolution. Editing/pasting over an identity span removes its intent under the existing draft rules. **User intent outlives the tool that created it.** Disabling Mentions removes its -chooser, not selected recipients, their inline chips, scoped drafts or pending -messages. Editing or deleting a selected mention removes its notification intent; -there is no separate avatar removal control. The session still owns roster/profile data, membership +chooser, not selected recipients, their inline chips and avatar removal controls, +scoped drafts or pending messages. Editing or deleting a selected mention removes +its notification intent. Removing an avatar clears that identity's notification +intent without changing the authored text. The session still owns roster/profile data, membership checks, signing and publication/retry. Plugins remain trusted same-process code; revocable editor commands do not sandbox the session capabilities they receive. diff --git a/src/features/conversation/ComposerTools.tsx b/src/features/conversation/ComposerTools.tsx index 31ca88f13..9c0b7bf13 100644 --- a/src/features/conversation/ComposerTools.tsx +++ b/src/features/conversation/ComposerTools.tsx @@ -1,4 +1,10 @@ -import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; import type { Contribution } from "../../plugins/contributions"; import type { ComposerTool, @@ -9,9 +15,12 @@ import { ContributionBoundary, contributionKey } from "./ContributionBoundary"; export function ComposerTools({ registry, + renderLeading, ...props }: ComposerToolProps & { registry: ContributionReader; + /** Host layout for tools ordered before the default group; preserves DOM order. */ + renderLeading?: (tools: ReactNode) => ReactNode; }) { const tools = useSyncExternalStore( registry.subscribe, @@ -25,14 +34,21 @@ export function ComposerTools({ (a, b) => order(a) - order(b) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0), ); - return sorted.map((tool) => ( + const render = (tool: Contribution) => ( {tool.title} unavailable} > - )); + ); + if (!renderLeading) return sorted.map(render); + return ( + <> + {renderLeading(sorted.filter((tool) => order(tool) < 0).map(render))} + {sorted.filter((tool) => order(tool) >= 0).map(render)} + + ); } function OwnedTool({ tool, diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 93fbd85f8..f76496e1b 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -27,6 +27,7 @@ import { controlFixture } from "../agents/control-testing"; import type { OutgoingEvent } from "../relay/outbox"; import { MessageComposer, type MessageComposerProps } from "./MessageComposer"; import type { RelaySession } from "../relay/session"; +import type { Profile } from "../relay/contracts"; import { emojiMatches, type CustomEmoji } from "../relay/emoji"; import { CustomEmoji as CustomEmojiImage } from "../../bundled/emoji/CustomEmoji"; import type { ComposerInputElement } from "./composer-dom"; @@ -115,14 +116,20 @@ function mount( reply: vi.fn(() => "reply-id"), }; const typing: ReturnType = []; - const profiles = new Map(); + let profiles: ReadonlyMap = new Map(); + const profileListeners = new Set<() => void>(); const library = { status: "ready", identities: [], definitions: [] }; const session = { messages, typing: { snapshot: () => typing, subscribe: () => () => {} }, profiles: { snapshot: () => profiles, - subscribe: () => () => {}, + subscribe(listener: () => void) { + profileListeners.add(listener); + return () => { + profileListeners.delete(listener); + }; + }, ensure: vi.fn(async () => {}), }, agentLibrary: { @@ -222,6 +229,12 @@ function mount( props = { ...props, ...next }; view.rerender(tree()); }, + setProfiles(next: ReadonlyMap) { + act(() => { + profiles = next; + for (const listener of profileListeners) listener(); + }); + }, setEmoji(entries: readonly CustomEmoji[]) { act(() => { emoji = { status: "ready", entries }; @@ -522,8 +535,10 @@ it.each([undefined, "root"])( }), ).toHaveLength(2); expect( - screen.queryByRole("region", { name: "Notification recipients" }), - ).not.toBeInTheDocument(); + within( + screen.getByRole("region", { name: "Notification recipients" }), + ).getAllByRole("button"), + ).toHaveLength(2); h.submit(); expect( (root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1), @@ -539,6 +554,68 @@ it.each([undefined, "root"])( }, ); +it("restores live profile avatars with one removal control per exact recipient", async () => { + const h = mount(); + const media = vi + .spyOn(h.session, "media") + .mockImplementation((url) => + url ? `https://media.test/${url}` : undefined, + ); + act(() => { + h.commands().insertMention(first); + h.commands().insertMention(second); + h.commands().insertMention(second); + }); + let region = screen.getByRole("region", { + name: "Notification recipients", + }); + const controls = within(region).getAllByRole("button"); + expect(controls).toHaveLength(2); + expect(controls[1]).toHaveTextContent("H"); + // Profiles can arrive after draft restoration; artwork must update without an edit. + h.setProfiles( + new Map([ + [first.pubkey, { name: "Honey", picture: "person.png" }], + [second.pubkey, { name: "Honey", picture: "agent.png", isAgent: true }], + ]), + ); + expect(controls[0]?.querySelector(".buzz-avatar")).toHaveAttribute( + "data-avatar-shape", + "circle", + ); + expect(controls[1]?.querySelector(".buzz-avatar")).toHaveAttribute( + "data-avatar-shape", + "squircle", + ); + expect(controls[1]?.querySelector("img")).toHaveAttribute( + "src", + "https://media.test/agent.png", + ); + expect(media).toHaveBeenCalledWith("agent.png", "small"); + h.retarget({ disabled: true }); + for (const control of controls) expect(control).toBeDisabled(); + h.retarget({ disabled: false, extensions: undefined }); + // The optional picker does not own saved intent or its removal controls. + region = screen.getByRole("region", { name: "Notification recipients" }); + await h.user.click( + within(region).getByRole("button", { + name: `Remove mention Honey ${second.pubkey}`, + }), + ); + expect(within(region).getAllByRole("button")).toHaveLength(1); + expect(h.input()).toHaveValue("@Honey @Honey @Honey "); + expect(h.input().querySelectorAll(".inline-chip")).toHaveLength(1); + h.submit(); + expect(h.messages.send).toHaveBeenCalledWith( + "channel", + "@Honey @Honey @Honey ", + [first.pubkey], + ); + expect( + screen.queryByRole("region", { name: "Notification recipients" }), + ).not.toBeInTheDocument(); +}); + // Explicit notification intent must remain visible even where Markdown previews are suppressed. it.each([ ["inline code", "`", " `"], @@ -1314,6 +1391,15 @@ it.each([undefined, "root"])( second.pubkey, ]); expect(h.input()).toHaveValue("@Honey "); + const recipients = screen.getByRole("region", { + name: "Notification recipients", + }); + expect(within(recipients).getAllByRole("button")).toHaveLength(1); + expect( + within(recipients).getByRole("button", { + name: `Remove mention Honey ${second.pubkey}`, + }), + ).toBeVisible(); expect( within(h.input()).getAllByRole("img", { name: "Agent Honey" }), ).toHaveLength(1); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index f10e9c3bd..30799f768 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,5 +1,6 @@ import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; import { useMentionAgents } from "../agents/mention-context"; import { enrollMentionedAgents } from "../agents/mention-enrollment"; import { knownAgentPubkeys } from "../agents/known"; @@ -513,6 +514,26 @@ function Composer({ open={(target) => onOpenLink?.(target) ?? false} /> ); + const renderLeadingTools = (tools: ReactNode) => ( +
+ {tools} + {!!value.recipients.length && ( + + saveDraft({ + ...value, + recipients: value.recipients.filter( + (item) => item.pubkey !== pubkey, + ), + }) + } + /> + )} +
+ ); if (!outbox?.supports(9)) return ( <> @@ -679,6 +700,7 @@ function Composer({ {extensions ? ( input.current?.focus()} /> - ) : null} + ) : ( + renderLeadingTools(null) + )} {trailingTool ?? (sessionConversation ? ( @@ -744,3 +768,65 @@ function Composer({ ); } + +/** Presentation stays host-owned even when the optional mention tool is disabled. */ +function RecipientAvatars({ + session, + recipients, + disabled, + remove, +}: { + session: RelaySession; + recipients: readonly MentionRecipient[]; + disabled: boolean; + remove(pubkey: string): void; +}) { + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + session.profiles.snapshot, + ); + const unique = [ + ...new Map(recipients.map((item) => [item.pubkey, item])).values(), + ]; + return ( +
+ {unique.map((recipient) => { + const profile = profiles.get(recipient.pubkey); + return ( + remove(recipient.pubkey)} + icon={ + + } + /> + ); + })} +
+ ); +} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 718e83027..534cb6225 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -325,6 +325,47 @@ html[data-keyboard-navigation] letter-spacing: var(--text-caption--letter-spacing); } +.composerLeadingTools { + display: flex; + align-items: center; + flex-wrap: wrap; + max-width: 100%; + gap: var(--space-half); + border-radius: var(--radius-control); +} +.composerLeadingTools:empty { + display: none; +} +.composerLeadingTools:has(.mentionRecipients) { + padding-inline: var(--space-half); + background: var(--affordance-accent); +} +.mentionRecipients { + display: flex; + flex-wrap: wrap; + align-items: center; + max-width: 100%; + gap: var(--space-half); +} +.mentionRecipientArtwork { + display: grid; + position: relative; + overflow: hidden; +} +.mentionRecipientRemove { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: var(--affordance-prominent); + color: var(--text-inverse); + opacity: 0; +} +.mentionRecipients + [data-mention-recipient]:not([data-disabled]):is(:hover, :focus-visible) + .mentionRecipientRemove { + opacity: 1; +} .mediaAttachments { display: grid; justify-items: start; diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index 8f7a64db1..6fd858e08 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -157,9 +157,42 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta 2, ); const chip = input.locator(".inline-chip").first(); + const recipients = page.getByRole("region", { + name: "Notification recipients", + }); + await expect(recipients.getByRole("button")).toHaveCount(2); await expect( - page.getByRole("region", { name: "Notification recipients" }), - ).toHaveCount(0); + recipients.locator('.buzz-avatar[data-avatar-shape="circle"]'), + ).toHaveCount(1); + await expect( + recipients.locator('.buzz-avatar[data-avatar-shape="squircle"]'), + ).toHaveCount(1); + await expect + .poll(() => + recipients + .locator("img") + .evaluateAll( + (images) => + images.length === 2 && + images.every((image) => image.complete && image.naturalWidth > 0), + ), + ) + .toBe(true); + const mentionTool = page.getByRole("button", { + name: "Mention a member", + exact: true, + }); + const emojiTool = page.getByRole("button", { + name: "Insert emoji", + exact: true, + }); + await mentionTool.focus(); + for (const recipient of await recipients.getByRole("button").all()) { + await page.keyboard.press("Tab"); + await expect(recipient).toBeFocused(); + } + await page.keyboard.press("Tab"); + await expect(emojiTool).toBeFocused(); const chipRoles = await chip.evaluate((element) => { const probe = document.createElement("span"); probe.style.backgroundColor = "var(--affordance-accent)"; @@ -188,6 +221,17 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta bounds.x + bounds.width, ); } + // Avatars stay beside @, before Emoji, even on the narrow composer. + const mentionBox = await mentionTool.boundingBox(); + const recipientsBox = await recipients.boundingBox(); + const emojiBox = await emojiTool.boundingBox(); + expect(recipientsBox.x).toBeGreaterThanOrEqual( + mentionBox.x + mentionBox.width, + ); + expect(recipientsBox.x + recipientsBox.width).toBeLessThanOrEqual( + emojiBox.x, + ); + expect(Math.abs(recipientsBox.y - mentionBox.y)).toBeLessThanOrEqual(2); await expect(input).toHaveJSProperty("value", "@Honey @Honey 😀"); } } @@ -235,6 +279,7 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await expect(page.getByRole("textbox").locator(".inline-chip")).toHaveCount( 1, ); + await expect(recipients.getByRole("button")).toHaveCount(1); await page .getByRole("button", { name: "Send message", exact: true }) .click(); @@ -278,6 +323,7 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await expect( page.getByRole("textbox", { name: "Reply to thread" }), ).toBeDisabled(); + await expect(recipients.getByRole("button")).toBeDisabled(); expect( await page.evaluate(() => window.mentionFixture.disabledCalls), ).toEqual([{ inputDisabled: true, text: false, mention: false }]); @@ -287,6 +333,7 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await expect(page.getByRole("textbox").locator(".inline-chip")).toHaveCount( 1, ); + await expect(recipients.getByRole("button")).toHaveCount(1); await page .getByRole("button", { name: "Toggle disabled", exact: true }) .click(); @@ -294,6 +341,32 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta await expect( page.getByRole("textbox", { name: "Reply to thread" }), ).toHaveJSProperty("value", "@Honey @Honey "); + await recipients + .getByRole("button", { + name: `Remove mention Honey ${keys.first}`, + exact: true, + }) + .click(); + await expect(page.getByRole("textbox")).toHaveJSProperty( + "value", + "@Honey @Honey ", + ); + await expect(recipients.getByRole("button")).toHaveCount(1); + await page + .getByRole("button", { name: "Send message", exact: true }) + .click(); + await expect + .poll(() => + page.evaluate(() => window.mentionFixture.publications.length), + ) + .toBe(3); + const afterRemoval = await page.evaluate(() => + window.mentionFixture.publications.at(-1), + ); + expect(afterRemoval.content).toBe("@Honey @Honey"); + expect(afterRemoval.tags.filter(([tag]) => tag === "p")).toEqual([ + ["p", keys.second], + ]); expect(errors).toEqual([]); } finally { await server.close(); diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index 5272c1882..82d5ca669 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -41,7 +41,8 @@ const owner = createRelaySession( { viewer: viewer.pubkey, relayAuthor: relay.pubkey, - media: (url) => url, + media: (url) => + url.startsWith("https://avatars.test/") ? new URL(url).pathname : url, // Synthetic, lazy capability: only the explicit fixture action loads it. async readAgentLibrary() { libraryReads++; @@ -69,8 +70,15 @@ const owner = createRelaySession( roster(relay, "other", [viewer.pubkey], time), metadata(relay, "other", "Other"), profile(viewer, { name: "Viewer" }), - profile(first, { name: delayed ? "Mary Jane" : "Honey" }), - profile(second, { name: "Honey", is_agent: true }), + profile(first, { + name: delayed ? "Mary Jane" : "Honey", + picture: "https://avatars.test/bestie.png", + }), + profile(second, { + name: "Honey", + is_agent: true, + picture: "https://avatars.test/app-icon.png", + }), ...publications, ]; return events.filter((event) => From b9ca5e469714eb480c5cfd861bf37a16e69de6e5 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Wed, 23 Sep 2026 08:57:32 -0600 Subject: [PATCH 2/2] Clarify explicit mention removal and honor loaded agent hints Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- docs/design-system-adoption.md | 5 +- docs/plugin-architecture.md | 9 +- docs/sessions/README.md | 3 +- .../messages/MessageComposer.test.tsx | 95 ++++++++++++++++--- src/features/messages/MessageComposer.tsx | 14 ++- .../sessions/NewSessionComposer.test.tsx | 44 +++++++-- tests/browser/mentions.spec.mjs | 2 +- 7 files changed, 141 insertions(+), 31 deletions(-) diff --git a/docs/design-system-adoption.md b/docs/design-system-adoption.md index 36ec24bac..4e1857217 100644 --- a/docs/design-system-adoption.md +++ b/docs/design-system-adoption.md @@ -37,9 +37,10 @@ pill actions, consistent fields and shared states. They are disclosures, not application menus; their content and state remain local. - Avatars, previews, links, mentions and thread summaries retain their identity and navigation ownership. Composer mentions use inert shared InlineChip rendering; - editing or deleting the mention removes its notification intent. Host-owned + editing or deleting the mention removes its explicit mention intent. Host-owned recipient avatars beside the mention tool also allow clearing that intent without - changing authored text. Shared appearance does not move their data. + changing authored text; Sessions can still route to the selected or sole agent. + Shared appearance does not move their data. - Panel marks its surface separately from interactive components. Native product and plugin content inside it can still receive host defaults. - Legacy utility names remain available through the host bridge for existing diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 5461e43f5..ebc11cdeb 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -404,10 +404,11 @@ removes its intent under the existing draft rules. **User intent outlives the tool that created it.** Disabling Mentions removes its chooser, not selected recipients, their inline chips and avatar removal controls, scoped drafts or pending messages. Editing or deleting a selected mention removes -its notification intent. Removing an avatar clears that identity's notification -intent without changing the authored text. The session still owns roster/profile data, membership -checks, signing and publication/retry. Plugins remain trusted same-process code; -revocable editor commands do not sandbox the session capabilities they receive. +its explicit mention intent. Removing an avatar clears that identity's explicit +mention intent without changing the authored text. This does not suppress Sessions +routing: the selected agent or sole session agent can still be addressed. The session +still owns roster/profile data, membership checks, signing and publication/retry. +Plugins remain trusted same-process code; revocable editor commands do not sandbox the session capabilities they receive. This preview is host-matched: a tool using `insertMention` needs a host providing that command. The generated type-only `@buzz/author` package and `apiVersion: 1` diff --git a/docs/sessions/README.md b/docs/sessions/README.md index 504cc6c65..d3a91708e 100644 --- a/docs/sessions/README.md +++ b/docs/sessions/README.md @@ -26,7 +26,8 @@ Sessions are focused work conversations built on ordinary private channels. channel-style titles. The avatar-and-name picker sits before @ and opens upward. Explicit mentions take precedence over the selected agent. Without a selection, a sole agent already in the session is addressed automatically; multiple agents - require a recipient. + require a recipient. Removing a mention avatar clears only its explicit mention + intent and preserves the draft text; it does not suppress this automatic routing. - Published replies appear in the main session conversation. Sessions uses normal paged channel queries and complete message overlays, without new relay filters. Existing thread replies are also presented inline. diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index f76496e1b..dc57cbe44 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -21,6 +21,7 @@ import type { CompletionResult, InlineRenderer, } from "../conversation/contracts"; +import type { AgentLibrarySnapshot } from "../agents/library"; import { AgentMentionContext } from "../agents/mention-context"; import { createAgentControl, type AgentControl } from "../agents/control"; import { controlFixture } from "../agents/control-testing"; @@ -118,7 +119,12 @@ function mount( const typing: ReturnType = []; let profiles: ReadonlyMap = new Map(); const profileListeners = new Set<() => void>(); - const library = { status: "ready", identities: [], definitions: [] }; + const libraryListeners = new Set<() => void>(); + let library: AgentLibrarySnapshot = { + status: "ready", + identities: [], + definitions: [], + }; const session = { messages, typing: { snapshot: () => typing, subscribe: () => () => {} }, @@ -134,6 +140,11 @@ function mount( }, agentLibrary: { snapshot: () => library, + subscribe(listener: () => void) { + libraryListeners.add(listener); + return () => libraryListeners.delete(listener); + }, + refresh: vi.fn(async () => {}), }, emoji: { snapshot: () => emoji, @@ -235,6 +246,12 @@ function mount( for (const listener of profileListeners) listener(); }); }, + setLibrary(identities: AgentLibrarySnapshot["identities"]) { + act(() => { + library = { ...library, identities }; + for (const listener of libraryListeners) listener(); + }); + }, setEmoji(entries: readonly CustomEmoji[]) { act(() => { emoji = { status: "ready", entries }; @@ -536,7 +553,7 @@ it.each([undefined, "root"])( ).toHaveLength(2); expect( within( - screen.getByRole("region", { name: "Notification recipients" }), + screen.getByRole("region", { name: "Explicit mentions" }), ).getAllByRole("button"), ).toHaveLength(2); h.submit(); @@ -567,7 +584,7 @@ it("restores live profile avatars with one removal control per exact recipient", h.commands().insertMention(second); }); let region = screen.getByRole("region", { - name: "Notification recipients", + name: "Explicit mentions", }); const controls = within(region).getAllByRole("button"); expect(controls).toHaveLength(2); @@ -592,11 +609,25 @@ it("restores live profile avatars with one removal control per exact recipient", "https://media.test/agent.png", ); expect(media).toHaveBeenCalledWith("agent.png", "small"); + // Loaded-library hints update both artwork layers without another keystroke; + // clearing them removes only that fallback, not self-declared agent metadata. + for (const control of controls) + expect(control.querySelectorAll("[data-avatar-shape]")).toHaveLength(2); + for (const identities of [[first], []]) { + h.setLibrary(identities); + for (const [index, control] of controls.entries()) + for (const artwork of control.querySelectorAll("[data-avatar-shape]")) + expect(artwork).toHaveAttribute( + "data-avatar-shape", + index === 1 || identities.length ? "squircle" : "circle", + ); + } + expect(h.session.agentLibrary.refresh).not.toHaveBeenCalled(); h.retarget({ disabled: true }); for (const control of controls) expect(control).toBeDisabled(); h.retarget({ disabled: false, extensions: undefined }); // The optional picker does not own saved intent or its removal controls. - region = screen.getByRole("region", { name: "Notification recipients" }); + region = screen.getByRole("region", { name: "Explicit mentions" }); await h.user.click( within(region).getByRole("button", { name: `Remove mention Honey ${second.pubkey}`, @@ -612,7 +643,7 @@ it("restores live profile avatars with one removal control per exact recipient", [first.pubkey], ); expect( - screen.queryByRole("region", { name: "Notification recipients" }), + screen.queryByRole("region", { name: "Explicit mentions" }), ).not.toBeInTheDocument(); }); @@ -1175,9 +1206,13 @@ it.each( }, ); -it.each([undefined, "root"])( - "resolves a sole session agent before calling shared send/reply, root=%s", - async (root) => { +it.each( + [undefined, "root"].flatMap((root) => + [false, true].map((removeMention) => ({ root, removeMention })), + ), +)( + "resolves a sole session agent before send/reply: root=$root, removed=$removeMention", + async ({ root, removeMention }) => { const view = mount(); const channel = { id: "channel", @@ -1209,7 +1244,25 @@ it.each([undefined, "root"])( sessionConversation: true, ...(root ? { threadRootId: root } : {}), }); - view.fill("Keep going"); + view.fill("Keep going "); + if (removeMention) { + await view.user.click( + screen.getByRole("button", { name: "First Honey" }), + ); + const remove = screen.getByRole("button", { + name: `Remove mention Honey ${first.pubkey}`, + }); + await view.user.hover(remove); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Remove explicit mention of Honey (aaaaaaaa)", + ); + await view.user.click(remove); + expect(view.input()).toHaveValue("Keep going @Honey "); + expect(view.input().querySelector(".inline-chip")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).not.toBeInTheDocument(); + } view.submit(); await waitFor(() => expect( @@ -1220,13 +1273,13 @@ it.each([undefined, "root"])( expect(view.messages.reply).toHaveBeenCalledExactlyOnceWith( "channel", root, - "Keep going", + removeMention ? "Keep going @Honey " : "Keep going ", [first.pubkey], ); else expect(view.messages.send).toHaveBeenCalledExactlyOnceWith( "channel", - "Keep going", + removeMention ? "Keep going @Honey " : "Keep going ", [first.pubkey], ); expect(session.workSessions.addAgents).not.toHaveBeenCalled(); @@ -1289,6 +1342,24 @@ it("routes to the avatar choice and lets an explicit mention override it", async [first.pubkey], ), ); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send message" })).toBeEnabled(), + ); + // The remembered explicit mention still overrides the picker until removed. + expect(view.input()).toHaveValue("@Honey "); + await view.user.click( + screen.getByRole("button", { + name: `Remove mention Honey ${first.pubkey}`, + }), + ); + expect(view.input()).toHaveValue("@Honey "); + expect(view.input().querySelector(".inline-chip")).toBeNull(); + view.submit(); + await waitFor(() => + expect(view.messages.send).toHaveBeenLastCalledWith("channel", "@Honey ", [ + second.pubkey, + ]), + ); expect(session.workSessions.addAgents).not.toHaveBeenCalled(); }); @@ -1392,7 +1463,7 @@ it.each([undefined, "root"])( ]); expect(h.input()).toHaveValue("@Honey "); const recipients = screen.getByRole("region", { - name: "Notification recipients", + name: "Explicit mentions", }); expect(within(recipients).getAllByRole("button")).toHaveLength(1); expect( diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 30799f768..b4263d941 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -4,6 +4,7 @@ import { Avatar } from "../../shared/design-system/ui/Avatar"; import { useMentionAgents } from "../agents/mention-context"; import { enrollMentionedAgents } from "../agents/mention-enrollment"; import { knownAgentPubkeys } from "../agents/known"; +import { useKnownAgentPubkeys } from "../agents/use-known"; import { rememberAgentsPreference } from "./mention-preferences"; import { SessionAgentControl } from "../sessions/SessionAgentControl"; import { sessionRecipients } from "../sessions/recipients"; @@ -786,13 +787,14 @@ function RecipientAvatars({ session.profiles.snapshot, session.profiles.snapshot, ); + const agentPubkeys = useKnownAgentPubkeys(session, profiles); const unique = [ ...new Map(recipients.map((item) => [item.pubkey, item])).values(), ]; return (
{unique.map((recipient) => { const profile = profiles.get(recipient.pubkey); @@ -802,14 +804,16 @@ function RecipientAvatars({ type="button" size="toolbar" data-mention-recipient="" - title={`Don't notify ${recipient.name} (${recipient.pubkey.slice(0, 8)})`} + title={`Remove explicit mention of ${recipient.name} (${recipient.pubkey.slice(0, 8)})`} aria-label={`Remove mention ${recipient.name} ${recipient.pubkey}`} disabled={disabled} onClick={() => remove(recipient.pubkey)} icon={