Root-caused directory-picker's directory-row-frontend failure: all four
in-app @gorhom/bottom-sheet sheets (DirectoryBrowserSheet, DirectorySwitcher,
ModelPicker, VariantPicker) provide static percentage snapPoints but rely on
v5's enableDynamicSizing default (true), which never resolves without content
wrapped in a size-reporting component — so useAnimatedDetents() permanently
early-exits and the sheets can never actually open. Set
enableDynamicSizing={false} on all four (they already have explicit
snapPoints, so dynamic sizing was never needed).
variant-picker's chip failure was a stale test assumption: src/lib/
model-selection.ts's chooseModelSelection() deliberately returns null for a
fresh session (issue #37/#35 — the provider registry default is unreliable),
so a brand-new session has no model selected and the reasoning-effort chip
has nothing to key off of. Added testIDs (model-chip, model-option-*) and
updated the flow to explicitly pick a model first, matching real usage.
diff-scroll's missing markdown text: switched src/components/markdown/
Markdown.tsx from react-native-marked's FlatList-based default export to its
useMarkdown() hook rendered into a plain View. The chat screen already nests
this inside its own *inverted* FlatList (one row per message) — a nested
VirtualizedList inside an inverted outer list is a known RN footgun where the
inner content can render at zero height instead of just warning. We already
forced scrollEnabled:false + a large initialNumToRender, defeating
virtualization anyway, so rendering the parsed blocks directly loses nothing.
Extended the existing react-native-marked .d.ts shim (added for a React
18/19 ReactNode mismatch) to also declare useMarkdown/useMarkdownHookOptions.
Added diagnostic screenshots to directory-picker.yaml and diff-scroll.yaml
at the previously-failing steps for faster triage if these regress again.
Closes #104.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -80,8 +80,17 @@ name: "DiffView + CodeBlock horizontal scroll - wide diff/code line reachable by
|
|||||||
# render both the tool card and the markdown code block.
|
# render both the tool card and the markdown code block.
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
text: "Edit wide_diff_target.ts"
|
text: "Edit wide_diff_target.ts"
|
||||||
- assertVisible:
|
# Diagnostic screenshot (issue #104): the tool-card title above renders via
|
||||||
|
# plain Text, while the paragraph below renders via react-native-marked's
|
||||||
|
# nested FlatList inside this screen's own *inverted* FlatList. If this ever
|
||||||
|
# regresses again, check whether this frame shows blank space between the
|
||||||
|
# message header and the tool card (nested-FlatList-in-inverted-list content
|
||||||
|
# not laying out) vs. genuinely missing/incorrect text.
|
||||||
|
- takeScreenshot: diffscroll-S1b_before_markdown_text_check
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
text: "Here is a wide code sample"
|
text: "Here is a wide code sample"
|
||||||
|
timeout: 20000
|
||||||
- takeScreenshot: diffscroll-S2_history_loaded
|
- takeScreenshot: diffscroll-S2_history_loaded
|
||||||
|
|
||||||
# --- CodeBlock: the wide fenced code block is already expanded (no tap
|
# --- CodeBlock: the wide fenced code block is already expanded (no tap
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ name: Directory picker - browse server folders and create a session in one
|
|||||||
id: "browse-folders-button"
|
id: "browse-folders-button"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
text: "Browse Folders"
|
text: "Browse Folders"
|
||||||
|
# Diagnostic screenshot before the (previously flaky, see issue #104) wait
|
||||||
|
# below — if this ever regresses again, compare this frame against
|
||||||
|
# dirpicker-S2 to see whether the sheet actually opened.
|
||||||
|
- takeScreenshot: dirpicker-S2b_browser_sheet_opened
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible:
|
visible:
|
||||||
id: "directory-row-frontend"
|
id: "directory-row-frontend"
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ name: Variant picker - reasoning-effort chip renders, selects, and still sends
|
|||||||
# on the runner host BEFORE this flow runs (same fresh, non-seeded normal-mode
|
# on the runner host BEFORE this flow runs (same fresh, non-seeded normal-mode
|
||||||
# server used by directory-picker.yaml — GET /provider's mock-model carries
|
# server used by directory-picker.yaml — GET /provider's mock-model carries
|
||||||
# low/medium/high variants; see tests/fixtures/mock-opencode-server.ts).
|
# low/medium/high variants; see tests/fixtures/mock-opencode-server.ts).
|
||||||
|
#
|
||||||
|
# A brand-new session has NO explicit model selection: src/lib/model-selection.ts
|
||||||
|
# chooseModelSelection() deliberately returns null until the user picks one
|
||||||
|
# (issue #37/#35 — the provider registry's "default" model is unreliable), so
|
||||||
|
# app/session/[id].tsx's currentModelVariants is undefined and the chip does
|
||||||
|
# NOT render on session open. This flow explicitly opens the model picker and
|
||||||
|
# selects the mock provider's model first, which is what actually makes
|
||||||
|
# variants available — matching how a real user reaches this chip.
|
||||||
|
|
||||||
- launchApp:
|
- launchApp:
|
||||||
clearState: true
|
clearState: true
|
||||||
@@ -47,6 +55,18 @@ name: Variant picker - reasoning-effort chip renders, selects, and still sends
|
|||||||
id: "chat-message-input"
|
id: "chat-message-input"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|
||||||
|
# Explicitly pick the mock provider's model — a fresh session has no model
|
||||||
|
# selection yet (see comment above), so the variant chip has nothing to key
|
||||||
|
# off of until this happens.
|
||||||
|
- tapOn:
|
||||||
|
id: "model-chip"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "model-option-mock-mock-model"
|
||||||
|
timeout: 10000
|
||||||
|
- tapOn:
|
||||||
|
id: "model-option-mock-mock-model"
|
||||||
|
|
||||||
# The chip only appears once catalog.load() (triggered on connect, see
|
# The chip only appears once catalog.load() (triggered on connect, see
|
||||||
# app/_layout.tsx) has resolved GET /provider and found variants for the
|
# app/_layout.tsx) has resolved GET /provider and found variants for the
|
||||||
# selected model — wait rather than assert immediately.
|
# selected model — wait rather than assert immediately.
|
||||||
|
|||||||
@@ -710,6 +710,7 @@ export default function SessionScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[s.modelChip, isDark && s.modelChipDark]}
|
style={[s.modelChip, isDark && s.modelChipDark]}
|
||||||
onPress={() => modelSheetRef.current?.expand()}
|
onPress={() => modelSheetRef.current?.expand()}
|
||||||
|
testID="model-chip"
|
||||||
>
|
>
|
||||||
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
|
||||||
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
|
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ export function DirectoryBrowserSheet({
|
|||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
index={-1}
|
index={-1}
|
||||||
snapPoints={["65%", "92%"]}
|
snapPoints={["65%", "92%"]}
|
||||||
|
// Static percentage snapPoints are provided above, but @gorhom/bottom-sheet
|
||||||
|
// v5 defaults enableDynamicSizing to true, which requires content wrapped
|
||||||
|
// in a size-reporting component (BottomSheetView) to ever compute a valid
|
||||||
|
// detent — this sheet's children are plain Views/BottomSheetFlatList, so
|
||||||
|
// contentHeight never resolves and the sheet can never open (expand() has
|
||||||
|
// no valid snap position to animate to). Disable dynamic sizing so the
|
||||||
|
// explicit snapPoints above are used directly. See GitHub issue #104.
|
||||||
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
keyboardBehavior="interactive"
|
keyboardBehavior="interactive"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa
|
|||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
index={-1}
|
index={-1}
|
||||||
snapPoints={["45%", "70%"]}
|
snapPoints={["45%", "70%"]}
|
||||||
|
// See DirectoryBrowserSheet.tsx for why this is required alongside
|
||||||
|
// static snapPoints (issue #104): without it the sheet can never open.
|
||||||
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
keyboardBehavior="interactive"
|
keyboardBehavior="interactive"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }:
|
|||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
index={-1}
|
index={-1}
|
||||||
snapPoints={["50%", "80%"]}
|
snapPoints={["50%", "80%"]}
|
||||||
|
// See DirectoryBrowserSheet.tsx for why this is required alongside
|
||||||
|
// static snapPoints (issue #104): without it the sheet can never open.
|
||||||
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
keyboardBehavior="interactive"
|
keyboardBehavior="interactive"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
@@ -123,6 +126,7 @@ export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }:
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
|
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
|
||||||
onPress={() => handleSelect(item.providerID, item.modelID)}
|
onPress={() => handleSelect(item.providerID, item.modelID)}
|
||||||
|
testID={`model-option-${item.providerID}-${item.modelID}`}
|
||||||
>
|
>
|
||||||
<View style={s.rowText}>
|
<View style={s.rowText}>
|
||||||
<Text style={[s.rowName, isDark && s.textWhite]} numberOfLines={1}>
|
<Text style={[s.rowName, isDark && s.textWhite]} numberOfLines={1}>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }
|
|||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
index={-1}
|
index={-1}
|
||||||
snapPoints={["30%", "50%"]}
|
snapPoints={["30%", "50%"]}
|
||||||
|
// See DirectoryBrowserSheet.tsx for why this is required alongside
|
||||||
|
// static snapPoints (issue #104): without it the sheet can never open.
|
||||||
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundStyle={isDark ? s.sheetDark : s.sheet}
|
backgroundStyle={isDark ? s.sheetDark : s.sheet}
|
||||||
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
|
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { View, Text, useColorScheme, Platform, type ViewStyle, type TextStyle } from "react-native"
|
import { View, Text, useColorScheme, Platform, type ViewStyle, type TextStyle } from "react-native"
|
||||||
import RNMarkdown, { Renderer } from "react-native-marked"
|
import { useMarkdown, Renderer } from "react-native-marked"
|
||||||
import { CodeBlock } from "./CodeBlock"
|
import { CodeBlock } from "./CodeBlock"
|
||||||
|
|
||||||
class CustomRenderer extends Renderer {
|
class CustomRenderer extends Renderer {
|
||||||
@@ -99,19 +99,22 @@ export function Markdown({ children }: Props) {
|
|||||||
const isDark = useColorScheme() === "dark"
|
const isDark = useColorScheme() === "dark"
|
||||||
const theme = isDark ? darkTheme : lightTheme
|
const theme = isDark ? darkTheme : lightTheme
|
||||||
|
|
||||||
|
// react-native-marked's default <RNMarkdown> export renders blocks inside a
|
||||||
|
// FlatList. Chat messages are rendered inside app/session/[id].tsx's own
|
||||||
|
// *inverted* FlatList (each row a MessageBubble) — nesting one
|
||||||
|
// VirtualizedList inside another, especially an inverted one, is a known
|
||||||
|
// React Native footgun where the inner list's content can fail to lay out
|
||||||
|
// (renders zero height) instead of just warning. We already force
|
||||||
|
// scrollEnabled: false and a large initialNumToRender here, which defeats
|
||||||
|
// virtualization anyway, so there's nothing to lose by rendering the parsed
|
||||||
|
// blocks directly with the useMarkdown hook instead (issue #104).
|
||||||
|
const elements = useMarkdown(children ?? "", {
|
||||||
|
renderer,
|
||||||
|
styles: theme,
|
||||||
|
colorScheme: isDark ? "dark" : "light",
|
||||||
|
})
|
||||||
|
|
||||||
if (!children?.trim()) return null
|
if (!children?.trim()) return null
|
||||||
|
|
||||||
return (
|
return <View style={{ backgroundColor: "transparent" }}>{elements}</View>
|
||||||
<RNMarkdown
|
|
||||||
value={children}
|
|
||||||
renderer={renderer}
|
|
||||||
styles={theme}
|
|
||||||
flatListProps={{
|
|
||||||
scrollEnabled: false,
|
|
||||||
initialNumToRender: 50,
|
|
||||||
style: { backgroundColor: "transparent" },
|
|
||||||
contentContainerStyle: { backgroundColor: "transparent" },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/types/react-native-marked.d.ts
vendored
14
src/types/react-native-marked.d.ts
vendored
@@ -95,6 +95,20 @@ declare module "react-native-marked" {
|
|||||||
hooks?: Hooks
|
hooks?: Hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used by src/components/markdown/Markdown.tsx to render parsed blocks
|
||||||
|
// directly (bypassing the FlatList-based default export) — see issue #104.
|
||||||
|
export interface useMarkdownHookOptions {
|
||||||
|
colorScheme?: "light" | "dark" | null
|
||||||
|
renderer?: RendererInterface
|
||||||
|
theme?: UserTheme
|
||||||
|
styles?: MarkedStyles | Record<string, unknown>
|
||||||
|
baseUrl?: string
|
||||||
|
tokenizer?: Tokenizer
|
||||||
|
hooks?: Hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkdown(value: string, options?: useMarkdownHookOptions): ReactNode[]
|
||||||
|
|
||||||
const Markdown: React.FC<MarkdownProps>
|
const Markdown: React.FC<MarkdownProps>
|
||||||
export default Markdown
|
export default Markdown
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user