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>
This commit is contained in:
Taylor Ho
2026-08-14 10:18:18 -07:00
committed by GitHub
co-authored by Carl
parent 17d2147eca
commit 5acb930821
40 changed files with 2520 additions and 327 deletions
+247 -2
View File
@@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink {
#[derive(Default)]
pub(crate) struct PendingCommunityDeepLinks(Mutex<VecDeque<PendingCommunityDeepLink>>);
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PendingNavigationDeepLink {
id: String,
kind: String,
channel_id: String,
message_id: Option<String>,
thread_root_id: Option<String>,
}
#[derive(Default)]
pub(crate) struct PendingNavigationDeepLinks(Mutex<VecDeque<PendingNavigationDeepLink>>);
impl PendingNavigationDeepLinks {
fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque<PendingNavigationDeepLink>> {
self.0.lock().unwrap_or_else(|poisoned| {
eprintln!("buzz-desktop: recovering poisoned pending navigation deep-link queue");
poisoned.into_inner()
})
}
fn enqueue(&self, pending: PendingNavigationDeepLink) {
let mut queue = self.lock();
if queue.iter().any(|item| {
item.kind == pending.kind
&& item.channel_id == pending.channel_id
&& item.message_id == pending.message_id
&& item.thread_root_id == pending.thread_root_id
}) {
return;
}
queue.push_back(pending);
}
fn clear(&self) {
self.lock().clear();
}
fn first(&self) -> Option<PendingNavigationDeepLink> {
self.lock().front().cloned()
}
fn acknowledge(&self, id: &str) -> bool {
let mut queue = self.lock();
if queue.front().is_some_and(|item| item.id == id) {
queue.pop_front();
true
} else {
false
}
}
}
#[tauri::command]
pub(crate) fn clear_pending_navigation_deep_links(pending: State<'_, PendingNavigationDeepLinks>) {
pending.clear();
}
#[tauri::command]
pub(crate) fn take_pending_navigation_deep_link(
pending: State<'_, PendingNavigationDeepLinks>,
) -> Option<PendingNavigationDeepLink> {
pending.first()
}
#[tauri::command]
pub(crate) fn acknowledge_pending_navigation_deep_link(
id: String,
pending: State<'_, PendingNavigationDeepLinks>,
) -> bool {
pending.acknowledge(&id)
}
impl PendingCommunityDeepLinks {
fn enqueue(&self, pending: PendingCommunityDeepLink) {
let mut queue = self.0.lock().expect("pending deep-link queue poisoned");
@@ -88,6 +161,20 @@ fn queue_community_deep_link(
});
}
fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) {
let Some(channel_id) = payload["channelId"].as_str() else {
return;
};
app.state::<PendingNavigationDeepLinks>()
.enqueue(PendingNavigationDeepLink {
id: uuid::Uuid::new_v4().to_string(),
kind: kind.to_owned(),
channel_id: channel_id.to_owned(),
message_id: payload["messageId"].as_str().map(str::to_owned),
thread_root_id: payload["threadRootId"].as_str().map(str::to_owned),
});
}
fn activate_main_window(app: &tauri::AppHandle) {
let Some(window) = app.get_webview_window("main") else {
return;
@@ -104,6 +191,19 @@ fn activate_main_window(app: &tauri::AppHandle) {
}
}
fn parse_channel_deep_link(url: &Url) -> Option<serde_json::Value> {
if url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() {
return None;
}
let mut segments = url.path_segments()?;
let channel_id = segments.next()?;
if segments.next().is_some() {
return None;
}
let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string();
Some(serde_json::json!({ "channelId": channel_id }))
}
/// Parse the query string of a `buzz://message?…` URL into the JSON
/// payload emitted on `deep-link-message`. Returns `None` when a required
/// param (`channel`, `id`) is missing or empty — mirroring the validation
@@ -350,6 +450,15 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
);
let _ = app.emit("deep-link-add-community", payload);
}
Some("channel") => {
let Some(payload) = parse_channel_deep_link(&url) else {
eprintln!("buzz-desktop: channel deep link missing/invalid channel: {url_str}");
return;
};
activate_main_window(app);
queue_navigation_deep_link(app, "channel", &payload);
let _ = app.emit("deep-link-channel", payload);
}
Some("message") => {
// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
//
@@ -364,6 +473,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
return;
};
activate_main_window(app);
queue_navigation_deep_link(app, "message", &payload);
let _ = app.emit("deep-link-message", payload);
}
Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) {
@@ -389,8 +499,9 @@ mod tests {
use url::Url;
use super::{
parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link,
parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks,
parse_add_community_deep_link, parse_channel_deep_link, parse_join_deep_link,
parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink,
PendingCommunityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks,
};
fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink {
@@ -404,6 +515,100 @@ mod tests {
}
}
fn pending_navigation(
id: &str,
kind: &str,
channel_id: &str,
message_id: Option<&str>,
thread_root_id: Option<&str>,
) -> PendingNavigationDeepLink {
PendingNavigationDeepLink {
id: id.to_owned(),
kind: kind.to_owned(),
channel_id: channel_id.to_owned(),
message_id: message_id.map(str::to_owned),
thread_root_id: thread_root_id.map(str::to_owned),
}
}
#[test]
fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() {
let queue = PendingNavigationDeepLinks::default();
queue.enqueue(pending_navigation(
"first",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"duplicate",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"second",
"message",
"channel-1",
Some("message-1"),
Some("root-1"),
));
assert_eq!(queue.first().unwrap().id, "first");
assert!(!queue.acknowledge("second"));
assert!(queue.acknowledge("first"));
assert_eq!(queue.first().unwrap().id, "second");
assert!(queue.acknowledge("second"));
assert!(queue.first().is_none());
}
#[test]
fn pending_navigation_links_can_be_cleared() {
let queue = PendingNavigationDeepLinks::default();
queue.enqueue(pending_navigation(
"first",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"second",
"message",
"channel-1",
Some("message-1"),
None,
));
queue.clear();
assert!(queue.first().is_none());
}
#[test]
fn pending_navigation_queue_recovers_after_mutex_poisoning() {
let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default());
let poisoner = std::sync::Arc::clone(&queue);
assert!(std::thread::spawn(move || {
let _guard = poisoner.0.lock().unwrap();
panic!("poison queue for recovery regression");
})
.join()
.is_err());
queue.enqueue(pending_navigation(
"after-poison",
"channel",
"channel-1",
None,
None,
));
assert_eq!(queue.first().unwrap().id, "after-poison");
assert!(queue.acknowledge("after-poison"));
assert!(queue.first().is_none());
}
#[test]
fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() {
let mut link = pending("join", "wss://relay.example", Some("invite"));
@@ -477,6 +682,46 @@ mod tests {
}
}
#[test]
fn parse_channel_deep_link_accepts_one_path_segment() {
let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap();
let payload = parse_channel_deep_link(&url).unwrap();
assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32");
}
#[test]
fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() {
for (raw, expected) in [
(
"buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9",
"018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9",
),
(
"buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32",
"580ca78b-9dae-46f3-8854-bd671853ba32",
),
] {
let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap();
assert_eq!(payload["channelId"], expected);
}
}
#[test]
fn parse_channel_deep_link_rejects_malformed_forms() {
for raw in [
"buzz://channel",
"buzz://channel/",
"buzz://channel/one/two",
"buzz://channel/one?extra=true",
"buzz://channel/one#fragment",
"buzz://channel/not-a-uuid",
"buzz://channel/%2F",
"buzz://channel/%00",
] {
assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none());
}
}
#[test]
fn parse_message_deep_link_extracts_required_params() {
let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap();
+7 -3
View File
@@ -49,8 +49,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState};
use builderlab::*;
use commands::*;
use deep_link::{
acknowledge_pending_community_deep_link, handle_deep_link_url,
take_pending_community_deep_link, PendingCommunityDeepLinks,
acknowledge_pending_community_deep_link, acknowledge_pending_navigation_deep_link,
clear_pending_navigation_deep_links, handle_deep_link_url, take_pending_community_deep_link,
take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingNavigationDeepLinks,
};
use huddle::audio_output::{
get_audio_output_device, list_audio_output_devices, set_audio_output_device,
@@ -291,7 +292,6 @@ pub fn run() {
} else {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};
let app = app_menu::install(builder)
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
@@ -303,6 +303,7 @@ pub fn run() {
.manage(build_app_state())
.manage(ClipboardState::new())
.manage(PendingCommunityDeepLinks::default())
.manage(PendingNavigationDeepLinks::default())
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
@@ -615,6 +616,9 @@ pub fn run() {
terminal_runtime::terminal_focus,
take_pending_community_deep_link,
acknowledge_pending_community_deep_link,
take_pending_navigation_deep_link,
acknowledge_pending_navigation_deep_link,
clear_pending_navigation_deep_links,
start_builderlab_login,
cancel_builderlab_login,
get_builderlab_auth,
@@ -15,6 +15,7 @@ import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { resetNavigationDeepLinkDrain } from "@/shared/deep-link";
import {
clearAllDrafts,
initDraftStore,
@@ -47,12 +48,13 @@ import type { Community } from "./types";
* destroyed via effect cleanup and do not need entries here.
* See AGENTS.md "Community Switching" for the full contract.
*/
function resetCommunityState({
async function resetCommunityState({
resetAvatarState,
}: {
resetAvatarState: boolean;
}): void {
}): Promise<void> {
relayClient.disconnect();
await resetNavigationDeepLinkDrain();
resetRateLimitGate();
clearAllDrafts();
resetAgentObserverStore();
@@ -128,7 +130,23 @@ export function useCommunityInit(
saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
prevCommunityIdRef.current = null;
}
resetCommunityState({ resetAvatarState: true });
try {
await resetCommunityState({ resetAvatarState: true });
} catch (error) {
console.error("Failed to reset community state:", error);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? `Could not safely leave community: ${error.message}`
: "Could not safely leave community",
});
}
return;
}
appliedRelayUrlRef.current = null;
hasInitializedRef.current = false;
}
@@ -207,10 +225,26 @@ export function useCommunityInit(
// store under the outgoing community ID and delete its snapshot.
prevCommunityIdRef.current = null;
}
resetCommunityState({
resetAvatarState:
appliedRelayUrlRef.current !== activeCommunity.relayUrl,
});
try {
await resetCommunityState({
resetAvatarState:
appliedRelayUrlRef.current !== activeCommunity.relayUrl,
});
} catch (error) {
console.error("Failed to reset community state:", error);
if (!cancelled) {
setResult({
isReady: false,
needsSetup: false,
appliedKey: null,
error:
error instanceof Error
? `Could not safely switch communities: ${error.message}`
: "Could not safely switch communities",
});
}
return;
}
}
hasInitializedRef.current = true;
appliedRelayUrlRef.current = activeCommunity.relayUrl;
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isChannelLink, parseChannelLink } from "./channelLink.ts";
test("parseChannelLink accepts the canonical channel path", () => {
assert.deepEqual(
parseChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"),
{
ok: true,
value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" },
},
);
});
test("parseChannelLink accepts v7 and canonicalizes uppercase UUIDs", () => {
assert.deepEqual(
parseChannelLink("buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9"),
{
ok: true,
value: { channelId: "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9" },
},
);
assert.deepEqual(
parseChannelLink("buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32"),
{
ok: true,
value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" },
},
);
});
test("parseChannelLink rejects malformed channel links", () => {
for (const href of [
"buzz://channel",
"buzz://channel/",
"buzz://channel/one/two",
"buzz://channel/one?extra=true",
"buzz://channel/one#fragment",
"https://channel/one",
"buzz://channel/not-a-uuid",
"buzz://channel/%",
"buzz://channel/%ZZ",
"buzz://channel/%2F",
"buzz://channel/%00",
]) {
assert.equal(parseChannelLink(href).ok, false, href);
}
});
test("isChannelLink recognizes only a valid canonical link", () => {
assert.equal(
isChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"),
true,
);
assert.equal(
isChannelLink("buzz://message?channel=channel-1&id=message-1"),
false,
);
});
@@ -0,0 +1,55 @@
/** `buzz://channel/<uuid>` link encoding and parsing. */
const CHANNEL_LINK_SCHEME = "buzz:";
const CHANNEL_LINK_HOST = "channel";
const CHANNEL_UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
export type ParsedChannelLink = { channelId: string };
export type ChannelLinkParseResult =
| { ok: true; value: ParsedChannelLink }
| { ok: false; reason: string };
export function buildChannelLink(channelId: string): string {
if (!channelId) {
throw new Error("buildChannelLink: channelId is required");
}
return `${CHANNEL_LINK_SCHEME}//${CHANNEL_LINK_HOST}/${encodeURIComponent(channelId)}`;
}
export function parseChannelLink(url: string): ChannelLinkParseResult {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { ok: false, reason: "invalid-url" };
}
if (parsed.protocol !== CHANNEL_LINK_SCHEME) {
return { ok: false, reason: "wrong-scheme" };
}
if (parsed.hostname !== CHANNEL_LINK_HOST) {
return { ok: false, reason: "wrong-host" };
}
if (parsed.search || parsed.hash || parsed.username || parsed.password) {
return { ok: false, reason: "unexpected-components" };
}
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length !== 1) {
return { ok: false, reason: "missing-or-extra-channel" };
}
let channelId: string;
try {
channelId = decodeURIComponent(segments[0]);
} catch {
return { ok: false, reason: "invalid-channel-encoding" };
}
if (!CHANNEL_UUID_PATTERN.test(channelId)) {
return { ok: false, reason: "invalid-channel-uuid" };
}
return { ok: true, value: { channelId: channelId.toLowerCase() } };
}
export function isChannelLink(href: string | undefined | null): boolean {
return href ? parseChannelLink(href).ok : false;
}
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import test from "node:test";
import {
ComposerMessageLinkNode,
registerComposerMessageLinkMarkdownIt,
resolveComposerMessageLinkAttributes,
} from "./composerMessageLinkNode.ts";
@@ -13,6 +14,11 @@ const MarkdownIt = requireFromTiptap("markdown-it");
const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const MESSAGE_ID = "root-event";
const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`;
const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`;
const OWNER = "a".repeat(64);
const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`;
const ISSUE_ID = "b".repeat(64);
const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`;
test("resolves a composer preview and canonicalizes the underlying href", () => {
assert.deepEqual(
@@ -34,6 +40,23 @@ test("rejects malformed message links", () => {
);
});
test("resolves channel and entity links as composer chips", () => {
assert.deepEqual(
resolveComposerMessageLinkAttributes(CHANNEL_HREF, (channelId) =>
channelId === CHANNEL_ID ? "general" : undefined,
),
{ channelName: "general", href: CHANNEL_HREF },
);
assert.deepEqual(
resolveComposerMessageLinkAttributes(REPO_HREF, () => undefined),
{ channelName: "", href: REPO_HREF },
);
assert.deepEqual(
resolveComposerMessageLinkAttributes(ISSUE_HREF, () => undefined),
{ channelName: "", href: ISSUE_HREF },
);
});
function captureMarkdownRule() {
let capturedAnchor = null;
let capturedRule = null;
@@ -84,11 +107,38 @@ test("real markdown-it parsing materializes a restored message link", () => {
});
const html = md.renderInline(`See ${HREF}.`);
assert.match(html, /See <span data-composer-message-link=""/);
assert.match(html, /See <span data-composer-buzz-link=""/);
assert.match(html, /data-channel-name="general"/);
assert.match(html, /data-href="buzz:\/\/message\?channel=.*&amp;id=/);
});
test("real markdown-it parsing materializes mixed Buzz permalink chips", () => {
const md = new MarkdownIt();
registerComposerMessageLinkMarkdownIt(md, {
resolveChannelName: (channelId) =>
channelId === CHANNEL_ID ? "general" : undefined,
});
const html = md.renderInline(`${HREF} ${CHANNEL_HREF} ${REPO_HREF}`);
assert.equal((html.match(/data-composer-buzz-link=""/g) ?? []).length, 3);
assert.match(html, /data-href="buzz:\/\/channel\/9a1657ac/);
assert.match(html, /data-href="buzz:\/\/repo\?owner=a{64}&amp;d=buzz-world/);
});
test("real markdown-it parsing preserves underscores in restored entity links", () => {
const md = new MarkdownIt();
registerComposerMessageLinkMarkdownIt(md, {
resolveChannelName: () => undefined,
});
const href = `buzz://repo?owner=${OWNER}&d=my_repo`;
const html = md.renderInline(href);
assert.equal((html.match(/data-composer-buzz-link=""/g) ?? []).length, 1);
assert.match(html, /data-href="buzz:\/\/repo\?owner=a{64}&amp;d=my_repo"/);
assert.doesNotMatch(html, /<\/span>_repo/);
});
test("markdown parsing resumes after markdown-it consumes the buzz prefix", () => {
const { rule } = captureMarkdownRule();
let token = null;
@@ -125,12 +175,62 @@ test("markdown parsing stops message links before emphasis delimiters", () => {
assert.deepEqual(token.meta, { channelName: "general", href: HREF });
});
test("composer node uses the sent-message chip presentation", () => {
const node = {
attrs: { channelName: "general", href: HREF },
};
const rendered = globalThis.structuredClone(
// TipTap invokes renderHTML with the extension instance as `this`.
// Exercise the production renderer directly so the composer and message
// list cannot silently drift back to separate visual languages.
ComposerMessageLinkNode.config.renderHTML.call(
{ options: { resolveChannelName: () => "general" } },
{ HTMLAttributes: {}, node },
),
);
assert.equal(rendered[0], "span");
assert.match(rendered[1].class, /mention-chip/);
assert.match(rendered[1].class, /inline-chip-with-icon/);
assert.match(rendered[1].class, /inline-chip-icon-message/);
assert.equal(rendered[1]["data-buzz-link"], "");
assert.equal(rendered[2], "general · root-eve");
});
test("composer node renders channel and entity chip presentations", () => {
const render = (href) =>
globalThis.structuredClone(
ComposerMessageLinkNode.config.renderHTML.call(
{ options: { resolveChannelName: () => "general" } },
{
HTMLAttributes: {},
node: { attrs: { channelName: "general", href } },
},
),
);
const channel = render(CHANNEL_HREF);
assert.equal(channel[1]["data-channel-deep-link"], "");
assert.match(channel[1].class, /inline-chip-icon-channel/);
assert.equal(channel[2], "general");
const repo = render(REPO_HREF);
assert.equal(repo[1]["data-buzz-link-kind"], "repo");
assert.match(repo[1].class, /inline-chip-icon-repo/);
assert.equal(repo[2], "buzz-world");
const issue = render(ISSUE_HREF);
assert.equal(issue[1]["data-buzz-link-kind"], "issue");
assert.match(issue[1].class, /inline-chip-icon-issue/);
assert.equal(issue[2], "buzz-world · bbbbbbbb");
});
test("markdown rendering stores identity in attributes, not visible id text", () => {
const { md } = captureMarkdownRule();
const render = md.renderer.rules.buzz_composer_message_link;
const html = render([{ meta: { channelName: "general", href: HREF } }], 0);
assert.match(html, /data-composer-message-link=""/);
assert.match(html, /data-composer-buzz-link=""/);
assert.match(html, /data-channel-name="general"/);
assert.match(html, /data-href="buzz:\/\/message\?channel=.*&amp;id=/);
assert.doesNotMatch(html, />[^<]*root-event/);
@@ -3,12 +3,19 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import { MENTION_CHIP_BASE_CLASSES } from "@/shared/ui/mentionChip";
import {
getMessageLinkChannelLabel,
getMessageLinkLabel,
MESSAGE_LINK_PREFIX,
} from "./messageLinkLabel";
buildIssueLink,
buildPullRequestLink,
buildRepoLink,
parseEntityLink,
} from "@/shared/lib/entityLink";
import {
inlineChipIconClasses,
type InlineChipIconKind,
MENTION_CHIP_BASE_CLASSES,
} from "@/shared/ui/mentionChip";
import { buildChannelLink, parseChannelLink } from "./channelLink";
import { getMessageLinkLabel } from "./messageLinkLabel";
import { buildMessageLink, parseMessageLink } from "./messageLink";
export const COMPOSER_MESSAGE_LINK_NODE_NAME = "composerMessageLink";
@@ -22,10 +29,13 @@ export type ComposerMessageLinkAttributes = {
href: string;
};
const BARE_MESSAGE_LINK_AT_START = /^(?:buzz):\/\/message\?[^\s<>"')\]}*_]+/i;
const BARE_BUZZ_LINK_AT_START =
/^buzz:\/\/(?:message\?|channel\/|(?:pr|issue|repo)\?)[^\s<>"')\]}*]+/i;
const BUZZ_LINK_SUFFIX_AT_START =
/^:\/\/(?:message\?|channel\/|(?:pr|issue|repo)\?)[^\s<>"')\]}*]+/i;
const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
function trimBareMessageLink(value: string): string {
function trimBareBuzzLink(value: string): string {
let trimmed = value.replace(TRAILING_PUNCTUATION, "");
while (/[)\]]$/.test(trimmed)) {
const closing = trimmed.at(-1) ?? "";
@@ -40,23 +50,56 @@ export function resolveComposerMessageLinkAttributes(
href: string,
resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"],
): ComposerMessageLinkAttributes | null {
const parsed = parseMessageLink(href);
if (!parsed.ok) return null;
return {
channelName: resolveChannelName(parsed.value.channelId) ?? "",
href: buildMessageLink({
channelId: parsed.value.channelId,
messageId: parsed.value.messageId,
threadRootId: parsed.value.threadRootId,
}),
};
const message = parseMessageLink(href);
if (message.ok) {
return {
channelName: resolveChannelName(message.value.channelId) ?? "",
href: buildMessageLink({
channelId: message.value.channelId,
messageId: message.value.messageId,
threadRootId: message.value.threadRootId,
}),
};
}
const channel = parseChannelLink(href);
if (channel.ok) {
return {
channelName: resolveChannelName(channel.value.channelId) ?? "",
href: buildChannelLink(channel.value.channelId),
};
}
const entity = parseEntityLink(href);
if (!entity.ok) return null;
switch (entity.value.type) {
case "repo":
return {
channelName: "",
href: buildRepoLink(entity.value),
};
case "pr":
return {
channelName: "",
href: buildPullRequestLink(entity.value),
};
case "issue":
return {
channelName: "",
href: buildIssueLink(entity.value),
};
}
}
function unwrapExactMessageLink(text: string): string | null {
function unwrapExactBuzzLink(text: string): string | null {
const href =
text.startsWith("<") && text.endsWith(">") ? text.slice(1, -1) : text;
if (!href || /\s/.test(href)) return null;
return parseMessageLink(href).ok ? href : null;
return parseMessageLink(href).ok ||
parseChannelLink(href).ok ||
parseEntityLink(href).ok
? href
: null;
}
function unwrapExactHttpLink(text: string): string | null {
@@ -83,16 +126,16 @@ export function createComposerLinkPasteHandler(
) {
return (view: EditorView, event: ClipboardEvent): boolean => {
const text = event.clipboardData?.getData("text/plain") ?? "";
const messageHref = unwrapExactMessageLink(text);
const messageLinkType =
const buzzHref = unwrapExactBuzzLink(text);
const buzzLinkType =
view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME];
if (messageHref && messageLinkType) {
if (buzzHref && buzzLinkType) {
const attrs = resolveComposerMessageLinkAttributes(
messageHref,
buzzHref,
resolveChannelName,
);
if (attrs) {
replaceSelectionWithNode(view, messageLinkType.create(attrs));
replaceSelectionWithNode(view, buzzLinkType.create(attrs));
event.preventDefault();
return true;
}
@@ -122,14 +165,14 @@ export function registerComposerMessageLinkMarkdownIt(
// biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent
const rule = (state: any, silent: boolean): boolean => {
const remaining = state.src.slice(state.pos);
const fullMatch = BARE_MESSAGE_LINK_AT_START.exec(remaining);
const suffixMatch = /^:\/\/message\?[^\s<>"')\]}*_]+/i.exec(remaining);
const fullMatch = BARE_BUZZ_LINK_AT_START.exec(remaining);
const suffixMatch = BUZZ_LINK_SUFFIX_AT_START.exec(remaining);
const resumesTextToken =
!fullMatch && suffixMatch && /buzz$/i.test(state.pending ?? "");
const rawHref =
fullMatch?.[0] ?? (resumesTextToken ? `buzz${suffixMatch[0]}` : null);
if (!rawHref) return false;
const href = trimBareMessageLink(rawHref);
const href = trimBareBuzzLink(rawHref);
const attrs = resolveComposerMessageLinkAttributes(
href,
options.resolveChannelName,
@@ -149,7 +192,79 @@ export function registerComposerMessageLinkMarkdownIt(
md.renderer.rules[tokenType] = (tokens: any[], index: number): string => {
const attrs = tokens[index].meta as ComposerMessageLinkAttributes;
const escapeHtml = md.utils.escapeHtml;
return `<span data-composer-message-link="" data-channel-name="${escapeHtml(attrs.channelName)}" data-href="${escapeHtml(attrs.href)}"></span>`;
return `<span data-composer-buzz-link="" data-channel-name="${escapeHtml(attrs.channelName)}" data-href="${escapeHtml(attrs.href)}"></span>`;
};
}
type ComposerLinkPresentation = {
ariaLabel: string;
channelName: string;
dataAttributes: Record<string, string>;
icon: InlineChipIconKind;
label: string;
};
function composerLinkPresentation(
href: string,
channelName: string,
resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"],
): ComposerLinkPresentation {
const message = parseMessageLink(href);
if (message.ok) {
const resolvedChannelName =
resolveChannelName(message.value.channelId) || channelName || "channel";
return {
ariaLabel: getMessageLinkLabel({ channelName: resolvedChannelName }),
channelName: resolvedChannelName,
dataAttributes: {
"data-composer-message-link": "",
"data-message-link": "",
},
icon: "message",
label: `${resolvedChannelName} · ${message.value.messageId.slice(0, 8)}`,
};
}
const channel = parseChannelLink(href);
if (channel.ok) {
const resolvedChannelName =
resolveChannelName(channel.value.channelId) ||
channelName ||
channel.value.channelId.slice(0, 8);
return {
ariaLabel: `Open channel ${resolvedChannelName}`,
channelName: resolvedChannelName,
dataAttributes: { "data-channel-deep-link": "" },
icon: "channel",
label: resolvedChannelName,
};
}
const entity = parseEntityLink(href);
if (!entity.ok) {
return {
ariaLabel: "Buzz link",
channelName: "",
dataAttributes: {},
icon: "message",
label: "Buzz link",
};
}
const shortId =
entity.value.type === "repo" ? "" : entity.value.id.slice(0, 8);
return {
ariaLabel:
entity.value.type === "repo"
? `Open repository ${entity.value.dtag}`
: `Open ${entity.value.type === "pr" ? "pull request" : "issue"} ${shortId} in repository ${entity.value.dtag}`,
channelName: "",
dataAttributes: { "data-buzz-link-kind": entity.value.type },
icon: entity.value.type,
label:
entity.value.type === "repo"
? entity.value.dtag
: `${entity.value.dtag} · ${shortId}`,
};
}
@@ -183,39 +298,32 @@ export const ComposerMessageLinkNode =
},
parseHTML() {
return [{ tag: "span[data-composer-message-link]" }];
return [
{ tag: "span[data-composer-buzz-link]" },
{ tag: "span[data-composer-message-link]" },
];
},
renderHTML({ node, HTMLAttributes }) {
const href = String(node.attrs.href ?? "");
const parsed = parseMessageLink(href);
const channelName = parsed.ok
? (this.options.resolveChannelName(parsed.value.channelId) ??
(String(node.attrs.channelName ?? "") || "channel"))
: "channel";
const label = getMessageLinkLabel({ channelName });
const channelLinkLabel = getMessageLinkChannelLabel(channelName);
const presentation = composerLinkPresentation(
href,
String(node.attrs.channelName ?? ""),
this.options.resolveChannelName,
);
return [
"span",
mergeAttributes(HTMLAttributes, {
"aria-label": label,
class:
"inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline",
"data-channel-name": channelName,
"data-composer-message-link": "",
"aria-label": presentation.ariaLabel,
class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`,
"data-buzz-link": "",
"data-channel-name": presentation.channelName,
"data-composer-buzz-link": "",
"data-href": href,
"data-message-link": "",
title: label,
...presentation.dataAttributes,
title: presentation.ariaLabel,
}),
["span", { class: "shrink-0" }, MESSAGE_LINK_PREFIX],
[
"span",
{
class: `${MENTION_CHIP_BASE_CLASSES} min-w-0 max-w-full truncate`,
"data-channel-link": "",
},
channelLinkLabel,
],
presentation.label,
];
},
@@ -2,6 +2,11 @@ import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import {
inlineChipIconClasses,
MENTION_CHIP_BASE_CLASSES,
} from "@/shared/ui/mentionChip";
export const mentionHighlightKey = new PluginKey("mentionHighlight");
/**
@@ -267,22 +272,24 @@ function buildDecorations(
node.text,
pos,
mentionPatterns,
"mention-chip",
`${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("human")}`,
{ hidePrefix: true },
);
addMatchesForPatterns(
decorations,
node.text,
pos,
agentMentionPatterns,
"mention-chip agent-mention-highlight",
{ hideMentionPrefix: true },
`${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("agent")}`,
{ hidePrefix: true },
);
addMatchesForPatterns(
decorations,
node.text,
pos,
channelPatterns,
"mention-chip",
`${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("channel")}`,
{ hidePrefix: true },
);
});
@@ -295,7 +302,7 @@ function addMatchesForPatterns(
position: number,
patterns: RegExp[],
className: string,
options?: { hideMentionPrefix?: boolean },
options?: { hidePrefix?: boolean },
) {
for (const pattern of patterns) {
pattern.lastIndex = 0;
@@ -303,10 +310,10 @@ function addMatchesForPatterns(
while (match !== null) {
const from = position + match.index;
const to = from + match[0].length;
if (options?.hideMentionPrefix && match[0].startsWith("@")) {
if (options?.hidePrefix && /^[@#]/.test(match[0])) {
decorations.push(
Decoration.inline(from, from + 1, {
class: "agent-mention-at-hidden",
class: "mention-prefix-hidden",
spellcheck: "false",
}),
);
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import remarkChannelDeepLinks from "./remarkChannelDeepLinks.ts";
function run(value) {
const tree = {
type: "root",
children: [{ type: "paragraph", children: [{ type: "text", value }] }],
};
remarkChannelDeepLinks()(tree);
return tree.children[0].children;
}
test("turns a bare channel deep link into a custom node", () => {
const children = run(
"Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32 now",
);
assert.equal(children[1].type, "channel-deep-link");
assert.equal(
children[1].value,
"buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32",
);
});
test("peels trailing sentence punctuation", () => {
const children = run(
"Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32.",
);
assert.equal(
children[1].value,
"buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32",
);
assert.equal(children[2].value, ".");
});
@@ -0,0 +1,22 @@
/** Detect bare `buzz://channel/<uuid>` URLs in markdown text nodes. */
import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts";
const CHANNEL_URL_PATTERN = /buzz:\/\/channel\/[^\s<>"')\]]+/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
export default function remarkChannelDeepLinks() {
return createRemarkPrefixPlugin(CHANNEL_URL_PATTERN, (matchText) => {
const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, "");
return {
node: {
type: "channel-deep-link",
value,
data: {
hName: "channel-deep-link",
hChildren: [{ type: "text", value }],
},
},
trailing: matchText.slice(value.length),
};
});
}
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import remarkEntityLinks from "./remarkEntityLinks.ts";
function run(value) {
const tree = {
type: "root",
children: [{ type: "paragraph", children: [{ type: "text", value }] }],
};
remarkEntityLinks()(tree);
return tree.children[0].children;
}
test("turns every bare Buzz entity permalink family into a chip node", () => {
const owner = "ab".repeat(32);
const id = "cd".repeat(32);
const links = [
`buzz://repo?owner=${owner}&d=buzz`,
`buzz://pr?id=${id}&owner=${owner}&d=buzz`,
`buzz://issue?id=${id}&owner=${owner}&d=buzz`,
];
for (const link of links) {
const children = run(link);
assert.equal(children[0].type, "entity-link");
assert.equal(children[0].value, link);
}
});
test("keeps sentence punctuation outside entity chip nodes", () => {
const link = `buzz://repo?owner=${"ab".repeat(32)}&d=buzz`;
const children = run(`${link}.`);
assert.equal(children[0].value, link);
assert.equal(children[1].value, ".");
});
@@ -0,0 +1,22 @@
/** Detect bare `buzz://pr|issue|repo?…` URLs in markdown text nodes. */
import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts";
const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo)\?[^\s<>"')\]]+/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
export default function remarkEntityLinks() {
return createRemarkPrefixPlugin(ENTITY_URL_PATTERN, (matchText) => {
const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, "");
return {
node: {
type: "entity-link",
value,
data: {
hName: "entity-link",
hChildren: [{ type: "text", value }],
},
},
trailing: matchText.slice(value.length),
};
});
}
@@ -19,12 +19,8 @@ import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
MENTION_CHIP_PREFIX_CLASS,
MESSAGE_MARKDOWN_CLASS,
} from "@/shared/ui/mentionChip";
import { InlineChip } from "@/shared/ui/InlineChip";
import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -278,24 +274,26 @@ function ProfileName({
underlineOnHover?: boolean;
}) {
const isAgentMention = highlight && isAgent;
const node = (
const node = highlight ? (
<InlineChip
data-mention=""
className={cn(
isAgentMention && "agent-mention-highlight",
underlineOnHover && "hover:underline",
)}
icon={isAgentMention ? "agent" : "human"}
interactive={Boolean(pubkey)}
>
{children}
</InlineChip>
) : (
<span
data-mention={highlight ? "" : undefined}
className={cn(
pubkey && "cursor-pointer",
highlight
? cn(
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
isAgentMention && "agent-mention-highlight",
)
: "rounded-xs transition-colors hover:text-foreground",
"rounded-xs transition-colors hover:text-foreground",
underlineOnHover && "hover:underline",
)}
>
{highlight && !isAgentMention ? (
<span className={MENTION_CHIP_PREFIX_CLASS}>@</span>
) : null}
{children}
</span>
);
+399
View File
@@ -0,0 +1,399 @@
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
const ipcHandlers = new Map();
let nextCallbackId = 1;
const callbacks = new Map();
const tauriInternals = {
invoke: (cmd, args) => {
const handler = ipcHandlers.get(cmd);
if (handler) return Promise.resolve(handler(args));
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
},
transformCallback: (callback) => {
const id = nextCallbackId++;
callbacks.set(id, callback);
return id;
},
};
globalThis.window = {
__TAURI_INTERNALS__: tauriInternals,
__TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} },
};
globalThis.__TAURI_INTERNALS__ = tauriInternals;
const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } =
await import("@/shared/deep-link.ts");
function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}
async function settle() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
afterEach(() => {
ipcHandlers.clear();
callbacks.clear();
});
test("listener teardown leaves an unaccepted FIFO item for the next mount", async () => {
const queue = [
{
id: "first",
kind: "channel",
channelId: "channel-1",
messageId: null,
threadRootId: null,
},
{
id: "second",
kind: "message",
channelId: "channel-2",
messageId: "message-2",
threadRootId: "root-2",
},
];
const firstAcknowledge = deferred();
const acknowledged = [];
let unlistenCount = 0;
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {
unlistenCount += 1;
});
ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null);
ipcHandlers.set(
"acknowledge_pending_navigation_deep_link",
async ({ id }) => {
if (id === "first") await firstAcknowledge.promise;
assert.equal(queue[0]?.id, id);
acknowledged.push(id);
queue.shift();
return true;
},
);
let firstMountActive = true;
const firstOpened = [];
const firstUnlisten = await listenForNavigationDeepLinks(
(payload) => {
if (!firstMountActive) return false;
firstOpened.push(payload.channelId);
return true;
},
(payload) => {
if (!firstMountActive) return false;
firstOpened.push(payload.messageId);
return true;
},
);
await settle();
assert.deepEqual(firstOpened, ["channel-1"]);
firstMountActive = false;
firstUnlisten();
firstAcknowledge.resolve();
await settle();
assert.deepEqual(acknowledged, ["first"]);
assert.equal(queue[0]?.id, "second");
const secondOpened = [];
const secondUnlisten = await listenForNavigationDeepLinks(
(payload) => {
secondOpened.push(payload.channelId);
return true;
},
(payload) => {
secondOpened.push(payload.messageId);
return true;
},
);
await settle();
assert.deepEqual(secondOpened, ["message-2"]);
assert.deepEqual(acknowledged, ["first", "second"]);
assert.equal(queue.length, 0);
secondUnlisten();
assert.equal(unlistenCount, 4);
});
test("concurrent listener remount does not take or acknowledge the in-flight head twice", async () => {
const queue = [
{
id: "in-flight",
kind: "channel",
channelId: "channel-1",
messageId: null,
threadRootId: null,
},
];
const acknowledgeGate = deferred();
const opened = [];
const acknowledged = [];
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null);
ipcHandlers.set(
"acknowledge_pending_navigation_deep_link",
async ({ id }) => {
acknowledged.push(id);
await acknowledgeGate.promise;
assert.equal(queue[0]?.id, id);
queue.shift();
return true;
},
);
const firstUnlisten = await listenForNavigationDeepLinks(
(payload) => {
opened.push(`first:${payload.channelId}`);
return true;
},
() => true,
);
await settle();
assert.deepEqual(opened, ["first:channel-1"]);
assert.deepEqual(acknowledged, ["in-flight"]);
firstUnlisten();
const secondUnlisten = await listenForNavigationDeepLinks(
(payload) => {
opened.push(`second:${payload.channelId}`);
return true;
},
() => true,
);
await settle();
assert.deepEqual(opened, ["first:channel-1"]);
assert.deepEqual(acknowledged, ["in-flight"]);
acknowledgeGate.resolve();
await settle();
await settle();
assert.deepEqual(opened, ["first:channel-1"]);
assert.deepEqual(acknowledged, ["in-flight"]);
assert.equal(queue.length, 0);
secondUnlisten();
});
test("community reset prevents an in-flight route from acknowledging", async () => {
const pending = {
id: "old-community",
kind: "channel",
channelId: "channel-1",
messageId: null,
threadRootId: null,
};
const routeGate = deferred();
let acknowledgeCount = 0;
let clearCount = 0;
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("clear_pending_navigation_deep_links", () => {
clearCount += 1;
});
ipcHandlers.set("take_pending_navigation_deep_link", () => pending);
ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => {
acknowledgeCount += 1;
return true;
});
const unlisten = await listenForNavigationDeepLinks(
async () => {
await routeGate.promise;
return true;
},
() => true,
);
await settle();
await resetNavigationDeepLinkDrain();
routeGate.resolve();
await settle();
assert.equal(clearCount, 1);
assert.equal(acknowledgeCount, 0);
unlisten();
});
test("community reset after take does not route the stale item", async () => {
const takeGate = deferred();
const opened = [];
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("clear_pending_navigation_deep_links", () => {});
ipcHandlers.set("take_pending_navigation_deep_link", async () => {
await takeGate.promise;
return {
id: "old-community",
kind: "channel",
channelId: "channel-1",
messageId: null,
threadRootId: null,
};
});
ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => true);
const unlisten = await listenForNavigationDeepLinks(
(payload) => {
opened.push(payload.channelId);
return true;
},
() => true,
);
await settle();
await resetNavigationDeepLinkDrain();
takeGate.resolve();
await settle();
assert.deepEqual(opened, []);
unlisten();
});
test("community reset stops the stale drain before taking another item", async () => {
const queue = [
{
id: "first",
kind: "channel",
channelId: "channel-1",
messageId: null,
threadRootId: null,
},
{
id: "second",
kind: "channel",
channelId: "channel-2",
messageId: null,
threadRootId: null,
},
];
const opened = [];
let takeCount = 0;
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("clear_pending_navigation_deep_links", () => {
queue.length = 0;
});
ipcHandlers.set("take_pending_navigation_deep_link", () => {
takeCount += 1;
return queue[0] ?? null;
});
ipcHandlers.set(
"acknowledge_pending_navigation_deep_link",
async ({ id }) => {
assert.equal(queue[0]?.id, id);
queue.shift();
await resetNavigationDeepLinkDrain();
return true;
},
);
const unlisten = await listenForNavigationDeepLinks(
(payload) => {
opened.push(payload.channelId);
return true;
},
() => true,
);
await settle();
await settle();
assert.deepEqual(opened, ["channel-1"]);
assert.equal(takeCount, 1);
unlisten();
});
test("failed community clear quarantines stale navigation from the next listener", async () => {
const stale = {
id: "old-community",
kind: "channel",
channelId: "old-channel",
messageId: null,
threadRootId: null,
};
const opened = [];
let takeCount = 0;
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("clear_pending_navigation_deep_links", () => {
throw new Error("clear failed");
});
ipcHandlers.set("take_pending_navigation_deep_link", () => {
takeCount += 1;
return stale;
});
await assert.rejects(resetNavigationDeepLinkDrain(), /clear failed/);
const unlisten = await listenForNavigationDeepLinks(
(payload) => {
opened.push(payload.channelId);
return true;
},
() => true,
);
await settle();
assert.equal(takeCount, 0);
assert.deepEqual(opened, []);
unlisten();
// Restore the module-level gate for later tests, just as a successful retry
// does in the application.
ipcHandlers.set("clear_pending_navigation_deep_links", () => {});
await resetNavigationDeepLinkDrain();
});
test("rejected navigation remains queued and is not acknowledged", async () => {
const pending = {
id: "retry-me",
kind: "channel",
channelId: "channel-1",
messageId: null,
threadRootId: null,
};
let acknowledgeCount = 0;
const warnings = [];
const originalWarn = console.warn;
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("take_pending_navigation_deep_link", () => pending);
ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => {
acknowledgeCount += 1;
return true;
});
console.warn = (...args) => warnings.push(args);
try {
const unlisten = await listenForNavigationDeepLinks(
async () => {
throw new Error("route failed");
},
async () => true,
);
await settle();
assert.equal(acknowledgeCount, 0);
assert.equal(warnings.length, 1);
assert.match(String(warnings[0][1]), /route failed/);
unlisten();
} finally {
console.warn = originalWarn;
}
});
+115 -9
View File
@@ -15,6 +15,8 @@ export interface DeepLinkDeps {
onAddCommunityAvailable: (listener: () => void) => () => void;
}
export type ChannelDeepLinkPayload = { channelId: string };
/**
* Payload emitted by the Rust deep-link handler for `buzz://message?…`.
* Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`.
@@ -25,6 +27,14 @@ export type MessageDeepLinkPayload = {
threadRootId: string | null;
};
type PendingNavigationDeepLink = {
id: string;
kind: "channel" | "message";
channelId: string;
messageId: string | null;
threadRootId: string | null;
};
export type NostrBindDeepLinkPayload = {
challengeId: string;
nonce: string;
@@ -106,7 +116,7 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) {
* relay's HTTP API — signed by this app's identity key and only adds and
* switches to the community once the relay has admitted the key.
*
* `buzz://message?…` is handled separately by `listenForMessageDeepLinks`,
* `buzz://message?…` is handled separately by `listenForNavigationDeepLinks`,
* because it needs to dispatch into the router which only exists below the
* `RouterProvider` in the component tree.
*/
@@ -152,17 +162,113 @@ export async function listenForDeepLinks(
};
}
let navigationDrainTail: Promise<void> = Promise.resolve();
let navigationDrainGeneration = 0;
let navigationDrainEnabled = true;
export async function resetNavigationDeepLinkDrain(): Promise<void> {
const generation = ++navigationDrainGeneration;
// Fail closed while the outgoing community's native queue is being cleared.
// A rejected clear leaves that queue's identity unknown, so no later listener
// may route it against a different community.
navigationDrainEnabled = false;
await invoke("clear_pending_navigation_deep_links");
if (generation === navigationDrainGeneration) {
navigationDrainEnabled = true;
}
}
function serializeNavigationDrain(task: () => Promise<void>): Promise<void> {
const drain = navigationDrainTail.then(task, task);
// Keep the shared tail fulfilled so one route failure cannot poison future
// listener mounts. The caller still receives `drain` and reports the error.
navigationDrainTail = drain.catch(() => {});
return drain;
}
async function drainPendingNavigationDeepLinks(
onOpenChannel: (
payload: ChannelDeepLinkPayload,
) => boolean | Promise<boolean>,
onOpenMessage: (
payload: MessageDeepLinkPayload,
) => boolean | Promise<boolean>,
) {
const generation = navigationDrainGeneration;
if (!navigationDrainEnabled) return;
while (navigationDrainEnabled && generation === navigationDrainGeneration) {
const pending = await invoke<PendingNavigationDeepLink | null>(
"take_pending_navigation_deep_link",
);
if (
!pending ||
!navigationDrainEnabled ||
generation !== navigationDrainGeneration
) {
return;
}
const accepted = await (pending.kind === "channel"
? onOpenChannel({ channelId: pending.channelId })
: pending.messageId
? onOpenMessage({
channelId: pending.channelId,
messageId: pending.messageId,
threadRootId: pending.threadRootId,
})
: false);
if (!accepted || generation !== navigationDrainGeneration) return;
const acknowledged = await invoke<boolean>(
"acknowledge_pending_navigation_deep_link",
{ id: pending.id },
);
if (!acknowledged) return;
}
}
/**
* Register a listener for `deep-link-message` events. Must be called from
* inside the router tree (e.g. AppShell) because the navigation callback
* uses TanStack Router state.
* Register listeners for queued channel/message navigation emitted by Rust.
* A consumer must explicitly accept each item before it is acknowledged, so
* effect teardown leaves an in-flight queue head available for the next mount.
*/
export function listenForMessageDeepLinks(
onOpen: (payload: MessageDeepLinkPayload) => void,
export async function listenForNavigationDeepLinks(
onOpenChannel: (
payload: ChannelDeepLinkPayload,
) => boolean | Promise<boolean>,
onOpenMessage: (
payload: MessageDeepLinkPayload,
) => boolean | Promise<boolean>,
): Promise<UnlistenFn> {
return listen<MessageDeepLinkPayload>("deep-link-message", (event) => {
onOpen(event.payload);
});
let drainRunning = false;
let drainRequested = false;
const drain = () => {
drainRequested = true;
if (drainRunning) return;
drainRunning = true;
void (async () => {
try {
while (drainRequested) {
drainRequested = false;
await serializeNavigationDrain(() =>
drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage),
);
}
} catch (error: unknown) {
console.warn("Failed to drain pending navigation deep links", error);
} finally {
drainRunning = false;
if (drainRequested) drain();
}
})();
};
const unlistens = await Promise.all([
listen<ChannelDeepLinkPayload>("deep-link-channel", drain),
listen<MessageDeepLinkPayload>("deep-link-message", drain),
]);
drain();
return () => {
for (const unlisten of unlistens) unlisten();
};
}
export function listenForNostrBindDeepLinks(
+75 -24
View File
@@ -8,8 +8,8 @@
var(--inline-chip-padding-block-end)
);
--inline-code-font-size: var(--text-xs);
--agent-icon-size: 0.95em;
--agent-icon-gap: 0.125rem;
--inline-chip-icon-size: 0.95em;
--inline-chip-icon-gap: 0.1875rem;
}
.message-markdown p:empty {
@@ -145,10 +145,70 @@
word-break: normal;
}
.message-markdown .mention-chip-prefix {
display: inline-block;
line-height: 1;
transform: translateY(-0.12em);
.message-markdown .inline-chip-with-icon {
position: relative;
padding-left: calc(
var(--inline-chip-padding-inline) +
var(--inline-chip-icon-size) +
var(--inline-chip-icon-gap)
);
}
.message-markdown .inline-chip-with-icon::before {
content: "";
position: absolute;
top: 50%;
left: var(--inline-chip-padding-inline);
width: var(--inline-chip-icon-size);
height: var(--inline-chip-icon-size);
background: currentColor;
pointer-events: none;
transform: translateY(-50%);
}
.message-markdown .inline-chip-icon-message::before {
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.message-markdown .inline-chip-icon-channel::before {
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-width='2'%3E%3Cpath d='M4 9h16M4 15h16M10 3 8 21M16 3l-2 18'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-width='2'%3E%3Cpath d='M4 9h16M4 15h16M10 3 8 21M16 3l-2 18'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.message-markdown .inline-chip-icon-repo::before {
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z'/%3E%3Cpath d='M9 13h6M12 10v6'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z'/%3E%3Cpath d='M9 13h6M12 10v6'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.message-markdown .inline-chip-icon-pr::before {
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Ccircle cx='18' cy='6' r='3'/%3E%3Cpath d='M6 3v12M18 9a9 9 0 0 1-9 9'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Ccircle cx='18' cy='6' r='3'/%3E%3Cpath d='M6 3v12M18 9a9 9 0 0 1-9 9'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.message-markdown .inline-chip-icon-issue::before {
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Ccircle cx='12' cy='12' r='1' fill='%23000'/%3E%3C/svg%3E")
center / contain no-repeat;
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Ccircle cx='12' cy='12' r='1' fill='%23000'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.message-markdown .inline-code-chip,
@@ -183,7 +243,7 @@
color: hsl(var(--primary) / 0.9);
}
.message-markdown .agent-mention-at-hidden {
.message-markdown .mention-prefix-hidden {
display: inline-block;
width: 0;
max-width: 0;
@@ -196,25 +256,16 @@
line-height: 1;
}
.message-markdown .agent-mention-highlight {
position: relative;
padding-left: calc(
var(--inline-chip-padding-inline) +
var(--agent-icon-size) +
var(--agent-icon-gap)
);
.message-markdown .inline-chip-icon-human::before {
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8'/%3E%3C/svg%3E")
center / contain no-repeat;
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.message-markdown .agent-mention-highlight::before {
content: "";
position: absolute;
top: 50%;
left: var(--inline-chip-padding-inline);
width: var(--agent-icon-size);
height: var(--agent-icon-size);
background: currentColor;
pointer-events: none;
transform: translateY(-50%);
.message-markdown .inline-chip-icon-agent::before {
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 8V4H8'/%3E%3Crect width='16' height='12' x='4' y='8' rx='2'/%3E%3Cpath d='M2 14h2'/%3E%3Cpath d='M20 14h2'/%3E%3Cpath d='M15 13v2'/%3E%3Cpath d='M9 13v2'/%3E%3C/svg%3E")
center / contain no-repeat;
+70
View File
@@ -0,0 +1,70 @@
import type * as React from "react";
import { cn } from "@/shared/lib/cn";
import {
inlineChipIconClasses,
type InlineChipIconKind,
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
type InlineChipCommonProps = {
children: React.ReactNode;
className?: string;
icon: InlineChipIconKind;
interactive?: boolean;
};
type InlineChipProps =
| (InlineChipCommonProps &
Omit<
React.ComponentPropsWithoutRef<"span">,
keyof InlineChipCommonProps
> & {
as?: "span";
})
| (InlineChipCommonProps &
Omit<
React.ComponentPropsWithoutRef<"button">,
keyof InlineChipCommonProps
> & {
as: "button";
});
/** Shared visual primitive for mention, channel, and Buzz permalink chips. */
export function InlineChip({
as = "span",
children,
className,
icon,
interactive = false,
...props
}: InlineChipProps) {
const classes = cn(
MENTION_CHIP_BASE_CLASSES,
inlineChipIconClasses(icon),
interactive && "cursor-pointer",
interactive && MENTION_CHIP_HOVER_CLASSES,
className,
);
if (as === "button") {
return (
<button
{...(props as React.ComponentPropsWithoutRef<"button">)}
type="button"
className={classes}
>
{children}
</button>
);
}
return (
<span
{...(props as React.ComponentPropsWithoutRef<"span">)}
className={classes}
>
{children}
</span>
);
}
+256 -5
View File
@@ -534,6 +534,7 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import { isChannelLink } from "../../features/messages/lib/channelLink.ts";
import { isMessageLink } from "../../features/messages/lib/messageLink.ts";
import { parseEntityLink } from "../lib/entityLink.ts";
import remarkSpoilers from "../lib/remarkSpoilers.ts";
@@ -545,7 +546,7 @@ const EVENT_HEX =
function buzzDeepLinkUrlTransform(value, key) {
if (key !== "href") return defaultUrlTransform(value);
if (isMessageLink(value)) return value;
if (isMessageLink(value) || isChannelLink(value)) return value;
if (parseEntityLink(value).ok) return value;
return defaultUrlTransform(value);
}
@@ -580,6 +581,23 @@ test("messageLinkUrlTransform: preserves buzz://message href with thread", () =>
assert.match(html, /href="buzz:\/\/message\?[^"]*thread=t1"/);
});
test("messageLinkUrlTransform: preserves buzz://channel href", () => {
const html = renderMarkdown(
"Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32)",
);
assert.match(
html,
/href="buzz:\/\/channel\/580ca78b-9dae-46f3-8854-bd671853ba32"/,
);
});
test("messageLinkUrlTransform: rejects malformed buzz://channel href", () => {
const html = renderMarkdown(
"Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32?extra=true)",
);
assert.match(html, /href=""/);
});
test("messageLinkUrlTransform: still strips javascript: scheme", () => {
const html = renderMarkdown("[xss](javascript:alert(1))");
// defaultUrlTransform replaces unsafe schemes with the empty string.
@@ -654,13 +672,15 @@ test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", ()
// the inline anchor click path (not just card extraction).
import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx";
import { createMarkdownComponents } from "../ui/markdown.tsx";
import { renderCachedMarkdown } from "../ui/markdown/nodeCache.ts";
import { MarkdownRuntimeContext } from "../ui/markdown/runtimeContext.ts";
const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`;
test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => {
// Origin matches active relay — anchor should navigate in-app (non-null).
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "my-repo"),
href: CLONE_URL,
onOpenEntityLink: () => {},
@@ -684,7 +704,6 @@ test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () =>
test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => {
// Origin does NOT match active relay — must fall through to ExternalLinkAnchor.
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "my-repo"),
href: CLONE_URL,
onOpenEntityLink: () => {},
@@ -700,7 +719,6 @@ test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => {
test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => {
// No known relay origin — must fail closed, not guess.
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "my-repo"),
href: CLONE_URL,
onOpenEntityLink: () => {},
@@ -717,7 +735,6 @@ test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin",
// A direct buzz://pr link always resolves in-app — it does not require origin.
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
const el = renderEntityLinkAnchor({
anchorProps: {},
children: React.createElement("span", null, "My PR"),
href: prLink,
onOpenEntityLink: () => {},
@@ -1042,3 +1059,237 @@ test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => {
"markdownNode must render when no sentinel is present",
);
});
test("bare Buzz permalinks render cohesive icon-prefixed chips", () => {
const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32";
const messageLink = `buzz://message?channel=${channelId}&id=${EVENT_HEX}`;
const channelLink = `buzz://channel/${channelId}`;
const links = [
messageLink,
channelLink,
`buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
`buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`,
`buzz://repo?owner=${OWNER_HEX}&d=buzz-world`,
];
const markdown = renderCachedMarkdown({
components: createMarkdownComponents(true, false),
content: links.join(" "),
variant: "entity-link-integration-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [{ id: channelId, name: "engineering" }],
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
},
},
markdown,
),
);
assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 5);
assert.match(html, /inline-chip-icon-message/);
assert.match(html, />engineering · c3b589fa</);
assert.match(html, /inline-chip-icon-channel/);
assert.match(html, />engineering</);
assert.match(html, /inline-chip-icon-pr/);
assert.match(html, /inline-chip-icon-issue/);
assert.match(html, /inline-chip-icon-repo/);
assert.equal((html.match(/>buzz-world · c3b589fa</g) ?? []).length, 2);
assert.match(html, />buzz-world</);
});
test("authored Buzz permalink labels remain ordinary links", () => {
const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32";
const links = [
`[the message](buzz://message?channel=${channelId}&id=${EVENT_HEX})`,
`[**design discussion**](buzz://channel/${channelId})`,
`[the issue](buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world)`,
];
const markdown = renderCachedMarkdown({
components: createMarkdownComponents(true, false),
content: links.join(" "),
variant: "authored-buzz-link-integration-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [{ id: channelId, name: "engineering" }],
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
},
},
markdown,
),
);
assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 0);
assert.match(html, />the message</);
assert.match(html, />design discussion</);
assert.match(html, /aria-label="Open channel: design discussion"/);
assert.doesNotMatch(html, /\[object Object\]/);
assert.match(html, />the issue</);
assert.equal((html.match(/underline-offset-4/g) ?? []).length, 3);
});
test("bare Buzz permalinks shorten unavailable channel identifiers", () => {
const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32";
const markdown = renderCachedMarkdown({
components: createMarkdownComponents(true, false),
content: [
`buzz://message?channel=${channelId}&id=${EVENT_HEX}`,
`buzz://channel/${channelId}`,
].join(" "),
variant: "unknown-channel-buzz-link-integration-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [],
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
},
},
markdown,
),
);
assert.match(html, />580ca78b · c3b589fa</);
assert.match(html, />580ca78b</);
assert.doesNotMatch(html, /#channel/);
});
test("channel references replace the authored hash with the channel icon", () => {
const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32";
const markdown = renderCachedMarkdown({
channelNames: ["engineering"],
components: createMarkdownComponents(true, false),
content: "See #engineering",
variant: "channel-reference-icon-integration-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [{ id: channelId, name: "engineering" }],
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
},
},
markdown,
),
);
assert.match(html, /inline-chip-icon-channel/);
assert.match(html, />engineering</);
assert.doesNotMatch(html, />#engineering</);
});
test("resolved human mentions replace the authored at-sign with the shared icon", () => {
const markdown = renderCachedMarkdown({
components: createMarkdownComponents(false, false),
content: "Ask @alice",
mentionNames: ["alice"],
variant: "human-mention-icon-integration-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
channels: [],
mentionPubkeysByName: { alice: HUMAN_PUBKEY },
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
},
},
markdown,
),
);
assert.match(html, /data-mention=""/);
assert.match(html, /inline-chip-icon-human/);
assert.match(html, />alice</);
assert.doesNotMatch(html, />@alice</);
});
test("agent mentions retain the bot treatment instead of the human icon", () => {
const markdown = renderCachedMarkdown({
components: createMarkdownComponents(false, false),
content: "Ask @alice",
mentionNames: ["alice"],
variant: "agent-mention-icon-integration-test",
});
const html = renderToStaticMarkup(
React.createElement(
MarkdownRuntimeContext.Provider,
{
value: {
agentMentionPubkeysByName: { alice: AGENT_PUBKEY },
channels: [],
mentionPubkeysByName: { alice: AGENT_PUBKEY },
onOpenChannel: () => {},
onOpenEntityLink: () => {},
onOpenMessageLink: () => {},
relayOrigin: null,
},
},
markdown,
),
);
assert.match(html, /data-mention=""/);
assert.match(html, /agent-mention-highlight/);
assert.match(html, /inline-chip-icon-agent/);
assert.match(html, />alice</);
assert.doesNotMatch(html, />@alice</);
});
test("renderEntityLinkAnchor renders Buzz entity links as chips", () => {
const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`;
const el = renderEntityLinkAnchor({
children: "PR · abc123",
href: prLink,
interactive: true,
onOpenEntityLink: () => {},
relayOrigin: null,
});
const html = renderToStaticMarkup(el);
assert.match(html, /data-buzz-link=""/);
assert.match(html, /<button/);
assert.doesNotMatch(html, /<a/);
});
test("renderEntityLinkAnchor keeps chip styling when interaction is disabled", () => {
const repoLink = `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`;
const el = renderEntityLinkAnchor({
children: "buzz-world",
href: repoLink,
interactive: false,
onOpenEntityLink: () => {},
relayOrigin: null,
});
const html = renderToStaticMarkup(el);
assert.match(html, /data-buzz-link=""/);
assert.match(html, /<span/);
assert.match(html, /class="mention-chip\s/);
assert.doesNotMatch(html, /<button/);
});
+60 -73
View File
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { requestOpenSnapshotImport } from "@/features/agents/openSnapshotImportFromUrlEvent";
import { parseChannelLink } from "@/features/messages/lib/channelLink";
import {
parseMessageLink,
resolveMessageLinkRenderTarget,
@@ -22,11 +23,13 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { invokeTauri } from "@/shared/api/tauri";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { cn } from "@/shared/lib/cn";
import { parseEntityLink } from "@/shared/lib/entityLink";
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { InlineChip } from "@/shared/ui/InlineChip";
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
@@ -36,9 +39,6 @@ import {
} from "@/shared/lib/computeConfigNudge";
import {
INLINE_CODE_CHIP_CLASS,
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
MENTION_CHIP_PREFIX_CLASS,
MESSAGE_MARKDOWN_CLASS,
} from "@/shared/ui/mentionChip";
@@ -62,6 +62,11 @@ import {
} from "./markdown/entityLinks";
import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor";
import { FileCard } from "./markdown/FileCard";
import {
ChannelDeepLinkAnchor,
MarkdownChannelDeepLink,
MarkdownChannelReference,
} from "./markdown/ChannelDeepLink";
import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover";
import { createLinkPreviewImageLightbox } from "./markdown/LinkPreviewImageLightbox";
import { MarkdownInput } from "./markdown/MarkdownInput";
@@ -113,6 +118,7 @@ import {
} from "./markdown/imageLightbox";
import { MarkdownTable } from "./markdown/MarkdownTable";
import { ProgressiveImage } from "./markdown/ProgressiveImage";
import { BuzzInlineLink } from "./markdown/BuzzLinkChip";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
import { useMessageLinkPreviews } from "./markdown/useMessageLinkPreviews";
@@ -1251,7 +1257,7 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
);
}
function createMarkdownComponents(
export function createMarkdownComponents(
interactive = true,
mediaInset = false,
): Components {
@@ -1328,10 +1334,20 @@ function createMarkdownComponents(
);
}
// Intercept `buzz://message?channel=…&id=…` links so a click navigates
// in-app instead of opening the URL in the OS browser. http(s) links
// continue to use the existing target="_blank" behavior.
// Intercept `buzz://channel/<uuid>` and `buzz://message?...` links so
// clicks navigate in-app instead of opening the URL in the OS browser.
if (href) {
if (parseChannelLink(href).ok) {
return (
<ChannelDeepLinkAnchor
{...props}
href={href}
interactive={interactive}
>
{children}
</ChannelDeepLinkAnchor>
);
}
const messageLinkTarget = resolveMessageLinkRenderTarget({
href,
label,
@@ -1349,17 +1365,13 @@ function createMarkdownComponents(
}
return (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenMessageLink(messageLinkTarget.link);
}}
<BuzzInlineLink
title={href}
interactive={interactive}
onOpenLink={() => onOpenMessageLink(messageLinkTarget.link)}
>
{children}
</a>
</BuzzInlineLink>
);
}
// Malformed message deep link — fall through to the default
@@ -1369,11 +1381,12 @@ function createMarkdownComponents(
// `buzz://pr|issue|repo?…` entity links navigate in-app; malformed ones
// fall through to the default anchor.
const entityAnchor = renderEntityLinkAnchor({
anchorProps: props,
children,
href,
onOpenEntityLink,
relayOrigin,
interactive,
asChip: label === href,
});
if (entityAnchor) return entityAnchor;
@@ -1599,30 +1612,19 @@ function createMarkdownComponents(
pubkey !== undefined &&
agentMentionPubkeysByName?.[mentionName] === pubkey;
const mentionLabel = mentionText.replace(/^@/, "");
const renderedMentionText = isAgentMention ? (
mentionLabel
) : (
<>
<span className={MENTION_CHIP_PREFIX_CLASS}>@</span>
{mentionLabel}
</>
);
// Only chips that actually open a profile get the clickable affordance.
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
// cursor there promises a click that does nothing.
const opensProfile = interactive && pubkey !== undefined;
const mentionNode = (
<span
<InlineChip
data-mention=""
className={cn(
MENTION_CHIP_BASE_CLASSES,
opensProfile && "cursor-pointer",
opensProfile && MENTION_CHIP_HOVER_CLASSES,
isAgentMention && "agent-mention-highlight",
)}
className={cn(isAgentMention && "agent-mention-highlight")}
icon={isAgentMention ? "agent" : "human"}
interactive={opensProfile}
>
{renderedMentionText}
</span>
{mentionLabel}
</InlineChip>
);
return opensProfile ? (
@@ -1648,45 +1650,32 @@ function createMarkdownComponents(
}
return <InlineEmojiPopover alt={alt} resolvedSrc={resolvedSrc} />;
},
"channel-link": function MarkdownChannelLink({
"channel-deep-link": ({ children }: { children?: React.ReactNode }) => (
<MarkdownChannelDeepLink interactive={interactive}>
{children}
</MarkdownChannelDeepLink>
),
"channel-link": ({ children }: { children?: React.ReactNode }) => (
<MarkdownChannelReference interactive={interactive}>
{children}
</MarkdownChannelReference>
),
"entity-link": function MarkdownEntityLink({
children,
}: {
children?: React.ReactNode;
}) {
const { channels, onOpenChannel } = useMarkdownRuntime();
const text = String(children ?? "");
const channelName = text.startsWith("#") ? text.slice(1) : text;
const channel = channels.find(
(c) =>
c.channelType !== "dm" &&
c.name.toLowerCase() === channelName.toLowerCase(),
);
if (channel && interactive) {
return (
<button
type="button"
data-channel-link=""
aria-label={`Open channel ${channelName}`}
className={cn(
"cursor-pointer",
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
)}
onClick={() => {
onOpenChannel(channel.id);
}}
>
{children}
</button>
);
}
return (
<span data-channel-link="" className={MENTION_CHIP_BASE_CLASSES}>
{children}
</span>
);
const { onOpenEntityLink, relayOrigin } = useMarkdownRuntime();
const href = String(children ?? "");
if (!parseEntityLink(href).ok)
return <span data-entity-link="">{href}</span>;
return renderEntityLinkAnchor({
children: href,
href,
interactive,
onOpenEntityLink,
relayOrigin,
});
},
"message-link": function MarkdownMessageLink({
children,
@@ -1697,11 +1686,9 @@ function createMarkdownComponents(
const href = String(children ?? "");
const parsed = parseMessageLink(href);
if (!parsed.ok) {
// Malformed `buzz://message?…` — render the raw URL as plain text
// rather than a misleading clickable pill.
// Malformed link: render the raw URL rather than a misleading pill.
return <span data-message-link="">{href}</span>;
}
return (
<MessageLinkPill
channels={channels}
@@ -1719,7 +1706,7 @@ function createMarkdownComponents(
* eight instances ever exist. Module-stable maps mean cached markdown element
* trees (see ./markdown/nodeCache.ts) never embed per-mount closures.
*/
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "6";
const MARKDOWN_COMPONENT_SCHEMA_VERSION = "8";
const markdownComponentsByVariant = new Map<string, MarkdownComponentSet>();
type MarkdownComponentSet = { components: Components; variant: string };
@@ -0,0 +1,152 @@
import * as React from "react";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import { InlineChip } from "@/shared/ui/InlineChip";
import type { InlineChipIconKind } from "@/shared/ui/mentionChip";
import {
MediaContextMenu,
type MediaContextMenuPosition,
useDismissMediaContextMenu,
} from "./MediaContextMenu";
function useBuzzLinkContextMenu({
href,
interactive,
onOpenLink,
}: {
href: string | undefined;
interactive: boolean;
onOpenLink: () => void;
}) {
const [position, setPosition] =
React.useState<MediaContextMenuPosition | null>(null);
const closeMenu = React.useCallback(() => setPosition(null), []);
useDismissMediaContextMenu(Boolean(position), closeMenu);
const onContextMenuCapture = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (!interactive || !href) return;
event.preventDefault();
setPosition({ x: event.clientX, y: event.clientY });
},
[href, interactive],
);
const contextMenu =
position && href ? (
<MediaContextMenu
dataAttributes={["data-buzz-link-context-menu"]}
items={[
{
label: "Open link",
onSelect: () => {
closeMenu();
onOpenLink();
},
},
{
label: "Copy link",
onSelect: () => {
closeMenu();
copyTextToClipboard(href, "Link copied to clipboard");
},
},
]}
position={position}
/>
) : null;
return { contextMenu, onContextMenuCapture };
}
export function BuzzLinkChip({
children,
className,
href,
icon: Icon,
interactive,
onOpenLink,
...props
}: Omit<React.ComponentPropsWithoutRef<"button">, "onClick"> & {
href?: string;
icon: InlineChipIconKind;
interactive: boolean;
onOpenLink: () => void;
}) {
const { contextMenu, onContextMenuCapture } = useBuzzLinkContextMenu({
href,
interactive,
onOpenLink,
});
if (!interactive) {
return (
<InlineChip
{...(props as React.HTMLAttributes<HTMLSpanElement>)}
data-buzz-link=""
className={className}
icon={Icon}
>
{children}
</InlineChip>
);
}
return (
<>
<InlineChip
{...props}
as="button"
data-buzz-link=""
className={className}
icon={Icon}
interactive
onClick={onOpenLink}
onContextMenuCapture={onContextMenuCapture}
>
{children}
</InlineChip>
{contextMenu}
</>
);
}
export function BuzzInlineLink({
children,
href,
interactive,
onOpenLink,
...props
}: Omit<React.ComponentPropsWithoutRef<"button">, "onClick"> & {
href?: string;
interactive: boolean;
onOpenLink: () => void;
}) {
const contextMenuHref =
href ?? (typeof props.title === "string" ? props.title : undefined);
const { contextMenu, onContextMenuCapture } = useBuzzLinkContextMenu({
href: contextMenuHref,
interactive,
onOpenLink,
});
if (!interactive) {
return <span className="font-medium text-current">{children}</span>;
}
return (
<>
<button
{...props}
type="button"
className="cursor-pointer font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
onClick={onOpenLink}
onContextMenuCapture={onContextMenuCapture}
>
{children}
</button>
{contextMenu}
</>
);
}
@@ -0,0 +1,118 @@
import type * as React from "react";
import {
buildChannelLink,
parseChannelLink,
} from "@/features/messages/lib/channelLink";
import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip";
import { useMarkdownRuntime } from "./runtimeContext";
import { getReactNodeText } from "./utils";
function channelPermalinkLabel(
channels: ReturnType<typeof useMarkdownRuntime>["channels"],
channelId: string,
): string {
return (
channels.find((candidate) => candidate.id === channelId)?.name ??
channelId.slice(0, 8)
);
}
export function ChannelDeepLinkAnchor({
children,
href,
interactive,
}: React.ComponentPropsWithoutRef<"a"> & { interactive: boolean }) {
const { channels, onOpenChannel } = useMarkdownRuntime();
if (!href) return <>{children}</>;
const parsed = parseChannelLink(href);
if (!parsed.ok) return <>{children}</>;
const authoredLabel = getReactNodeText(children);
if (authoredLabel !== href) {
return (
<BuzzInlineLink
href={href}
title={href}
aria-label={`Open channel: ${authoredLabel}`}
interactive={interactive}
onOpenLink={() => onOpenChannel(parsed.value.channelId)}
>
{children}
</BuzzInlineLink>
);
}
const label = channelPermalinkLabel(channels, parsed.value.channelId);
return (
<BuzzLinkChip
href={href}
icon="channel"
title={href}
aria-label={`Open channel ${label}`}
interactive={interactive}
onOpenLink={() => onOpenChannel(parsed.value.channelId)}
>
{label}
</BuzzLinkChip>
);
}
export function MarkdownChannelDeepLink({
children,
interactive,
}: {
children?: React.ReactNode;
interactive: boolean;
}) {
const { channels, onOpenChannel } = useMarkdownRuntime();
const href = String(children ?? "");
const parsed = parseChannelLink(href);
if (!parsed.ok) return <span data-channel-deep-link="">{href}</span>;
const label = channelPermalinkLabel(channels, parsed.value.channelId);
return (
<BuzzLinkChip
data-channel-deep-link=""
href={href}
icon="channel"
title={href}
aria-label={`Open channel ${label}`}
interactive={interactive}
onOpenLink={() => onOpenChannel(parsed.value.channelId)}
>
{label}
</BuzzLinkChip>
);
}
export function MarkdownChannelReference({
children,
interactive,
}: {
children?: React.ReactNode;
interactive: boolean;
}) {
const { channels, onOpenChannel } = useMarkdownRuntime();
const text = String(children ?? "");
const channelName = text.startsWith("#") ? text.slice(1) : text;
const channel = channels.find(
(candidate) =>
candidate.channelType !== "dm" &&
candidate.name.toLowerCase() === channelName.toLowerCase(),
);
return (
<BuzzLinkChip
data-channel-link=""
href={channel ? buildChannelLink(channel.id) : undefined}
icon="channel"
aria-label={
channel ? `Open channel ${channelName}` : `Channel ${channelName}`
}
interactive={Boolean(channel) && interactive}
onOpenLink={() => {
if (channel) onOpenChannel(channel.id);
}}
>
{channelName}
</BuzzLinkChip>
);
}
@@ -1,17 +1,11 @@
import * as React from "react";
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { cn } from "@/shared/lib/cn";
import {
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
} from "@/shared/ui/mentionChip";
import { BuzzLinkChip } from "./BuzzLinkChip";
import type { MessageLinkPillProps } from "./types";
import {
getMessageLinkChannelLabel,
getMessageLinkLabel,
MESSAGE_LINK_PREFIX,
} from "@/features/messages/lib/messageLinkLabel";
import { getMessageLinkLabel } from "@/features/messages/lib/messageLinkLabel";
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
@@ -46,6 +40,7 @@ function segmentLinkLabel(label: string): Array<{
export function MessageLinkPill({
channels,
href,
interactive,
link,
onOpenMessageLink,
@@ -54,65 +49,38 @@ export function MessageLinkPill({
}: MessageLinkPillProps) {
const [isHovered, setIsHovered] = React.useState(false);
const channel = channels.find((c) => c.id === link.channelId);
const channelLabel = channel?.name ?? "channel";
const channelLabel = channel?.name ?? link.channelId.slice(0, 8);
const shortId = link.messageId.slice(0, 8);
const isSentFromThread = variant === "sent-from-thread";
const permalink = href ?? buildMessageLink(link);
const label = getMessageLinkLabel({
channelName: channelLabel,
threadExcerpt,
variant,
});
const channelLinkLabel = getMessageLinkChannelLabel(channelLabel);
if (!interactive) {
if (!isSentFromThread) {
return (
<span
className="inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline"
data-message-link=""
>
<span className="shrink-0">{MESSAGE_LINK_PREFIX}</span>
<span
className={cn(
MENTION_CHIP_BASE_CLASSES,
"min-w-0 max-w-full truncate",
)}
data-channel-link=""
>
{channelLinkLabel}
</span>
</span>
);
}
return (
<span className="inline-block max-w-80 truncate" data-message-link="">
{label}
</span>
);
}
if (!isSentFromThread) {
return (
<span
className="inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline"
<BuzzLinkChip
data-message-link=""
href={permalink}
icon="message"
aria-label={`Open message ${shortId} in channel ${channelLabel}`}
title={label}
interactive={interactive}
onOpenLink={() => {
onOpenMessageLink(link);
}}
>
<span className="shrink-0">{MESSAGE_LINK_PREFIX}</span>
<button
type="button"
aria-label={`Open thread in ${channelLabel}`}
title={label}
className={cn(
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
"min-w-0 max-w-full cursor-pointer truncate",
)}
data-channel-link=""
onClick={() => {
onOpenMessageLink(link);
}}
>
{channelLinkLabel}
</button>
{channelLabel} · {shortId}
</BuzzLinkChip>
);
}
if (!interactive) {
return (
<span className="inline-block max-w-80 truncate" data-message-link="">
{label}
</span>
);
}
+53 -11
View File
@@ -12,6 +12,31 @@ import {
type SupportedLinkPreview,
} from "@/shared/lib/linkPreview";
import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip";
function entityLinkPresentation(link: ParsedEntityLink) {
switch (link.type) {
case "repo":
return {
ariaLabel: `Open repository ${link.dtag}`,
icon: "repo" as const,
label: link.dtag,
};
case "pr":
return {
ariaLabel: `Open pull request ${link.id.slice(0, 8)} in repository ${link.dtag}`,
icon: "pr" as const,
label: `${link.dtag} · ${link.id.slice(0, 8)}`,
};
case "issue":
return {
ariaLabel: `Open issue ${link.id.slice(0, 8)} in repository ${link.dtag}`,
icon: "issue" as const,
label: `${link.dtag} · ${link.id.slice(0, 8)}`,
};
}
}
/**
* Navigate to the project detail view for a `buzz://pr|issue|repo` link.
* The link's (owner, d) coordinate is exactly the `/projects/$projectId`
@@ -76,17 +101,19 @@ function resolveEntityHref(
* default anchor.
*/
export function renderEntityLinkAnchor({
anchorProps,
children,
href,
onOpenEntityLink,
relayOrigin,
interactive = true,
asChip = true,
}: {
anchorProps: React.ComponentPropsWithoutRef<"a">;
children: React.ReactNode;
href: string | undefined;
onOpenEntityLink: (link: ParsedEntityLink) => void;
relayOrigin: string | null;
interactive?: boolean;
asChip?: boolean;
}): React.ReactElement | null {
if (!href) return null;
@@ -95,18 +122,33 @@ export function renderEntityLinkAnchor({
const parsed = parseEntityLink(canonicalHref);
if (!parsed.ok) return null;
const presentation = entityLinkPresentation(parsed.value);
if (!asChip) {
return (
<BuzzInlineLink
href={href}
title={href}
aria-label={presentation.ariaLabel}
interactive={interactive}
onOpenLink={() => onOpenEntityLink(parsed.value)}
>
{children}
</BuzzInlineLink>
);
}
return (
<a
{...anchorProps}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
<BuzzLinkChip
data-buzz-link-kind={parsed.value.type}
href={href}
onClick={(event) => {
event.preventDefault();
onOpenEntityLink(parsed.value);
}}
icon={presentation.icon}
title={href}
aria-label={presentation.ariaLabel}
interactive={interactive}
onOpenLink={() => onOpenEntityLink(parsed.value)}
>
{children}
</a>
{presentation.label}
</BuzzLinkChip>
);
}
@@ -3,7 +3,9 @@ import ReactMarkdown, { type Components } from "react-markdown";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkChannelDeepLinks from "@/features/messages/lib/remarkChannelDeepLinks";
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
import remarkEntityLinks from "@/features/messages/lib/remarkEntityLinks";
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
import rehypeLeadingInlineContent from "@/shared/lib/rehypeLeadingInlineContent";
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
@@ -104,7 +106,9 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement {
remarkGfm,
remarkBreaks,
remarkSpoilers,
remarkChannelDeepLinks,
remarkMessageLinks,
remarkEntityLinks,
[remarkMentions, { mentionNames: input.mentionNames }],
[remarkChannelLinks, { channelNames: input.channelNames }],
[remarkCustomEmoji, { customEmoji: input.customEmoji }],
+2
View File
@@ -22,6 +22,8 @@ export type ImetaLookup = Map<string, ImetaEntry>;
export type MessageLinkPillProps = {
channels: Channel[];
/** Original permalink text, preserved for the context menu's Copy action. */
href?: string;
interactive: boolean;
link: ParsedMessageLink;
onOpenMessageLink: (link: ParsedMessageLink) => void;
+2 -1
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import { defaultUrlTransform } from "react-markdown";
import { isChannelLink } from "@/features/messages/lib/channelLink";
import { isMessageLink } from "@/features/messages/lib/messageLink";
import { parseEntityLink } from "@/shared/lib/entityLink";
@@ -182,7 +183,7 @@ export function isInsideHiddenSpoiler(element: Element): boolean {
*/
export function buzzDeepLinkUrlTransform(value: string, key: string): string {
if (key !== "href") return defaultUrlTransform(value);
if (isMessageLink(value)) return value;
if (isMessageLink(value) || isChannelLink(value)) return value;
if (parseEntityLink(value).ok) return value;
return defaultUrlTransform(value);
}
+23 -1
View File
@@ -2,7 +2,29 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip";
export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover";
export const MENTION_CHIP_PREFIX_CLASS = "mention-chip-prefix";
export type InlineChipIconKind =
| "agent"
| "human"
| "channel"
| "message"
| "repo"
| "pr"
| "issue";
const INLINE_CHIP_ICON_KIND_CLASSES: Record<InlineChipIconKind, string> = {
agent: "inline-chip-icon-agent agent-mention-highlight",
human: "inline-chip-icon-human human-mention-highlight",
channel: "inline-chip-icon-channel",
message: "inline-chip-icon-message",
repo: "inline-chip-icon-repo",
pr: "inline-chip-icon-pr",
issue: "inline-chip-icon-issue",
};
/** Shared icon-box contract for React chips and ProseMirror decorations. */
export function inlineChipIconClasses(kind: InlineChipIconKind): string {
return `inline-chip-with-icon ${INLINE_CHIP_ICON_KIND_CLASSES[kind]}`;
}
/** Wrapper on rendered message Markdown — scopes inline chip CSS. */
export const MESSAGE_MARKDOWN_CLASS = "message-markdown";
+17 -9
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { listenForMessageDeepLinks } from "@/shared/deep-link";
import { listenForNavigationDeepLinks } from "@/shared/deep-link";
/**
* Subscribe to `buzz://message` deep links emitted by the Tauri backend
@@ -24,16 +24,24 @@ export function useMessageDeepLinks(enabled = true) {
if (!enabled) return;
let cancelled = false;
const unlistenPromise = listenForMessageDeepLinks((payload) => {
if (cancelled) return;
void goChannel(payload.channelId, {
messageId: payload.messageId,
threadRootId: payload.threadRootId,
});
});
const unlistenPromise = listenForNavigationDeepLinks(
async (payload) => {
if (cancelled) return false;
await goChannel(payload.channelId);
return true;
},
async (payload) => {
if (cancelled) return false;
await goChannel(payload.channelId, {
messageId: payload.messageId,
threadRootId: payload.threadRootId,
});
return true;
},
);
return () => {
cancelled = true;
void unlistenPromise.then((fn) => fn());
void unlistenPromise.then((unlisten) => unlisten());
};
}, [enabled, goChannel]);
}
+44
View File
@@ -326,6 +326,8 @@ type E2eConfig = {
/** Delay (ms) for `apply_workspace` so e2e tests can observe the
* community-switch gate. 0/undefined = instant. */
applyCommunityDelayMs?: number;
/** Reject `clear_pending_navigation_deep_links` with this message. */
clearPendingNavigationDeepLinksError?: string;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Hold the media proxy at port 0 until the E2E release seam is invoked. */
@@ -476,6 +478,13 @@ type E2eConfig = {
code?: string | null;
name?: string | null;
}>;
pendingNavigationDeepLinks?: Array<{
id: string;
kind: "channel" | "message";
channelId: string;
messageId?: string | null;
threadRootId?: string | null;
}>;
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
identityLost?: boolean;
@@ -4363,6 +4372,24 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) {
}));
}
let mockPendingNavigationDeepLinks: Array<{
id: string;
kind: "channel" | "message";
channelId: string;
messageId: string | null;
threadRootId: string | null;
}> = [];
function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) {
mockPendingNavigationDeepLinks = (
config?.mock?.pendingNavigationDeepLinks ?? []
).map((pending) => ({
...pending,
messageId: pending.messageId ?? null,
threadRootId: pending.threadRootId ?? null,
}));
}
function recordMockUserStatus(event: RelayEvent) {
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
if (dTag) {
@@ -10176,6 +10203,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockPersonaCatalogEvents(config);
resetMockSaveSubscriptions(config);
resetMockPendingCommunityDeepLinks(config);
resetMockPendingNavigationDeepLinks(config);
initializeMockHuddle(config.mock?.huddle, config);
mockWebsocketSendMutexWedged = false;
if (config.mock?.windowLabel) {
@@ -11926,6 +11954,22 @@ export function maybeInstallE2eTauriMocks() {
mockPendingCommunityDeepLinks.splice(index, 1);
return true;
}
case "clear_pending_navigation_deep_links":
if (activeConfig?.mock?.clearPendingNavigationDeepLinksError) {
throw new Error(
activeConfig.mock.clearPendingNavigationDeepLinksError,
);
}
mockPendingNavigationDeepLinks.length = 0;
return;
case "take_pending_navigation_deep_link":
return mockPendingNavigationDeepLinks[0] ?? null;
case "acknowledge_pending_navigation_deep_link": {
const { id } = payload as { id: string };
if (mockPendingNavigationDeepLinks[0]?.id !== id) return false;
mockPendingNavigationDeepLinks.shift();
return true;
}
case "get_relay_http_url":
return getRelayHttpUrl(activeConfig);
case "relay_requires_membership":
+2 -2
View File
@@ -1871,7 +1871,7 @@ test("channel with messages shows content", async ({ page }) => {
);
await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Welcome to general",
);
});
@@ -2384,7 +2384,7 @@ test("sidebar shows unread indicator for newly active channels", async ({
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("message-timeline")).toContainText(
"Unread update for #random",
"Unread update for random",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
});
+41
View File
@@ -834,6 +834,16 @@ test.describe("community rail", () => {
// The app settles into the new community once apply completes.
await expect(buttonB).toHaveAttribute("aria-current", "true");
await expect
.poll(() =>
page.evaluate(
() =>
window.__BUZZ_E2E_COMMANDS__?.filter(
(command) => command === "clear_pending_navigation_deep_links",
).length ?? 0,
),
)
.toBe(1);
});
test("leaving the final community returns to setup without resetting identity", async ({
@@ -904,6 +914,37 @@ test.describe("community rail", () => {
.toEqual(identityBefore);
});
test("shows a recoverable error when leaving the final community cannot clear navigation", async ({
page,
}) => {
await installMockBridge(
page,
{ clearPendingNavigationDeepLinksError: "queue unavailable" },
{ skipCommunitySeed: true },
);
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
await page.goto("/");
await page.getByTestId("sidebar-profile-avatar-button").click();
await page.getByTestId("community-switcher").click();
await page
.getByRole("menu", { name: "Community actions" })
.getByRole("menuitem", { name: "Leave community" })
.click();
const error = page.getByTestId("community-apply-error");
await expect(error).toBeVisible();
await expect(error).toContainText(
"Could not safely leave community: queue unavailable",
);
await expect(page.getByText("Join or create a community")).toHaveCount(0);
await expect(page.getByTestId("community-switch-gate")).toHaveCount(0);
await expect(page.getByTestId("community-apply-error-retry")).toBeVisible();
await expect(
page.getByRole("button", { name: "Change community" }),
).toBeVisible();
});
test("hides the rail with a single community", async ({ page }) => {
await installMockBridge(page, undefined, { skipCommunitySeed: true });
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
+4 -2
View File
@@ -7,6 +7,7 @@ import { installMockBridge } from "../helpers/bridge";
// 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(
@@ -87,8 +88,9 @@ test("cancelling the empty-edit delete keeps the message", async ({ page }) => {
await expect(page.getByTestId("edit-target")).toBeVisible();
await expect(row).toBeVisible();
await expect(page.getByTestId("message-timeline")).toContainText(
ORIGINAL_CONTENT,
RENDERED_ORIGINAL_CONTENT,
);
await expect(row.getByLabel("Open channel general")).toBeVisible();
});
test("a non-empty edit still edits and never deletes", async ({ page }) => {
@@ -115,6 +117,6 @@ test("a non-empty edit still edits and never deletes", async ({ page }) => {
editedContent,
);
await expect(page.getByTestId("message-timeline")).not.toContainText(
ORIGINAL_CONTENT,
RENDERED_ORIGINAL_CONTENT,
);
});
+2 -2
View File
@@ -314,7 +314,7 @@ test("live mentions refetch the home feed without waiting for polling", async ({
.click();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
message.replace("@tyler", "tyler"),
);
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1);
@@ -371,7 +371,7 @@ test("live forum mentions refetch the home feed without waiting for polling", as
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible();
await expect(targetPage.getByTestId("home-inbox-list")).toContainText(
message,
message.replace("@tyler", "tyler"),
);
await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0);
await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1);
+56 -11
View File
@@ -634,11 +634,56 @@ test("selecting a person mention inserts @Name into input", async ({
await dropdown.getByText("bob").click();
await expect(input).toHaveText("Hey @bob ");
const mentionChip = input.locator(".mention-chip", {
hasText: "@bob",
const mentionChip = input.locator(".human-mention-highlight", {
hasText: "bob",
});
await expect(mentionChip).toBeVisible();
await expect(mentionChip).toHaveText("bob");
await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/);
await expect(mentionChip).toHaveCSS("display", "inline-flex");
await expect(
input.locator(".mention-prefix-hidden", { hasText: "@" }),
).toHaveCount(1);
const iconMask = await mentionChip.evaluate((element) =>
getComputedStyle(element, "::before").getPropertyValue(
"-webkit-mask-image",
),
);
expect(iconMask).toContain("data:image/svg+xml");
});
test("channel references keep caret movement through the channel name", async ({
page,
}) => {
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("#general");
const channelChip = input.locator(".inline-chip-icon-channel", {
hasText: "general",
});
await expect(channelChip).toBeVisible();
await expect(channelChip).toHaveText("general");
await expect(
input.locator(".mention-prefix-hidden", { hasText: "#" }),
).toHaveCount(1);
const iconMask = await channelChip.evaluate((element) =>
getComputedStyle(element, "::before").getPropertyValue(
"-webkit-mask-image",
),
);
expect(iconMask).toContain("data:image/svg+xml");
await input.focus();
await input.press("ArrowLeft");
await input.press("ArrowLeft");
await input.press("ArrowLeft");
await page.keyboard.type("X");
await expect(input).toHaveText("#geneXral");
});
test("selecting a managed agent mention inserts @Name into input", async ({
@@ -2112,9 +2157,9 @@ test("sent non-member person mention uses the normal mention style", async ({
const mentionChip = page
.getByTestId("message-row")
.last()
.locator("[data-mention]", { hasText: "@outsider" });
.locator("[data-mention]", { hasText: "outsider" });
await expect(mentionChip).toBeVisible();
await expect(mentionChip.locator("svg")).toHaveCount(0);
await expect(mentionChip).toHaveClass(/inline-chip-icon-human/);
});
test("sent managed non-member agent mention uses the agent mention style", async ({
@@ -2252,8 +2297,8 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
.last()
.locator("[data-mention].mention-chip", { hasText: "bob" });
await expect(mentionChip).toBeVisible();
await expect(mentionChip.locator(".mention-chip-prefix")).toHaveText("@");
await expect(mentionChip.locator("svg")).toHaveCount(0);
await expect(mentionChip).toHaveText("bob");
await expect(mentionChip).toHaveClass(/inline-chip-icon-human/);
});
test("clicking author name opens user profile panel", async ({ page }) => {
@@ -2312,8 +2357,8 @@ test("clicking a mention chip in the timeline opens the profile panel", async ({
const mentionChip = page
.getByTestId("message-row")
.filter({ hasText: "Ping @bob about the launch" })
.locator("[data-mention]", { hasText: "@bob" });
.filter({ hasText: "Ping bob about the launch" })
.locator("[data-mention]", { hasText: "bob" });
await expect(mentionChip).toBeVisible();
await mentionChip.click();
@@ -2340,8 +2385,8 @@ test("mention text matching the kind-0 name alias resolves and opens the profile
const mentionChip = page
.getByTestId("message-row")
.filter({ hasText: "Ask @bobby to review the doc" })
.locator("[data-mention]", { hasText: "@bobby" });
.filter({ hasText: "Ask bobby to review the doc" })
.locator("[data-mention]", { hasText: "bobby" });
await expect(mentionChip).toBeVisible();
await mentionChip.click();
@@ -2366,7 +2411,7 @@ test("clicking a mention chip in a forum post opens the profile panel", async ({
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
const mentionChip = page.locator("[data-mention]", { hasText: "@bob" });
const mentionChip = page.locator("[data-mention]", { hasText: "bob" });
await expect(mentionChip).toBeVisible();
await mentionChip.click();
+5 -5
View File
@@ -1852,7 +1852,7 @@ test("day divider appears in timeline", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Welcome to general",
);
await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible();
});
@@ -2210,7 +2210,7 @@ test("opens a single-level thread panel with inline expansion", async ({
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Welcome to general",
);
const timeline = page.getByTestId("message-timeline");
@@ -2233,7 +2233,7 @@ test("opens a single-level thread panel with inline expansion", async ({
await rootMessage.getByRole("button", { name: "Reply" }).click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
"Welcome to general",
);
await threadComposer.fill(firstReply);
@@ -2379,7 +2379,7 @@ test("opens a single-level thread panel with inline expansion", async ({
await rootSummaryRow.click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
"Welcome to general",
);
const firstReplyRow = threadReplies
@@ -2390,7 +2390,7 @@ test("opens a single-level thread panel with inline expansion", async ({
await firstReplyRow.getByRole("button", { name: "Reply" }).click();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
"Welcome to general",
);
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
+163 -18
View File
@@ -337,6 +337,61 @@ test("settings shortcut returns without opening search dialog", async ({
await expect(page.getByTestId("search-results")).not.toBeVisible();
});
test("mixed Buzz permalinks render as chips in the composer", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const owner = "a".repeat(64);
const pullRequestId = "c".repeat(64);
const issueId = "b".repeat(64);
const links = [
`buzz://message?channel=${channelId}&id=mock-general-welcome`,
`buzz://channel/${channelId}`,
`buzz://repo?owner=${owner}&d=buzz-world`,
`buzz://pr?id=${pullRequestId}&owner=${owner}&d=buzz-world`,
`buzz://issue?id=${issueId}&owner=${owner}&d=buzz-world`,
].join(" ");
const composerInput = page.getByTestId("message-input");
await composerInput.evaluate((element, text) => {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", text);
element.dispatchEvent(
new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData,
}),
);
}, links);
const chips = composerInput.locator('[data-composer-buzz-link=""]');
await expect(chips).toHaveCount(5);
await expect(chips.nth(0)).toHaveText("general · mock-gen");
await expect(chips.nth(1)).toHaveText("general");
await expect(chips.nth(2)).toHaveText("buzz-world");
await expect(chips.nth(3)).toHaveText("buzz-world · cccccccc");
await expect(chips.nth(4)).toHaveText("buzz-world · bbbbbbbb");
await expect(chips.nth(1)).toHaveClass(/inline-chip-icon-channel/);
await expect(chips.nth(2)).toHaveClass(/inline-chip-icon-repo/);
await expect(chips.nth(3)).toHaveClass(/inline-chip-icon-pr/);
await expect(chips.nth(4)).toHaveClass(/inline-chip-icon-issue/);
for (const index of [0, 1, 2, 3, 4]) {
const iconMask = await chips
.nth(index)
.evaluate((element) =>
getComputedStyle(element, "::before").getPropertyValue(
"-webkit-mask-image",
),
);
expect(iconMask).toContain("data:image/svg+xml");
}
await expect(composerInput).not.toContainText("buzz://");
});
test("message links to visible root messages open the thread panel", async ({
page,
}) => {
@@ -344,13 +399,13 @@ test("message links to visible root messages open the thread panel", async ({
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Welcome to general",
);
const link =
"buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome";
const composerInput = page.getByTestId("message-input");
await composerInput.fill("Root link repro ");
await composerInput.fill("Root link repro #random ");
await composerInput.focus();
await composerInput.evaluate((element, href) => {
const clipboardData = new DataTransfer();
@@ -364,11 +419,10 @@ test("message links to visible root messages open the thread panel", async ({
);
}, link);
const composerLink = composerInput.locator('[data-composer-message-link=""]');
await expect(composerLink).toContainText("Thread in");
const composerChannelLink = composerLink.locator('[data-channel-link=""]');
await expect(composerChannelLink).toHaveText("#general");
await expect(composerChannelLink).toHaveClass(/mention-chip/);
await expect(composerLink).not.toHaveClass(/mention-chip/);
await expect(composerLink).toHaveText("general · mock-gen");
await expect(composerLink).toHaveClass(/mention-chip/);
await expect(composerLink).toHaveClass(/inline-chip-icon-message/);
await expect(composerLink).toHaveAttribute("data-buzz-link", "");
await expect(composerLink).toHaveAttribute("title", "Thread in #general");
await expect(composerInput).not.toContainText("buzz://message");
await page.getByTestId("send-message").click();
@@ -379,20 +433,51 @@ test("message links to visible root messages open the thread panel", async ({
.last();
await expect(linkMessage).toBeVisible();
const rootThreadLink = linkMessage.getByRole("button", {
name: "Open thread in general",
name: "Open message mock-gen in channel general",
});
await expect(linkMessage.locator('[data-message-link=""]')).toContainText(
"Thread in",
);
await expect(rootThreadLink).toHaveText("#general");
await expect(rootThreadLink).toHaveText("general · mock-gen");
await expect(rootThreadLink).toHaveClass(/mention-chip/);
await rootThreadLink.click();
const randomChannelLink = linkMessage.getByRole("button", {
name: "Open channel random",
});
await expect(randomChannelLink).toBeVisible();
await rootThreadLink.click({ button: "right" });
const linkMenu = page.locator("[data-buzz-link-context-menu]");
await expect(linkMenu).toBeVisible();
await randomChannelLink.click({ button: "right" });
await expect(linkMenu).toHaveCount(1);
await rootThreadLink.click({ button: "right" });
await expect(linkMenu).toHaveCount(1);
await expect(
linkMenu.getByRole("button", { name: "Open link" }),
).toBeVisible();
await linkMenu.getByRole("button", { name: "Copy link" }).click();
await expect
.poll(() =>
page.evaluate(() => {
return (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { text?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__?.findLast(
({ command }) => command === "copy_text_to_clipboard",
)?.payload.text;
}),
)
.toBe(link);
await rootThreadLink.click({ button: "right" });
await linkMenu.getByRole("button", { name: "Open link" }).click();
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(page).toHaveURL(/thread=mock-general-welcome/);
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
"Welcome to general",
);
});
@@ -407,7 +492,7 @@ test("message links reopen a closed thread when the same messageId is already in
const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
"Welcome to general",
);
await threadPanel.getByRole("button", { name: "Close panel" }).click();
@@ -426,14 +511,14 @@ test("message links reopen a closed thread when the same messageId is already in
.last();
await expect(linkMessage).toBeVisible();
const rootThreadLink = linkMessage.getByRole("button", {
name: "Open thread in general",
name: "Open message mock-gen in channel general",
});
await expect(rootThreadLink).toHaveText("#general");
await expect(rootThreadLink).toHaveText("general · mock-gen");
await rootThreadLink.click();
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
"Welcome to general",
);
});
@@ -454,3 +539,63 @@ test("message deep links survive reload", async ({ page }) => {
"Engineering shipped the desktop build.",
);
});
// Cold-start OS links are queued natively until AppShell mounts its router listener.
test("cold-start channel deep link drains after the router mounts", async ({
page,
}) => {
await installMockBridge(page, {
pendingNavigationDeepLinks: [
{
id: "navigation-channel-1",
kind: "channel",
channelId: ENGINEERING_CHANNEL_ID,
},
],
});
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
await expect(page).toHaveURL(
new RegExp(`#/channels/${ENGINEERING_CHANNEL_ID}$`),
);
await expect
.poll(() =>
page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
(entry) =>
entry.command === "acknowledge_pending_navigation_deep_link",
),
),
)
.toEqual([
{
command: "acknowledge_pending_navigation_deep_link",
payload: { id: "navigation-channel-1" },
},
]);
});
test("cold-start message deep link preserves its thread target", async ({
page,
}) => {
await installMockBridge(page, {
pendingNavigationDeepLinks: [
{
id: "navigation-message-1",
kind: "message",
channelId: WATERCOLOR_CHANNEL_ID,
messageId: "mock-forum-release-reply",
threadRootId: "mock-forum-release-thread",
},
],
});
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(page).toHaveURL(/messageId=mock-forum-release-reply/);
await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/);
});
+2 -2
View File
@@ -3214,7 +3214,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => {
// Greeted by the name typed above — the @mention pill also files the opener
// into the new user's Inbox mentions feed.
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi @Morty QA, I'm Fizz. Welcome to Buzz.",
"Hi Morty QA, I'm Fizz. Welcome to Buzz.",
);
await expect(page.getByTestId("message-timeline")).toContainText(
"Honey and Bumble, introduce yourselves",
@@ -3238,7 +3238,7 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async
await expectPrivateWelcomeLanding(page);
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
await expect(page.getByTestId("message-timeline")).toContainText(
"Hi @Morty QA, I'm Fizz. Welcome to Buzz.",
"Hi Morty QA, I'm Fizz. Welcome to Buzz.",
);
await page.waitForTimeout(1_500);
expect(await commandCount(page, "create_managed_agent")).toBe(3);
+1 -1
View File
@@ -349,7 +349,7 @@ test("passive relay watchdog does not write while the websocket is half-open", a
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Welcome to general",
);
await setMockWebsocketSendsStalled(page, true);
+3 -3
View File
@@ -437,7 +437,7 @@ test("global search offers an optional current-channel scope", async ({
const firstScopedResult = page
.locator('[data-search-section="messages"] .search-result-row')
.first();
await expect(page.getByText("Welcome to #general")).toBeVisible();
await expect(page.getByText("Welcome to general")).toBeVisible();
await expect(page.getByText(/Searching messages in/)).toHaveCount(0);
await expect(relevantHeader).toBeVisible();
await expect(firstScopedResult).toBeVisible();
@@ -756,7 +756,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => {
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
"Welcome to general",
);
await page.getByTestId("channel-random").click();
@@ -766,7 +766,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => {
"This is the beginning of the regular channel.",
);
await expect(page.getByTestId("message-timeline")).not.toContainText(
"Welcome to #general",
"Welcome to general",
);
await expect(page.getByTestId("message-timeline")).toHaveCount(1);
await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0);
+10
View File
@@ -280,6 +280,8 @@ type MockBridgeOptions = {
canvasReadError?: string;
/** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */
applyCommunityDelayMs?: number;
/** Reject `clear_pending_navigation_deep_links` with this message. */
clearPendingNavigationDeepLinksError?: string;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
/** Hold the media proxy at port 0 until the E2E release seam is invoked. */
@@ -463,6 +465,14 @@ type MockBridgeOptions = {
code?: string | null;
name?: string | null;
}>;
/** Pending channel/message links that arrived before AppShell mounted. */
pendingNavigationDeepLinks?: Array<{
id: string;
kind: "channel" | "message";
channelId: string;
messageId?: string | null;
threadRootId?: string | null;
}>;
/**
* Global agent config returned by `get_global_agent_config`. Defaults to
* an empty config (no provider, model, or env vars) if not specified.