Files
buzz/desktop/tests/e2e/empty-edit-delete.spec.ts
5acb930821 feat(desktop-messages): render compact Buzz permalink chips (#5638)
**Category:** improvement
**User Impact:** Buzz channel, message, repository, pull request, and
issue links now open reliably and display recognizable context in the
desktop app.
**Problem:** Buzz links could appear as raw or ambiguous URLs, and
navigation links received during startup or community transitions could
be dropped before the UI was ready. Repository and issue shares in
particular required hover context to understand at a glance.
**Solution:** Queue desktop channel/message navigation until the UI is
ready, then render bare Buzz permalinks as icon-prefixed chips with
concise entity context while preserving user-authored Markdown labels as
ordinary links.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/deep_link.rs**
Adds validated channel-link parsing and a deduplicated, acknowledged
queue so navigation survives frontend startup.

**desktop/src-tauri/src/lib.rs**
Registers the pending-navigation state and commands with the desktop
application.

**desktop/src/features/communities/useCommunityInit.ts**
Resets queued navigation safely across community boundaries without
leaking stale destinations.

**desktop/src/features/messages/lib/channelLink.test.mjs**
Covers valid, malformed, and canonical channel permalink forms.

**desktop/src/features/messages/lib/channelLink.ts**
Defines strict parsing and detection for `buzz://channel/<uuid>` links.

**desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs**
Extends composer-node coverage for normalized Buzz link content.

**desktop/src/features/messages/lib/composerMessageLinkNode.ts**
Keeps composer link-node handling aligned with the expanded Buzz link
surface.

**desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs**
Verifies bare channel URLs become renderable deep-link nodes without
touching code.

**desktop/src/features/messages/lib/remarkChannelDeepLinks.ts**
Transforms eligible bare channel links into dedicated Markdown nodes.

**desktop/src/features/messages/lib/remarkEntityLinks.test.mjs**
Covers bare repository, pull-request, and issue detection and code-span
exclusions.

**desktop/src/features/messages/lib/remarkEntityLinks.ts**
Adds dedicated Markdown nodes for bare Buzz project entities.

**desktop/src/shared/deep-link.test.mjs**
Exercises queued navigation, acknowledgement, serialization, and
community-switch behavior.

**desktop/src/shared/deep-link.ts**
Serializes pending deep-link drains and acknowledges destinations only
after successful navigation.

**desktop/src/shared/styles/globals/markdown.css**
Aligns permalink icon geometry and spacing with agent mention chips.

**desktop/src/shared/ui/markdown.test.mjs**
Adds integration coverage for every permalink chip, authored labels,
fallbacks, icons, and static rendering.

**desktop/src/shared/ui/markdown.tsx**
Routes channel and entity nodes through the shared presentation path
while preserving authored link text.

**desktop/src/shared/ui/markdown/BuzzLinkChip.tsx**
Introduces the shared interactive/static permalink chip and
authored-label inline-link components.

**desktop/src/shared/ui/markdown/ChannelDeepLink.tsx**
Renders channel shares and references with Hash icons, names, and
shortened-ID fallbacks.

**desktop/src/shared/ui/markdown/MessageLinkPill.tsx**
Renders ordinary message shares with message icons and channel/message
context while retaining sent-from-thread behavior.

**desktop/src/shared/ui/markdown/entityLinks.tsx**
Maps repositories, pull requests, and issues to Projects-aligned icons
and contextual labels.

**desktop/src/shared/ui/markdown/nodeCache.ts**
Includes entity-link rendering in cached Markdown node handling.

**desktop/src/shared/ui/markdown/utils.ts**
Allows validated channel links through the Buzz URL transform.

**desktop/src/shared/useMessageDeepLinks.ts**
Drains queued navigation links safely and clears them during teardown.

**desktop/src/testing/e2eBridge.ts**
Extends the mock bridge with pending-navigation command behavior.

**desktop/tests/e2e/community-rail.spec.ts**
Verifies queued links do not cross community boundaries.

**desktop/tests/e2e/navigation.spec.ts**
Covers channel/message deep-link navigation during startup and active
sessions.

**desktop/tests/helpers/bridge.ts**
Adds reusable deep-link mock state and acknowledgement helpers.


</details>

## Reproduction steps
1. Run the desktop app and open a channel containing bare
`buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and
`buzz://issue` URLs.
2. Confirm each bare URL renders as one cohesive chip with a type icon,
a useful name or shortened identifier, and no duplicated channel `#`
character.
3. Add an authored Markdown link such as `[design
discussion](buzz://issue?...)` and confirm the supplied label remains an
ordinary link rather than becoming a chip.
4. Select channel and message links and confirm they navigate correctly
in warm and cold-start states.

## Screenshots / demos
Houston dark theme with custom purple accent (`#a855f7`), captured from
rebased visual implementation `ad411cc06`; current head `0aafa144f` only
adjusts E2E expectations for the visible mention-label behavior shown
here.

**Composer — channel, message, repository, pull request, and issue
pills**

![Composer with all Buzz permalink pill types in Houston dark theme and
purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5638/composer-all-permalink-pills-dark-purple.png)

**Message list — channel, message, repository, pull request, and issue
pills**

![Message list with all Buzz permalink pill types in Houston dark theme
and purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5638/message-list-all-pill-types-dark-purple.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-14 10:18:18 -07:00

123 lines
5.0 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// The mock identity's own pre-seeded message in #general (authored by
// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own
// message is exactly Sam's workflow: "delete a message by clearing its edit."
const OWN_MESSAGE_ID = "mock-general-welcome";
const ORIGINAL_CONTENT = "Welcome to #general";
const RENDERED_ORIGINAL_CONTENT = "Welcome to general";
// Open the more-actions menu for a message row and wait for the menu to mount.
async function openMoreActionsMenu(
page: import("@playwright/test").Page,
messageId: string,
) {
const row = page.locator(`[data-message-id="${messageId}"]`);
await row.hover();
await page.getByTestId(`more-actions-${messageId}`).click();
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
timeout: 5_000,
});
}
// Enter edit mode for a message, clear it to empty, and submit — the gesture
// that triggers the empty-edit delete confirmation.
async function submitEmptyEdit(
page: import("@playwright/test").Page,
messageId: string,
) {
await openMoreActionsMenu(page, messageId);
await page.getByTestId(`edit-message-${messageId}`).click();
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
// Edit mode sets the editor content via Tiptap's async transaction pipeline;
// wait for it to populate before we clear it.
const input = page.getByTestId("message-input");
await expect(input).not.toBeEmpty({ timeout: 5_000 });
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.press("Backspace");
await expect(input).toBeEmpty();
await page.keyboard.press("Enter");
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
});
test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({
page,
}) => {
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await submitEmptyEdit(page, OWN_MESSAGE_ID);
// The same "Delete message?" confirmation the Delete menu action shows — an
// empty edit is routed through it, not silently deleted.
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
await expect(dialog).toContainText("Delete message?");
// Edit mode stays active while the dialog is open — it exits only on confirm.
await expect(page.getByTestId("edit-target")).toBeVisible();
// Confirm → the message row is removed and edit mode has exited.
await dialog.getByRole("button", { name: "Delete" }).click();
await expect(dialog).toBeHidden({ timeout: 5_000 });
await expect(page.getByTestId("edit-target")).toBeHidden();
await expect(row).toBeHidden({ timeout: 5_000 });
});
test("cancelling the empty-edit delete keeps the message", async ({ page }) => {
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await submitEmptyEdit(page, OWN_MESSAGE_ID);
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
// Cancel → nothing is deleted, the original message survives, and the user is
// left in edit mode (the editing session is preserved, not discarded).
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toBeHidden({ timeout: 5_000 });
await expect(page.getByTestId("edit-target")).toBeVisible();
await expect(row).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
RENDERED_ORIGINAL_CONTENT,
);
await expect(row.getByLabel("Open channel general")).toBeVisible();
});
test("a non-empty edit still edits and never deletes", async ({ page }) => {
const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`);
await expect(row).toBeVisible({ timeout: 10_000 });
await openMoreActionsMenu(page, OWN_MESSAGE_ID);
await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click();
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
const input = page.getByTestId("message-input");
await expect(input).not.toBeEmpty({ timeout: 5_000 });
const editedContent = `Edited, not deleted ${Date.now()}`;
await input.click();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.type(editedContent);
await page.keyboard.press("Enter");
// No delete confirmation, edit mode exits, the row survives with new text.
await expect(page.getByRole("alertdialog")).toHaveCount(0);
await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 10_000 });
await expect(row).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
editedContent,
);
await expect(page.getByTestId("message-timeline")).not.toContainText(
RENDERED_ORIGINAL_CONTENT,
);
});