diff --git a/src/components/shared/Markdown/Markdown.test.tsx b/src/components/shared/Markdown/Markdown.test.tsx
new file mode 100644
index 0000000000..4d1a2d323d
--- /dev/null
+++ b/src/components/shared/Markdown/Markdown.test.tsx
@@ -0,0 +1,138 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { Markdown, UntrustedMarkdown } from "./Markdown";
+
+function fontClasses(element: HTMLElement) {
+ return element.className
+ .split(" ")
+ .filter((name) => name.startsWith("text-"));
+}
+
+describe("
", () => {
+ afterEach(cleanup);
+
+ it("gives headings a visible hierarchy", () => {
+ render(
);
+
+ const [h1, h2, h3, h4] = ["One", "Two", "Three", "Four"].map((text) =>
+ screen.getByText(text),
+ );
+
+ expect(h1.tagName).toBe("H1");
+ expect(h4.tagName).toBe("H4");
+ expect(fontClasses(h1)).toContain("text-lg");
+ expect(fontClasses(h2)).toContain("text-base");
+ expect(fontClasses(h3)).toContain("text-sm");
+ expect(h1.className).toContain("font-bold");
+ expect(h2.className).toContain("font-semibold");
+ });
+
+ it("renders a fenced block as a block, not a run of inline pills", () => {
+ const { container } = render(
+
,
+ );
+
+ const pre = container.querySelector("pre");
+ expect(pre).not.toBeNull();
+ expect(pre?.className).toContain("overflow-x-auto");
+ expect(pre?.querySelector("code")).not.toBeNull();
+ expect(pre?.textContent).toContain("const b = 2;");
+ });
+
+ it("renders GFM tables with delineated rows", () => {
+ const { container } = render(
+
,
+ );
+
+ expect(container.querySelector("table")).not.toBeNull();
+ expect(container.querySelector("thead")?.className).toContain(
+ "bg-muted/50",
+ );
+ expect(container.querySelector("tr")?.className).toContain("border-b");
+ expect(container.querySelector("td")?.className).toContain("px-2");
+ });
+
+ it("drops the bullet from a task list item", () => {
+ const { container } = render(
+
,
+ );
+
+ expect(container.querySelector("li")?.className).toContain("list-none");
+ expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(
+ 2,
+ );
+ });
+
+ it("does not render raw HTML", () => {
+ const { container } = render(
+
window.__mdXss = true;bold"} />,
+ );
+
+ expect(container.querySelector("script")).toBeNull();
+ expect(screen.queryByText("bold")).not.toBeInTheDocument();
+ expect(
+ (window as unknown as Record).__mdXss,
+ ).toBeUndefined();
+ });
+
+ it("renders an image and keeps a relative link", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("img")).toHaveAttribute("src", "/local.png");
+ expect(screen.getByRole("link", { name: "runs" })).toHaveAttribute(
+ "href",
+ "/runs",
+ );
+ });
+
+ it("lets a caller override a base element", () => {
+ render(
+ {children}
,
+ }}
+ />,
+ );
+
+ expect(screen.getByTestId("custom")).toHaveTextContent("text");
+ });
+});
+
+describe("", () => {
+ afterEach(cleanup);
+
+ it("shows an image's alt text without requesting the image", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector("img")).toBeNull();
+ expect(screen.getByText("Diagram of the outage")).toBeInTheDocument();
+ });
+
+ it("renders an absolute http link as an external anchor", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: "docs" });
+ expect(link).toHaveAttribute("href", "https://example.com/docs");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ });
+
+ it("renders a rejected link as plain text rather than a dead anchor", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ expect(container.querySelector("a")).toBeNull();
+ expect(container.textContent).toBe("script and relative");
+ });
+});
diff --git a/src/components/shared/Markdown/Markdown.tsx b/src/components/shared/Markdown/Markdown.tsx
new file mode 100644
index 0000000000..10b07e0ea1
--- /dev/null
+++ b/src/components/shared/Markdown/Markdown.tsx
@@ -0,0 +1,181 @@
+import type { ComponentProps, ReactNode } from "react";
+import ReactMarkdown, {
+ type Components,
+ defaultUrlTransform,
+} from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+import { Separator } from "@/components/ui/separator";
+import { Heading, Paragraph, Text } from "@/components/ui/typography";
+import { cn } from "@/lib/utils";
+import { toAbsoluteHttpUrl } from "@/utils/URL";
+
+interface ElementProps {
+ children?: ReactNode;
+ className?: string;
+}
+
+export const INLINE_CODE_CLASS =
+ "rounded bg-muted px-1 py-0.5 text-xs font-mono";
+
+type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
+
+const HEADING_STYLES: Record<
+ HeadingLevel,
+ Omit, "level" | "children">
+> = {
+ 1: { size: "lg", weight: "bold", className: "mt-2 mb-1 block" },
+ 2: { size: "md", weight: "semibold", className: "mt-2 mb-1 block" },
+ 3: { size: "sm", weight: "semibold", className: "mt-2 mb-1 block" },
+ 4: {
+ size: "sm",
+ weight: "semibold",
+ tone: "subdued",
+ className: "mt-1 block",
+ },
+ 5: {
+ size: "xs",
+ weight: "semibold",
+ tone: "subdued",
+ className: "mt-1 block",
+ },
+ 6: {
+ size: "xs",
+ weight: "semibold",
+ tone: "subdued",
+ className: "mt-1 block",
+ },
+};
+
+function renderHeading(level: HeadingLevel) {
+ const MarkdownHeading = ({ children }: ElementProps) => (
+
+ {children}
+
+ );
+
+ return MarkdownHeading;
+}
+
+const baseComponents = {
+ p: ({ children }: ElementProps) => (
+
+ {children}
+
+ ),
+ a: ({ href, children }: ElementProps & { href?: string }) =>
+ href ? (
+
+ {children}
+
+ ) : (
+ <>{children}>
+ ),
+ h1: renderHeading(1),
+ h2: renderHeading(2),
+ h3: renderHeading(3),
+ h4: renderHeading(4),
+ h5: renderHeading(5),
+ h6: renderHeading(6),
+ ul: ({ children, className }: ElementProps) => (
+
+ ),
+ ol: ({ children }: ElementProps) => (
+
+ {children}
+
+ ),
+ li: ({ children, className }: ElementProps) => (
+ input]:mr-1.5 [&>input]:align-middle",
+ className?.includes("task-list-item") && "list-none",
+ )}
+ >
+ {children}
+
+ ),
+ blockquote: ({ children }: ElementProps) => (
+
+ {children}
+
+ ),
+ code: ({ children }: ElementProps) => (
+ {children}
+ ),
+ pre: ({ children }: ElementProps) => (
+
+ {children}
+
+ ),
+ table: ({ children }: ElementProps) => (
+
+ ),
+ thead: ({ children }: ElementProps) => (
+ {children}
+ ),
+ tr: ({ children }: ElementProps) => {children}
,
+ th: ({ children }: ElementProps) => (
+ {children} |
+ ),
+ td: ({ children }: ElementProps) => (
+ {children} |
+ ),
+ img: ({ alt, src }: { alt?: string; src?: string }) => (
+
+ ),
+ hr: () => ,
+} satisfies Components;
+const AltTextOnlyImage = ({ alt }: { alt?: string }) =>
+ alt ? (
+
+ {alt}
+
+ ) : null;
+
+interface MarkdownProps {
+ body: string;
+ components?: Components;
+ urlTransform?: (url: string) => string;
+}
+
+export const Markdown = ({
+ body,
+ components,
+ urlTransform = defaultUrlTransform,
+}: MarkdownProps) => (
+
+ {body}
+
+);
+
+export const UntrustedMarkdown = ({
+ body,
+ components,
+}: Omit) => (
+ toAbsoluteHttpUrl(url) ?? ""}
+ />
+);
diff --git a/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx b/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx
index 0cce2bde3c..1685a3b572 100644
--- a/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx
+++ b/src/components/shared/ReactFlow/FlowSidebar/FlowSidebar.tsx
@@ -1,6 +1,6 @@
import { BlockStack } from "@/components/ui/layout";
import { VerticalResizeHandle } from "@/components/ui/resize-handle";
-import { BOTTOM_FOOTER_HEIGHT, TOP_NAV_HEIGHT } from "@/utils/constants";
+import { BOTTOM_FOOTER_HEIGHT, contentHeight } from "@/utils/constants";
import FileActions from "./sections/FileActions";
import GraphComponents from "./sections/GraphComponents";
@@ -19,7 +19,7 @@ const FlowSidebar = () => {
width: `${DEFAULT_WIDTH}px`,
minWidth: `${MIN_WIDTH}px`,
maxWidth: `${MAX_WIDTH}px`,
- maxHeight: `calc(100vh - ${TOP_NAV_HEIGHT}px - ${BOTTOM_FOOTER_HEIGHT}px)`,
+ maxHeight: contentHeight(BOTTOM_FOOTER_HEIGHT),
}}
>
diff --git a/src/components/ui/layout.tsx b/src/components/ui/layout.tsx
index cfa35d31a9..8f83ffa71b 100644
--- a/src/components/ui/layout.tsx
+++ b/src/components/ui/layout.tsx
@@ -49,6 +49,8 @@ interface BlockStackProps
fill?: boolean;
/** Additional CSS classes */
className?: string;
+ /** Focusability, for a stack that scrolls and so must be keyboard-operable */
+ tabIndex?: number;
}
export const BlockStack = forwardRef<
@@ -88,6 +90,7 @@ const inlineStackVariants = cva("flex flex-row", {
align: {
start: "justify-start",
center: "justify-center",
+ "safe-center": "justify-center-safe",
end: "justify-end",
"space-around": "justify-around",
"space-between": "justify-between",
@@ -127,6 +130,8 @@ interface InlineStackProps
fill?: boolean;
/** Additional CSS classes */
className?: string;
+ /** Focusability, for a stack that scrolls and so must be keyboard-operable */
+ tabIndex?: number;
}
export const InlineStack = forwardRef<
diff --git a/src/config/announcements.ts b/src/config/announcements.ts
deleted file mode 100644
index c7aa2d32f1..0000000000
--- a/src/config/announcements.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export interface Announcement {
- id: string;
- title: string;
- body: string;
- variant?: "warning" | "info" | "success" | "error";
- dismissible?: boolean;
- expiresAt?: string; // ISO date string, e.g. "2026-04-01"
-}
-
-declare global {
- interface Window {
- __TANGLE_ANNOUNCEMENTS__?: Announcement[];
- }
-}
diff --git a/src/config/bannerTestSource.ts b/src/config/bannerTestSource.ts
new file mode 100644
index 0000000000..b17e54b3a3
--- /dev/null
+++ b/src/config/bannerTestSource.ts
@@ -0,0 +1,18 @@
+import {
+ BANNER_SOURCE_EVENT,
+ type TangleBanner,
+ type TangleBannerSource,
+} from "@/config/banners";
+
+export function installRawSource(source: unknown) {
+ window.__TANGLE_BANNER_SOURCE__ = source as TangleBannerSource;
+ window.dispatchEvent(new CustomEvent(BANNER_SOURCE_EVENT));
+}
+
+export function installSource(banners: Partial[]) {
+ installRawSource({
+ version: 1,
+ getSnapshot: () => banners,
+ subscribe: () => () => {},
+ });
+}
diff --git a/src/config/banners.test.ts b/src/config/banners.test.ts
new file mode 100644
index 0000000000..42780719cb
--- /dev/null
+++ b/src/config/banners.test.ts
@@ -0,0 +1,300 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { installRawSource } from "@/config/bannerTestSource";
+
+import {
+ getBannersSnapshot,
+ refreshBanners,
+ subscribeToBanners,
+} from "./banners";
+
+function staticSource(getSnapshot: () => unknown, refresh?: () => void) {
+ return {
+ version: 1,
+ getSnapshot,
+ subscribe: () => () => {},
+ ...(refresh ? { refresh } : {}),
+ };
+}
+
+describe("banners", () => {
+ afterEach(() => {
+ delete window.__TANGLE_BANNER_SOURCE__;
+ });
+
+ describe("getBannersSnapshot", () => {
+ it("is empty and reference-stable with no source installed", () => {
+ expect(getBannersSnapshot()).toEqual([]);
+ expect(getBannersSnapshot()).toBe(getBannersSnapshot());
+ });
+
+ it("ignores a source declaring an unsupported major version", () => {
+ installRawSource({
+ version: 2,
+ getSnapshot: () => [{ id: "a", title: "Later contract", body: "" }],
+ subscribe: () => () => {},
+ });
+
+ expect(getBannersSnapshot()).toEqual([]);
+ });
+
+ it("ignores a malformed source", () => {
+ installRawSource({
+ version: 1,
+ getSnapshot: [],
+ subscribe: () => () => {},
+ });
+ expect(getBannersSnapshot()).toEqual([]);
+
+ installRawSource({ version: 1, getSnapshot: () => [] });
+ expect(getBannersSnapshot()).toEqual([]);
+
+ installRawSource("not a source");
+ expect(getBannersSnapshot()).toEqual([]);
+ });
+
+ it("survives a source whose getSnapshot throws", () => {
+ installRawSource(
+ staticSource(() => {
+ throw new Error("host is broken");
+ }),
+ );
+
+ expect(getBannersSnapshot()).toEqual([]);
+ });
+
+ it("returns the same reference until the content changes", () => {
+ let published: unknown = [{ id: "a", title: "First", body: "" }];
+ installRawSource(staticSource(() => published));
+
+ const first = getBannersSnapshot();
+ expect(first).toBe(getBannersSnapshot());
+ expect(first).toHaveLength(1);
+
+ published = [{ id: "b", title: "Second", body: "" }];
+ const second = getBannersSnapshot();
+
+ expect(second).not.toBe(first);
+ expect(second[0]?.id).toBe("b");
+ });
+
+ it("returns the same reference to a host that rebuilds its array per read", () => {
+ const banners = [{ id: "a", title: "First", body: "" }];
+ installRawSource(
+ staticSource(() => banners.map((banner) => ({ ...banner }))),
+ );
+
+ const first = getBannersSnapshot();
+
+ expect(first).toBe(getBannersSnapshot());
+ expect(first).toHaveLength(1);
+ });
+
+ it("ignores a non-array snapshot", () => {
+ installRawSource(staticSource(() => ({ banners: [] })));
+ expect(getBannersSnapshot()).toEqual([]);
+ });
+
+ it("coerces an unrecognised variant to info", () => {
+ installRawSource(
+ staticSource(() => [
+ { id: "a", title: "Notice", body: "", variant: "catastrophe" },
+ ]),
+ );
+
+ expect(getBannersSnapshot()[0]?.variant).toBe("info");
+ });
+
+ it("keeps the four known variants", () => {
+ installRawSource(
+ staticSource(() => [
+ { id: "a", title: "A", body: "", variant: "info" },
+ { id: "b", title: "B", body: "", variant: "warning" },
+ { id: "c", title: "C", body: "", variant: "success" },
+ { id: "d", title: "D", body: "", variant: "error" },
+ ]),
+ );
+
+ expect(getBannersSnapshot().map((banner) => banner.variant)).toEqual([
+ "info",
+ "warning",
+ "success",
+ "error",
+ ]);
+ });
+
+ it("drops entries without a usable id", () => {
+ installRawSource(
+ staticSource(() => [
+ { title: "No id", body: "" },
+ { id: " ", title: "Blank id", body: "" },
+ { id: {}, title: "Object id", body: "" },
+ { id: "kept", title: "Kept", body: "" },
+ ]),
+ );
+
+ expect(getBannersSnapshot().map((banner) => banner.id)).toEqual(["kept"]);
+ });
+
+ it("drops entries with neither a title nor a non-blank body", () => {
+ installRawSource(
+ staticSource(() => [
+ { id: "a", title: " ", body: " \n " },
+ { id: "b", title: "Title only", body: "" },
+ { id: "c", title: "", body: "Body only" },
+ ]),
+ );
+
+ expect(getBannersSnapshot().map((banner) => banner.id)).toEqual([
+ "b",
+ "c",
+ ]);
+ });
+
+ it("drops an action whose url is not absolute http(s)", () => {
+ installRawSource(
+ staticSource(() => [
+ {
+ id: "a",
+ title: "Script url",
+ body: "",
+ action: { url: "javascript:alert(1)", text: "Run" },
+ },
+ {
+ id: "b",
+ title: "Relative url",
+ body: "",
+ action: { url: "/internal/page", text: "Open" },
+ },
+ {
+ id: "c",
+ title: "Absolute url",
+ body: "",
+ action: { url: "https://example.com/notes", text: "Read" },
+ },
+ ]),
+ );
+
+ const [scriptUrl, relativeUrl, absoluteUrl] = getBannersSnapshot();
+
+ expect(scriptUrl?.action).toBeUndefined();
+ expect(relativeUrl?.action).toBeUndefined();
+ expect(absoluteUrl?.action).toEqual({
+ url: "https://example.com/notes",
+ text: "Read",
+ });
+ });
+
+ it("falls back to default action text when the host omits it", () => {
+ installRawSource(
+ staticSource(() => [
+ {
+ id: "a",
+ title: "Notice",
+ body: "",
+ action: { url: "https://example.com" },
+ },
+ ]),
+ );
+
+ expect(getBannersSnapshot()[0]?.action?.text).toBe("Learn more");
+ });
+
+ it("only marks a banner dismissible when the host says so explicitly", () => {
+ installRawSource(
+ staticSource(() => [
+ { id: "a", title: "A", body: "", dismissible: true },
+ { id: "b", title: "B", body: "", dismissible: "yes" },
+ { id: "c", title: "C", body: "" },
+ ]),
+ );
+
+ expect(getBannersSnapshot().map((banner) => banner.dismissible)).toEqual([
+ true,
+ undefined,
+ undefined,
+ ]);
+ });
+
+ it("preserves body whitespace, which is meaningful in Markdown", () => {
+ installRawSource(
+ staticSource(() => [
+ { id: "a", title: "Notice", body: " - indented list item" },
+ ]),
+ );
+
+ expect(getBannersSnapshot()[0]?.body).toBe(" - indented list item");
+ });
+ });
+
+ describe("subscribeToBanners", () => {
+ it("forwards host notifications and unsubscribes cleanly", () => {
+ const hostListeners = new Set<() => void>();
+ installRawSource({
+ version: 1,
+ getSnapshot: () => [],
+ subscribe: (listener: () => void) => {
+ hostListeners.add(listener);
+ return () => hostListeners.delete(listener);
+ },
+ });
+
+ const listener = vi.fn();
+ const unsubscribe = subscribeToBanners(listener);
+
+ hostListeners.forEach((hostListener) => hostListener());
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ unsubscribe();
+ expect(hostListeners.size).toBe(0);
+ });
+
+ it("binds to a source installed after the subscription", () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeToBanners(listener);
+
+ expect(listener).not.toHaveBeenCalled();
+
+ installRawSource(staticSource(() => []));
+
+ expect(listener).toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it("tolerates a source that does not return an unsubscribe function", () => {
+ installRawSource({
+ version: 1,
+ getSnapshot: () => [],
+ subscribe: () => undefined,
+ });
+
+ const unsubscribe = subscribeToBanners(vi.fn());
+ expect(() => unsubscribe()).not.toThrow();
+ });
+ });
+
+ describe("refreshBanners", () => {
+ it("asks the host to re-fetch when it supports it", () => {
+ const refresh = vi.fn();
+ installRawSource(staticSource(() => [], refresh));
+
+ refreshBanners();
+ expect(refresh).toHaveBeenCalledTimes(1);
+ });
+
+ it("is a no-op when refresh is absent or throws", () => {
+ installRawSource(staticSource(() => []));
+ expect(() => refreshBanners()).not.toThrow();
+
+ installRawSource(
+ staticSource(
+ () => [],
+ () => {
+ throw new Error("host is broken");
+ },
+ ),
+ );
+ expect(() => refreshBanners()).not.toThrow();
+ });
+ });
+});
diff --git a/src/config/banners.ts b/src/config/banners.ts
new file mode 100644
index 0000000000..859d56bd9b
--- /dev/null
+++ b/src/config/banners.ts
@@ -0,0 +1,172 @@
+import { isRecord } from "@/utils/typeGuards";
+import { toAbsoluteHttpUrl } from "@/utils/URL";
+
+export interface TangleBannerAction {
+ url: string;
+ text: string;
+}
+
+export interface TangleBanner {
+ id: string;
+ title: string;
+ body: string;
+ variant: "info" | "warning" | "success" | "error";
+ dismissible?: boolean;
+ action?: TangleBannerAction;
+}
+
+export interface TangleBannerSource {
+ version: 1;
+ getSnapshot: () => TangleBanner[];
+ subscribe: (listener: () => void) => () => void;
+ refresh?: () => void;
+}
+
+declare global {
+ interface Window {
+ __TANGLE_BANNER_SOURCE__?: TangleBannerSource;
+ }
+}
+
+export const BANNER_SOURCE_EVENT = "tangle:banner-source";
+
+const SUPPORTED_SOURCE_VERSION = 1;
+const VARIANTS: TangleBanner["variant"][] = [
+ "info",
+ "warning",
+ "success",
+ "error",
+];
+const DEFAULT_ACTION_TEXT = "Learn more";
+
+const EMPTY_BANNERS: readonly TangleBanner[] = Object.freeze([]);
+
+let lastSnapshotSignature: string | null = null;
+let lastValidatedSnapshot: readonly TangleBanner[] = EMPTY_BANNERS;
+
+function isBannerSource(value: unknown): value is TangleBannerSource {
+ return (
+ isRecord(value) &&
+ value.version === SUPPORTED_SOURCE_VERSION &&
+ typeof value.getSnapshot === "function" &&
+ typeof value.subscribe === "function"
+ );
+}
+
+function getBannerSource(): TangleBannerSource | null {
+ if (typeof window === "undefined") return null;
+ const source: unknown = window.__TANGLE_BANNER_SOURCE__;
+ return isBannerSource(source) ? source : null;
+}
+
+function readTrimmedString(value: unknown): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function readId(value: unknown): string {
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
+ return readTrimmedString(value);
+}
+
+function readVariant(value: unknown): TangleBanner["variant"] {
+ return VARIANTS.find((variant) => variant === value) ?? "info";
+}
+
+function readAction(value: unknown): TangleBannerAction | null {
+ if (!isRecord(value)) return null;
+
+ const url = toAbsoluteHttpUrl(value.url);
+ if (!url) return null;
+
+ return { url, text: readTrimmedString(value.text) || DEFAULT_ACTION_TEXT };
+}
+
+function readBanner(value: unknown): TangleBanner | null {
+ if (!isRecord(value)) return null;
+
+ const id = readId(value.id);
+ if (!id) return null;
+
+ const title = readTrimmedString(value.title);
+ const body = typeof value.body === "string" ? value.body : "";
+ if (!title && !body.trim()) return null;
+
+ const action = readAction(value.action);
+
+ return {
+ id,
+ title,
+ body,
+ variant: readVariant(value.variant),
+ ...(value.dismissible === true ? { dismissible: true } : {}),
+ ...(action ? { action } : {}),
+ };
+}
+
+function readRawSnapshot(): unknown {
+ const source = getBannerSource();
+ if (!source) return null;
+ try {
+ return source.getSnapshot();
+ } catch {
+ return null;
+ }
+}
+
+export function getBannersSnapshot(): readonly TangleBanner[] {
+ const raw = readRawSnapshot();
+ if (!Array.isArray(raw)) return EMPTY_BANNERS;
+
+ const validated = raw
+ .map(readBanner)
+ .filter((banner): banner is TangleBanner => banner !== null);
+
+ const signature = JSON.stringify(validated);
+ if (signature === lastSnapshotSignature) return lastValidatedSnapshot;
+
+ lastSnapshotSignature = signature;
+ lastValidatedSnapshot =
+ validated.length === 0 ? EMPTY_BANNERS : Object.freeze(validated);
+
+ return lastValidatedSnapshot;
+}
+
+function subscribeToSource(listener: () => void): (() => void) | null {
+ const source = getBannerSource();
+ if (!source) return null;
+ try {
+ const unsubscribe = source.subscribe(listener);
+ return typeof unsubscribe === "function" ? unsubscribe : null;
+ } catch {
+ return null;
+ }
+}
+
+export function subscribeToBanners(listener: () => void): () => void {
+ if (typeof window === "undefined") return () => {};
+
+ let unsubscribe = subscribeToSource(listener);
+
+ const rebindToSource = () => {
+ unsubscribe?.();
+ unsubscribe = subscribeToSource(listener);
+ listener();
+ };
+
+ window.addEventListener(BANNER_SOURCE_EVENT, rebindToSource);
+
+ return () => {
+ unsubscribe?.();
+ window.removeEventListener(BANNER_SOURCE_EVENT, rebindToSource);
+ };
+}
+
+export function refreshBanners(): void {
+ const source = getBannerSource();
+ if (typeof source?.refresh !== "function") return;
+ try {
+ source.refresh();
+ } catch {
+ // A host that fails to re-fetch must not surface an error in the app.
+ }
+}
diff --git a/src/hooks/useBannerInbox.ts b/src/hooks/useBannerInbox.ts
new file mode 100644
index 0000000000..c806525454
--- /dev/null
+++ b/src/hooks/useBannerInbox.ts
@@ -0,0 +1,169 @@
+import { useSyncExternalStore } from "react";
+
+import type { TangleBanner } from "@/config/banners";
+import { useBanners } from "@/hooks/useBanners";
+import { getStorage } from "@/utils/typedStorage";
+
+const STORAGE_KEYS = [
+ "dismissed-banners",
+ "hidden-banners",
+ "read-banners",
+] as const;
+
+type BannerInboxKey = (typeof STORAGE_KEYS)[number];
+
+const storage = getStorage>();
+
+const PROMOTION_ORDER: Record = {
+ error: 0,
+ warning: 1,
+ success: 2,
+ info: 3,
+};
+
+const hiddenForThisSession = new Set();
+const dismissedForThisSession = new Set();
+
+let isOpen = false;
+let revision = 0;
+const listeners = new Set<() => void>();
+
+function publish() {
+ revision += 1;
+ listeners.forEach((listener) => listener());
+}
+
+function handleStorage(event: StorageEvent) {
+ if (STORAGE_KEYS.some((key) => key === event.key)) publish();
+}
+
+function subscribe(listener: () => void): () => void {
+ if (listeners.size === 0) window.addEventListener("storage", handleStorage);
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0)
+ window.removeEventListener("storage", handleStorage);
+ };
+}
+
+function getRevision(): number {
+ return revision;
+}
+
+function readIds(key: BannerInboxKey): string[] {
+ const stored = storage.getItem(key);
+ return Array.isArray(stored) ? stored : [];
+}
+
+interface InboxState {
+ dismissedIds: Set;
+ hiddenIds: Set;
+ readIds: Set;
+}
+
+let cachedState: InboxState | null = null;
+let cachedRevision = -1;
+
+function getInboxState(revision: number): InboxState {
+ if (cachedState && cachedRevision === revision) return cachedState;
+
+ cachedRevision = revision;
+ cachedState = {
+ dismissedIds: new Set([
+ ...readIds("dismissed-banners"),
+ ...dismissedForThisSession,
+ ]),
+ hiddenIds: new Set([...readIds("hidden-banners"), ...hiddenForThisSession]),
+ readIds: new Set(readIds("read-banners")),
+ };
+
+ return cachedState;
+}
+
+function addIds(key: BannerInboxKey, ids: string[]) {
+ const stored = readIds(key);
+ const merged = [...new Set([...stored, ...ids])];
+ if (merged.length === stored.length) return;
+ storage.setItem(key, merged);
+}
+
+export function closeBannerInbox() {
+ isOpen = false;
+ publish();
+}
+
+function openBannerInbox(banners: readonly TangleBanner[]) {
+ addIds(
+ "read-banners",
+ banners.map((banner) => banner.id),
+ );
+ isOpen = true;
+ publish();
+}
+
+function hideStrip(banners: readonly TangleBanner[]) {
+ banners
+ .filter((banner) => !banner.dismissible)
+ .forEach((banner) => hiddenForThisSession.add(banner.id));
+
+ addIds(
+ "hidden-banners",
+ banners.filter((banner) => banner.dismissible).map((banner) => banner.id),
+ );
+ publish();
+}
+
+function showStrip() {
+ hiddenForThisSession.clear();
+ storage.setItem("hidden-banners", []);
+ publish();
+}
+
+function dismissBanner(banner: TangleBanner) {
+ addIds("dismissed-banners", [banner.id]);
+ if (!readIds("dismissed-banners").includes(banner.id))
+ dismissedForThisSession.add(banner.id);
+ publish();
+}
+
+export interface BannerInbox {
+ banners: readonly TangleBanner[];
+ showing: readonly TangleBanner[];
+ unreadCount: number;
+ isStripHidden: boolean;
+ isOpen: boolean;
+ setOpen: (open: boolean) => void;
+ hideStrip: () => void;
+ showStrip: () => void;
+ dismiss: (banner: TangleBanner) => void;
+}
+
+export function useBannerInbox(): BannerInbox {
+ const banners = useBanners();
+ const revision = useSyncExternalStore(subscribe, getRevision);
+ const {
+ dismissedIds,
+ hiddenIds,
+ readIds: readIdSet,
+ } = getInboxState(revision);
+
+ const listed = banners
+ .filter((banner) => !dismissedIds.has(banner.id))
+ .sort((a, b) => PROMOTION_ORDER[a.variant] - PROMOTION_ORDER[b.variant]);
+
+ const showing = listed.filter((banner) => !hiddenIds.has(banner.id));
+
+ return {
+ banners: listed,
+ showing,
+ unreadCount: listed.filter((banner) => !readIdSet.has(banner.id)).length,
+ isStripHidden: showing.length === 0 && listed.length > 0,
+ isOpen,
+ setOpen: (open) => (open ? openBannerInbox(listed) : closeBannerInbox()),
+ hideStrip: () => hideStrip(listed),
+ showStrip,
+ dismiss: dismissBanner,
+ };
+}
diff --git a/src/hooks/useBanners.ts b/src/hooks/useBanners.ts
new file mode 100644
index 0000000000..2886cf3df9
--- /dev/null
+++ b/src/hooks/useBanners.ts
@@ -0,0 +1,28 @@
+import { useEffect, useSyncExternalStore } from "react";
+
+import {
+ getBannersSnapshot,
+ refreshBanners,
+ subscribeToBanners,
+ type TangleBanner,
+} from "@/config/banners";
+
+let isRefreshWatcherStarted = false;
+
+function startRefreshWatcher() {
+ if (isRefreshWatcherStarted) return;
+ isRefreshWatcherStarted = true;
+
+ refreshBanners();
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") refreshBanners();
+ });
+}
+
+export function useBanners(): readonly TangleBanner[] {
+ const banners = useSyncExternalStore(subscribeToBanners, getBannersSnapshot);
+
+ useEffect(startRefreshWatcher, []);
+
+ return banners;
+}
diff --git a/src/routes/Dashboard/DashboardComponentsV2View.tsx b/src/routes/Dashboard/DashboardComponentsV2View.tsx
index 82c6aff14a..19247a1ef4 100644
--- a/src/routes/Dashboard/DashboardComponentsV2View.tsx
+++ b/src/routes/Dashboard/DashboardComponentsV2View.tsx
@@ -81,7 +81,7 @@ import {
import type { ComponentFolder } from "@/types/componentLibrary";
import type { ComponentReference } from "@/utils/componentSpec";
import { componentMetadata } from "@/utils/componentTracking";
-import { HOURS, TOP_NAV_HEIGHT } from "@/utils/constants";
+import { contentHeight, HOURS } from "@/utils/constants";
import { getComponentName } from "@/utils/getComponentName";
import { tracking } from "@/utils/tracking";
@@ -1564,7 +1564,7 @@ export const DashboardComponentsV2View = () => {
// `min-height: auto` defaults fight against that flex chain.
{/* Header zone: page title, description, search input. shrink-0 so it
never gets squeezed by the body below. */}
diff --git a/src/routes/Dashboard/DashboardComponentsView.tsx b/src/routes/Dashboard/DashboardComponentsView.tsx
index 9b0fb95b19..83c79f228c 100644
--- a/src/routes/Dashboard/DashboardComponentsView.tsx
+++ b/src/routes/Dashboard/DashboardComponentsView.tsx
@@ -31,7 +31,7 @@ import { fetchAndStoreComponentLibrary } from "@/services/componentService";
import type { ComponentFolder } from "@/types/componentLibrary";
import type { ComponentReference } from "@/utils/componentSpec";
import { componentMetadata } from "@/utils/componentTracking";
-import { TOP_NAV_HEIGHT } from "@/utils/constants";
+import { contentHeight } from "@/utils/constants";
import { fetchWithErrorHandling } from "@/utils/fetchWithErrorHandling";
import { getComponentName } from "@/utils/getComponentName";
import { tracking } from "@/utils/tracking";
@@ -389,7 +389,7 @@ export function DashboardComponentsView() {
return (
{/* Left: component list */}
diff --git a/src/routes/Dashboard/DashboardHomeView.tsx b/src/routes/Dashboard/DashboardHomeView.tsx
index 9a0b15203d..56bb6961d1 100644
--- a/src/routes/Dashboard/DashboardHomeView.tsx
+++ b/src/routes/Dashboard/DashboardHomeView.tsx
@@ -1,7 +1,6 @@
import { Link } from "@tanstack/react-router";
import { RunSection } from "@/components/Home/RunSection/RunSection";
-import { AnnouncementBanners } from "@/components/shared/AnnouncementBanners";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import {
Tooltip,
@@ -184,8 +183,6 @@ const RecentComponentsPreview = () => {
export function DashboardHomeView() {
return (
-
-
diff --git a/src/routes/Dashboard/DashboardLayout.tsx b/src/routes/Dashboard/DashboardLayout.tsx
index b96c2db15f..fffa19f012 100644
--- a/src/routes/Dashboard/DashboardLayout.tsx
+++ b/src/routes/Dashboard/DashboardLayout.tsx
@@ -13,12 +13,12 @@ import { useOnboarding } from "@/providers/OnboardingProvider/OnboardingProvider
import { APP_ROUTES } from "@/routes/appRoutes";
import {
ABOUT_URL,
+ contentHeight,
DOCUMENTATION_URL,
GIT_COMMIT,
GIT_REPO_URL,
GIVE_FEEDBACK_URL,
PRIVACY_POLICY_URL,
- TOP_NAV_HEIGHT,
} from "@/utils/constants";
interface SidebarItem {
@@ -84,7 +84,7 @@ export function DashboardLayout() {
return (
{/* Sidebar — fixed height, independent scroll */}
diff --git a/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx b/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx
index 9a2e983253..f24905c949 100644
--- a/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx
+++ b/src/routes/v2/shared/components/AiChat/components/renderMarkdown.tsx
@@ -5,12 +5,13 @@ import {
type ReactNode,
useContext,
} from "react";
-import Markdown, { defaultUrlTransform } from "react-markdown";
-import remarkGfm from "remark-gfm";
+import { defaultUrlTransform } from "react-markdown";
+import {
+ INLINE_CODE_CLASS,
+ Markdown,
+} from "@/components/shared/Markdown/Markdown";
import { Link } from "@/components/ui/link";
-import { Separator } from "@/components/ui/separator";
-import { Heading, Paragraph } from "@/components/ui/typography";
import { getComponentQueryKey } from "@/hooks/useHydrateComponentReference";
import type { ComponentRefData } from "@/routes/v2/shared/components/AiChat/types";
import { CodeBlock } from "@/routes/v2/shared/components/CodeBlock";
@@ -22,8 +23,6 @@ import { EntityChip } from "./EntityChip";
const ENTITY_PROTOCOL = "entity://";
const COMPONENT_PROTOCOL = "component://";
-const INLINE_CODE_CLASS = "rounded bg-muted px-1 py-0.5 text-xs font-mono";
-
const ComponentRefsContext = createContext<
Record
| undefined
>(undefined);
@@ -135,74 +134,13 @@ function MarkdownCode({
);
}
-const markdownComponents = {
- h1: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- h2: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- h3: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- h4: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- p: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- ul: ({ children }: { children?: ReactNode }) => (
-
- ),
- ol: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- li: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- blockquote: ({ children }: { children?: ReactNode }) => (
-
- {children}
-
- ),
- table: ({ children }: { children?: ReactNode }) => (
-
- ),
- thead: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- tbody: ({ children }: { children?: ReactNode }) => {children},
- tr: ({ children }: { children?: ReactNode }) => (
- {children}
- ),
- th: ({ children }: { children?: ReactNode }) => (
- {children} |
- ),
- td: ({ children }: { children?: ReactNode }) => (
- {children} |
- ),
- hr: () => ,
+const chatComponents = {
a: MarkdownLink,
code: MarkdownCode,
+ // `MarkdownCode` already renders a fenced block as a `CodeBlock`, which brings
+ // its own surround.
pre: ({ children }: { children?: ReactNode }) => <>{children}>,
-} as const;
+};
export function renderMarkdown(
text: string,
@@ -210,12 +148,10 @@ export function renderMarkdown(
): ReactNode {
const markdown = (
- {text}
-
+ />
);
if (!componentReferences) return markdown;
diff --git a/src/routes/v2/shared/components/AppMenuActions.tsx b/src/routes/v2/shared/components/AppMenuActions.tsx
index 20de56208f..eda5d69f8f 100644
--- a/src/routes/v2/shared/components/AppMenuActions.tsx
+++ b/src/routes/v2/shared/components/AppMenuActions.tsx
@@ -4,6 +4,7 @@ import { AiModelQuickSelect } from "@/components/layout/AiModelQuickSelect";
import { OnboardingNavPill } from "@/components/Onboarding/OnboardingNavPill";
import { isAuthorizationRequired } from "@/components/shared/Authentication/helpers";
import { TopBarAuthentication } from "@/components/shared/Authentication/TopBarAuthentication";
+import { BannerInbox } from "@/components/shared/Banners/BannerInbox";
import TooltipButton from "@/components/shared/Buttons/TooltipButton";
import { EditorVersionToggle } from "@/components/shared/EditorVersionToggle";
import { RunVersionToggle } from "@/components/shared/RunVersionToggle";
@@ -30,6 +31,7 @@ export function AppMenuActions() {
+
{tourMode ? (
{
+ if (typeof value !== "string" || !value.trim()) return null;
+ try {
+ const url = new URL(value.trim());
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+ return url.toString();
+ } catch {
+ return null;
+ }
+};
+
const normalizeUrl = (url: string) => {
if (url.trim() === "") {
return "";
@@ -223,4 +234,5 @@ export {
getIdOrTitleFromPath,
isGithubUrl,
normalizeUrl,
+ toAbsoluteHttpUrl,
};
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index d2cbe36dac..445e751ba2 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -55,6 +55,15 @@ export const USER_COMPONENTS_LIST_NAME = "user_components";
export const TOP_NAV_HEIGHT = 56; // px
export const BOTTOM_FOOTER_HEIGHT = 0; // px
+export const CONTENT_OFFSET_VAR = "--tangle-content-offset";
+
+export function contentHeight(subtractPx = 0): string {
+ const offset = `var(${CONTENT_OFFSET_VAR}, ${TOP_NAV_HEIGHT}px)`;
+ return subtractPx > 0
+ ? `calc(100vh - ${offset} - ${subtractPx}px)`
+ : `calc(100vh - ${offset})`;
+}
+
export const DEFAULT_NODE_DIMENSIONS = { w: 300, h: undefined };
export const FONT_SIZE_MD = 12;