diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 0a0e19207..723945409 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -3,6 +3,7 @@ export { AssistantMarkdownCodeLink, AssistantMarkdownLink, } from "./link"; +export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context"; export { classifyAssistantFileLink, normalizeInlinePathTarget, diff --git a/packages/app/src/assistant-file-links/link-press-context.ts b/packages/app/src/assistant-file-links/link-press-context.ts new file mode 100644 index 000000000..2d6ae1963 --- /dev/null +++ b/packages/app/src/assistant-file-links/link-press-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import type { TextProps } from "react-native"; + +// Carries a link's press handler down to the leaf text spans that render its +// label. On iOS an assistant link is a nested UITextView span, and +// react-native-uitextview only attaches onPress to the *string* children it +// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the +// MarkdownInheritedText spans markdown produces for link text) pass through +// untouched, so an onPress placed on the wrapping span never reaches a tappable +// native node. Threading the handler through context lets each leaf span hand +// onPress to its own string children, where the native tap recognizer can find +// it. Provided only on iOS (Android/web links tap fine via their own paths). +export interface AssistantLinkPress { + onPress: () => void; + accessibilityRole?: TextProps["accessibilityRole"]; +} + +const AssistantLinkPressContext = createContext(null); + +export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider; + +export function useAssistantLinkPress(): AssistantLinkPress | null { + return useContext(AssistantLinkPressContext); +} diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 4c19322a6..d209411ac 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; import { + Platform, Pressable, Text, View, @@ -10,6 +11,7 @@ import { import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; import { MarkdownTextSpan } from "@/components/markdown-text"; +import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useStableEvent } from "@/hooks/use-stable-event"; @@ -56,23 +58,42 @@ export function AssistantMarkdownLink({ () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); + const linkPress = useMemo( + () => ({ onPress, accessibilityRole: "link" }), + [onPress], + ); if (isNative) { // Must be a MarkdownTextSpan, not a plain : on iOS the link renders // inside the paragraph's native UITextView, and a plain nested there // is not hoisted into a UITextViewChild, so its text is silently dropped - // (the link disappears). The span composes correctly and stays selectable; - // onPress is forwarded (reliable tap-to-open on iOS is tracked by #21). + // (the link disappears). The span composes correctly and stays selectable. + // + // Tap-to-open: react-native-uitextview only wires onPress onto the *string* + // children it turns into RNUITextViewChild nodes — the element children that + // markdown emits for link text pass through untouched, so an onPress placed + // here never reaches a tappable native node. We thread it down through + // AssistantLinkPressProvider so each leaf text span re-attaches it to its + // own string children, where the native tap recognizer can find it. iOS + // only: Android forwards onPress through nested already, and web uses + // the path below. + const span = ( + + {children} + + ); return ( - - {children} - + {Platform.OS === "ios" ? ( + {span} + ) : ( + span + )} ); } diff --git a/packages/app/src/components/markdown-text.ios.tsx b/packages/app/src/components/markdown-text.ios.tsx index 8b925cebd..b280240ee 100644 --- a/packages/app/src/components/markdown-text.ios.tsx +++ b/packages/app/src/components/markdown-text.ios.tsx @@ -8,10 +8,11 @@ interface MarkdownTextSpanProps { children: ReactNode; // Links route through this span too (see assistant-file-links/link.tsx). A // plain nested in the paragraph UITextView is dropped, so the link - // must be a UITextView span to be visible. onPress is forwarded best-effort: - // react-native-uitextview nulls onPress on the root native view, so reliable - // tap-to-open is still tracked by #21 — but visible+selectable text beats an - // invisible link. + // must be a UITextView span to be visible. onPress is wired onto the leaf + // string children here: react-native-uitextview attaches it to the + // RNUITextViewChild nodes it builds from string content, which the native tap + // recognizer dispatches to. The link's handler reaches these leaf spans via + // AssistantLinkPressProvider (see assistant-file-links/link-press-context). onPress?: TextProps["onPress"]; accessibilityRole?: TextProps["accessibilityRole"]; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index ce2c738f2..4d986a41d 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -100,6 +100,7 @@ import { AssistantMarkdownLink, type InlinePathTarget, useAssistantFileLinkActions, + useAssistantLinkPress, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -1532,8 +1533,19 @@ function MarkdownInheritedText({ () => [inheritedStyles, textStyle, overrideStyle], [inheritedStyles, textStyle, overrideStyle], ); + // When this span renders link label text on iOS, pick up the link's press + // handler from context and hand it to MarkdownTextSpan, which forwards it to + // the leaf string children react-native-uitextview makes tappable. Null + // outside a link (and on every other platform, where no provider mounts), so + // ordinary text is unaffected. See assistant-file-links/link-press-context. + const linkPress = useAssistantLinkPress(); return ( - + {children} );