diff --git a/apps/mobile/src/components/agents/chat-link-actions.test.ts b/apps/mobile/src/components/agents/chat-link-actions.test.ts new file mode 100644 index 0000000000..e9da2fe15d --- /dev/null +++ b/apps/mobile/src/components/agents/chat-link-actions.test.ts @@ -0,0 +1,127 @@ +import * as Clipboard from 'expo-clipboard'; +import { Share } from 'react-native'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { toast } from 'sonner-native'; + +import { openExternalUrl } from '@/lib/external-link'; + +import { + buildChatLinkActionSheet, + getSelectedChatLinkAction, + performChatLinkAction, +} from './chat-link-actions'; + +vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })); +vi.mock('react-native', () => ({ + Share: { share: vi.fn(), dismissedAction: 'dismissedAction', sharedAction: 'sharedAction' }, +})); +vi.mock('sonner-native', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); +vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() })); + +// Share.share is a jest/vitest mock function assigned via vi.mock above, not a bound instance method. +// eslint-disable-next-line typescript-eslint/unbound-method -- see above +const mockedShare = vi.mocked(Share.share); + +describe('chat link actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('orders the approved actions and marks cancel', () => { + const sheet = buildChatLinkActionSheet(); + + expect(sheet.options).toEqual(['Open link', 'Copy link', 'Share link', 'Cancel']); + expect(sheet.cancelButtonIndex).toBe(3); + expect(getSelectedChatLinkAction(sheet, 0)).toBe('open'); + expect(getSelectedChatLinkAction(sheet, 1)).toBe('copy'); + expect(getSelectedChatLinkAction(sheet, 2)).toBe('share'); + expect(getSelectedChatLinkAction(sheet, 3)).toBeNull(); + expect(getSelectedChatLinkAction(sheet, undefined)).toBeNull(); + }); + + it('opens the exact URL through the existing browser helper with retry enabled', async () => { + await performChatLinkAction('open', 'https://kilo.ai/docs?source=chat#links'); + + expect(openExternalUrl).toHaveBeenCalledWith('https://kilo.ai/docs?source=chat#links', { + label: 'link', + retryOnError: true, + }); + }); + + it('copies the exact URL and confirms success', async () => { + vi.mocked(Clipboard.setStringAsync).mockResolvedValue(true); + + await performChatLinkAction('copy', 'https://kilo.ai/docs'); + + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('https://kilo.ai/docs'); + expect(toast.success).toHaveBeenCalledWith('Link copied'); + }); + + it('retries only copying after a clipboard failure', async () => { + vi.mocked(Clipboard.setStringAsync) + .mockRejectedValueOnce(new Error('clipboard unavailable')) + .mockResolvedValueOnce(true); + + await performChatLinkAction('copy', 'https://kilo.ai/docs'); + + expect(toast.error).toHaveBeenCalledWith('Could not copy link', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + const options = vi.mocked(toast.error).mock.calls[0]?.[1]; + if (!options?.action || typeof options.action !== 'object' || !('onClick' in options.action)) { + throw new Error('Expected retry action'); + } + + options.action.onClick(); + await vi.waitFor(() => { + expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(2); + }); + expect(openExternalUrl).not.toHaveBeenCalled(); + expect(mockedShare).not.toHaveBeenCalled(); + }); + + it('treats a false clipboard result as a retryable failure', async () => { + vi.mocked(Clipboard.setStringAsync).mockResolvedValue(false); + + await performChatLinkAction('copy', 'https://kilo.ai/docs'); + + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith('Could not copy link', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + }); + + it('shares the exact URL without treating dismissal as failure', async () => { + mockedShare.mockResolvedValue({ action: Share.dismissedAction }); + + await performChatLinkAction('share', 'https://kilo.ai/docs'); + + expect(mockedShare).toHaveBeenCalledWith({ message: 'https://kilo.ai/docs' }); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('retries only sharing after a share failure', async () => { + mockedShare + .mockRejectedValueOnce(new Error('share unavailable')) + .mockResolvedValueOnce({ action: Share.sharedAction }); + + await performChatLinkAction('share', 'https://kilo.ai/docs'); + + expect(toast.error).toHaveBeenCalledWith('Could not share link', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + const options = vi.mocked(toast.error).mock.calls[0]?.[1]; + if (!options?.action || typeof options.action !== 'object' || !('onClick' in options.action)) { + throw new Error('Expected retry action'); + } + + options.action.onClick(); + await vi.waitFor(() => { + expect(mockedShare).toHaveBeenCalledTimes(2); + }); + expect(openExternalUrl).not.toHaveBeenCalled(); + expect(Clipboard.setStringAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/agents/chat-link-actions.ts b/apps/mobile/src/components/agents/chat-link-actions.ts new file mode 100644 index 0000000000..8ead408ead --- /dev/null +++ b/apps/mobile/src/components/agents/chat-link-actions.ts @@ -0,0 +1,78 @@ +import * as Clipboard from 'expo-clipboard'; +import { Share } from 'react-native'; +import { toast } from 'sonner-native'; + +import { openExternalUrl } from '@/lib/external-link'; + +type ChatLinkAction = 'open' | 'copy' | 'share'; + +type ChatLinkActionOption = + | { kind: ChatLinkAction; label: string } + | { kind: 'cancel'; label: 'Cancel' }; + +export function buildChatLinkActionSheet() { + const actions: ChatLinkActionOption[] = [ + { kind: 'open', label: 'Open link' }, + { kind: 'copy', label: 'Copy link' }, + { kind: 'share', label: 'Share link' }, + { kind: 'cancel', label: 'Cancel' }, + ]; + + return { + actions, + options: actions.map(action => action.label), + cancelButtonIndex: actions.length - 1, + }; +} + +export function getSelectedChatLinkAction( + sheet: ReturnType, + index: number | undefined +): ChatLinkAction | null { + if (index === undefined) { + return null; + } + const action = sheet.actions[index]; + return action && action.kind !== 'cancel' ? action.kind : null; +} + +function showRetryableError(message: string, retry: () => Promise) { + toast.error(message, { + action: { + label: 'Try again', + onClick: () => { + void retry(); + }, + }, + }); +} + +export async function performChatLinkAction(action: ChatLinkAction, href: string): Promise { + if (action === 'open') { + await openExternalUrl(href, { label: 'link', retryOnError: true }); + return; + } + + if (action === 'copy') { + try { + const copied = await Clipboard.setStringAsync(href); + if (!copied) { + throw new Error('Clipboard rejected link'); + } + toast.success('Link copied'); + } catch { + showRetryableError('Could not copy link', async () => { + await performChatLinkAction('copy', href); + }); + } + return; + } + + try { + await Share.share({ message: href }); + } catch { + showRetryableError('Could not share link', async () => { + await performChatLinkAction('share', href); + }); + } +} diff --git a/apps/mobile/src/components/agents/chat-markdown-text.tsx b/apps/mobile/src/components/agents/chat-markdown-text.tsx new file mode 100644 index 0000000000..1f2afbd6bc --- /dev/null +++ b/apps/mobile/src/components/agents/chat-markdown-text.tsx @@ -0,0 +1,43 @@ +import { useActionSheet } from '@expo/react-native-action-sheet'; +import { useCallback } from 'react'; +import { type GestureResponderEvent } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { + buildChatLinkActionSheet, + getSelectedChatLinkAction, + performChatLinkAction, +} from './chat-link-actions'; +import { MarkdownText, type MarkdownTextProps } from './markdown-text'; + +type ChatMarkdownTextProps = Omit; + +export function ChatMarkdownText(props: Readonly) { + const { showActionSheetWithOptions } = useActionSheet(); + const { bottom } = useSafeAreaInsets(); + + const handleLongPressLink = useCallback( + (href: string, event?: GestureResponderEvent) => { + event?.stopPropagation(); + const sheet = buildChatLinkActionSheet(); + showActionSheetWithOptions( + { + options: sheet.options, + cancelButtonIndex: sheet.cancelButtonIndex, + title: 'Link actions', + message: href, + containerStyle: { paddingBottom: bottom }, + }, + index => { + const action = getSelectedChatLinkAction(sheet, index); + if (action) { + void performChatLinkAction(action, href); + } + } + ); + }, + [bottom, showActionSheetWithOptions] + ); + + return ; +} diff --git a/apps/mobile/src/components/agents/markdown-link.test.ts b/apps/mobile/src/components/agents/markdown-link.test.ts index 2703c7a1e3..8b2dd4dbcc 100644 --- a/apps/mobile/src/components/agents/markdown-link.test.ts +++ b/apps/mobile/src/components/agents/markdown-link.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { resolveLinkAccessibilityLabel } from './markdown-link'; +import { + getLinkAccessibilityActions, + getLinkLongPressHandler, + LINK_ACCESSIBILITY_HINT, + resolveLinkAccessibilityLabel, +} from './markdown-link'; + +const onLongPressLink = () => undefined; describe('resolveLinkAccessibilityLabel', () => { it('prefers an explicit title', () => { @@ -25,3 +32,21 @@ describe('resolveLinkAccessibilityLabel', () => { expect(resolveLinkAccessibilityLabel([], 'mailto:hello@kilo.ai')).toBe('mailto:hello@kilo.ai'); }); }); + +describe('link action accessibility', () => { + it('describes the existing in-app browser behavior', () => { + expect(LINK_ACCESSIBILITY_HINT).toBe('Opens in browser'); + }); + + it('exposes link actions only when the chat callback is enabled', () => { + expect(getLinkAccessibilityActions(false)).toBeUndefined(); + expect(getLinkAccessibilityActions(true)).toEqual([ + { name: 'showLinkActions', label: 'Show link actions' }, + ]); + }); + + it('attaches a long-press handler only when chat link actions are enabled', () => { + expect(getLinkLongPressHandler(undefined, 'https://kilo.ai')).toBeUndefined(); + expect(getLinkLongPressHandler(onLongPressLink, 'https://kilo.ai')).toBeTypeOf('function'); + }); +}); diff --git a/apps/mobile/src/components/agents/markdown-link.ts b/apps/mobile/src/components/agents/markdown-link.ts index e108d6cbb2..1a1b5a9492 100644 --- a/apps/mobile/src/components/agents/markdown-link.ts +++ b/apps/mobile/src/components/agents/markdown-link.ts @@ -1,4 +1,5 @@ import { type ReactNode } from 'react'; +import { type AccessibilityActionInfo, type GestureResponderEvent } from 'react-native'; const URL_HOST_PATTERN = /^[a-z][a-z\d+.-]*:\/\/([^/?#]+)/i; @@ -20,3 +21,22 @@ export function resolveLinkAccessibilityLabel( } return getUrlHost(href) ?? href; } + +export const LINK_ACCESSIBILITY_HINT = 'Opens in browser'; + +export function getLinkAccessibilityActions( + enabled: boolean +): AccessibilityActionInfo[] | undefined { + return enabled ? [{ name: 'showLinkActions', label: 'Show link actions' }] : undefined; +} + +export function getLinkLongPressHandler( + onLongPressLink: ((href: string, event?: GestureResponderEvent) => void) | undefined, + href: string +): ((event: GestureResponderEvent) => void) | undefined { + return onLongPressLink + ? event => { + onLongPressLink(href, event); + } + : undefined; +} diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx index 643c05389e..aecafa114c 100644 --- a/apps/mobile/src/components/agents/markdown-text.tsx +++ b/apps/mobile/src/components/agents/markdown-text.tsx @@ -1,11 +1,24 @@ import { type ReactNode, useMemo } from 'react'; -import { Text, type TextStyle, useColorScheme, View, type ViewStyle } from 'react-native'; +import { + type AccessibilityActionEvent, + type GestureResponderEvent, + Text, + type TextStyle, + useColorScheme, + View, + type ViewStyle, +} from 'react-native'; import { Renderer, useMarkdown } from 'react-native-marked'; import { openExternalUrl } from '@/lib/external-link'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { resolveLinkAccessibilityLabel } from './markdown-link'; +import { + getLinkAccessibilityActions, + getLinkLongPressHandler, + LINK_ACCESSIBILITY_HINT, + resolveLinkAccessibilityLabel, +} from './markdown-link'; import { getMarkdownStyles, getPalette, @@ -14,10 +27,13 @@ import { } from './markdown-palette'; import { MarkdownTable } from './markdown-table'; -type MarkdownTextProps = { +export type MarkdownLinkLongPressHandler = (href: string, event?: GestureResponderEvent) => void; + +export type MarkdownTextProps = { value: string; variant?: MarkdownVariant; selectable?: boolean; + onLongPressLink?: MarkdownLinkLongPressHandler; }; // The library's default `Renderer` renders code blocks with the `em` text @@ -37,11 +53,17 @@ type MarkdownTextProps = { class MarkdownRenderer extends Renderer { private readonly palette: MarkdownPalette; private readonly selectable: boolean; + private readonly onLongPressLink?: MarkdownLinkLongPressHandler; - constructor(palette: MarkdownPalette, selectable = true) { + constructor( + palette: MarkdownPalette, + selectable = true, + onLongPressLink?: MarkdownLinkLongPressHandler + ) { super(); this.palette = palette; this.selectable = selectable; + this.onLongPressLink = onLongPressLink; } private textNode(children: string | ReactNode[], styles?: TextStyle): ReactNode { @@ -88,17 +110,25 @@ class MarkdownRenderer extends Renderer { styles?: TextStyle, title?: string ): ReactNode { + const accessibilityLabel = resolveLinkAccessibilityLabel(children, href, title); + const linkActionsEnabled = this.onLongPressLink !== undefined; + return ( { + if (event.nativeEvent.actionName === 'showLinkActions') { + this.onLongPressLink?.(href); + } + }} + onLongPress={getLinkLongPressHandler(this.onLongPressLink, href)} onPress={() => { - void openExternalUrl(href, { - label: resolveLinkAccessibilityLabel(children, href, title), - }); + void openExternalUrl(href, { label: accessibilityLabel }); }} style={styles} > @@ -151,6 +181,7 @@ export function MarkdownText({ value, variant = 'assistant', selectable = true, + onLongPressLink, }: Readonly) { const colorScheme = useColorScheme(); const colors = useThemeColors(); @@ -159,7 +190,7 @@ export function MarkdownText({ const palette = getPalette(variant, colors); return { styles: getMarkdownStyles(palette), - renderer: new MarkdownRenderer(palette, selectable), + renderer: new MarkdownRenderer(palette, selectable, onLongPressLink), theme: { colors: { text: palette.textColor, @@ -169,7 +200,7 @@ export function MarkdownText({ }, }, }; - }, [variant, colors, selectable]); + }, [variant, colors, selectable, onLongPressLink]); const elements = useMarkdown(value, { colorScheme, diff --git a/apps/mobile/src/components/agents/message-bubble.tsx b/apps/mobile/src/components/agents/message-bubble.tsx index 69f09eb26c..5bbf19d5a3 100644 --- a/apps/mobile/src/components/agents/message-bubble.tsx +++ b/apps/mobile/src/components/agents/message-bubble.tsx @@ -3,9 +3,9 @@ import { type AccessibilityActionEvent, Pressable, View } from 'react-native'; import { Bubble } from '@/components/ui/bubble'; +import { ChatMarkdownText } from './chat-markdown-text'; import { CompactionSeparator } from './compaction-separator'; import { FilePartRenderer } from './file-part-renderer'; -import { MarkdownText } from './markdown-text'; import { PartRenderer } from './part-renderer'; import { isFilePart, isTextPart } from './part-types'; import { useMessageCopy } from './use-message-copy'; @@ -74,7 +74,7 @@ export function MessageBubble({ > {textContent ? ( - + ) : null} {fileParts.map(part => ( diff --git a/apps/mobile/src/components/agents/text-part-renderer.tsx b/apps/mobile/src/components/agents/text-part-renderer.tsx index 94e7e3ad2d..991c70318d 100644 --- a/apps/mobile/src/components/agents/text-part-renderer.tsx +++ b/apps/mobile/src/components/agents/text-part-renderer.tsx @@ -1,4 +1,4 @@ -import { MarkdownText } from './markdown-text'; +import { ChatMarkdownText } from './chat-markdown-text'; type TextPartRendererProps = { text: string; @@ -11,5 +11,5 @@ export function TextPartRenderer({ text }: Readonly) { // selectable={false}: the message Pressable's long-press copy sheet and iOS // native text selection would both trigger on the same gesture otherwise. - return ; + return ; } diff --git a/apps/mobile/src/components/kilo-chat/message-markdown.tsx b/apps/mobile/src/components/kilo-chat/message-markdown.tsx index d3d681a63b..4c7d9795f2 100644 --- a/apps/mobile/src/components/kilo-chat/message-markdown.tsx +++ b/apps/mobile/src/components/kilo-chat/message-markdown.tsx @@ -1,6 +1,6 @@ import { Text } from '@/components/ui/text'; -import { MarkdownText } from '../agents/markdown-text'; +import { ChatMarkdownText } from '../agents/chat-markdown-text'; import { isMessageTextSelectionEnabled } from './message-presentation'; type MessageMarkdownProps = { @@ -15,7 +15,7 @@ export function MessageMarkdown({ text, isFromMe }: Readonly ({ openBrowserAsync: vi.fn() })); +vi.mock('react-native', () => ({ Linking: { openURL: vi.fn() } })); +vi.mock('sonner-native', () => ({ + toast: { error: vi.fn() }, +})); + +// Linking.openURL is a Vitest mock assigned above, not a bound instance method. +// eslint-disable-next-line typescript-eslint/unbound-method -- see above +const mockedOpenUrl = vi.mocked(Linking.openURL); + +describe('openExternalUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens the URL without feedback when the browser succeeds', async () => { + vi.mocked(WebBrowser.openBrowserAsync).mockResolvedValue(OPENED_RESULT); + + await openExternalUrl('https://kilo.ai'); + + expect(WebBrowser.openBrowserAsync).toHaveBeenCalledWith('https://kilo.ai'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('opens non-web URL schemes with the platform handler', async () => { + mockedOpenUrl.mockResolvedValue(undefined); + + await openExternalUrl('mailto:hello@kilo.ai'); + + expect(mockedOpenUrl).toHaveBeenCalledWith('mailto:hello@kilo.ai'); + expect(WebBrowser.openBrowserAsync).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('does not dispatch unsupported URL schemes to the platform', async () => { + await openExternalUrl(UNSUPPORTED_URL, { label: 'link', retryOnError: true }); + + expect(mockedOpenUrl).not.toHaveBeenCalled(); + expect(WebBrowser.openBrowserAsync).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalledWith('Could not open link', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + }); + + it('keeps the existing error toast when retry is not requested', async () => { + vi.mocked(WebBrowser.openBrowserAsync).mockRejectedValue(new Error('browser unavailable')); + + await openExternalUrl('https://kilo.ai', { label: 'Kilo' }); + + expect(toast.error).toHaveBeenCalledWith('Could not open Kilo'); + }); + + it('retries only the same URL when the retry action is pressed', async () => { + vi.mocked(WebBrowser.openBrowserAsync) + .mockRejectedValueOnce(new Error('browser unavailable')) + .mockResolvedValueOnce(OPENED_RESULT); + + await openExternalUrl('https://kilo.ai/docs', { label: 'link', retryOnError: true }); + + expect(toast.error).toHaveBeenCalledWith('Could not open link', { + action: { label: 'Try again', onClick: expect.any(Function) }, + }); + const options = vi.mocked(toast.error).mock.calls[0]?.[1]; + if (!options?.action || typeof options.action !== 'object' || !('onClick' in options.action)) { + throw new Error('Expected retry action'); + } + + options.action.onClick(); + await vi.waitFor(() => { + expect(WebBrowser.openBrowserAsync).toHaveBeenCalledTimes(2); + }); + expect(WebBrowser.openBrowserAsync).toHaveBeenLastCalledWith('https://kilo.ai/docs'); + }); +}); diff --git a/apps/mobile/src/lib/external-link.ts b/apps/mobile/src/lib/external-link.ts index 2cfb5a4a3e..ed8db289d2 100644 --- a/apps/mobile/src/lib/external-link.ts +++ b/apps/mobile/src/lib/external-link.ts @@ -1,10 +1,47 @@ import * as WebBrowser from 'expo-web-browser'; +import { Linking } from 'react-native'; import { toast } from 'sonner-native'; -export async function openExternalUrl(url: string, { label = 'link' }: { label?: string } = {}) { - try { +type ExternalLinkOptions = { + label?: string; + retryOnError?: boolean; +}; + +const WEB_URL_PATTERN = /^https?:\/\//i; +const PLATFORM_URL_PATTERN = /^(mailto|tel):/i; + +async function openUrl(url: string) { + if (WEB_URL_PATTERN.test(url)) { await WebBrowser.openBrowserAsync(url); + return; + } + if (PLATFORM_URL_PATTERN.test(url)) { + await Linking.openURL(url); + return; + } + throw new Error('Unsupported URL scheme'); +} + +export async function openExternalUrl( + url: string, + { label = 'link', retryOnError = false }: ExternalLinkOptions = {} +) { + try { + await openUrl(url); } catch { - toast.error(`Could not open ${label}`); + const message = `Could not open ${label}`; + if (!retryOnError) { + toast.error(message); + return; + } + + toast.error(message, { + action: { + label: 'Try again', + onClick: () => { + void openExternalUrl(url, { label, retryOnError: true }); + }, + }, + }); } }