Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/design-system-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +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. Shared appearance
does not move their data.
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; 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
Expand Down
17 changes: 10 additions & 7 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -401,11 +402,13 @@ 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
checks, signing and publication/retry. Plugins remain trusted same-process code;
revocable editor commands do not sandbox the session capabilities they receive.
chooser, not selected recipients, their inline chips and avatar removal controls,
scoped drafts or pending messages. Editing or deleting a selected mention removes
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`
Expand Down
3 changes: 2 additions & 1 deletion docs/sessions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 19 additions & 3 deletions src/features/conversation/ComposerTools.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,9 +15,12 @@ import { ContributionBoundary, contributionKey } from "./ContributionBoundary";

export function ComposerTools({
registry,
renderLeading,
...props
}: ComposerToolProps & {
registry: ContributionReader<ComposerTool>;
/** Host layout for tools ordered before the default group; preserves DOM order. */
renderLeading?: (tools: ReactNode) => ReactNode;
}) {
const tools = useSyncExternalStore(
registry.subscribe,
Expand All @@ -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<ComposerTool>) => (
<ContributionBoundary
key={contributionKey(tool)}
fallback={<span role="status">{tool.title} unavailable</span>}
>
<OwnedTool tool={tool} registry={registry} {...props} />
</ContributionBoundary>
));
);
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,
Expand Down
179 changes: 168 additions & 11 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ 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";
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";
Expand Down Expand Up @@ -115,18 +117,34 @@ function mount(
reply: vi.fn<RelaySession["messages"]["reply"]>(() => "reply-id"),
};
const typing: ReturnType<RelaySession["typing"]["snapshot"]> = [];
const profiles = new Map();
const library = { status: "ready", identities: [], definitions: [] };
let profiles: ReadonlyMap<string, Profile> = new Map();
const profileListeners = new Set<() => void>();
const libraryListeners = new Set<() => void>();
let library: AgentLibrarySnapshot = {
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: {
snapshot: () => library,
subscribe(listener: () => void) {
libraryListeners.add(listener);
return () => libraryListeners.delete(listener);
},
refresh: vi.fn(async () => {}),
},
emoji: {
snapshot: () => emoji,
Expand Down Expand Up @@ -222,6 +240,18 @@ function mount(
props = { ...props, ...next };
view.rerender(tree());
},
setProfiles(next: ReadonlyMap<string, Profile>) {
act(() => {
profiles = next;
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 };
Expand Down Expand Up @@ -522,8 +552,10 @@ it.each([undefined, "root"])(
}),
).toHaveLength(2);
expect(
screen.queryByRole("region", { name: "Notification recipients" }),
).not.toBeInTheDocument();
within(
screen.getByRole("region", { name: "Explicit mentions" }),
).getAllByRole("button"),
).toHaveLength(2);
h.submit();
expect(
(root ? h.messages.reply : h.messages.send).mock.calls[0]?.at(-1),
Expand All @@ -539,6 +571,82 @@ 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: "Explicit mentions",
});
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");
// 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: "Explicit mentions" });
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: "Explicit mentions" }),
).not.toBeInTheDocument();
});

// Explicit notification intent must remain visible even where Markdown previews are suppressed.
it.each([
["inline code", "`", " `"],
Expand Down Expand Up @@ -1098,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",
Expand Down Expand Up @@ -1132,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(
Expand All @@ -1143,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();
Expand Down Expand Up @@ -1212,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();
});

Expand Down Expand Up @@ -1314,6 +1462,15 @@ it.each([undefined, "root"])(
second.pubkey,
]);
expect(h.input()).toHaveValue("@Honey ");
const recipients = screen.getByRole("region", {
name: "Explicit mentions",
});
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);
Expand Down
Loading
Loading