Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b478a55
Add rich links and clickable message previews
klopez4212 Sep 13, 2026
f17265f
Preserve link focus during profile updates
klopez4212 Sep 13, 2026
746c127
Match mention hover height to inline links
klopez4212 Sep 13, 2026
ac2d934
Tighten message preview metadata spacing
klopez4212 Sep 13, 2026
ba2badd
Preview rendered links and mentions in the composer
klopez4212 Sep 13, 2026
fc57e83
Merge branch 'main' into kennylopez-link-previews
wesbillman Sep 13, 2026
038f396
Merge main into rich links branch
Sep 13, 2026
c8e040f
test: settle rich-link browser assertions
Sep 13, 2026
a90ec4c
fix: harden Buzz link previews and thread/panel navigation
Sep 13, 2026
44abe40
Merge main into kennylopez-link-previews
Sep 14, 2026
b9dc3b6
Render rich links directly in the editable composer
klopez4212 Sep 14, 2026
3e17b2c
Truncate long URL labels without changing destinations
klopez4212 Sep 14, 2026
ffdb0a8
Truncate Unicode URL labels in the composer
klopez4212 Sep 14, 2026
3c6c821
Fix browser journey regressions
klopez4212 Sep 14, 2026
70799da
Address link preview review feedback
klopez4212 Sep 14, 2026
5f1f8dd
Merge current main into link previews
klopez4212 Sep 14, 2026
9f74826
Integrate current main composer behavior
klopez4212 Sep 14, 2026
acacf4b
Recognize mixed-case bare URL schemes
klopez4212 Sep 14, 2026
adbe1f3
Handle exact previews and Markdown link titles
klopez4212 Sep 14, 2026
ed517c6
Merge current main into link previews
klopez4212 Sep 14, 2026
5ddb1c1
Merge latest main into link previews
klopez4212 Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/plugin-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pub fn bundled_manifests() -> Vec<Manifest> {
.expect("terminal manifest"),
serde_json::from_str(include_str!("../../../src/bundled/profiles/manifest.json"))
.expect("valid bundled Profiles manifest"),
serde_json::from_str(include_str!("../../../src/bundled/links/manifest.json"))
.expect("links manifest"),
serde_json::from_str(include_str!("../../../src/bundled/mentions/manifest.json"))
.expect("mentions manifest"),
serde_json::from_str(include_str!("../../../src/bundled/emoji/manifest.json"))
Expand Down
1 change: 1 addition & 0 deletions crates/plugin-manager/tests/management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ fn bundled_plugins_have_independent_flags_and_all_ids_are_reserved() {
"buzz.agents",
"buzz.emoji",
"buzz.mentions",
"buzz.links",
] {
assert!(
manager
Expand Down
61 changes: 54 additions & 7 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ render failures and remounts on target or revision changes. Unloading a plugin
removes its contributions and closes its panel. Other pages can use these same
contracts with their own layout and local navigation.

The initial distribution contains Channels, Projects, Agents, GitHub, Bestie, Emoji, Mentions, Profiles and Terminal. Projects
The initial distribution contains Channels, Projects, Agents, GitHub, Bestie, Emoji, Mentions, Profiles, Terminal and Links. Projects
is an enabled-by-default scaffold with only a centered title and no relay dependency.
GitHub recognizes repository,
pull request, issue, and commit URLs and loads public object details on demand.
Expand Down Expand Up @@ -281,19 +281,57 @@ into versioned route parameters. These are host-matched preview types through

Browser `#buzz=` addresses and session history support reload and Back/Forward.
`targetLink`/`parseTargetLink` define a `buzz://open` locator codec that omits the
sender's viewer; `bindSharedTarget` pins it for an admitted recipient. **This slice
does not install native OS deep-link or notification-click ingress or migrate legacy
Buzz links.** Message-addressed conversations show the selected verified row in a
bounded detail surface, ignoring optional `threadRootId` hints. Completion requires
the exact row to be visible and focused; unavailable targets never fall back to the
channel head. Ingress adapters must reuse this validated target/completion lifecycle.
sender's viewer; `bindSharedTarget` pins it for an admitted recipient. Messages also
recognize legacy `buzz://channel/<id>` and
`buzz://message?channel=<id>&id=<event>&thread=<optional-root>` links. Legacy links
use the receiving conversation's community and viewer; shared versioned links
retain their community and use the recipient's viewer. Both pass through existing
navigation admission and session ownership checks. Message targets open their
verified thread, reveal the exact message after bounded history loading, and only
then acknowledge navigation. Supplied root hints do not override verified events.
Missing or unavailable messages report failure. Ingress adapters must reuse this
validated target/completion lifecycle. Native OS deep-link and notification-click
ingress remain outside this slice.

Drafts, reading geometry and sidebar view intent remain domain-owned, outside
visit history. Saved sidebar preferences live in the relay session, not in the
mounted page; see [sidebar ownership](channels.md#ownership).

## Conversation contributions

`registerLink({ id, title, matches, className?, component })` contributes optional
presentation for links already recognized by messages. The host retains the anchor,
destination, new-tab/modifier behavior and panel activation. Components receive
`{ url }` and render non-interactive inline content inside that anchor. They must
not nest links or buttons. The first active matching renderer wins; throwing
matchers are skipped. A render failure or plugin removal restores the ordinary
link, including its styling. Registration follows the existing plugin lifetime.

The bundled Links plugin uses blue text, a blue fill only on hover, 2px padding
4px corners, and service icons for GitHub, Google Drive, Figma, Notion, Slack,
Dropbox, OneDrive, GitLab, YouTube, Loom, Zoom and Teams. Google Docs, Sheets and
Slides use distinct file-type icons; unknown websites use a globe. Host matching
does not fetch metadata or infer a service from names in paths or query strings.
It does not fetch titles. Messages currently recognize
credential-free HTTPS and supported Buzz links. Markdown labels preserve their
formatting, escaped pasted wrappers are normalized outside code, and paired `<…>`
autolink wrappers are hidden in display. Buzz links use known channel names with corresponding icons, falling back
to Channel, Message or Thread when that name is unavailable in the current community;
the full destination remains on the anchor. Buzz activation stays inside the host,
including modifier/middle clicks, even when the optional Links plugin is disabled.
Unsupported Buzz formats remain plain text. The host-matched author preview exports `LinkRenderer`; older hosts do not
provide `registerLink`.

Message hover/focus previews are host-owned. The entire card is a keyboard-accessible
link to the same destination, routed through the same host navigation handler. Opening a preview allocates the current
session's bounded thread reader; closing disposes it. The reader supplies verified
message content, author and timestamp and preserves edit/deletion/access handling.
Previewing never acknowledges reading or switches community. The host offers a
resolved label to the bundled presentation via `LinkLabelContext`; no author API
contract or relay protocol changes are required. Known channel references and
unambiguous signed person/agent mentions share the inline hover styling. Names in
ordinary prose never create notification intent or establish an identity.

The conversation preview exposes top-level `registerTool`, `registerCompletion` and `registerInline`
methods and stable `conversation.ui.Composer` / `.Message` components. Generated
type-only `@buzz/author` declarations support the independent Composer Lab example.
Expand All @@ -311,6 +349,15 @@ same `registerTool` contract. No page imports their implementations. Optional nu
keyboard order stable across asynchronous activation and re-enable. Mentions uses
`-10` to retain its position before default-order tools such as Emoji.

Links, channel references, selected mentions and custom emoji render through shared
message components directly in the editable draft. Display tokens retain the exact authored source;
copying and sending preserve that source. Arrow keys and deletion open adjacent
links for ordinary text editing. Partially deleting a link keeps it plain during
the editing session; double-click selects the link and triple-click selects its
whole paragraph. The host owns source offsets, plain-text paste, composition, undo and
selected recipient metadata. Token renderers are display-only while editing.
Names pasted as text never create notification intent.

Tools receive `insertText`, `insertMention({ pubkey, name })` and `focus` commands.
Mention insertion atomically records visible text and exact notification intent;
`true` means the edit was accepted, **not** that membership or delivery succeeded.
Expand Down
13 changes: 13 additions & 0 deletions src/app/pages.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ test("the app runtime exposes ready bundled pages and removes them on disable",
assert.equal(services.conversation.tools.snapshot().length, 2),
);
assert.equal(services.conversation.inline.snapshot().length, 1);
await vi.waitFor(() =>
assert.equal(services.conversation.links.snapshot().length, 1),
);
assert.equal(
services.conversation.links.snapshot()[0].pluginId,
"buzz.links",
);
await services.plugins.change("disable", "buzz.links");
assert.equal(services.conversation.links.snapshot().length, 0);
await services.plugins.change("enable", "buzz.links");
await vi.waitFor(() =>
assert.equal(services.conversation.links.snapshot().length, 1),
);
await services.plugins.change("disable", "buzz.emoji");
assert.deepEqual(
services.conversation.tools.snapshot().map((tool) => tool.pluginId),
Expand Down
23 changes: 22 additions & 1 deletion src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { useChannelPanels } from "./useChannelPanels";
import type { PageNavigation } from "../../features/navigation/service";
import type { Navigation } from "../../features/navigation/controller";
import {
buzzLinkTarget,
isBuzzLink,
} from "../../features/navigation/buzz-links";
import { UnreadBadge, UnreadOptions } from "./UnreadBadge";
import { SidebarUnread } from "./SidebarUnread";
import type { ConversationExtensions } from "../../features/conversation/contracts";
Expand Down Expand Up @@ -379,6 +383,23 @@ function ChannelWorkspace({
}, [currentId, showingThread?.navigation]);
const openLink = useCallback(
(url: string) => {
if (isBuzzLink(url)) {
if (!navigator || !viewer) return false;
const target = buzzLinkTarget(url, {
viewer,
communityOrigin: scope.slice(0, -(viewer.length + 1)),
});
if (!target) return false;
if (target.kind === "conversation" && target.messageId)
threadTrigger.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setThread(undefined);
open(undefined);
void navigator.open(target);
return true;
}
const candidate = panels.resolve(url);
const context = linkContext.current;
if (context.channelId && candidate) {
Expand All @@ -397,7 +418,7 @@ function ChannelWorkspace({
}
return false;
},
[panels, open, select],
[panels, open, select, navigator, viewer, scope],
);
const panelActive = () => {
const connection = relay.snapshot();
Expand Down
3 changes: 3 additions & 0 deletions src/bundled/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import * as projects from "./projects";
import workflowsManifest from "./workflows/manifest.json";
import * as workflows from "./workflows";
import type { BundledPlugin } from "../plugins/manager";
import linksManifest from "./links/manifest.json";
import * as links from "./links";

export const bundledPlugins: readonly BundledPlugin[] = [
{ manifest: { ...activityManifest, apiVersion: 1 }, module: activity },
{ manifest: { ...terminalManifest, apiVersion: 1 }, module: terminal },
{ manifest: { ...profilesManifest, apiVersion: 1 }, module: profiles },
{ manifest: { ...linksManifest, apiVersion: 1 }, module: links },
{ manifest: { ...mentionsManifest, apiVersion: 1 }, module: mentions },
{ manifest: { ...emojiManifest, apiVersion: 1 }, module: emoji },
{ manifest: { ...channelsManifest, apiVersion: 1 }, module: channels },
Expand Down
51 changes: 51 additions & 0 deletions src/bundled/link-lab/LinkLab.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
.page {
max-width: 760px;
margin: 0 auto;
padding: 48px 24px;
font-family: var(--font-sans);
color: var(--text-primary);
}

.page h1 {
margin: 0 0 12px;
}

.intro,
.status {
color: var(--text-secondary);
}

.samples {
margin-block: 40px;
display: grid;
gap: 32px;
}

.samples p {
margin: 8px 0 0;
font-size: var(--text-body-lg);
line-height: 1.8;
}

.label {
font-size: var(--text-body-sm);
color: var(--text-secondary);
}

.services {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
margin-top: 12px;
}

.page input {
width: 100%;
margin: 10px 0;
padding: 10px 12px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-panel);
color: inherit;
font: inherit;
}
46 changes: 46 additions & 0 deletions src/bundled/link-lab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Link Lab

First visual experiment for link rendering, activated as a page plugin in the
isolated `/tests/fixtures/link-lab.html` preview. Add `?theme=dark` for dark mode.
Uses the design system's blue 11 text, blue 4 hover fill and Tabler icons.
Links are transparent by default. Padding stays 2px on every side, including
wrapped line fragments, with -2px inline margins so it adds no horizontal gap
to surrounding text. Hovering does not move the surrounding text.
Hover backgrounds have 4px corners. GitHub, Google Drive, Figma, Notion, Slack,
Dropbox, OneDrive, GitLab, YouTube, Loom, Zoom and Teams use their service icons.
Google Docs, Sheets and Slides use document, spreadsheet and presentation icons.
Other websites use a globe. Icons use the same blue foreground and do not fetch
favicons, titles or remote metadata.

Recognition uses host boundaries and known short-link domains. OneDrive also
recognizes Microsoft's documented
[`<tenant>-my.sharepoint.com` hosts](https://learn.microsoft.com/en-us/sharepoint/list-onedrive-urls).
Unknown or self-hosted service domains retain the globe icon.

Sample activation stays in the preview. The separate bundled `buzz.links` plugin
now applies the same component and CSS to HTTPS and supported Buzz links in messages.
Buzz channel, message and thread links have distinct icons and use known channel
names from the current community. Generic labels remain when names are unavailable.
Hover or keyboard focus opens a clickable message preview with author and timestamp on one
row beside the author's avatar, the channel below, and an excerpt clamped to four
lines, ending early before a blank paragraph. The card opens the same destination
with pointer or keyboard activation. Recency follows Buzz desktop's thread-summary ladder: just now, minutes,
hours under 24 hours, days under seven days, then a short date. Hovering the time
shows its full date and time. Hidden channels and
DMs use a lock; other known channels use a hash. This creates a disposable, bounded session thread reader only while open;
it does not mark messages read. Other-community previews do not connect or switch
communities on hover. Mentions with signed recipient IDs and unambiguous loaded
names share the hover styling; known local agent identities use a robot icon.
Known channel names in `#channel` text open through the same host link path.
Both legacy channel/message links and versioned `buzz://open` links are supported.
The host opens them through scoped navigation; message links reveal the selected
message in its verified thread after bounded history loading. Missing targets
report navigation failure. The lab only previews their appearance.
It uses `registerLink`, leaving the existing inline-text contract unchanged.
The lab remains separate from the default catalog. No relay reads, signing, or
persistence are involved in the preview.

`/tests/fixtures/link-messages.html` exercises real message rows, plugin removal,
failed rendering, labeled Markdown links, clickable previews, and plain fallback.
`tests/browser/buzz-links.spec.mjs` covers scoped navigation and card activation in
Chromium and WebKit. Run the contribution workflow's full batch scan before integration.
Loading
Loading