From c3f7c8be19a96b9770acf6aa120eeaa500a0171a Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Sat, 22 Aug 2026 00:58:02 -0700 Subject: [PATCH] Let markdown tables and code blocks scroll sideways in mobile messages On the new architecture a ScrollView refuses to take over a touch while one of its ancestors is the JS responder, and a Pressable claims the responder as soon as a touch starts. Timeline messages wrap their markdown in long-press Pressables, so tables under them never scrolled sideways; CodeBlock wrapped its own ScrollView in a copy Pressable and never scrolled anywhere. Move each long-press target inside its ScrollView (a descendant responder is cancelled normally once the drag starts). Tables take the block's quote handler, or the body-level message handler as a fallback, so user and generated messages keep their long-press on tables. Code bodies carry the body padding on the inner Pressable so gutter drags scroll too. Share one LONG_PRESS_DELAY_MS across nested targets. The work-rows showcase ends with a wide table; the phase4a-work-rows e2e flow swipes it, asserts the hidden column appears, and long-presses it. Co-Authored-By: Claude --- apps/mobile/e2e/flows/phase4a-work-rows.yaml | 37 ++++++++++ apps/mobile/src/composer/Composer.tsx | 3 +- apps/mobile/src/markdown/CodeBlock.tsx | 70 +++++++++++-------- apps/mobile/src/markdown/Markdown.tsx | 2 + apps/mobile/src/markdown/MarkdownContext.tsx | 10 +++ apps/mobile/src/markdown/MarkdownTable.tsx | 28 +++++++- apps/mobile/src/markdown/render-blocks.tsx | 31 ++++++-- .../src/screens/dev/work-row-fixtures.ts | 12 +++- apps/mobile/src/screens/files/FilePathRow.tsx | 4 +- .../src/screens/sidebar/SidebarRows.tsx | 6 +- .../conversation/AssistantMessageRow.tsx | 5 +- .../conversation/AuthoredUserMessage.tsx | 5 +- .../conversation/GeneratedMessageRow.tsx | 1 + apps/mobile/src/ui/index.ts | 1 + apps/mobile/src/ui/long-press.ts | 6 ++ 15 files changed, 170 insertions(+), 51 deletions(-) create mode 100644 apps/mobile/src/ui/long-press.ts diff --git a/apps/mobile/e2e/flows/phase4a-work-rows.yaml b/apps/mobile/e2e/flows/phase4a-work-rows.yaml index 2ac20d0420..14891e2609 100644 --- a/apps/mobile/e2e/flows/phase4a-work-rows.yaml +++ b/apps/mobile/e2e/flows/phase4a-work-rows.yaml @@ -219,3 +219,40 @@ env: - assertVisible: id: "timeline-workflow-usage" - takeScreenshot: phase4a-work-rows-workflow-failed +# The closing assistant message carries a table wider than the screen. It +# must scroll sideways under the message's long-press Pressables (new +# architecture: a ScrollView will not scroll while an ancestor is the JS +# responder), and a long-press on it must still open the message actions. +- scrollUntilVisible: + element: + text: "(?s).*Summary of the changes.*" + direction: DOWN + timeout: 30000 + speed: 40 +# The table is the last content on the page: bottom the list out so the +# whole table is on screen on any device height. (Cells inside the nested +# horizontal ScrollView report as visible before they are, so they are not +# reliable scroll targets.) +- scroll +- scroll +- scroll +- assertNotVisible: "(?s).*scroll sideways inside the timeline.*" +- swipe: + from: + text: ".*horizontal scroll.*" + direction: LEFT + duration: 600 +- extendedWaitUntil: + visible: "(?s).*scroll sideways inside the timeline.*" + timeout: 10000 +- takeScreenshot: phase4a-work-rows-table-scrolled +# The Reviewer column is fully on screen after the swipe; long-press lands +# inside the table regardless of how far the fling carried it. +- longPressOn: "sawyer" +- extendedWaitUntil: + visible: "Copy text" + timeout: 10000 +- tapOn: "Copy text" +- extendedWaitUntil: + visible: "Copied" + timeout: 10000 diff --git a/apps/mobile/src/composer/Composer.tsx b/apps/mobile/src/composer/Composer.tsx index fd40ef065b..9a6531c9e0 100644 --- a/apps/mobile/src/composer/Composer.tsx +++ b/apps/mobile/src/composer/Composer.tsx @@ -24,6 +24,7 @@ import { ActionSheet, Button, Icon, + LONG_PRESS_DELAY_MS, SheetPresenceContext, Spinner, useOverlayBounds, @@ -625,7 +626,7 @@ export const Composer = forwardRef( ? () => submit("steer") : undefined } - delayLongPress={350} + delayLongPress={LONG_PRESS_DELAY_MS} testID={`${testID}-submit`} style={({ pressed }) => ({ width: 36, diff --git a/apps/mobile/src/markdown/CodeBlock.tsx b/apps/mobile/src/markdown/CodeBlock.tsx index 47c5029d8d..c59902a39d 100644 --- a/apps/mobile/src/markdown/CodeBlock.tsx +++ b/apps/mobile/src/markdown/CodeBlock.tsx @@ -4,6 +4,7 @@ import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { FONT_FAMILIES } from "@/theme/fonts"; import { nativeTypography } from "@/theme/theme.native"; import { Icon } from "@/ui/Icon"; +import { LONG_PRESS_DELAY_MS } from "@/ui/long-press"; import { toast } from "@/ui/Toast"; import { codeTokenColor, @@ -105,38 +106,47 @@ export const CodeBlock = memo(function CodeBlock({ horizontal showsHorizontalScrollIndicator={false} nestedScrollEnabled - contentContainerStyle={{ - paddingHorizontal: 12, - paddingBottom: 10, - paddingTop: 2, - }} > - - {lines.map((line, lineIndex) => ( - - {line.map((span, spanIndex) => ( - - {span.text} - - ))} - {lineIndex < lines.length - 1 ? "\n" : null} - - ))} - + + {lines.map((line, lineIndex) => ( + + {line.map((span, spanIndex) => ( + + {span.text} + + ))} + {lineIndex < lines.length - 1 ? "\n" : null} + + ))} + + ); diff --git a/apps/mobile/src/markdown/Markdown.tsx b/apps/mobile/src/markdown/Markdown.tsx index b437ac9599..b26b35a08e 100644 --- a/apps/mobile/src/markdown/Markdown.tsx +++ b/apps/mobile/src/markdown/Markdown.tsx @@ -174,6 +174,7 @@ function MarkdownComponent({ onThreadPress, onMentionPress, onBlockLongPress, + onLongPress, renderDirective, resolveImageSource, }: MarkdownProps) { @@ -232,6 +233,7 @@ function MarkdownComponent({ onThreadPress, onMentionPress, onBlockLongPress, + onLongPress, renderDirective, resolveImageSource, }); diff --git a/apps/mobile/src/markdown/MarkdownContext.tsx b/apps/mobile/src/markdown/MarkdownContext.tsx index 0e7dcdc0cb..6e555165e5 100644 --- a/apps/mobile/src/markdown/MarkdownContext.tsx +++ b/apps/mobile/src/markdown/MarkdownContext.tsx @@ -73,6 +73,13 @@ export interface MarkdownCallbacks { * blocks are memoized on the context. */ onBlockLongPress?: (block: MarkdownBlockPress) => void; + /** + * Long-pressed a block that owns its touch target (a table: its target + * sits inside the horizontal ScrollView, so a Pressable around the whole + * body never sees the press) and that has no `onBlockLongPress`. Pass the + * body's message-level long-press here so such blocks are not dead zones. + */ + onLongPress?: () => void; /** * Directive cards. Return a node to render a card, or null to fall back to * the literal directive source. @@ -140,6 +147,7 @@ export function useMarkdownContextValue( onThreadPress, onMentionPress, onBlockLongPress, + onLongPress, renderDirective, resolveImageSource, } = inputs; @@ -160,6 +168,7 @@ export function useMarkdownContextValue( onThreadPress, onMentionPress, onBlockLongPress, + onLongPress, renderDirective, resolveImageSource, }), @@ -179,6 +188,7 @@ export function useMarkdownContextValue( onThreadPress, onMentionPress, onBlockLongPress, + onLongPress, renderDirective, resolveImageSource, ], diff --git a/apps/mobile/src/markdown/MarkdownTable.tsx b/apps/mobile/src/markdown/MarkdownTable.tsx index f4feb6960c..2a1c4f42c3 100644 --- a/apps/mobile/src/markdown/MarkdownTable.tsx +++ b/apps/mobile/src/markdown/MarkdownTable.tsx @@ -1,9 +1,10 @@ import type { AlignType, Table, TableCell } from "mdast"; import { toString as mdastToString } from "mdast-util-to-string"; import { memo, useMemo } from "react"; -import { ScrollView, Text as RNText, View } from "react-native"; +import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { FONT_FAMILIES } from "@/theme/fonts"; import { nativeTypography } from "@/theme/theme.native"; +import { LONG_PRESS_DELAY_MS } from "@/ui/long-press"; import { buildTableModel } from "./blocks"; import { useMarkdownContext } from "./MarkdownContext"; import { renderInline } from "./render-inline"; @@ -59,8 +60,14 @@ function textAlign(align: AlignType): "left" | "center" | "right" { export const MarkdownTable = memo(function MarkdownTable({ table, + onLongPress, }: { table: Table; + /** + * Long-press on the table body: the quote-this-block shortcut, or the + * body-level message actions when no block handler exists. + */ + onLongPress?: () => void; }) { const ctx = useMarkdownContext(); const { tokens } = ctx; @@ -118,7 +125,22 @@ export const MarkdownTable = memo(function MarkdownTable({ showsHorizontalScrollIndicator={false} nestedScrollEnabled > - ))} - + ); }); diff --git a/apps/mobile/src/markdown/render-blocks.tsx b/apps/mobile/src/markdown/render-blocks.tsx index 6b63be0883..a7e0a03d59 100644 --- a/apps/mobile/src/markdown/render-blocks.tsx +++ b/apps/mobile/src/markdown/render-blocks.tsx @@ -5,6 +5,7 @@ import { Pressable, Text as RNText, View, type TextStyle } from "react-native"; import { FONT_FAMILIES, FONT_WEIGHT_VALUES } from "@/theme/fonts"; import { nativeTypography } from "@/theme/theme.native"; import { Icon } from "@/ui/Icon"; +import { LONG_PRESS_DELAY_MS } from "@/ui/long-press"; import { getNodeSource, splitParagraphSegments } from "./blocks"; import { CodeBlock } from "./CodeBlock"; import type { MarkdownContextValue } from "./MarkdownContext"; @@ -409,8 +410,6 @@ function renderBlockContent( return ; case "math": return ; - case "table": - return ; case "html": return ( onBlockLongPress({ source }) + : undefined; + const style = blockMargins(node, isFirst, isLast, options); + if (node.type === "table") { + // A table owns its long-press target (inside its ScrollView; see + // MarkdownTable), so it gets no Pressable wrapper. Without a block + // handler it falls back to the body-level one. Code blocks also own + // their target, but theirs copies the code instead of quoting it. + return ( + + + + ); + } const rendered = renderBlockContent(node, ctx, content, options, keyPrefix); if (rendered === null) { return null; } - const style = blockMargins(node, isFirst, isLast, options); - const onBlockLongPress = ctx.onBlockLongPress; - if (onBlockLongPress !== undefined && source !== null) { + if (onLongPress !== undefined) { // Not an accessibility element of its own: the text inside stays the // screen-reader target; long-press is a shortcut to quote this block. return ( onBlockLongPress({ source })} - delayLongPress={350} + onLongPress={onLongPress} + delayLongPress={LONG_PRESS_DELAY_MS} style={style} > {rendered} diff --git a/apps/mobile/src/screens/dev/work-row-fixtures.ts b/apps/mobile/src/screens/dev/work-row-fixtures.ts index 58481f90c5..c42a8774f2 100644 --- a/apps/mobile/src/screens/dev/work-row-fixtures.ts +++ b/apps/mobile/src/screens/dev/work-row-fixtures.ts @@ -808,7 +808,17 @@ export function buildWorkRowFixtureSections(): WorkRowFixtureSection[] { taskStatus: "stopped", completedAt: T0 + 9_000, }), - assistant("a-done", "All done."), + assistant( + "a-done", + [ + "All done. Summary of the changes:", + "", + "| File | Change | Lines | Reviewer | Notes |", + "| --- | --- | ---: | --- | --- |", + "| src/markdown/MarkdownTable.tsx | horizontal scroll | 12 | sawyer | wide tables scroll sideways inside the timeline |", + "| src/markdown/CodeBlock.tsx | inner press target | 8 | bee | code bodies scroll sideways too |", + ].join("\n"), + ), ], }, ]; diff --git a/apps/mobile/src/screens/files/FilePathRow.tsx b/apps/mobile/src/screens/files/FilePathRow.tsx index 3a0f7ee873..6a4eb34ed2 100644 --- a/apps/mobile/src/screens/files/FilePathRow.tsx +++ b/apps/mobile/src/screens/files/FilePathRow.tsx @@ -2,7 +2,7 @@ import { memo } from "react"; import { Pressable, View } from "react-native"; import { buildHighlightSegments, splitPathForRow } from "@/data/files"; import { useTheme } from "@/theme"; -import { cn, Icon, Text, type IconName } from "@/ui"; +import { cn, Icon, LONG_PRESS_DELAY_MS, Text, type IconName } from "@/ui"; interface FilePathRowProps { /** Root-relative (or absolute) path shown split into name + directory. */ @@ -53,7 +53,7 @@ export const FilePathRow = memo(function FilePathRow({ accessibilityLabel={path} onPress={onPress} onLongPress={onLongPress} - delayLongPress={350} + delayLongPress={LONG_PRESS_DELAY_MS} testID={testID} className="min-h-[44px] flex-row items-center gap-3 px-4 py-2 active:bg-state-hover" > diff --git a/apps/mobile/src/screens/sidebar/SidebarRows.tsx b/apps/mobile/src/screens/sidebar/SidebarRows.tsx index f19362afcd..4740534113 100644 --- a/apps/mobile/src/screens/sidebar/SidebarRows.tsx +++ b/apps/mobile/src/screens/sidebar/SidebarRows.tsx @@ -3,7 +3,7 @@ import { memo } from "react"; import { Pressable, View } from "react-native"; import { getThreadDisplayTitle } from "@/data/threads"; import { useTheme } from "@/theme"; -import { Icon, Text, cn } from "@/ui"; +import { Icon, LONG_PRESS_DELAY_MS, Text, cn } from "@/ui"; import { getCollapsedActivityIndicatorState, type SidebarEmptyRow, @@ -113,7 +113,7 @@ export const SidebarThreadRowView = memo(function SidebarThreadRowView({ accessibilityHint={subtitleText(subtitle)} onPress={() => onPress(row)} onLongPress={() => onLongPress(row)} - delayLongPress={350} + delayLongPress={LONG_PRESS_DELAY_MS} className="flex-row items-center gap-1 active:bg-state-hover" style={{ minHeight: ROW_MIN_HEIGHT, @@ -219,7 +219,7 @@ export const SidebarHeaderRowView = memo(function SidebarHeaderRowView({ accessibilityState={{ expanded: !row.collapsed }} onPress={() => onToggleCollapsed(row)} onLongPress={() => onLongPress(row)} - delayLongPress={350} + delayLongPress={LONG_PRESS_DELAY_MS} className="flex-row items-center gap-1 active:bg-state-hover" style={{ minHeight: HEADER_MIN_HEIGHT, diff --git a/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx b/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx index 84800e27d2..8ce82549f7 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx +++ b/apps/mobile/src/screens/thread/timeline/renderers/conversation/AssistantMessageRow.tsx @@ -8,7 +8,7 @@ import { type MarkdownBlockPress, type MarkdownThreadMentions, } from "@/markdown"; -import { Text } from "@/ui"; +import { LONG_PRESS_DELAY_MS, Text } from "@/ui"; import type { TimelineMessageActionsTarget } from "../../../actions/message-actions-model"; import { useTimelineRowHost } from "../../host/TimelineRowHostProvider"; import { TimelineRowShell } from "../shared/ExpandableRowHeader"; @@ -107,7 +107,7 @@ export function AssistantMessageRow({ @@ -122,6 +122,7 @@ export function AssistantMessageRow({ onFilePress={onFilePress} onLinkPress={onLinkPress} onBlockLongPress={onBlockLongPress} + onLongPress={onLongPress} resolveImageSource={resolveImageSource} /> ) : attachmentItems.imageItems.length === 0 && diff --git a/apps/mobile/src/screens/thread/timeline/renderers/conversation/AuthoredUserMessage.tsx b/apps/mobile/src/screens/thread/timeline/renderers/conversation/AuthoredUserMessage.tsx index bc78990dff..6f9e8ad6ca 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/conversation/AuthoredUserMessage.tsx +++ b/apps/mobile/src/screens/thread/timeline/renderers/conversation/AuthoredUserMessage.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo } from "react"; import { Pressable, View, type LayoutChangeEvent } from "react-native"; import { Markdown, type MarkdownThreadMentions } from "@/markdown"; import { nativeTypography } from "@/theme"; -import { Text } from "@/ui"; +import { LONG_PRESS_DELAY_MS, Text } from "@/ui"; import { TIMELINE_ROW_HORIZONTAL_PADDING_PX, timelineRowLeftPadding, @@ -135,7 +135,7 @@ export function AuthoredUserMessage({ // reachable by screen readers and UI tests; long-press is a shortcut. accessible={false} onLongPress={onLongPress} - delayLongPress={350} + delayLongPress={LONG_PRESS_DELAY_MS} className="max-w-full rounded-xl border border-border-seam bg-surface-recessed px-3.5 py-2.5 active:opacity-90" testID="conversation-user-bubble" > @@ -162,6 +162,7 @@ export function AuthoredUserMessage({ serverHostname={serverHostname} onThreadPress={onThreadPress} onFilePress={onFilePress} + onLongPress={onLongPress} /> ) : ( {body.content} diff --git a/apps/mobile/src/screens/thread/timeline/renderers/conversation/GeneratedMessageRow.tsx b/apps/mobile/src/screens/thread/timeline/renderers/conversation/GeneratedMessageRow.tsx index df46df7be5..e1875ef61d 100644 --- a/apps/mobile/src/screens/thread/timeline/renderers/conversation/GeneratedMessageRow.tsx +++ b/apps/mobile/src/screens/thread/timeline/renderers/conversation/GeneratedMessageRow.tsx @@ -313,6 +313,7 @@ export function GeneratedMessageRow({ onImagePress={onImagePress} onFilePress={onFilePress} onLinkPress={onLinkPress} + onLongPress={onLongPress} resolveImageSource={ suppressImages ? resolveImageSource : undefined } diff --git a/apps/mobile/src/ui/index.ts b/apps/mobile/src/ui/index.ts index b28b554e6d..da976391cb 100644 --- a/apps/mobile/src/ui/index.ts +++ b/apps/mobile/src/ui/index.ts @@ -10,6 +10,7 @@ export { KeyboardPaddingView, } from "./KeyboardPaddingView"; export { ListRow } from "./ListRow"; +export { LONG_PRESS_DELAY_MS } from "./long-press"; export { OverlayBounds, useOverlayBounds } from "./OverlayBounds"; export { Pill } from "./Pill"; export { Separator } from "./Separator"; diff --git a/apps/mobile/src/ui/long-press.ts b/apps/mobile/src/ui/long-press.ts new file mode 100644 index 0000000000..edd8d36a6f --- /dev/null +++ b/apps/mobile/src/ui/long-press.ts @@ -0,0 +1,6 @@ +/** + * Long-press delay shared by every long-press shortcut (message actions, + * quote-this-block, sidebar rows, composer chips). One value, so nested + * targets (a block inside a message) never race each other. + */ +export const LONG_PRESS_DELAY_MS = 350;