diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 643a960b50..b33368dbf2 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -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 `` (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 diff --git a/docs/plugin-authoring-guide.md b/docs/plugin-authoring-guide.md index f2d55188e1..7af3f33c9f 100644 --- a/docs/plugin-authoring-guide.md +++ b/docs/plugin-authoring-guide.md @@ -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? }`. | diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index c919e6e1a2..3c0df5890b 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -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 ``, 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; +}>; /** 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<{ @@ -253,6 +273,7 @@ export type ConversationExtensions = Readonly<{ inline: ContributionReader; completions?: ContributionReader; links?: ContributionReader; + linkPreviews?: ContributionReader; codeBlocks?: ContributionReader; }>; diff --git a/src/features/conversation/service.test.tsx b/src/features/conversation/service.test.tsx index c4a7fdf7e6..45f53c8ac6 100644 --- a/src/features/conversation/service.test.tsx +++ b/src/features/conversation/service.test.tsx @@ -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"], diff --git a/src/features/conversation/service.tsx b/src/features/conversation/service.tsx index f03b3266d0..60b1e68897 100644 --- a/src/features/conversation/service.tsx +++ b/src/features/conversation/service.tsx @@ -27,6 +27,7 @@ import type { ComposerCompletion, InlineRenderer, LinkRenderer, + LinkPreviewRenderer, MessageRenderer, MessageAction, ContributionReader, @@ -72,6 +73,9 @@ export type Conversation = { registerInline(renderer: InlineRenderer): void; links: ContributionReader; registerLink(renderer: LinkRenderer): void; + /** Cards below messages for their links; see `LinkPreviewRenderer`. */ + linkPreviews: ContributionReader; + registerLinkPreview(renderer: LinkPreviewRenderer): void; codeBlocks: ContributionReader; registerCodeBlock(renderer: CodeBlockRenderer): void; actions: ContributionReader; @@ -121,6 +125,7 @@ function validate( | ComposerCompletion | ComposerAccessory | LinkRenderer + | LinkPreviewRenderer | CodeBlockRenderer | MessageRenderer | MessageAction, @@ -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; @@ -196,6 +203,12 @@ export class ConversationService extends Service implements Conversation { const links = createContributions(ctx); this.linkEntries = links; this.links = { snapshot: links.snapshot, subscribe: links.subscribe }; + const linkPreviews = createContributions(ctx); + this.linkPreviewEntries = linkPreviews; + this.linkPreviews = { + snapshot: linkPreviews.snapshot, + subscribe: linkPreviews.subscribe, + }; const codeBlocks = createContributions(ctx); this.codeBlockEntries = codeBlocks; this.codeBlocks = { @@ -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") diff --git a/src/features/messages/LinkPreviews.module.css b/src/features/messages/LinkPreviews.module.css new file mode 100644 index 0000000000..14e87d6681 --- /dev/null +++ b/src/features/messages/LinkPreviews.module.css @@ -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%; +} diff --git a/src/features/messages/LinkPreviews.test.tsx b/src/features/messages/LinkPreviews.test.tsx new file mode 100644 index 0000000000..ca8c925f37 --- /dev/null +++ b/src/features/messages/LinkPreviews.test.tsx @@ -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 { + return { + id, + title: id, + matches, + order, + component, + key: id, + pluginId: id, + revision: "1", + } as unknown as Contribution; +} +const reader = ( + entries: Contribution[], +): ContributionReader => ({ + snapshot: () => entries, + subscribe: () => () => {}, +}); +const card = + (label: string) => + ({ url, open }: LinkPreviewProps) => ( + + ); + +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( + 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( + 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( + url.includes("broken"), 0, Broken), + entry("web", () => true, 100, card("web")), + ])} + onOpenLink={() => true} + />, + ); + expect(screen.getByText("web https://ok.example/")).toBeTruthy(); +}); diff --git a/src/features/messages/LinkPreviews.tsx b/src/features/messages/LinkPreviews.tsx new file mode 100644 index 0000000000..4cebcdbed7 --- /dev/null +++ b/src/features/messages/LinkPreviews.tsx @@ -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[] = []; +const noSubscribe = () => () => {}; +const noSnapshot = () => none; + +export function LinkPreviews({ + urls, + messageId, + registry, + onOpenLink, +}: { + urls: readonly string[]; + messageId: string; + registry: ContributionReader | 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 ( +
+ {shown.map(({ url, renderer }) => { + const Preview = renderer.component; + const key = contributionKey(renderer); + return ( + + + live.current && registry?.snapshot().includes(renderer) + ? onOpenLink(url) + : false + } + /> + + ); + })} + {(hidden > 0 || (open && cards.length > FOLD_AFTER)) && ( + + )} +
+ ); +} diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 93071fae8f..e85628569c 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -95,6 +95,8 @@ import { useReferenceDirectory } from "./ReferenceText"; import { EditedMarker, MessageMarkdown } from "./MessageMarkdown"; import { safeMessageUrl } from "../relay/message-content"; import styles from "./Messages.module.css"; +import { LinkPreviews } from "./LinkPreviews"; +import { previewLinks } from "./preview-links"; import { usesLargeEmojiPresentation } from "./emoji-size"; import { separateChatCards } from "./chat-cards"; import { MessageReactionControls, MessageReactions } from "./MessageReactions"; @@ -603,11 +605,29 @@ function MessageRowContent({ // The host's Markdown marks an edit after the caption's last line. Plugin // cards are marked inside their bubble; diffs and captionless rows after the // payload. - const editedInBody = + const markdownBody = bodyShown && !row.diff && !!displayRow.content.trim() && (!messageRenderer || messageRenderer === failedRenderer); + const editedInBody = markdownBody; + const linkPreviewRegistry = extensions?.linkPreviews; + const previewUrls = useMemo( + () => + linkPreviewRegistry && markdownBody && !row.agentEnvelope + ? previewLinks( + displayRow.content, + row.attachments.map((attachment) => attachment.url), + ) + : [], + [ + linkPreviewRegistry, + markdownBody, + row.agentEnvelope, + displayRow.content, + row.attachments, + ], + ); const claimed = (attachment: ChannelMessage["attachments"][number]) => { try { return !!claims?.call(messageRenderer, row, attachment); @@ -1353,6 +1373,14 @@ function MessageRowContent({ {reactionsInBubble && reactionContent} )} + {previewUrls.length > 0 && ( + + )} {attachmentGroups.length > 0 && (
{ + it("lists bare and labeled HTTPS links once each, in order", () => { + expect( + previewLinks( + "See https://example.com/a, [docs](https://docs.example.com/) and https://example.com/a again.", + ), + ).toEqual(["https://example.com/a", "https://docs.example.com/"]); + }); + + it("skips angle links, code, images, other schemes and attachments", () => { + expect( + previewLinks( + [ + "", + "`https://code.example.com`", + "```\nhttps://block.example.com\n```", + "![img](https://img.example.com/a.png)", + "http://plain.example.com buzz://channel/abc", + "https://files.example.com/a.pdf", + "https://kept.example.com", + ].join("\n\n"), + ["https://files.example.com/a.pdf"], + ), + ).toEqual(["https://kept.example.com/"]); + }); + + it("keeps spoiler links hidden and shows the ones around them", () => { + expect( + previewLinks( + [ + "||[twist](https://plot.example.com/twist)|| and https://after.example.com", + "before ||https://bare.example.com/hidden|| https://shown.example.com", + "*||https://emphasis.example.com||* https://also.example.com", + "|| unclosed https://open.example.com", + ].join("\n\n"), + ), + ).toEqual([ + "https://after.example.com/", + "https://shown.example.com/", + "https://also.example.com/", + "https://open.example.com/", + ]); + }); + + it("resolves reference links through their definitions", () => { + expect( + previewLinks( + "[docs][guide], [Guide] and [missing][nope]\n\n[guide]: https://example.org/docs", + ), + ).toEqual(["https://example.org/docs"]); + }); + + it("reads the HTTPS scheme in any case", () => { + expect(previewLinks("HTTPS://example.org/docs")).toEqual([ + "https://example.org/docs", + ]); + }); + + it("finds nothing in prose without links", () => { + expect(previewLinks("hello there")).toEqual([]); + }); +}); diff --git a/src/features/messages/preview-links.ts b/src/features/messages/preview-links.ts new file mode 100644 index 0000000000..5f0f42e57c --- /dev/null +++ b/src/features/messages/preview-links.ts @@ -0,0 +1,98 @@ +import { scanMarkdown } from "../relay/message-content"; +import { prepareMarkdown } from "./markdown-preparation"; +import { messageLinkParts } from "./message-link-parts"; + +type Node = { + type: string; + url?: string; + identifier?: string; + value?: string; + children?: Node[] | undefined; + position?: { start: { offset?: number }; end: { offset?: number } }; +}; + +const skipped = new Set([ + "code", + "inlineCode", + "html", + "definition", + "image", + "imageReference", +]); + +const https = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password + ? url.href + : undefined; + } catch { + return undefined; + } +}; + +/** The HTTPS links a message's prose names, in order, once each: bare, + * labeled and reference links, but not ``, which asks for no preview, + * nor links in code or spoilers, nor ones naming one of `exclude`. */ +export function previewLinks( + content: string, + exclude: readonly string[] = [], +): readonly string[] { + const prepared = prepareMarkdown(content); + if (prepared.kind === "plain" || !/https:\/\//i.test(content)) return []; + const scan = scanMarkdown(prepared.content); + if (scan.tooDeep) return []; + const source = prepared.content; + const delimiters = prepared.spoilerDelimiters; + const excluded = new Set(exclude.map((url) => https(url) ?? url)); + const found: string[] = []; + const add = (raw: string | undefined) => { + const url = raw && https(raw); + if (url && !excluded.has(url) && !found.includes(url)) found.push(url); + }; + const span = (node: Node) => [ + node.position?.start.offset, + node.position?.end.offset, + ]; + // Spoilers pair `||` among one parent's text children, as remarkSpoilers does. + const hiddenRanges = (children: readonly Node[]) => { + const own = children.flatMap((child) => { + const [start, end] = span(child); + return child.type === "text" && start !== undefined && end !== undefined + ? delimiters.filter((at) => at >= start && at + 2 <= end) + : []; + }); + const ranges: [number, number][] = []; + for (let i = 0; i + 1 < own.length; i += 2) + ranges.push([own[i] ?? 0, (own[i + 1] ?? 0) + 2]); + return ranges; + }; + const visit = (node: Node, hidden: readonly [number, number][]) => { + if (skipped.has(node.type)) return; + const [start, end] = span(node); + const inside = (at: number) => + hidden.some(([from, to]) => at >= from && at < to); + if (node.type === "link" || node.type === "linkReference") { + if (start === undefined || source[start] === "<" || inside(start)) return; + add( + node.type === "link" + ? node.url + : scan.definitions.get(node.identifier?.toLowerCase() ?? ""), + ); + return; + } + if (node.type === "text") { + if (start === undefined || end === undefined || !node.value) return; + const text = hidden.length ? source.slice(start, end) : node.value; + messageLinkParts(text, undefined, (from, to, url) => { + if (text[from] !== "<" && to > from && !inside(start + from)) add(url); + }); + return; + } + const children = node.children ?? []; + const own = hiddenRanges(children); + for (const child of children) visit(child, own); + }; + visit(scan.tree as Node, []); + return found; +}