Skip to content

Commit e0e7952

Browse files
fix(ui): rewrite docs markdown links to docs host
1 parent e3a6da0 commit e0e7952

13 files changed

Lines changed: 218 additions & 31 deletions

ui/src/ui/chat/grouped-render.test.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@ import { normalizeMessage } from "./message-normalizer.ts";
1313

1414
const localStorageValues = vi.hoisted(() => new Map<string, string>());
1515
const markdownRenderMock = vi.hoisted(() =>
16-
vi.fn((value: string, _options?: { codeBlockChrome?: "copy" | "none" }) => value),
16+
vi.fn(
17+
(
18+
value: string,
19+
_options?: {
20+
codeBlockChrome?: "copy" | "none";
21+
preserveControlUiRoutes?: boolean;
22+
rootRelativeLinkBaseUrl?: string;
23+
},
24+
) => value,
25+
),
1726
);
1827
const streamingTextRenderMock = vi.hoisted(() =>
1928
vi.fn((value: string) => `<div class="markdown-plain-text-fallback">${value}</div>`),
@@ -31,6 +40,13 @@ vi.mock("../../local-storage.ts", () => ({
3140
}));
3241

3342
vi.mock("../markdown.ts", () => ({
43+
OPENCLAW_DOCS_MARKDOWN_OPTIONS: {
44+
rootRelativeLinkBaseUrl: "https://docs.openclaw.ai",
45+
},
46+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS: {
47+
preserveControlUiRoutes: true,
48+
rootRelativeLinkBaseUrl: "https://docs.openclaw.ai",
49+
},
3450
toSanitizedMarkdownHtml: markdownRenderMock,
3551
toStreamingMarkdownHtml: streamingMarkdownRenderMock,
3652
toStreamingPlainTextHtml: streamingTextRenderMock,
@@ -605,7 +621,10 @@ describe("grouped chat rendering", () => {
605621
timestamp: 1000,
606622
});
607623

608-
expect(markdownRenderMock).toHaveBeenCalledWith(markdown, undefined);
624+
expect(markdownRenderMock).toHaveBeenCalledWith(markdown, {
625+
preserveControlUiRoutes: true,
626+
rootRelativeLinkBaseUrl: "https://docs.openclaw.ai",
627+
});
609628
});
610629

611630
it("positions delete confirm by message side", () => {
@@ -905,7 +924,10 @@ describe("grouped chat rendering", () => {
905924

906925
expect(markdownRenderMock).not.toHaveBeenCalled();
907926
expect(streamingTextRenderMock).not.toHaveBeenCalled();
908-
expect(streamingMarkdownRenderMock).toHaveBeenCalledWith("**live**\nreply", undefined);
927+
expect(streamingMarkdownRenderMock).toHaveBeenCalledWith("**live**\nreply", {
928+
preserveControlUiRoutes: true,
929+
rootRelativeLinkBaseUrl: "https://docs.openclaw.ai",
930+
});
909931
const text = container.querySelector(".streaming-markdown");
910932
expect(text?.textContent).toBe("**live**\nreply");
911933
});
@@ -2467,6 +2489,7 @@ describe("grouped chat rendering", () => {
24672489

24682490
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
24692491
expect(sidebar.kind).toBe("markdown");
2492+
expect(sidebar.rewriteOpenClawDocsLinks).toBe(true);
24702493
expect(sidebar.fullMessageRequest).toEqual({
24712494
sessionKey: "global",
24722495
agentId: "work",

ui/src/ui/chat/grouped-render.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
66
import type { AssistantIdentity } from "../assistant-identity.ts";
77
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
88
import { icons } from "../icons.ts";
9-
import { toSanitizedMarkdownHtml, toStreamingMarkdownHtml } from "../markdown.ts";
9+
import {
10+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
11+
toSanitizedMarkdownHtml,
12+
toStreamingMarkdownHtml,
13+
type MarkdownRenderOptions,
14+
} from "../markdown.ts";
1015
import { openExternalUrlSafe } from "../open-external-url.ts";
1116
import type { SidebarContent } from "../sidebar-content.ts";
1217
import { detectTextDirection } from "../text-direction.ts";
@@ -1587,6 +1592,7 @@ function renderExpandButton(
15871592
onOpenSidebar({
15881593
kind: "markdown",
15891594
content: markdown,
1595+
rewriteOpenClawDocsLinks: true,
15901596
...(options?.sessionKey && options?.messageId
15911597
? {
15921598
fullMessageRequest: {
@@ -1672,7 +1678,8 @@ function renderGroupedMessage(
16721678
const markdownBase = extractedText?.trim() ? extractedText : null;
16731679
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null;
16741680
const markdown = markdownBase;
1675-
const markdownRenderOptions = role === "user" ? { codeBlockChrome: "none" as const } : undefined;
1681+
const markdownRenderOptions: MarkdownRenderOptions =
1682+
role === "user" ? { codeBlockChrome: "none" } : OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS;
16761683
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
16771684
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());
16781685
const hasActions = canCopyMarkdown || canExpand;
@@ -1824,7 +1831,12 @@ function renderGroupedMessage(
18241831
)}
18251832
${reasoningMarkdown
18261833
? html`<div class="chat-thinking">
1827-
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
1834+
${unsafeHTML(
1835+
toSanitizedMarkdownHtml(
1836+
reasoningMarkdown,
1837+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
1838+
),
1839+
)}
18281840
</div>`
18291841
: nothing}
18301842
${jsonResult
@@ -1881,7 +1893,12 @@ function renderGroupedMessage(
18811893
)}
18821894
${reasoningMarkdown
18831895
? html`<div class="chat-thinking">
1884-
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
1896+
${unsafeHTML(
1897+
toSanitizedMarkdownHtml(
1898+
reasoningMarkdown,
1899+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
1900+
),
1901+
)}
18851902
</div>`
18861903
: nothing}
18871904
${normalizedRole === "assistant" && assistantViewBlocks.length > 0
@@ -1936,7 +1953,7 @@ function renderGroupedMessage(
19361953
function renderMarkdownText(
19371954
markdown: string,
19381955
isStreaming: boolean,
1939-
markdownRenderOptions?: { codeBlockChrome: "copy" | "none" },
1956+
markdownRenderOptions: MarkdownRenderOptions,
19401957
) {
19411958
if (isStreaming) {
19421959
return html`

ui/src/ui/chat/run-controls.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ vi.mock("../icons.ts", () => ({
2222
}));
2323

2424
vi.mock("../markdown.ts", () => ({
25+
OPENCLAW_DOCS_MARKDOWN_OPTIONS: {
26+
rootRelativeLinkBaseUrl: "https://docs.openclaw.ai",
27+
},
28+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS: {
29+
preserveControlUiRoutes: true,
30+
rootRelativeLinkBaseUrl: "https://docs.openclaw.ai",
31+
},
2532
toSanitizedMarkdownHtml: (value: string) => value,
2633
}));
2734

ui/src/ui/chat/side-result-render.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { html, nothing, type TemplateResult } from "lit";
33
import { unsafeHTML } from "lit/directives/unsafe-html.js";
44
import { icons } from "../icons.ts";
5-
import { toSanitizedMarkdownHtml } from "../markdown.ts";
5+
import { OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS, toSanitizedMarkdownHtml } from "../markdown.ts";
66
import { detectTextDirection } from "../text-direction.ts";
77
import type { ChatSideResult } from "./side-result.ts";
88

@@ -37,7 +37,9 @@ export function renderSideResult(
3737
</div>
3838
<div class="chat-side-result__question">${sideResult.question}</div>
3939
<div class="chat-side-result__body" dir=${detectTextDirection(sideResult.text)}>
40-
${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))}
40+
${unsafeHTML(
41+
toSanitizedMarkdownHtml(sideResult.text, OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS),
42+
)}
4143
</div>
4244
</section>
4345
`;

ui/src/ui/markdown.test.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest";
44
import { i18n } from "../i18n/index.ts";
55
import {
66
md,
7+
OPENCLAW_DOCS_MARKDOWN_OPTIONS,
8+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
79
toSanitizedMarkdownHtml,
810
toStreamingMarkdownHtml,
911
toStreamingPlainTextHtml,
@@ -603,25 +605,73 @@ PY
603605
);
604606
});
605607

606-
it("rewrites docs-root links to the public docs host", () => {
608+
it("keeps docs-root links local by default", () => {
609+
const html = toSanitizedMarkdownHtml("[workspace](/concepts/agent-workspace)");
610+
expect(html).toBe(
611+
'<p><a href="/concepts/agent-workspace" rel="noreferrer noopener" target="_blank">workspace</a></p>\n',
612+
);
613+
});
614+
615+
it("rewrites docs-root links to the public docs host in docs-link mode", () => {
607616
const html = toSanitizedMarkdownHtml(
608617
"[workspace](/concepts/agent-workspace) [hooks](/automation/hooks#session-memory) [telegram](/channels/telegram?tab=setup) [shortlink](/telegram) [openai](/openai) [images](/images) [groups](/groups) [camera](/nodes/camera) [macOS](/platforms/macos) [cliSessions](/cli/sessions) [toolSkills](/tools/skills) [pluginDocs](/plugins/reference/diffs) [prose](/prose) [refactor](/refactor/ingress-core)",
618+
OPENCLAW_DOCS_MARKDOWN_OPTIONS,
609619
);
610620
expect(html).toBe(
611621
'<p><a href="https://docs.openclaw.ai/concepts/agent-workspace" rel="noreferrer noopener" target="_blank">workspace</a> <a href="https://docs.openclaw.ai/automation/hooks#session-memory" rel="noreferrer noopener" target="_blank">hooks</a> <a href="https://docs.openclaw.ai/channels/telegram?tab=setup" rel="noreferrer noopener" target="_blank">telegram</a> <a href="https://docs.openclaw.ai/telegram" rel="noreferrer noopener" target="_blank">shortlink</a> <a href="https://docs.openclaw.ai/openai" rel="noreferrer noopener" target="_blank">openai</a> <a href="https://docs.openclaw.ai/images" rel="noreferrer noopener" target="_blank">images</a> <a href="https://docs.openclaw.ai/groups" rel="noreferrer noopener" target="_blank">groups</a> <a href="https://docs.openclaw.ai/nodes/camera" rel="noreferrer noopener" target="_blank">camera</a> <a href="https://docs.openclaw.ai/platforms/macos" rel="noreferrer noopener" target="_blank">macOS</a> <a href="https://docs.openclaw.ai/cli/sessions" rel="noreferrer noopener" target="_blank">cliSessions</a> <a href="https://docs.openclaw.ai/tools/skills" rel="noreferrer noopener" target="_blank">toolSkills</a> <a href="https://docs.openclaw.ai/plugins/reference/diffs" rel="noreferrer noopener" target="_blank">pluginDocs</a> <a href="https://docs.openclaw.ai/prose" rel="noreferrer noopener" target="_blank">prose</a> <a href="https://docs.openclaw.ai/refactor/ingress-core" rel="noreferrer noopener" target="_blank">refactor</a></p>\n',
612622
);
613623
});
614624

615-
it("keeps app and resource routes instead of treating them as docs roots", () => {
625+
it("keeps app and resource routes in Mission Control docs-link mode", () => {
616626
const html = withControlUiBasePath("/control", () =>
617627
toSanitizedMarkdownHtml(
618628
"[channels](/channels) [automation](/automation) [workshop](/skills/workshop) [chat](/chat) [baseChat](/control/chat?session=abc) [baseSessions](/control/sessions) [health](/healthz) [pluginDynamic](/googlechat) [asset](/api/files/1) [baseApi](/control/api/files/1) [baseAvatar](/control/avatar/main) [plugin](/plugins/diffs/view/id/token) [basePlugin](/control/plugins/diffs/view/id/token) [artifact](/__openclaw__/canvas/documents/x/index.html) [baseArtifact](/control/__openclaw__/canvas/x)",
629+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
619630
),
620631
);
621632
expect(html).toBe(
622633
'<p><a href="/channels" rel="noreferrer noopener" target="_blank">channels</a> <a href="/automation" rel="noreferrer noopener" target="_blank">automation</a> <a href="/skills/workshop" rel="noreferrer noopener" target="_blank">workshop</a> <a href="/chat" rel="noreferrer noopener" target="_blank">chat</a> <a href="/control/chat?session=abc" rel="noreferrer noopener" target="_blank">baseChat</a> <a href="/control/sessions" rel="noreferrer noopener" target="_blank">baseSessions</a> <a href="/healthz" rel="noreferrer noopener" target="_blank">health</a> <a href="/googlechat" rel="noreferrer noopener" target="_blank">pluginDynamic</a> <a href="/api/files/1" rel="noreferrer noopener" target="_blank">asset</a> <a href="/control/api/files/1" rel="noreferrer noopener" target="_blank">baseApi</a> <a href="/control/avatar/main" rel="noreferrer noopener" target="_blank">baseAvatar</a> <a href="/plugins/diffs/view/id/token" rel="noreferrer noopener" target="_blank">plugin</a> <a href="/control/plugins/diffs/view/id/token" rel="noreferrer noopener" target="_blank">basePlugin</a> <a href="/__openclaw__/canvas/documents/x/index.html" rel="noreferrer noopener" target="_blank">artifact</a> <a href="/control/__openclaw__/canvas/x" rel="noreferrer noopener" target="_blank">baseArtifact</a></p>\n',
623634
);
624635
});
636+
637+
it("rewrites docs subpaths that share a segment with Control UI routes", () => {
638+
const html = toSanitizedMarkdownHtml(
639+
"[Channel pairing](/channels/pairing) [Cron jobs](/automation/cron-jobs)",
640+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
641+
);
642+
expect(html).toBe(
643+
'<p><a href="https://docs.openclaw.ai/channels/pairing" rel="noreferrer noopener" target="_blank">Channel pairing</a> <a href="https://docs.openclaw.ai/automation/cron-jobs" rel="noreferrer noopener" target="_blank">Cron jobs</a></p>\n',
644+
);
645+
});
646+
647+
it("caches default and docs-link rendering separately", () => {
648+
const markdown = "[Nodes](/nodes)";
649+
const local = toSanitizedMarkdownHtml(markdown);
650+
const docs = toSanitizedMarkdownHtml(markdown, OPENCLAW_DOCS_MARKDOWN_OPTIONS);
651+
const missionControl = toSanitizedMarkdownHtml(
652+
markdown,
653+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
654+
);
655+
expect(local).toBe(
656+
'<p><a href="/nodes" rel="noreferrer noopener" target="_blank">Nodes</a></p>\n',
657+
);
658+
expect(docs).toBe(
659+
'<p><a href="https://docs.openclaw.ai/nodes" rel="noreferrer noopener" target="_blank">Nodes</a></p>\n',
660+
);
661+
expect(missionControl).toBe(
662+
'<p><a href="/nodes" rel="noreferrer noopener" target="_blank">Nodes</a></p>\n',
663+
);
664+
});
665+
666+
it("preserves docs path query strings and hashes when rewriting", () => {
667+
const html = toSanitizedMarkdownHtml(
668+
"[Control UI](/web/control-ui?from=dashboard#device-pairing-first-connection)",
669+
OPENCLAW_MISSION_CONTROL_MARKDOWN_OPTIONS,
670+
);
671+
expect(html).toBe(
672+
'<p><a href="https://docs.openclaw.ai/web/control-ui?from=dashboard#device-pairing-first-connection" rel="noreferrer noopener" target="_blank">Control UI</a></p>\n',
673+
);
674+
});
625675
});
626676

627677
describe("ReDoS protection", () => {

0 commit comments

Comments
 (0)