diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx
index aea6ced..c28c965 100644
--- a/apps/mobile/src/screens/session-transcript.test.tsx
+++ b/apps/mobile/src/screens/session-transcript.test.tsx
@@ -1,7 +1,7 @@
import { afterEach, expect, jest, test } from "@jest/globals";
import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter";
import { fireEvent, render, screen } from "@testing-library/react-native";
-import { View } from "react-native";
+import { Alert, Linking, View } from "react-native";
import { resetTranscriptPerformanceMetrics } from "../state/transcript-performance";
import { SessionTranscriptRow } from "./session-transcript";
@@ -161,6 +161,73 @@ test("reveals large text in bounded steps", () => {
expect(screen.getByText(/tail$/)).toBeOnTheScreen();
});
+test("opens HTTP and HTTPS transcript URLs as confirmed external links", () => {
+ const open = jest.spyOn(Linking, "openURL").mockResolvedValue(true);
+ const alert = jest
+ .spyOn(Alert, "alert")
+ .mockImplementation((_title, _message, buttons) =>
+ buttons?.find((button) => button.text === "Open")?.onPress?.(),
+ );
+ render(
+
+
+
+ ,
+ );
+
+ const secureLink = screen.getByRole("link", { name: "https://example.test/docs" });
+ expect(secureLink).toHaveStyle({ color: "#B6F26C", textDecorationLine: "underline" });
+ expect(screen.getByRole("link", { name: "http://localhost:4096/status" })).toBeOnTheScreen();
+ expect(screen.getByRole("link", { name: "https://assistant.test/guide" })).toHaveStyle({
+ fontWeight: "800",
+ });
+
+ fireEvent.press(secureLink);
+ expect(alert).toHaveBeenCalledWith(
+ "Open external link?",
+ expect.stringContaining("opens example.test"),
+ expect.any(Array),
+ );
+ expect(open).toHaveBeenCalledWith("https://example.test/docs");
+
+ alert.mockRestore();
+ open.mockRestore();
+});
+
+test("keeps URLs in fenced code blocks inert", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("https://example.test/code")).toBeOnTheScreen();
+ expect(screen.queryByRole("link")).toBeNull();
+});
+
test("renders fenced assistant code without markdown fence markers", () => {
render(
) : (
-
- {visibleText}
-
+
)}
{canShowMore ? (
{prefix ? {`${prefix} `} : null}
- {splitBoldText(text).map((token) =>
- token.bold ? (
-
- {token.text}
-
- ) : (
- {token.text}
- ),
- )}
+ {splitBoldText(text).map((token) => (
+
+ ))}
+
+ );
+}
+
+function LinkifiedText({ style, text }: { style: object; text: string }) {
+ return (
+
+
);
}
+function LinkifiedTextContent({ style, text }: { style?: object; text: string }) {
+ return splitWebUrls(text).map((token) => {
+ const href = token.href;
+ return href ? (
+ openTranscriptUrl(href)}
+ style={[style, styles.linkText]}
+ >
+ {token.text}
+
+ ) : (
+
+ {token.text}
+
+ );
+ });
+}
+
+function splitWebUrls(text: string) {
+ const tokens: { href?: string; key: string; text: string }[] = [];
+ const pattern = /https?:\/\/[^\s<>"']+/gi;
+ let cursor = 0;
+ let ordinal = 0;
+
+ for (const match of text.matchAll(pattern)) {
+ const start = match.index;
+ const candidate = match[0];
+ if (start > cursor) {
+ tokens.push({ key: `text:${ordinal}`, text: text.slice(cursor, start) });
+ ordinal += 1;
+ }
+
+ const { suffix, url } = trimUrlPunctuation(candidate);
+ let href: string | undefined;
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") href = parsed.toString();
+ } catch {
+ // Keep malformed URL-like text selectable without making it actionable.
+ }
+ tokens.push({ ...(href ? { href } : {}), key: `url:${ordinal}`, text: url });
+ ordinal += 1;
+ if (suffix) {
+ tokens.push({ key: `text:${ordinal}`, text: suffix });
+ ordinal += 1;
+ }
+ cursor = start + candidate.length;
+ }
+
+ if (cursor < text.length || tokens.length === 0) {
+ tokens.push({ key: `text:${ordinal}`, text: text.slice(cursor) });
+ }
+ return tokens;
+}
+
+function trimUrlPunctuation(candidate: string) {
+ let end = candidate.length;
+ while (end > 0 && /[.,!?;:]/.test(candidate[end - 1] as string)) end -= 1;
+
+ const pairs = { ")": "(", "]": "[", "}": "{" } as const;
+ while (end > 0) {
+ const closing = candidate[end - 1] as keyof typeof pairs;
+ const opening = pairs[closing];
+ if (!opening) break;
+ const value = candidate.slice(0, end);
+ if (value.split(closing).length <= value.split(opening).length) break;
+ end -= 1;
+ }
+ return { suffix: candidate.slice(end), url: candidate.slice(0, end) };
+}
+
+function openTranscriptUrl(url: string) {
+ const parsed = new URL(url);
+ Alert.alert(
+ "Open external link?",
+ `This leaves ${applicationName} and opens ${parsed.host}. The site will receive your device's network address.`,
+ [
+ { style: "cancel", text: "Cancel" },
+ {
+ onPress: () => void Linking.openURL(parsed.toString()).catch(() => undefined),
+ text: "Open",
+ },
+ ],
+ );
+}
+
function splitBoldText(text: string) {
const tokens: { bold: boolean; key: string; text: string }[] = [];
let cursor = 0;
@@ -1298,6 +1391,7 @@ const styles = StyleSheet.create({
diffActionLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" },
errorText: { color: palette.danger, fontSize: 14, lineHeight: 21 },
markdownBlockSpacing: { marginTop: space.sm },
+ linkText: { color: palette.signal, textDecorationLine: "underline" },
notice: {
borderBottomColor: palette.border,
borderBottomWidth: StyleSheet.hairlineWidth,