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
69 changes: 68 additions & 1 deletion apps/mobile/src/screens/session-transcript.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
<View>
<SessionTranscriptRow
message={{
id: "msg_links",
text: "Read https://example.test/docs, then http://localhost:4096/status.",
time: { created: 1 },
type: "user",
}}
/>
<SessionTranscriptRow
message={{
agent: "build",
content: [{ text: "See **https://assistant.test/guide**.", type: "text" }],
id: "msg_assistant_link",
model: { id: "model-1", providerID: "provider" },
time: { created: 2 },
type: "assistant",
}}
/>
</View>,
);

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(
<SessionTranscriptRow
message={{
agent: "build",
content: [{ text: "```text\nhttps://example.test/code\n```", type: "text" }],
id: "msg_code_link",
model: { id: "model-1", providerID: "provider" },
time: { created: 1 },
type: "assistant",
}}
/>,
);

expect(screen.getByText("https://example.test/code")).toBeOnTheScreen();
expect(screen.queryByRole("link")).toBeNull();
});

test("renders fenced assistant code without markdown fence markers", () => {
render(
<SessionTranscriptRow
Expand Down
120 changes: 107 additions & 13 deletions apps/mobile/src/screens/session-transcript.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter";
import { memo, useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Alert, Linking, Pressable, StyleSheet, Text, View } from "react-native";

import { applicationName } from "../application-name";
import { recordTranscriptRowCommit } from "../state/transcript-performance";
import { palette, radius, space, typeRamp } from "../theme";
import {
Expand Down Expand Up @@ -765,9 +766,7 @@ function ExpandableText({
{markdown ? (
<MarkdownText style={style} text={visibleText} />
) : (
<Text dynamicTypeRamp={typeRamp.body} selectable style={style}>
{visibleText}
</Text>
<LinkifiedText style={style} text={visibleText} />
)}
{canShowMore ? (
<Pressable
Expand Down Expand Up @@ -840,19 +839,113 @@ function InlineMarkdownText({
return (
<Text dynamicTypeRamp={typeRamp.body} selectable style={style}>
{prefix ? <Text style={prefixStyle}>{`${prefix} `}</Text> : null}
{splitBoldText(text).map((token) =>
token.bold ? (
<Text key={token.key} style={styles.boldText}>
{token.text}
</Text>
) : (
<Text key={token.key}>{token.text}</Text>
),
)}
{splitBoldText(text).map((token) => (
<LinkifiedTextContent
key={token.key}
{...(token.bold ? { style: styles.boldText } : {})}
text={token.text}
/>
))}
</Text>
);
}

function LinkifiedText({ style, text }: { style: object; text: string }) {
return (
<Text dynamicTypeRamp={typeRamp.body} selectable style={style}>
<LinkifiedTextContent text={text} />
</Text>
);
}

function LinkifiedTextContent({ style, text }: { style?: object; text: string }) {
return splitWebUrls(text).map((token) => {
const href = token.href;
return href ? (
<Text
accessibilityRole="link"
key={token.key}
onPress={() => openTranscriptUrl(href)}
style={[style, styles.linkText]}
>
{token.text}
</Text>
) : (
<Text key={token.key} style={style}>
{token.text}
</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;
Expand Down Expand Up @@ -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,
Expand Down