Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2373,6 +2373,22 @@ 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.

`registerLinkPreview({ id, title, matches, order?, component })` contributes a
card below a message for one of its links. The host lists the credential-free
HTTPS links in the message's Markdown prose, once each in the order written:
bare and labeled links, but not `<url>` (which asks for no preview), links in
code, images, or links naming one of the message's attachments. Diffs, agent
envelopes and messages a message renderer draws get no cards. For each link the
lowest-`order` match draws its card under the panel
[resolution order](#resolution-order), so a plugin claims its own URLs by
registering below the default web card's catch-all band; a component that
renders nothing leaves that link without a card. Components receive
`{ url, messageId, open }`; `open()` routes `url` through the host's link
handler, as clicking the link does, and returns false once the contribution or
message has retired. Cards sit between the message bubble and its attachments.
After three, the rest fold behind "Show N more previews". A render failure drops
only that card. Registration follows the existing plugin lifetime.

`registerCodeBlock({ id, title, matches, component, action? })` contributes optional
presentation for a fenced code block. `matches(language)` receives the fence's
info-string language as written; `component` and `action` receive `{ code, language }`. The
Expand Down
2 changes: 1 addition & 1 deletion docs/plugin-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function apply(ctx) {
| `panels` | A panel for a link/target string | `ctx.panels.register({ id, title, matches(target) => boolean, component, order?, launcher?, channelLauncher?, channelPlacement?, keepOpen? })`. `component` receives `{ target, close, context?, channelContext? }`. `launcher: { icon: string, target: string }` adds a top-bar button (use `matches: () => false` for launcher-only). `channelLauncher` is a component for the channel header (`channelPlacement`: `"bottom"` default, `"side"`, `"tab"`). |
| `settingsCards` | A Settings destination | `ctx.settingsCards.register({ id, title, component, group?, personal?, identityRow? })`. `component` receives `{ active(), community?, navigation? }`; `navigation.target.route` can carry bounded, versioned presentation data for the card. Opening a route must never submit an operation. Optional `identityRow` receives `{ active() }` and contributes a compact account-status link inside Identity. Reuse the plugin’s existing session; check `active()` before navigation or an action. The row retires with its owning registration. Omit `icon` (it is a host icon component). The host supplies navigation and the content column, not a heading. |
| `sidebarSections` (optional) | A section in the Team sidebar | `ctx.reflect.get("sidebarSections")?.register({ version: 1, id, title, icon, rows: { snapshot, subscribe }, select, actions? })`. `snapshot()` returns a stable array of `{ id, label, icon?, badge?, count?, status? }` until data changes, then notify subscribers. `select(row)` returns an open target such as `{ version: 1, kind: "home" }`. Actions: `{ id, label, icon: ReactElement, onSelect }`. |
| `conversation` | Composer/message UI | `registerAccessory({ id, title, component, order?, placement? })` (read-only UI above the composer; props `{ session, scope, channelId, threadRootId?, canOpen, open }`; `placement`: `"composer"`, `"message"`, `"response"`). `registerMessage({ id, title, matches(message), component })`, `registerMessageAction({ id, title, matches(message, session), component })`, `registerLink({ id, title, matches(url), component })`, `registerTool({ id, title, component })`. |
| `conversation` | Composer/message UI | `registerAccessory({ id, title, component, order?, placement? })` (read-only UI above the composer; props `{ session, scope, channelId, threadRootId?, canOpen, open }`; `placement`: `"composer"`, `"message"`, `"response"`). `registerMessage({ id, title, matches(message), component })`, `registerMessageAction({ id, title, matches(message, session), component })`, `registerLink({ id, title, matches(url), component })`, `registerLinkPreview({ id, title, matches(url), order?, component })` (a card below the message for one of its HTTPS links; props `{ url, messageId, open }`; lowest `order` wins, so claim your URLs below the default web card's `100`), `registerTool({ id, title, component })`. |
| `agents` | Tools and hooks for agents | `ctx.agents.addTool({ name, description, inputSchema, call })`. `name` matches `/^[a-z][a-z0-9_]{0,63}$/`, is unique across plugins and not a Buzz tool name (prefix it with your plugin: `notes_get`). `inputSchema` is a JSON-serializable object with `type: "object"`. `async call(client, context, args)` resolves to a string (or `{ data, type, text? }`) and runs as the agent; a throw is shown to the agent as a tool error. Changing a tool's name, description or schema restarts some agent sessions, so edit deliberately. `ctx.agents.registerHook({ id, harness, event, run })` needs `host.agentHooks`. `ctx.agents.wake(agentKey, { channel, root?, text })` wakes a local agent (`root` is a 64-hex event id). |
| `shortcuts` | In-app key bindings | `ctx.shortcuts.register({ id, title, binding, run, when?, order?, allowInEditable?, allowInModal?, repeat? })`. `binding` is `{ key, mod?, shift?, alt?, ctrl? }` or an array; `mod` is Command on macOS, Control elsewhere. `key` is the logical key, case-insensitive (Space is `" "`). Skipped in text fields and modals unless opted in. |
| `navigation` | Open destinations | `await ctx.navigation.open(target)` returns `{ status: "opened" \| "failed" \| "superseded" \| "cancelled" }`. Targets: `{ version: 1, kind: "plugin", pluginId, pageId?, params? }`, `{ version: 1, kind: "home" }`, `{ version: 1, kind: "settings", section? }`. |
Expand Down
21 changes: 21 additions & 0 deletions src/features/conversation/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ export type LinkRenderer = Readonly<{
className?: string | undefined;
component: ComponentType<{ url: string }>;
}>;
export type LinkPreviewProps = Readonly<{
url: string;
/** The host's id for the message the link is in. */
messageId: string;
/** Opens `url` through the host, as clicking the link does. False once the
* contribution or message retires. */
open(): boolean;
}>;
/** A card below a message for one of its links. The lowest `order` among
* matches draws it, so a plugin can claim its own URLs ahead of the default
* web card; a component that renders nothing leaves the link without a card.
* Links written `<url>`, in code, or naming an attachment get no card. */
export type LinkPreviewRenderer = Readonly<{
id: string;
title: string;
matches(url: string): boolean;
/** Claim strength when several renderers match a URL; see `MatchOrder`. */
order?: MatchOrder | undefined;
component: ComponentType<LinkPreviewProps>;
}>;
/** Presentation for a fenced code block, chosen by its info-string language.
* The host retains the frame and copy action; failure or removal restores the code. */
export type CodeBlockRenderer = Readonly<{
Expand Down Expand Up @@ -253,6 +273,7 @@ export type ConversationExtensions = Readonly<{
inline: ContributionReader<InlineRenderer>;
completions?: ContributionReader<ComposerCompletion>;
links?: ContributionReader<LinkRenderer>;
linkPreviews?: ContributionReader<LinkPreviewRenderer>;
codeBlocks?: ContributionReader<CodeBlockRenderer>;
}>;

Expand Down
38 changes: 38 additions & 0 deletions src/features/conversation/service.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,44 @@ it("rejects a link renderer order that is neither a number nor a function", asyn
expect(h.runtime.snapshot()[h.plugin.manifest.id]?.status).toBe("active");
});

it("registers link previews and withdraws them with their plugin", async () => {
const h = harness({
inject: ["conversation"],
apply(ctx) {
const base = { id: "preview", title: "Preview", matches: () => true };
for (const order of [null, "100", {}, true]) {
expect(() =>
ctx.conversation.registerLinkPreview({
...base,
order,
component: Component,
} as never),
).toThrow("order");
}
expect(() =>
ctx.conversation.registerLinkPreview({
...base,
matches: undefined,
component: Component,
} as never),
).toThrow("matcher");
ctx.conversation.registerLinkPreview({
...base,
order: 100,
component: Component,
});
},
});
h.runtime.reconcile([h.plugin]);
await vi.waitFor(() =>
expect(h.service.linkPreviews.snapshot()).toHaveLength(1),
);
h.runtime.reconcile([]);
await vi.waitFor(() =>
expect(h.service.linkPreviews.snapshot()).toHaveLength(0),
);
});

it("rejects an attachment renderer order that is neither a number nor a function", async () => {
const h = harness({
inject: ["conversation"],
Expand Down
27 changes: 27 additions & 0 deletions src/features/conversation/service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
ComposerCompletion,
InlineRenderer,
LinkRenderer,
LinkPreviewRenderer,
MessageRenderer,
MessageAction,
ContributionReader,
Expand Down Expand Up @@ -72,6 +73,9 @@ export type Conversation = {
registerInline(renderer: InlineRenderer): void;
links: ContributionReader<LinkRenderer>;
registerLink(renderer: LinkRenderer): void;
/** Cards below messages for their links; see `LinkPreviewRenderer`. */
linkPreviews: ContributionReader<LinkPreviewRenderer>;
registerLinkPreview(renderer: LinkPreviewRenderer): void;
codeBlocks: ContributionReader<CodeBlockRenderer>;
registerCodeBlock(renderer: CodeBlockRenderer): void;
actions: ContributionReader<MessageAction>;
Expand Down Expand Up @@ -121,6 +125,7 @@ function validate(
| ComposerCompletion
| ComposerAccessory
| LinkRenderer
| LinkPreviewRenderer
| CodeBlockRenderer
| MessageRenderer
| MessageAction,
Expand Down Expand Up @@ -151,6 +156,8 @@ export class ConversationService extends Service implements Conversation {
private readonly inlineEntries;
readonly links;
private readonly linkEntries;
readonly linkPreviews;
private readonly linkPreviewEntries;
readonly codeBlocks;
private readonly codeBlockEntries;
readonly actions;
Expand Down Expand Up @@ -196,6 +203,12 @@ export class ConversationService extends Service implements Conversation {
const links = createContributions<LinkRenderer>(ctx);
this.linkEntries = links;
this.links = { snapshot: links.snapshot, subscribe: links.subscribe };
const linkPreviews = createContributions<LinkPreviewRenderer>(ctx);
this.linkPreviewEntries = linkPreviews;
this.linkPreviews = {
snapshot: linkPreviews.snapshot,
subscribe: linkPreviews.subscribe,
};
const codeBlocks = createContributions<CodeBlockRenderer>(ctx);
this.codeBlockEntries = codeBlocks;
this.codeBlocks = {
Expand Down Expand Up @@ -333,6 +346,20 @@ export class ConversationService extends Service implements Conversation {
throw new Error("A link renderer class must be a string");
this.linkEntries.register(this.ctx, value);
}
registerLinkPreview(value: LinkPreviewRenderer) {
validate(value);
if (typeof value.matches !== "function")
throw new Error("A link preview renderer needs a matcher");
if (
value.order !== undefined &&
typeof value.order !== "number" &&
typeof value.order !== "function"
)
throw new Error(
"A link preview renderer order must be a number or function",
);
this.linkPreviewEntries.register(this.ctx, value);
}
registerCodeBlock(value: CodeBlockRenderer) {
validate(value);
if (typeof value.matches !== "function")
Expand Down
12 changes: 12 additions & 0 deletions src/features/messages/LinkPreviews.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.previews {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-half);
margin-top: var(--space-half);
min-width: 0;
max-width: 100%;
}
.previews > * {
max-width: 100%;
}
105 changes: 105 additions & 0 deletions src/features/messages/LinkPreviews.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { Contribution } from "../../plugins/contributions";
import type {
ContributionReader,
LinkPreviewProps,
LinkPreviewRenderer,
} from "../conversation/contracts";
import { LinkPreviews } from "./LinkPreviews";

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function entry(
id: string,
matches: (url: string) => boolean,
order: number,
component: LinkPreviewRenderer["component"],
): Contribution<LinkPreviewRenderer> {
return {
id,
title: id,
matches,
order,
component,
key: id,
pluginId: id,
revision: "1",
} as unknown as Contribution<LinkPreviewRenderer>;
}
const reader = (
entries: Contribution<LinkPreviewRenderer>[],
): ContributionReader<LinkPreviewRenderer> => ({
snapshot: () => entries,
subscribe: () => () => {},
});
const card =
(label: string) =>
({ url, open }: LinkPreviewProps) => (
<button type="button" onClick={open}>
{label} {url}
</button>
);

it("lets the lowest order claim a link ahead of the default card", () => {
const registry = reader([
entry("web", () => true, 100, card("web")),
entry("special", (url) => url.includes("special"), 0, card("special")),
]);
render(
<LinkPreviews
urls={["https://a.example/", "https://special.example/"]}
messageId="m"
registry={registry}
onOpenLink={() => true}
/>,
);
expect(screen.getByText("web https://a.example/")).toBeTruthy();
expect(screen.getByText("special https://special.example/")).toBeTruthy();
expect(screen.queryByText("web https://special.example/")).toBeNull();
});

it("folds past three cards and opens links through the host", () => {
const onOpenLink = vi.fn(() => true);
const urls = [1, 2, 3, 4, 5].map((n) => `https://${n}.example/`);
render(
<LinkPreviews
urls={urls}
messageId="m"
registry={reader([entry("web", () => true, 100, card("web"))])}
onOpenLink={onOpenLink}
/>,
);
expect(screen.getAllByRole("button", { name: /^web/ })).toHaveLength(3);
fireEvent.click(screen.getByRole("button", { name: "Show 2 more previews" }));
expect(screen.getAllByRole("button", { name: /^web/ })).toHaveLength(5);
fireEvent.click(
screen.getByRole("button", { name: "web https://5.example/" }),
);
expect(onOpenLink).toHaveBeenCalledWith("https://5.example/");
fireEvent.click(screen.getByRole("button", { name: "Show fewer previews" }));
expect(screen.getAllByRole("button", { name: /^web/ })).toHaveLength(3);
});

it("drops a card whose renderer throws and keeps the rest", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
const Broken = () => {
throw new Error("broken");
};
render(
<LinkPreviews
urls={["https://broken.example/", "https://ok.example/"]}
messageId="m"
registry={reader([
entry("broken", (url) => url.includes("broken"), 0, Broken),
entry("web", () => true, 100, card("web")),
])}
onOpenLink={() => true}
/>,
);
expect(screen.getByText("web https://ok.example/")).toBeTruthy();
});
89 changes: 89 additions & 0 deletions src/features/messages/LinkPreviews.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { resolveMatch, type Contribution } from "../../plugins/contributions";
import {
ContributionBoundary,
contributionKey,
} from "../conversation/ContributionBoundary";
import type {
ContributionReader,
LinkPreviewRenderer,
} from "../conversation/contracts";
import { Button } from "../../shared/design-system/ui/Button";
import styles from "./LinkPreviews.module.css";

/** A message shows this many cards before the rest fold behind one button. */
export const FOLD_AFTER = 3;

const none: readonly Contribution<LinkPreviewRenderer>[] = [];
const noSubscribe = () => () => {};
const noSnapshot = () => none;

export function LinkPreviews({
urls,
messageId,
registry,
onOpenLink,
}: {
urls: readonly string[];
messageId: string;
registry: ContributionReader<LinkPreviewRenderer> | undefined;
onOpenLink(url: string): boolean;
}) {
const renderers = useSyncExternalStore(
registry?.subscribe ?? noSubscribe,
registry?.snapshot ?? noSnapshot,
registry?.snapshot ?? noSnapshot,
);
const [open, setOpen] = useState(false);
const live = useRef(true);
useEffect(() => {
live.current = true;
return () => {
live.current = false;
};
}, []);
const cards = urls.flatMap((url) => {
const renderer = resolveMatch(renderers, url);
return renderer ? [{ url, renderer }] : [];
});
if (!cards.length) return null;
const shown = open ? cards : cards.slice(0, FOLD_AFTER);
const hidden = cards.length - shown.length;
return (
<div className={styles.previews} data-link-previews>
{shown.map(({ url, renderer }) => {
const Preview = renderer.component;
const key = contributionKey(renderer);
return (
<ContributionBoundary
key={`${key}:${url}`}
fallback={null}
resetIdentity={key}
>
<Preview
url={url}
messageId={messageId}
open={() =>
live.current && registry?.snapshot().includes(renderer)
? onOpenLink(url)
: false
}
/>
</ContributionBoundary>
);
})}
{(hidden > 0 || (open && cards.length > FOLD_AFTER)) && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => setOpen((value) => !value)}
>
{open
? "Show fewer previews"
: `Show ${hidden} more ${hidden === 1 ? "preview" : "previews"}`}
</Button>
)}
</div>
);
}
Loading
Loading