diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx
index 4ba1d934..e8709d5c 100644
--- a/apps/client/src/features/ai-chat/components/message-item.tsx
+++ b/apps/client/src/features/ai-chat/components/message-item.tsx
@@ -10,6 +10,18 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
+ /**
+ * Forwarded to ToolCallCard: whether tool cards render page citation links.
+ * Defaults to true (internal chat). The public share passes false.
+ */
+ showCitations?: boolean;
+ /**
+ * Neutralize internal/relative markdown links in the rendered answer (drop
+ * their href so they become inert text). Defaults to false (internal chat,
+ * links stay clickable). The anonymous public share passes true so internal
+ * UUIDs/routes in the assistant's markdown don't leak as clickable links.
+ */
+ neutralizeInternalLinks?: boolean;
}
/**
@@ -24,7 +36,11 @@ interface MessageItemProps {
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
*/
-export default function MessageItem({ message }: MessageItemProps) {
+export default function MessageItem({
+ message,
+ showCitations = true,
+ neutralizeInternalLinks = false,
+}: MessageItemProps) {
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -53,7 +69,9 @@ export default function MessageItem({ message }: MessageItemProps) {
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
- const html = renderChatMarkdown(part.text);
+ const html = renderChatMarkdown(part.text, {
+ neutralizeInternalLinks,
+ });
if (html) {
return (
;
+ return (
+
+ );
}
return null;
diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx
index ed0fb73d..3d9c5024 100644
--- a/apps/client/src/features/ai-chat/components/message-list.tsx
+++ b/apps/client/src/features/ai-chat/components/message-list.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { ReactNode, useEffect, useRef } from "react";
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
@@ -10,6 +10,26 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
+ /**
+ * Content shown when the transcript is empty and no turn is in flight.
+ * Defaults to the internal chat's prompt. The public share passes its own
+ * documentation-focused copy. This is purely the empty-state text; the
+ * streaming/typing/markdown/tool-card paths below are shared verbatim.
+ */
+ emptyState?: ReactNode;
+ /**
+ * Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
+ * citation links. Defaults to true (internal chat). The public share passes
+ * false because an anonymous reader cannot open the linked internal pages.
+ */
+ showCitations?: boolean;
+ /**
+ * Forwarded to MessageItem: neutralize internal/relative markdown links in
+ * the rendered answers (drop their href so they render as inert text).
+ * Defaults to false (internal chat). The public share passes true so internal
+ * UUIDs/routes don't leak as clickable links to anonymous readers.
+ */
+ neutralizeInternalLinks?: boolean;
}
// Distance (px) from the bottom within which the viewport still counts as
@@ -24,7 +44,7 @@ const BOTTOM_THRESHOLD = 40;
* - the last (assistant) message has no non-empty text and no tool part.
* Once any text/tool part arrives, MessageItem renders it and this hides.
*/
-function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
+export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
if (!last) return true; // submitted with nothing rendered yet.
@@ -41,7 +61,13 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
* but only while the user is pinned to the bottom — if they scrolled up to read
* earlier messages, streamed deltas no longer yank them back down.
*/
-export default function MessageList({ messages, isStreaming }: MessageListProps) {
+export default function MessageList({
+ messages,
+ isStreaming,
+ emptyState,
+ showCitations = true,
+ neutralizeInternalLinks = false,
+}: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef(null);
// Whether the viewport is currently pinned to the bottom. Starts true so the
@@ -104,9 +130,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
if (messages.length === 0 && !typing) {
return (
-
- {t("Ask the AI agent anything about your workspace.")}
-
+ {emptyState ?? (
+
+ {t("Ask the AI agent anything about your workspace.")}
+
+ )}
);
}
@@ -115,7 +143,12 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
{messages.map((message) => (
-
+
))}
{typing && }
diff --git a/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts b/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts
new file mode 100644
index 00000000..5cc023d9
--- /dev/null
+++ b/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import type { UIMessage } from "@ai-sdk/react";
+import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
+
+/**
+ * Pure-helper tests for the typing-indicator bridging logic that the internal
+ * chat and the public share widget now share. This is the behavior that decides
+ * whether the animated "AI agent is typing…" placeholder shows in the gap
+ * between sending and the first streamed token.
+ */
+const msg = (
+ role: "user" | "assistant",
+ parts: UIMessage["parts"],
+): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
+
+describe("showTypingIndicator", () => {
+ it("is hidden when not streaming", () => {
+ expect(showTypingIndicator([], false)).toBe(false);
+ expect(
+ showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
+ ).toBe(false);
+ });
+
+ it("shows while streaming with no messages yet (just submitted)", () => {
+ expect(showTypingIndicator([], true)).toBe(true);
+ });
+
+ it("shows while streaming when the last message is still the user's", () => {
+ expect(
+ showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
+ ).toBe(true);
+ });
+
+ it("shows while streaming when the assistant row has no visible content", () => {
+ expect(
+ showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
+ ).toBe(true);
+ expect(
+ showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
+ ).toBe(true);
+ });
+
+ it("hides once the assistant streams non-empty text", () => {
+ expect(
+ showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
+ ).toBe(false);
+ });
+
+ it("hides once a tool part appears (even before any text)", () => {
+ const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
+ expect(
+ showTypingIndicator([msg("assistant", [toolPart])], true),
+ ).toBe(false);
+ });
+});
diff --git a/apps/client/src/features/ai-chat/components/tool-call-card.tsx b/apps/client/src/features/ai-chat/components/tool-call-card.tsx
index 921be2fb..d337bd1f 100644
--- a/apps/client/src/features/ai-chat/components/tool-call-card.tsx
+++ b/apps/client/src/features/ai-chat/components/tool-call-card.tsx
@@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ToolCallCardProps {
part: ToolUiPart;
+ /**
+ * Whether to render page citation links. Defaults to true (the internal chat,
+ * where the reader is authenticated and the `/p/{id}` links resolve). The
+ * public share passes false: an anonymous reader cannot open internal pages,
+ * so the links would 404/redirect to login. Suppressing them keeps the card
+ * (the action log itself) while dropping the unusable links.
+ */
+ showCitations?: boolean;
}
/**
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
* agent DID (the agent writes without confirmation — D2), its run state
* (running / done / error), and citation link(s) to any referenced page(s).
*/
-export default function ToolCallCard({ part }: ToolCallCardProps) {
+export default function ToolCallCard({
+ part,
+ showCitations = true,
+}: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
- const citations = toolCitations(part);
+ const citations = showCitations ? toolCitations(part) : [];
return (
diff --git a/apps/client/src/features/ai-chat/utils/markdown.test.ts b/apps/client/src/features/ai-chat/utils/markdown.test.ts
new file mode 100644
index 00000000..993dcf4d
--- /dev/null
+++ b/apps/client/src/features/ai-chat/utils/markdown.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
+
+/**
+ * Tests for the internal-link neutralization used by the anonymous public
+ * share. Now that the share renders the assistant's MARKDOWN (not plain text),
+ * internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
+ * `
`, leaking internal UUIDs/structure and linking to auth-gated
+ * routes. With the flag ON those links are made inert (href removed) while the
+ * visible text and the rest of the markdown formatting are preserved; genuinely
+ * EXTERNAL http(s) links are kept with a safe rel/target. With the flag OFF
+ * (internal default) links keep their href so the authenticated chat is unchanged.
+ */
+
+/** Parse the rendered HTML and return the first element (or null). */
+function firstAnchor(html: string): HTMLAnchorElement | null {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return doc.querySelector("a");
+}
+
+describe("renderChatMarkdown — internal link neutralization", () => {
+ it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
+ const html = renderChatMarkdown("[x](/p/abc)", {
+ neutralizeInternalLinks: true,
+ });
+ const a = firstAnchor(html);
+ expect(a).not.toBeNull();
+ expect(a!.hasAttribute("href")).toBe(false);
+ expect(a!.hasAttribute("target")).toBe(false);
+ // Visible link text is preserved.
+ expect(a!.textContent).toBe("x");
+ });
+
+ it("neutralizes bare-fragment links when the flag is ON", () => {
+ const html = renderChatMarkdown("[here](#section)", {
+ neutralizeInternalLinks: true,
+ });
+ const a = firstAnchor(html);
+ expect(a).not.toBeNull();
+ expect(a!.hasAttribute("href")).toBe(false);
+ });
+
+ it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
+ const html = renderChatMarkdown("[y](https://example.com)", {
+ neutralizeInternalLinks: true,
+ });
+ const a = firstAnchor(html);
+ expect(a).not.toBeNull();
+ expect(a!.getAttribute("href")).toBe("https://example.com");
+ expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
+ expect(a!.getAttribute("target")).toBe("_blank");
+ });
+
+ it("keeps internal links clickable when the flag is OFF (internal default)", () => {
+ const html = renderChatMarkdown("[x](/p/abc)");
+ const a = firstAnchor(html);
+ expect(a).not.toBeNull();
+ expect(a!.getAttribute("href")).toBe("/p/abc");
+ });
+
+ it("does not leave a global DOMPurify hook that affects a later internal render", () => {
+ // A neutralizing render first, then an internal render: the internal link
+ // must survive (the hook is removed after the share render).
+ renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
+ const html = renderChatMarkdown("[x](/p/abc)");
+ const a = firstAnchor(html);
+ expect(a!.getAttribute("href")).toBe("/p/abc");
+ });
+});
diff --git a/apps/client/src/features/ai-chat/utils/markdown.ts b/apps/client/src/features/ai-chat/utils/markdown.ts
index 529b3140..d7ba4e74 100644
--- a/apps/client/src/features/ai-chat/utils/markdown.ts
+++ b/apps/client/src/features/ai-chat/utils/markdown.ts
@@ -1,6 +1,51 @@
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
+export interface RenderChatMarkdownOptions {
+ /**
+ * Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
+ * Used by the anonymous public share: the assistant's answer can contain
+ * relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
+ * that would otherwise become clickable ``, leaking internal
+ * UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
+ * still follow genuinely EXTERNAL `http(s)` links, so those are kept (with a
+ * safe `rel`/`target`). Defaults to false — the internal chat keeps internal
+ * links clickable for authenticated users.
+ */
+ neutralizeInternalLinks?: boolean;
+}
+
+/**
+ * Whether `href` points at an EXTERNAL absolute URL we are happy for an
+ * anonymous reader to follow. Only absolute `http(s)://` URLs qualify;
+ * everything else (relative `/...`, bare fragments `#...`, protocol-relative
+ * `//...`, other schemes) is treated as internal/unsafe and neutralized.
+ */
+function isExternalHttpUrl(href: string): boolean {
+ return /^https?:\/\//i.test(href.trim());
+}
+
+/**
+ * DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
+ * Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
+ * for the duration of a single sanitize call (added then removed in
+ * `renderChatMarkdown`) — it must never leak into the internal chat's renders.
+ */
+function neutralizeInternalLinksHook(node: Element): void {
+ if (node.nodeName !== "A") return;
+ const href = node.getAttribute("href");
+ if (href !== null && isExternalHttpUrl(href)) {
+ // Genuinely external link: keep it, but force a safe rel/target.
+ node.setAttribute("rel", "noopener noreferrer nofollow");
+ node.setAttribute("target", "_blank");
+ return;
+ }
+ // Internal/relative/fragment link (or no href): make it inert text. Drop the
+ // href and any target so it is no longer clickable; the visible text stays.
+ node.removeAttribute("href");
+ node.removeAttribute("target");
+}
+
/**
* Render AI markdown to sanitized HTML for read-only display. We reuse the
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
@@ -12,9 +57,31 @@ import DOMPurify from "dompurify";
* synchronously, but we guard the Promise case by returning a safe empty string
* for that branch (the caller renders the raw text fallback instead).
*/
-export function renderChatMarkdown(markdown: string): string {
+export function renderChatMarkdown(
+ markdown: string,
+ options: RenderChatMarkdownOptions = {},
+): string {
if (!markdown) return "";
const html = markdownToHtml(markdown);
if (typeof html !== "string") return "";
- return DOMPurify.sanitize(html);
+
+ if (!options.neutralizeInternalLinks) {
+ // Internal chat: unchanged behavior, no hook registered.
+ return DOMPurify.sanitize(html);
+ }
+
+ // Public share: register the neutralization hook only for THIS sanitize call,
+ // then remove it immediately so it can never affect the internal chat (hooks
+ // are global on the shared DOMPurify instance).
+ DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
+ try {
+ return DOMPurify.sanitize(html);
+ } finally {
+ // Remove by reference (not a bare pop) so we only ever remove OUR hook,
+ // robust to any other afterSanitizeAttributes hook registered in future.
+ DOMPurify.removeHook(
+ "afterSanitizeAttributes",
+ neutralizeInternalLinksHook,
+ );
+ }
}
diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx
index 90d0b9af..5212e2c4 100644
--- a/apps/client/src/features/share/components/share-ai-widget.tsx
+++ b/apps/client/src/features/share/components/share-ai-widget.tsx
@@ -7,8 +7,6 @@ import {
Box,
Group,
Paper,
- ScrollArea,
- Stack,
Text,
Textarea,
Tooltip,
@@ -22,6 +20,7 @@ import {
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
+import MessageList from "@/features/ai-chat/components/message-list.tsx";
interface ShareAiWidgetProps {
/** The share id (or key) the assistant is scoped to. */
@@ -30,17 +29,6 @@ interface ShareAiWidgetProps {
pageId: string;
}
-/** Concatenate the visible text parts of a UIMessage. */
-function messageText(message: UIMessage): string {
- return (message.parts ?? [])
- .filter(
- (p): p is { type: "text"; text: string } =>
- p?.type === "text" && typeof (p as { text?: string }).text === "string",
- )
- .map((p) => p.text)
- .join("");
-}
-
/**
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
*
@@ -49,6 +37,15 @@ function messageText(message: UIMessage): string {
* memory (this component's `useChat` store) and is sent with `credentials:
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
* nothing.
+ *
+ * Presentation is now shared with the internal chat: the same `MessageList`
+ * renders the streamed transcript, so the public share gets the SAME
+ * incremental markdown render, animated typing indicator, and tool-call cards
+ * as the internal chat. Only the anonymous specifics differ — no auth, no
+ * history, `credentials: "omit"`, suppressed page citations (an anonymous
+ * reader cannot open the linked internal pages), neutralized internal markdown
+ * links (so internal UUIDs/auth-gated routes in the answer don't leak as
+ * clickable links), and a documentation-focused empty state.
*/
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
const { t } = useTranslation();
@@ -147,53 +144,39 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
-
- {messages.length === 0 ? (
-
- {t("Ask a question about this documentation.")}
-
- ) : (
-
- {messages.map((message) => (
-
-
-
- {messageText(message) ||
- (isStreaming ? t("Thinking…") : "")}
-
-
-
- ))}
-
- )}
+ {/* Shared transcript: same incremental streaming render, animated typing
+ indicator, markdown, and tool-call cards as the internal chat. The
+ share is anonymous, so page citation links are suppressed (an
+ anonymous reader cannot open the linked internal pages). */}
+
+
+ {t("Ask a question about this documentation.")}
+
+ }
+ />
+
- {error && (
- }
- mt="sm"
- title={t("Something went wrong")}
- >
- {t("The assistant is unavailable right now. Please try again.")}
-
- )}
-
+ {error && (
+ }
+ mx="sm"
+ mb="xs"
+ title={t("Something went wrong")}
+ >
+ {t("The assistant is unavailable right now. Please try again.")}
+
+ )}