mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
**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>
191 lines
6.7 KiB
TypeScript
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,
|
|
);
|
|
});
|