Files
buzz/desktop/tests/e2e/send-channel-binding.spec.ts
Taylor Ho 7ba1197aa6 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.

![Light theme persistent agent
addressing](https://raw.githubusercontent.com/block/buzz/f34e87a8c083d36abf20422ac9d8ca2c43a49af6/pr-6714--01-light-theme.png)

#### Dark theme
Persistent addressing remains clear and legible in the dark composer
treatment.

![Dark theme persistent agent
addressing](https://raw.githubusercontent.com/block/buzz/f34e87a8c083d36abf20422ac9d8ca2c43a49af6/pr-6714--02-dark-theme.png)

### 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>
2026-08-25 18:44:25 +00:00

191 lines
6.7 KiB
TypeScript

/**
* E2E regression for the wrong-channel send bug.
*
* Repro: compose a message in channel A that tags a non-member managed agent
* (which forces the slow `add_channel_members` path), submit, then immediately
* switch to channel B before the agent-attach await resolves. Without the fix,
* the message lands in B's timeline; with the fix it must land in A's.
*
* The `addChannelMembersDelayMs` bridge knob holds the `add_channel_members`
* handler open long enough for the channel click to race the in-flight send.
*/
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// A managed agent that is NOT a member of any channel in the seed data.
const OUT_OF_CHANNEL_BOT_PUBKEY =
"ee00000000000000000000000000000000000000000000000000000000000001";
/** Locator scoped to the mention autocomplete dropdown inside the composer. */
function autocomplete(page: import("@playwright/test").Page) {
return page
.getByTestId("message-composer")
.getByTestId("mention-autocomplete");
}
async function readCommandLog(page: import("@playwright/test").Page) {
return page.evaluate(() => {
return (
(window as Window & { __BUZZ_E2E_COMMANDS__?: string[] })
.__BUZZ_E2E_COMMANDS__ ?? []
);
});
}
function commandCount(commands: string[], command: string) {
return commands.filter((c) => c === command).length;
}
// The channel timeline renders off a `useDeferredValue` snapshot; poll for the
// pending marker to clear before asserting on freshly-sent content.
async function waitForTimelineSettled(page: import("@playwright/test").Page) {
await expect(page.locator("[data-render-pending]")).toHaveCount(0);
}
// ---------------------------------------------------------------------------
// Main regression: message always lands in the compose-time channel
// ---------------------------------------------------------------------------
test("message with agent mention lands in compose-time channel despite mid-send navigation", async ({
page,
}) => {
const MESSAGE_TEXT = `send-binding-repro-${Date.now()}`;
// Install bridge with:
// - a managed agent that is NOT in general (forces add_channel_members path)
// - a 500ms delay on add_channel_members to open the race window
await installMockBridge(page, {
addChannelMembersDelayMs: 500,
managedAgents: [
{
pubkey: OUT_OF_CHANNEL_BOT_PUBKEY,
name: "BotA",
status: "running",
// No channelNames → agent is not a member of any channel
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Type a message that mentions the out-of-channel agent
const input = page.getByTestId("message-input");
await input.fill("@BotA");
const dropdown = autocomplete(page);
const botRow = dropdown.locator("button", { hasText: "BotA" });
await expect(botRow).toBeVisible();
await expect(botRow.getByText("not in channel")).toBeVisible();
// Select BotA from the autocomplete
await input.press("Enter");
await page.keyboard.type(` ${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();
// Snapshot the baseline command count before sending
const baselineCommands = await readCommandLog(page);
const baselineAddCount = commandCount(
baselineCommands,
"add_channel_members",
);
// Submit the message — this triggers the async add_channel_members path
await page.getByTestId("send-message").click();
// Immediately switch to channel-agents BEFORE the 500ms delay resolves.
// This is the race the fix closes.
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
// Wait for add_channel_members to fire (confirms the race window opened and
// the fix's captured channel id was used for the agent-attach call).
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "add_channel_members"),
)
.toBeGreaterThan(baselineAddCount);
// Let the in-flight send finish (500ms delay + buffer).
await page.waitForTimeout(800);
// --- Assert message landed in general (compose-time channel) ---
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForTimelineSettled(page);
// The message must appear in general's timeline (not the switched-to channel).
await expect(page.getByTestId("message-timeline")).toContainText(
MESSAGE_TEXT,
);
// --- Assert message did NOT land in agents (switched-to channel) ---
await page.getByTestId("channel-agents").click();
await expect(page.getByTestId("chat-title")).toHaveText("agents");
await waitForTimelineSettled(page);
await expect(page.getByTestId("message-timeline")).not.toContainText(
MESSAGE_TEXT,
);
});
// ---------------------------------------------------------------------------
// Invariant: without mid-send navigation, normal agent-mention send still works
// ---------------------------------------------------------------------------
test("message with agent mention delivers correctly when no channel switch occurs", async ({
page,
}) => {
const MESSAGE_TEXT = `no-switch-verify-${Date.now()}`;
await installMockBridge(page, {
addChannelMembersDelayMs: 0,
managedAgents: [
{
pubkey: OUT_OF_CHANNEL_BOT_PUBKEY,
name: "BotA",
status: "running",
},
],
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const input = page.getByTestId("message-input");
await input.fill("@BotA");
const dropdown = autocomplete(page);
await expect(dropdown.locator("button", { hasText: "BotA" })).toBeVisible();
await input.press("Enter");
await page.keyboard.type(` ${MESSAGE_TEXT}`);
const baselineCommands = await readCommandLog(page);
const baselineAddCount = commandCount(
baselineCommands,
"add_channel_members",
);
await page.getByTestId("send-message").click();
// Wait for the agent-attach step to complete before asserting the timeline.
await expect
.poll(async () =>
commandCount(await readCommandLog(page), "add_channel_members"),
)
.toBeGreaterThan(baselineAddCount);
await waitForTimelineSettled(page);
await expect(page.getByTestId("message-timeline")).toContainText(
MESSAGE_TEXT,
);
});