Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@
"expo-sharing": "~14.0.8",
"expo-status-bar": "~3.0.0",
"lucide-react-native": "^0.468.0",
"markdown-it": "14.1.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-blob-util": "^0.22.2",
"react-native-get-random-values": "~1.11.0",
"react-native-markdown-renderer": "4.1.1",
"react-native-pdf": "^7.0.3",
"react-native-safe-area-context": "~5.6.0",
"react-native-svg": "15.12.1",
Expand All @@ -38,6 +40,7 @@
"devDependencies": {
"@config-plugins/react-native-blob-util": "^12.0.0",
"@config-plugins/react-native-pdf": "^12.0.0",
"@types/markdown-it": "14.1.2",
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
}
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/assistant-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import MarkdownIt from "markdown-it";

export const assistantMarkdown = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
typographer: false,
});

export function isSafeAssistantUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && !!url.hostname;
} catch {
return false;
}
}
63 changes: 63 additions & 0 deletions apps/mobile/src/assistant-response.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useCallback, useState } from "react";
import { Linking, Text, type TextStyle } from "react-native";
import Markdown, { type MarkdownStyles, type RenderRules } from "react-native-markdown-renderer";
import { assistantMarkdown, isSafeAssistantUrl } from "./assistant-markdown";
import { colors, ErrorNotice } from "./ui";

const textStyle = { color: colors.text, fontSize: 16, lineHeight: 24 };
const style: Partial<MarkdownStyles> = {
text: textStyle,
paragraph: { marginTop: 0, marginBottom: 6 },
list: { marginBottom: 6 },
headingContainer: { marginTop: 8, marginBottom: 4 },
heading1: { fontSize: 21, lineHeight: 27 },
heading2: { fontSize: 19, lineHeight: 25 },
heading3: { fontSize: 17, lineHeight: 23 },
link: { color: colors.blueDark, textDecorationLine: "underline" },
codeInline: { backgroundColor: "#E2E4E7", color: colors.text },
codeBlock: { backgroundColor: "#E2E4E7", color: colors.text },
};
const renderCodeBlock: RenderRules["fence"] = (node, _children, _parent, styles) => (
<Text key={node.key} selectable style={styles.codeBlock as TextStyle}>
{node.content.replace(/\n$/, "")}
</Text>
);
const rules: RenderRules = {
textgroup: (node, children) => (
<Text key={node.key} selectable style={textStyle}>
{children}
</Text>
),
image: (node) => (
<Text key={node.key} selectable style={{ color: colors.muted }}>
{node.attributes.alt ? `[Image: ${node.attributes.alt}]` : "[Image]"}
</Text>
),
code_block: renderCodeBlock,
fence: renderCodeBlock,
};

export function AssistantResponse({ content }: { content: string }) {
const [linkError, setLinkError] = useState("");
const onLinkPress = useCallback((url: string) => {
if (!isSafeAssistantUrl(url)) return false;
setLinkError("");
void Linking.openURL(url).catch((error) =>
setLinkError(error instanceof Error ? error.message : String(error)),
);
return false;
}, []);
return (
<>
<Markdown
markdownit={assistantMarkdown}
style={style}
rules={rules}
onLinkPress={onLinkPress}
>
{content}
</Markdown>
<ErrorNotice error={linkError} />
</>
);
}
11 changes: 8 additions & 3 deletions apps/mobile/src/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { z } from "zod";
import { ArtifactCard } from "./agent-ui";
import { useAgentWorkspace } from "./agent-workspace";
import { AssistantResponse } from "./assistant-response";
import { BackgroundUpdates } from "./background-updates";
import { BrowserRunContext, BrowserToolCard } from "./browser-tool-card";
import { BrowserThreadCard } from "./computer";
Expand Down Expand Up @@ -434,9 +435,13 @@ export function ChatScreen({
backgroundColor: user ? colors.blue : "#EEEEF0",
}}
>
<Text selectable style={[s.text, { fontSize: 16, lineHeight: 24 }]}>
{text}
</Text>
{user ? (
<Text selectable style={[s.text, { fontSize: 16, lineHeight: 24 }]}>
{text}
</Text>
) : (
<AssistantResponse content={text} />
)}
</View>
)}
<BrowserRunContext
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/test/assistant-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { assistantMarkdown, isSafeAssistantUrl } from "../src/assistant-markdown.ts";

test("assistant Markdown structures the reported response", () => {
const html = assistantMarkdown.render(
"**Short answer:** Use `SKILL.md`.\n\n- First check\n- Second check\n\n**Source URL:** https://docs.copilotkit.ai/learning",
);
assert.match(html, /<strong>Short answer:<\/strong>/);
assert.match(html, /<code>SKILL\.md<\/code>/);
assert.match(html, /<li>/);
assert.match(html, /href="https:\/\/docs\.copilotkit\.ai\/learning"/);
assert.doesNotMatch(html, /\*\*Short answer/);
});

test("incomplete Markdown and raw HTML stay harmless", () => {
assert.doesNotThrow(() => assistantMarkdown.render("**Still streaming"));
assert.match(assistantMarkdown.render("<script>alert(1)</script>"), /&lt;script&gt;/);
});

test("only absolute HTTP(S) links can open", () => {
assert.equal(isSafeAssistantUrl("https://docs.copilotkit.ai/learning"), true);
assert.equal(isSafeAssistantUrl("http://example.com"), true);
for (const url of ["javascript:alert(1)", "file:///tmp/private", "//example.com", "not a URL"]) {
assert.equal(isSafeAssistantUrl(url), false);
}
});
75 changes: 75 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading