diff --git a/docs/channels.md b/docs/channels.md
index cc62118723..8c551265dd 100644
--- a/docs/channels.md
+++ b/docs/channels.md
@@ -637,14 +637,16 @@ left-to-right fade on hover or keyboard focus (always visible on touch, no anima
for reduced motion). The pencil follows the final text line;
balanced side padding keeps the text centered and lets long titles wrap without clipping it.
Description has a small pencil after its label,
-revealed with a short left-to-right fade on whole-row hover or keyboard focus
+revealed with a short left-to-right fade on row hover or keyboard focus
(always visible on touch, no animation for reduced motion) without shifting layout;
-the whole row opens the shared Dialog with one Name/Description/Duration draft.
+only the label, a 24px target, opens the shared Dialog with one Name/Description/Duration draft.
+Every viewer sees the description rendered as message Markdown: its URLs, Buzz links
+and `#channel` references open their targets, so its value is not part of the Edit target.
Opening Description focuses its textarea; the title focuses Name.
-Only authorized editors get the interactive rows; other viewers retain plain metadata.
+Only authorized editors get the edit actions; other viewers see the same linked description.
Members has a right chevron and opens the existing member list. Channel ID has a
-small inline copy icon with the same hover/focus/touch behavior; selecting its row
-copies the exact ID. Only a successful copy opens a confirmation tooltip, without
+small inline copy icon revealed on whole-row hover or keyboard focus (always visible
+on touch, no animation for reduced motion); selecting its row copies the exact ID. Only a successful copy opens a confirmation tooltip, without
growing the row; hovering or focusing does not open a hint or replay old feedback.
Clipboard failure shows an inline error and leaves the row available to retry. The redundant standalone
Edit details button, header-menu Edit details entry and informational Channel type
diff --git a/src/bundled/channels/ChannelDetailsEditor.test.tsx b/src/bundled/channels/ChannelDetailsEditor.test.tsx
index c154a9adb5..ac19e251d1 100644
--- a/src/bundled/channels/ChannelDetailsEditor.test.tsx
+++ b/src/bundled/channels/ChannelDetailsEditor.test.tsx
@@ -18,6 +18,7 @@ import type {
DetailsAttempt,
} from "../../features/relay/channel-details";
import type { ChannelDetails } from "../../features/relay/channel-details-protocol";
+import { createRelaySession } from "../../features/relay/session";
afterEach(() => {
cleanup();
@@ -438,6 +439,79 @@ it("orders metadata before actions and dismisses each edit layer with Escape", a
await user.keyboard("{Escape}");
expect(close).toHaveBeenCalledOnce();
});
+it.each([
+ ["an editor", true],
+ ["a member", false],
+])(
+ "links the description for %s without the editor taking its clicks",
+ async (_, canEdit) => {
+ const h = harness();
+ h.load.mockResolvedValue({ ...base, canEdit });
+ const user = userEvent.setup();
+ const owned = createRelaySession(null);
+ const ops = { id: "ops", name: "ops", channelType: "stream" as const };
+ const session = {
+ ...owned.session,
+ channels: {
+ ...owned.session.channels,
+ list: () => ({ status: "ready" as const, channels: [channel, ops] }),
+ },
+ };
+ const open = vi.fn(() => true);
+ const panel = (description: string | undefined) => (
+ {}}
+ >
+ Diagnostics
+
+ );
+ const { rerender } = render(
+ panel("R2 runbook: https://example.com/r2-runbook · see #ops"),
+ );
+ await waitFor(() =>
+ expect(
+ screen.getByRole("complementary", { name: "Channel settings" }),
+ ).toHaveAttribute("aria-busy", "false"),
+ );
+ const edit = screen.queryByRole("button", { name: "Edit description" });
+ expect(!!edit).toBe(canEdit);
+ const description = screen.getAllByRole("definition")[0];
+ if (!description) throw new Error("Missing description");
+ await user.click(
+ within(description).getByRole("link", {
+ name: "https://example.com/r2-runbook",
+ }),
+ );
+ expect(open).toHaveBeenLastCalledWith("https://example.com/r2-runbook");
+ await user.click(within(description).getByRole("link", { name: "#ops" }));
+ expect(open).toHaveBeenLastCalledWith("buzz://channel/ops");
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ if (edit) {
+ // The rendered value follows; its Markdown source would read twice.
+ expect(edit).not.toHaveAccessibleDescription();
+ await user.click(edit);
+ await waitFor(() =>
+ expect(
+ screen.getByRole("textbox", { name: "Description" }),
+ ).toHaveFocus(),
+ );
+ await user.keyboard("{Escape}");
+ }
+ for (const [value, shown] of [
+ ["", "No description"],
+ [undefined, "Not available"],
+ ] as const) {
+ rerender(panel(value));
+ expect(screen.getAllByRole("definition")[0]).toHaveTextContent(shown);
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ }
+ },
+);
it("only enables Save for normalized, changed, valid fields with connected errors", async () => {
const h = harness();
const user = userEvent.setup();
diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx
index 8ce971f2dd..a8c7dd9d87 100644
--- a/src/bundled/channels/ChannelSettingsPanel.test.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx
@@ -1,7 +1,15 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
-import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { createPortal } from "react-dom";
import { afterEach, assert, expect, it, vi } from "vitest";
import { ChannelSettingsPanel } from "./ChannelSettingsPanel";
import type { ChannelSummary } from "../../features/relay/contracts";
@@ -78,6 +86,27 @@ it("closes with Escape or its close button without swallowing other keys", async
expect(close).toHaveBeenCalledTimes(3);
});
+it("leaves Escape to descendants that handle it and to portalled layers", () => {
+ const close = vi.fn();
+ render(
+
+
+ {createPortal(, document.body)}
+ ,
+ );
+ fireEvent.keyDown(screen.getByText("Handled"), { key: "Escape" });
+ fireEvent.keyDown(screen.getByText("Portalled"), { key: "Escape" });
+ expect(close).not.toHaveBeenCalled();
+ fireEvent.keyDown(screen.getByText("Diagnostics"), { key: "Escape" });
+ expect(close).toHaveBeenCalledOnce();
+});
+
it("does not invent unknown metadata and still exposes diagnostics without a channel", async () => {
const user = userEvent.setup();
const { rerender } = render(
diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx
index dc9bb1b048..a0af3f5f9e 100644
--- a/src/bundled/channels/ChannelSettingsPanel.tsx
+++ b/src/bundled/channels/ChannelSettingsPanel.tsx
@@ -17,6 +17,9 @@ import {
import { PublishNotSent, PublishRejected } from "../../features/relay/outbox";
import { managesCommunity } from "../../features/communities/roster";
import type { CommunityMembership } from "../moderation/membership";
+import type { RelaySession } from "../../features/relay/session";
+import { MessageMarkdown } from "../../features/messages/MessageMarkdown";
+import { useReferenceDirectory } from "../../features/messages/ReferenceText";
import { channelIcon } from "../../features/channels/channel-icon";
import {
GearIcon,
@@ -48,6 +51,9 @@ export function ChannelSettingsPanel({
lifecycle,
membership,
openMembers,
+ session,
+ canOpenLink,
+ onOpenLink,
}: {
channel: ChannelSummary | undefined;
scope: string;
@@ -59,6 +65,10 @@ export function ChannelSettingsPanel({
lifecycle?: ChannelLifecycleCapability | undefined;
membership?: CommunityMembership | undefined;
openMembers?(trigger: HTMLButtonElement): void;
+ /** Links the description like a message; without these it stays plain text. */
+ session?: RelaySession | undefined;
+ canOpenLink?(target: string): boolean;
+ onOpenLink?(url: string): boolean;
}) {
const tabbed = !!usePanelTabHost();
const header = useRef(null);
@@ -86,7 +96,12 @@ export function ChannelSettingsPanel({
aria-label="Channel settings"
aria-busy={loading}
onKeyDown={(event) => {
- if (event.key === "Escape") {
+ // Portalled menus and cards bubble here through React but own their Escape.
+ if (
+ event.key === "Escape" &&
+ !event.defaultPrevented &&
+ event.currentTarget.contains(event.target as Node)
+ ) {
event.stopPropagation();
close();
}
@@ -175,7 +190,22 @@ export function ChannelSettingsPanel({
? "Not available"
: channel.description || "No description"
}
+ content={
+ channel.description &&
+ session &&
+ onOpenLink && (
+
+ )
+ }
valueClassName={styles.settingsDescription}
+ labelOnly
actionLabel="Edit"
loading={edit?.pending}
onOpen={
@@ -336,10 +366,53 @@ function AutoJoinSetting({
);
}
+/** Only a linked description reads the reference directory, so its snapshots
+ * re-render this value rather than the whole panel. */
+function ChannelDescription({
+ channelId,
+ content,
+ session,
+ scope,
+ onOpenLink,
+ canOpenLink,
+}: {
+ channelId: string;
+ content: string;
+ session: RelaySession;
+ scope: string;
+ onOpenLink(url: string): boolean;
+ canOpenLink?: ((target: string) => boolean) | undefined;
+}) {
+ const directory = useReferenceDirectory(session);
+ return (
+
+ );
+}
+
/** A native button's hit area covers the row without nesting controls or losing list semantics. */
function SettingsDetail({
label,
value,
+ content,
valueClassName,
feedback,
actionLabel,
@@ -349,10 +422,14 @@ function SettingsDetail({
hasPopup = "dialog",
disabled = false,
loading = false,
+ labelOnly = false,
renderAction = (action) => action,
}: {
label: string;
+ /** Plain value; also describes a whole-row action. */
value: string;
+ /** Rendered in place of `value` when present. */
+ content?: ReactNode;
valueClassName?: string | undefined;
feedback?: ReactNode;
actionLabel?: string | undefined;
@@ -361,6 +438,8 @@ function SettingsDetail({
hasPopup?: "dialog" | false;
disabled?: boolean;
loading?: boolean | undefined;
+ /** Only the label opens the action, so the value's own links stay usable. */
+ labelOnly?: boolean;
renderAction?(action: ReactElement>): ReactNode;
onOpen?: ((trigger: HTMLButtonElement) => void) | undefined;
}) {
@@ -371,13 +450,17 @@ function SettingsDetail({
? renderAction(