Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
17cb87e
fix(mobile): chat code blocks colour diff, kotlin, sql and other lang…
juliusmarminge Nov 10, 2026
a230081
fix(mobile): keep the fallback code block mounted while its fence str…
juliusmarminge Nov 10, 2026
33151da
docs(mobile): say when a code fence counts as complete
juliusmarminge Nov 10, 2026
558ee8d
Merge remote-tracking branch 'origin/main' into fix/issue-33638
juliusmarminge Nov 10, 2026
0c6994d
fix(mobile): mermaid and math fences keep their renderers ahead of th…
juliusmarminge Nov 10, 2026
f2f0112
Merge remote-tracking branch 'origin/main' into fix/issue-33638
juliusmarminge Nov 10, 2026
bda955f
fix(mobile): highlighted code fences keep the long-press copy menu
juliusmarminge Nov 10, 2026
25fea91
fix(mobile): highlighted code fences scroll sideways again on iOS
juliusmarminge Nov 10, 2026
e455e68
Merge remote-tracking branch 'origin/main' into pr-33680
juliusmarminge Nov 10, 2026
0f0c652
test(mobile): pin which renderer each code fence gets
juliusmarminge Nov 10, 2026
6c54d4a
docs(mobile): name the file the fence display names come from
juliusmarminge Nov 10, 2026
6e5d2a0
fix(mobile): Android menus stay on screen when their anchor is taller…
juliusmarminge Nov 10, 2026
63069af
fix(mobile): first long-press on a scrolled code block keeps the keyb…
juliusmarminge Nov 10, 2026
c6afa44
docs(mobile): say what keeping taps on the code scroller does
juliusmarminge Nov 10, 2026
06c360d
fix(mobile): code block headers grow with large text instead of clipp…
juliusmarminge Nov 10, 2026
b00d15f
Merge remote-tracking branch 'origin/main' into fix/issue-33638
juliusmarminge Nov 10, 2026
e0bc83b
refactor(mobile): drop the Android menu's duplicate root height
juliusmarminge Nov 10, 2026
b4ca413
Merge remote-tracking branch 'origin/main' into fix/issue-33638
juliusmarminge Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 19 additions & 20 deletions apps/mobile/src/components/ui/AndroidAnchoredMenu.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { Pressable, ScrollView, View } from "react-native";
import { useKeyboardState } from "react-native-keyboard-controller";

import { useHardwareBack } from "../../lib/backHandler";
import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement";
import {
deriveMenuHorizontalPlacement,
deriveMenuVerticalPlacement,
} from "../../lib/menu-placement";
import { WindowHingeContext } from "../../native/window-hinge-context";
import { OverlayPortal } from "./OverlayPortal";
import { useAndroidControlSizing } from "./useAndroidControlSizing";
Expand Down Expand Up @@ -41,10 +44,9 @@ export type AndroidAnchoredMenuProps = {
};

export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const { scale, menuWidth: desiredMenuWidth } = useAndroidControlSizing();
const { scale, menuWidth: desiredMenuWidth, menuItemHeight } = useAndroidControlSizing();
const [anchor, setAnchor] = useState<AnchorSnapshot | null>(null);
const [path, setPath] = useState<readonly MenuAction[]>([]);
const [rootHeight, setRootHeight] = useState<number | null>(null);
const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
const windowHinge = use(WindowHingeContext);
// State, not a ref: `open` reaches render-prop children, and the compiler
Expand All @@ -58,7 +60,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
setAnchor(null);
setPath([]);
setOverlay(null);
setRootHeight(null);
};

const open = () => {
Expand All @@ -70,7 +71,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const measureOverlay = () => {
overlayRef.current?.measureInWindow((x, y, width, height) => {
setOverlay({ x, y, width, height });
setRootHeight(height);
});
};

Expand Down Expand Up @@ -110,17 +110,19 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
margin: SCREEN_MARGIN,
hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width },
});
// This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable.
const usableBottom =
overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0);
const spaceBelow =
const verticalPlacement =
local === null || overlay === null
? 0
: usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN;
const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN;
const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove;
const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480);
const placeable = local !== null && rootHeight !== null;
? null
: deriveMenuVerticalPlacement({
anchorY: local.y,
anchorHeight: local.height,
overlayHeight: overlay.height,
// This in-window menu preserves keyboard focus; subtract the IME area so its items remain tappable.
keyboardInset: keyboardVisible ? keyboardHeight : 0,
gap: ANCHOR_GAP,
margin: SCREEN_MARGIN,
minHeight: 2 * menuItemHeight + 14 * scale,
});

