diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index d3308c411..fc413ca34 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -129,6 +129,12 @@ const styles = StyleSheet.create((theme) => { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, + ...(Platform.OS === "web" + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), }, headerLine: { backgroundColor: theme.colors.surface1, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4105cfa1d..af8eb0fdc 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -117,6 +117,8 @@ const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = ` } `; let webToolCallShimmerRegistered = false; +const SCROLL_EDGE_EPSILON = 0.5; +type ScrollAxis = "x" | "y"; function ensureWebToolCallShimmerKeyframes() { if (Platform.OS !== "web") { @@ -143,6 +145,113 @@ function ensureWebToolCallShimmerKeyframes() { webToolCallShimmerRegistered = true; } +function getWheelEventElementTarget( + event: WheelEvent, + fallback: HTMLElement +): HTMLElement { + const { target } = event; + if (target instanceof HTMLElement) { + return target; + } + if (target instanceof Node && target.parentElement) { + return target.parentElement; + } + return fallback; +} + +function canElementScrollInDirection( + element: HTMLElement, + axis: ScrollAxis, + delta: number +): boolean { + if (delta === 0) { + return false; + } + + const computedStyle = window.getComputedStyle(element); + const overflow = axis === "x" ? computedStyle.overflowX : computedStyle.overflowY; + const isScrollableOverflow = + overflow === "auto" || overflow === "scroll" || overflow === "overlay"; + if (!isScrollableOverflow) { + return false; + } + + const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop; + const scrollSize = + axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight; + if (scrollSize <= SCROLL_EDGE_EPSILON) { + return false; + } + + if (delta > 0) { + return scrollPosition < scrollSize - SCROLL_EDGE_EPSILON; + } + return scrollPosition > SCROLL_EDGE_EPSILON; +} + +function canScrollInsideDetailFromTarget( + detailRoot: HTMLElement, + startElement: HTMLElement, + axis: ScrollAxis, + delta: number +): boolean { + if (delta === 0) { + return false; + } + + let current: HTMLElement | null = startElement; + while (current) { + if (canElementScrollInDirection(current, axis, delta)) { + return true; + } + if (current === detailRoot) { + break; + } + current = current.parentElement; + } + return false; +} + +function shouldStopDetailWheelPropagation( + detailRoot: HTMLElement, + event: WheelEvent +): boolean { + const startElement = getWheelEventElementTarget(event, detailRoot); + const verticalDelta = event.deltaY; + const horizontalDelta = + event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0); + + const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON; + const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON; + if (!hasVerticalIntent && !hasHorizontalIntent) { + return false; + } + + const canScrollVertically = hasVerticalIntent + ? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta) + : false; + const canScrollHorizontally = hasHorizontalIntent + ? canScrollInsideDetailFromTarget( + detailRoot, + startElement, + "x", + horizontalDelta + ) + : false; + + if (hasVerticalIntent && hasHorizontalIntent) { + const isVerticalDominant = Math.abs(verticalDelta) >= Math.abs(horizontalDelta); + return isVerticalDominant + ? canScrollVertically || canScrollHorizontally + : canScrollHorizontally || canScrollVertically; + } + + if (hasVerticalIntent) { + return canScrollVertically; + } + return canScrollHorizontally; +} + const userMessageStylesheet = StyleSheet.create((theme) => ({ container: { flexDirection: "row", @@ -1121,6 +1230,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ const hasDetailContent = Boolean(renderDetails); const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null; + const detailWrapperRef = useRef(null); const nativeGradientIdRef = useRef( `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}` @@ -1240,6 +1350,28 @@ const ExpandableBadge = memo(function ExpandableBadge({ shimmerTranslateX, ]); + useEffect(() => { + if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { + return; + } + + const node = detailWrapperRef.current as unknown as HTMLElement | null; + if (!node || typeof node.addEventListener !== "function") { + return; + } + + const stopWheelPropagation = (event: WheelEvent) => { + if (shouldStopDetailWheelPropagation(node, event)) { + event.stopPropagation(); + } + }; + + node.addEventListener("wheel", stopWheelPropagation, { passive: true }); + return () => { + node.removeEventListener("wheel", stopWheelPropagation); + }; + }, [isExpanded, hasDetailContent]); + const nativeShimmerPeakStyle = useAnimatedStyle(() => ({ transform: [{ translateX: shimmerTranslateX.value }], })); @@ -1476,6 +1608,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ {detailContent ? ( onDetailHoverChange?.(true)} onHoverOut={() => onDetailHoverChange?.(false)} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 0482e1ef3..49a1a7a37 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -393,6 +393,12 @@ const styles = StyleSheet.create((theme) => { fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, + ...(Platform.OS === "web" + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), }, shellPrompt: { color: theme.colors.foregroundMuted,