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,