const onPressItem = (action: MenuAction) => {
if ((action.subactions?.length ?? 0) > 0) {
Expand Down Expand Up @@ -167,7 +169,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
onLayout={measureOverlay}
>
<Pressable accessible={false} className="absolute inset-0" onPress={close} />
{!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
{local === null ? null : !anchor.keyboardWasVisible ? (
<MenuPopup
// Compose aligns to the trigger's start if it fits; hinge-safe triggers keep menus on their side.
anchor={windowHinge ? { ...local, x: left, width: menuWidth } : local}
Expand All @@ -185,12 +187,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
className="absolute overflow-hidden bg-card-alt shadow-md"
style={{
left,
maxHeight,
width: menuWidth,
borderRadius: 4 * scale,
...(opensDown
? { top: local.y + local.height + ANCHOR_GAP }
: { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),
...verticalPlacement,
}}
>
{/* Compose DropdownMenu takes focus; in-window editor menus preserve the keyboard. */}
Expand Down
27 changes: 27 additions & 0 deletions apps/mobile/src/lib/menu-placement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vite-plus/test";

import { deriveMenuVerticalPlacement } from "./menu-placement";

const base = { gap: 6, margin: 12, minHeight: 110 };

describe("deriveMenuVerticalPlacement", () => {
it.each([
{
name: "anchor taller than the keyboard-free area stays on screen",
input: { anchorY: -100, anchorHeight: 1000, overlayHeight: 800, keyboardInset: 300 },
expected: { maxHeight: 476, top: 12 },
},
{
name: "room below opens down",
input: { anchorY: 100, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 },
expected: { maxHeight: 342, top: 146 },
},
{
name: "room above opens up",
input: { anchorY: 400, anchorHeight: 40, overlayHeight: 800, keyboardInset: 300 },
expected: { maxHeight: 382, bottom: 406 },
},
])("$name", ({ input, expected }) => {
expect(deriveMenuVerticalPlacement({ ...base, ...input })).toEqual(expected);
});
});
28 changes: 28 additions & 0 deletions apps/mobile/src/lib/menu-placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,31 @@ export function deriveMenuHorizontalPlacement(input: {
const left = Math.min(Math.max(preferredLeft, start + input.margin), end - width - input.margin);
return { left, width };
}

/** In the overlay's coordinates; `bottom` is measured up from the overlay's bottom edge. */
export function deriveMenuVerticalPlacement(input: {
readonly anchorY: number;
readonly anchorHeight: number;
readonly overlayHeight: number;
readonly keyboardInset: number;
readonly gap: number;
readonly margin: number;
readonly minHeight: number;
}) {
const usableBottom = input.overlayHeight - input.keyboardInset;
const spaceBelow = usableBottom - (input.anchorY + input.anchorHeight) - input.gap - input.margin;
const spaceAbove = input.anchorY - input.gap - input.margin;
const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove;
const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480);
if (maxHeight < input.minHeight) {
// Neither side fits (e.g. a code block taller than the screen): overlap the anchor instead.
const top = Math.max(
input.margin,
Math.min(input.anchorY, usableBottom - input.margin - input.minHeight),
);
return { maxHeight: Math.min(usableBottom - input.margin - top, 480), top };
}
return opensDown
? { maxHeight, top: input.anchorY + input.anchorHeight + input.gap }
: { maxHeight, bottom: input.overlayHeight - input.anchorY + input.gap };
}
98 changes: 98 additions & 0 deletions apps/mobile/src/native/CodeBlockFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { ReactNode } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

import { SymbolView, type AppSymbolName } from "../components/ui/AppSymbol";
import { themeColorWithAlpha } from "../lib/mobileTheme";
import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";

/**
* The native code block's border and header, for a fence drawn in React once it closes.
* The block slot already applies the code block's outer margins.
*/
export function CodeBlockFrame(props: {
readonly label: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly actions: ReactNode;
readonly children: ReactNode;
}) {
const { textStyle } = props;
return (
<View
style={[
styles.block,
{
backgroundColor: textStyle.codeBlockBackgroundColor,
borderColor: textStyle.dividerColor,
},
]}
>
<View
style={[
styles.header,
{ borderBottomColor: themeColorWithAlpha(textStyle.codeColor, 0.2) },
]}
>
<Text
style={[
styles.label,
{
color: themeColorWithAlpha(textStyle.codeColor, 0.6),
fontSize: textStyle.fontSize * 0.9 * 0.85,
},
]}
>
{props.label}
</Text>
<View style={styles.actions}>{props.actions}</View>
</View>
{props.children}
</View>
);
}

/** A header icon; `active` draws it at full strength, as the native wrap toggle when on. */
export function CodeBlockHeaderButton(props: {
readonly icon: AppSymbolName;
readonly label: string;
readonly textStyle: NativeMarkdownTextStyle;
readonly active?: boolean;
readonly onPress: () => void;
}) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={props.label}
hitSlop={10}
onPress={props.onPress}
>
<SymbolView
name={props.icon}
size={14}
tintColor={
props.active
? props.textStyle.codeColor
: themeColorWithAlpha(props.textStyle.codeColor, 0.6)
}
type="monochrome"
/>
</Pressable>
);
}

const styles = StyleSheet.create({
block: {
borderRadius: 8,
borderWidth: StyleSheet.hairlineWidth,
overflow: "hidden",
},
header: {
minHeight: 32,
paddingHorizontal: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: StyleSheet.hairlineWidth,
},
label: { fontWeight: "500" },
actions: { flexDirection: "row", alignItems: "center", gap: 14 },
});
42 changes: 35 additions & 7 deletions apps/mobile/src/native/EnrichedMarkdownText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
type MarkdownLinkOccurrence,
type MarkdownStyle,
} from "react-native-enriched-markdown";
import { codeFenceRenderer } from "./codeBlockMarkdown";
import { HighlightedCodeBlock } from "./HighlightedCodeBlock";
import { MermaidCodeBlock } from "./MermaidCodeBlock";
import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types";

Expand All @@ -29,6 +31,7 @@ import { useEnrichedLinkVariants } from "./useEnrichedLinkVariants";
import { useRetryingImageSource } from "./useRetryingImageSource";
import * as Dialog from "../components/ui/Dialog";
import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
import type { HighlightScheme } from "../lib/highlights";
import {
ENRICHED_INLINE_FILE_LINK_REGEX,
enrichedTextLinkRegex,
Expand Down Expand Up @@ -166,19 +169,25 @@ function enrichedStyle(
};
}

/** Diagram and math fences, as web draws them. A fence streams as native code until it closes. */
/**
* Diagram and math fences, as web draws them, and code enriched-markdown cannot colour.
* Diagrams and math stay native until the library reports them complete; fallback code keeps one block throughout.
*/
function codeBlockElement(
block: MarkdownCodeBlock,
textStyle: SelectableMarkdownTextProps["textStyle"],
markdownStyle: MarkdownStyle,
mathRenderingEnabled: boolean,
scheme: HighlightScheme,
): ReactElement | undefined {
if (!block.isComplete) return undefined;
if (block.language === "mermaid") {
return <MermaidCodeBlock code={block.code} textStyle={textStyle} />;
const renderer = codeFenceRenderer(block.language, mathRenderingEnabled);
if (renderer.kind === "mermaid") {
return block.isComplete ? (
<MermaidCodeBlock code={block.code} textStyle={textStyle} />
) : undefined;
}
if (block.language === "math" && mathRenderingEnabled) {
return (
if (renderer.kind === "math") {
return block.isComplete ? (
<EnrichedMarkdownText
// A blank line would end the paragraph, and so the math, early.
markdown={`$$\n${block.code.replace(/\n\s*\n/g, "\n").trim()}\n$$`}
Expand All @@ -190,6 +199,19 @@ function codeBlockElement(
selectionColor={textStyle.selectionColor}
selectionHandleColor={textStyle.selectionHandleColor}
/>
) : undefined;
}
if (renderer.kind === "highlight" && markdownStyle.codeBlock) {
return (
<HighlightedCodeBlock
code={block.code}
fence={block.language}
language={renderer.language}
complete={block.isComplete}
scheme={scheme}
textStyle={textStyle}
codeStyle={markdownStyle.codeBlock}
/>
);
}
return undefined;
Expand Down Expand Up @@ -347,7 +369,13 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
}
: undefined,
code: (block: MarkdownCodeBlock) =>
codeBlockElement(block, props.textStyle, baseMarkdownStyle, mathRenderingEnabled),
codeBlockElement(
block,
props.textStyle,
baseMarkdownStyle,
mathRenderingEnabled,
themeAppearance,
),
};
const resolveNativeImageSource = useRetryingImageSource(resolveImageSource);
const renderMarkdown = (openAndroidMenu?: () => void) => {
Expand Down
Loading
Loading