diff --git a/.maestro/flows/diff-scroll.yaml b/.maestro/flows/diff-scroll.yaml index cb2c0f1..a36e413 100644 --- a/.maestro/flows/diff-scroll.yaml +++ b/.maestro/flows/diff-scroll.yaml @@ -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. - assertVisible: text: "Edit wide_diff_target.ts" -- assertVisible: - text: "Here is a wide code sample" +# 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" + timeout: 20000 - takeScreenshot: diffscroll-S2_history_loaded # --- CodeBlock: the wide fenced code block is already expanded (no tap diff --git a/.maestro/flows/directory-picker.yaml b/.maestro/flows/directory-picker.yaml index aa18bc0..602310e 100644 --- a/.maestro/flows/directory-picker.yaml +++ b/.maestro/flows/directory-picker.yaml @@ -62,6 +62,10 @@ name: Directory picker - browse server folders and create a session in one id: "browse-folders-button" - assertVisible: 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: visible: id: "directory-row-frontend" diff --git a/.maestro/flows/variant-picker.yaml b/.maestro/flows/variant-picker.yaml index bc8b6d5..18c4c34 100644 --- a/.maestro/flows/variant-picker.yaml +++ b/.maestro/flows/variant-picker.yaml @@ -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 # server used by directory-picker.yaml — GET /provider's mock-model carries # 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: clearState: true @@ -47,6 +55,18 @@ name: Variant picker - reasoning-effort chip renders, selects, and still sends id: "chat-message-input" 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 # app/_layout.tsx) has resolved GET /provider and found variants for the # selected model — wait rather than assert immediately. diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 7bba1ea..f9c0bc7 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -710,6 +710,7 @@ export default function SessionScreen() { modelSheetRef.current?.expand()} + testID="model-chip" > diff --git a/src/components/chat/DirectoryBrowserSheet.tsx b/src/components/chat/DirectoryBrowserSheet.tsx index abd8236..48ef946 100644 --- a/src/components/chat/DirectoryBrowserSheet.tsx +++ b/src/components/chat/DirectoryBrowserSheet.tsx @@ -154,6 +154,14 @@ export function DirectoryBrowserSheet({ ref={sheetRef} index={-1} 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 keyboardBehavior="interactive" keyboardBlurBehavior="restore" diff --git a/src/components/chat/DirectorySwitcher.tsx b/src/components/chat/DirectorySwitcher.tsx index ebad0b1..314de86 100644 --- a/src/components/chat/DirectorySwitcher.tsx +++ b/src/components/chat/DirectorySwitcher.tsx @@ -55,6 +55,9 @@ export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDa ref={sheetRef} index={-1} 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 keyboardBehavior="interactive" keyboardBlurBehavior="restore" diff --git a/src/components/chat/ModelPicker.tsx b/src/components/chat/ModelPicker.tsx index f2845a8..e66c83e 100644 --- a/src/components/chat/ModelPicker.tsx +++ b/src/components/chat/ModelPicker.tsx @@ -84,6 +84,9 @@ export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: ref={sheetRef} index={-1} 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 keyboardBehavior="interactive" keyboardBlurBehavior="restore" @@ -123,6 +126,7 @@ export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: handleSelect(item.providerID, item.modelID)} + testID={`model-option-${item.providerID}-${item.modelID}`} > diff --git a/src/components/chat/VariantPicker.tsx b/src/components/chat/VariantPicker.tsx index fd64fd6..a1aad87 100644 --- a/src/components/chat/VariantPicker.tsx +++ b/src/components/chat/VariantPicker.tsx @@ -50,6 +50,9 @@ export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef } ref={sheetRef} index={-1} 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 backgroundStyle={isDark ? s.sheetDark : s.sheet} handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }} diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx index 85a9fe6..69f20c2 100644 --- a/src/components/markdown/Markdown.tsx +++ b/src/components/markdown/Markdown.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react" 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" class CustomRenderer extends Renderer { @@ -99,19 +99,22 @@ export function Markdown({ children }: Props) { const isDark = useColorScheme() === "dark" const theme = isDark ? darkTheme : lightTheme + // react-native-marked's default 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 - return ( - - ) + return {elements} } diff --git a/src/types/react-native-marked.d.ts b/src/types/react-native-marked.d.ts index 9a7d410..1338379 100644 --- a/src/types/react-native-marked.d.ts +++ b/src/types/react-native-marked.d.ts @@ -95,6 +95,20 @@ declare module "react-native-marked" { 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 + baseUrl?: string + tokenizer?: Tokenizer + hooks?: Hooks + } + + export function useMarkdown(value: string, options?: useMarkdownHookOptions): ReactNode[] + const Markdown: React.FC export default Markdown }