diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 137b23c2bf..e1aa39ef11 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -74,7 +74,8 @@
"@deepseek-ai/dsh-api-gateway": "0.2.1-rc.2",
"@types/react": "~19.3.0",
"@types/react-dom": "~19.3.0",
- "expo-doctor": "1.21.4"
+ "expo-doctor": "1.21.4",
+ "yoga-layout": "3.2.1"
},
"enriched-markdown": {
"enableCodeHighlight": false,
diff --git a/apps/mobile/src/ui/markdown-text.tsx b/apps/mobile/src/ui/markdown-text.tsx
index 310643a5d6..38394314b8 100644
--- a/apps/mobile/src/ui/markdown-text.tsx
+++ b/apps/mobile/src/ui/markdown-text.tsx
@@ -122,7 +122,7 @@ export function MarkdownText({
? `${Number(token.start) + itemIndex}.`
: '•'}
-
+
{blocks(item.tokens, animate && itemIndex === token.items.length - 1)}
diff --git a/apps/mobile/test/markdown-bubble-layout.test.tsx b/apps/mobile/test/markdown-bubble-layout.test.tsx
new file mode 100644
index 0000000000..178a2a8910
--- /dev/null
+++ b/apps/mobile/test/markdown-bubble-layout.test.tsx
@@ -0,0 +1,185 @@
+// @vitest-environment jsdom
+import { type ReactNode } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
+import { beforeEach, expect, it, vi } from 'vitest';
+import Yoga, { type Node as YogaNode } from 'yoga-layout';
+
+const native = vi.hoisted(() => ({
+ width: 390,
+ fontScale: 1,
+ styles: new Map(),
+}));
+
+vi.mock('react-native', async () => {
+ const shim = await import('./react-native-shim');
+ const isStyleList = (
+ style: StyleProp,
+ ): style is readonly StyleProp[] => Array.isArray(style);
+ const flatten = (style: StyleProp): ViewStyle & TextStyle => {
+ if (isStyleList(style)) return Object.assign({}, ...style.map(flatten));
+ if (typeof style === 'number') throw new Error('Registered styles need native resolution');
+ return style || {};
+ };
+ const host = (text: boolean) =>
+ function NativeNode({
+ children,
+ style,
+ }: {
+ children?: ReactNode;
+ style?: StyleProp;
+ }) {
+ const id = String(native.styles.size);
+ native.styles.set(id, { style: flatten(style), text });
+ return {children}
;
+ };
+ return {
+ ...shim,
+ View: host(false),
+ Pressable: host(false),
+ Text: host(true),
+ Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android },
+ useWindowDimensions: () => ({
+ width: native.width,
+ height: 800,
+ scale: 1,
+ fontScale: native.fontScale,
+ }),
+ };
+});
+vi.mock('react-native-reanimated', async () => {
+ const shim = await import('./reanimated-shim');
+ const { View } = await import('react-native');
+ return { ...shim, default: { ...shim.default, View } };
+});
+vi.mock('react-native-worklets', () => ({ scheduleOnRN: (callback: () => void) => callback() }));
+vi.mock('../src/ui/markdown-view', () => import('../src/ui/markdown-view.android'));
+vi.mock('react-native-enriched-markdown', () => ({
+ EnrichedMarkdownText: () => {
+ throw new Error('Unexpected rich renderer');
+ },
+}));
+vi.mock('../src/ui/primitives/stream-reveal', async () => ({
+ nativeStreamReveal: true,
+ StreamReveal: (await import('react-native')).View,
+}));
+vi.mock('../src/ui/chat/message-menu', () => ({
+ copyMessage: () => {},
+ openMessageMenu: () => {},
+}));
+vi.mock('../src/ui/chat/message-attachments', () => ({ MessageAttachments: () => null }));
+vi.mock('../src/ui/chat/message-reactions', () => ({ ReactionChips: () => null }));
+vi.mock('../src/ui/chat/speaker-row', () => ({ SpeakerRow: () => null }));
+vi.mock('../src/ui/bot-avatar', () => ({ BotAvatar: () => null }));
+vi.mock('../src/ui/haptics', () => ({ haptic: () => {} }));
+
+import { MessageBubble } from '../src/ui/chat/message-bubble';
+
+beforeEach(() => {
+ native.width = 390;
+ native.fontScale = 1;
+ native.styles.clear();
+});
+
+function layout(body: string, streaming = false) {
+ native.styles.clear();
+ const container = document.createElement('div');
+ container.innerHTML = renderToStaticMarkup(
+ ,
+ );
+ const config = Yoga.Config.create();
+ config.setUseWebDefaults(false);
+ const root = Yoga.Node.create(config);
+ root.setWidth(native.width);
+ const texts: Array<{ body: string; node: YogaNode }> = [];
+ const build = (element: Element, parent: YogaNode) => {
+ const entry = native.styles.get(element.getAttribute('data-layout') ?? '');
+ if (entry === undefined) throw new Error('Layout node was not captured');
+ const node = Yoga.Node.create(config);
+ parent.insertChild(node, parent.getChildCount());
+ const { style } = entry;
+ if (style.flex !== undefined) node.setFlex(style.flex);
+ if (style.flexGrow !== undefined) node.setFlexGrow(style.flexGrow);
+ if (style.flexShrink !== undefined) node.setFlexShrink(style.flexShrink);
+ if (style.flexDirection === 'row') node.setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
+ if (style.alignSelf === 'flex-start') node.setAlignSelf(Yoga.ALIGN_FLEX_START);
+ if (style.alignItems === 'flex-start') node.setAlignItems(Yoga.ALIGN_FLEX_START);
+ if (typeof style.maxWidth === 'string' && style.maxWidth.endsWith('%'))
+ node.setMaxWidthPercent(Number.parseFloat(style.maxWidth));
+ if (typeof style.gap === 'number') node.setGap(Yoga.GUTTER_ALL, style.gap);
+ if (typeof style.marginLeft === 'number') node.setMargin(Yoga.EDGE_LEFT, style.marginLeft);
+ if (typeof style.paddingHorizontal === 'number')
+ node.setPadding(Yoga.EDGE_HORIZONTAL, style.paddingHorizontal);
+ if (typeof style.paddingVertical === 'number')
+ node.setPadding(Yoga.EDGE_VERTICAL, style.paddingVertical);
+ if (entry.text) {
+ const value = element.textContent ?? '';
+ const glyph = (style.fontSize ?? 16) * native.fontScale;
+ const line = (style.lineHeight ?? 24) * native.fontScale;
+ node.setMeasureFunc((available, mode) => {
+ const natural = Array.from(value).length * glyph;
+ const width =
+ mode === Yoga.MEASURE_MODE_UNDEFINED
+ ? natural
+ : mode === Yoga.MEASURE_MODE_EXACTLY
+ ? available
+ : Math.min(available, natural);
+ return { width, height: Math.ceil(natural / Math.max(glyph, width)) * line };
+ });
+ texts.push({ body: value, node });
+ } else {
+ for (const child of element.children) build(child, node);
+ }
+ };
+ try {
+ for (const child of container.children) build(child, root);
+ root.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR);
+ return texts.map(({ body, node }) => ({ body, width: node.getComputedWidth() }));
+ } finally {
+ root.freeRecursive();
+ config.free();
+ }
+}
+
+const paragraph = '雨水经过蒸发凝结形成云层并降落到地面。'.repeat(16);
+
+it.each([false, true])(
+ 'keeps a list-only reply readable inside its actual bubble (streaming=%s)',
+ (streaming) => {
+ const source = Array.from({ length: 40 }, (_, i) => `${i + 1}. ${paragraph}`).join('\n\n');
+ const texts = layout(source, streaming).filter((text) => text.body === paragraph);
+ expect(texts).toHaveLength(40);
+ for (const text of texts) {
+ expect(text.width).toBeGreaterThan(150);
+ expect(text.width).toBeLessThan(native.width * 0.8);
+ }
+ },
+);
+
+it.each([320, 390, 600])('wraps nested lists and scaled text at window width %s', (width) => {
+ native.width = width;
+ native.fontScale = 2;
+ const texts = layout(`- ${paragraph}\n - ${paragraph}`);
+ const prose = texts.filter((text) => text.body === paragraph);
+ expect(prose).toHaveLength(2);
+ for (const text of prose) expect(text.width).toBeGreaterThan(50);
+});
+
+it('keeps short list content compact while long prose uses available space', () => {
+ const short = layout('- 雨').find((text) => text.body === '雨');
+ const long = layout(paragraph).find((text) => text.body === paragraph);
+ expect(short).toBeDefined();
+ expect(long).toBeDefined();
+ expect(short?.width).toBeLessThan(40);
+ expect(long?.width).toBeGreaterThan(200);
+});
diff --git a/changes/2596-markdown-list-width.md b/changes/2596-markdown-list-width.md
new file mode 100644
index 0000000000..371cd598e6
--- /dev/null
+++ b/changes/2596-markdown-list-width.md
@@ -0,0 +1,11 @@
+---
+section: Fixed
+---
+
+## en
+
+- Fixed Android Markdown lists collapsing into a narrow column inside chat bubbles, including long replies and nested lists ([#2596](https://github.com/BotHarness/BotHarness/issues/2596)).
+
+## zh
+
+- 修复 Android 聊天气泡中的 Markdown 列表缩成窄列的问题,覆盖长回复和嵌套列表([#2596](https://github.com/BotHarness/BotHarness/issues/2596))。
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 671adfea75..997d9ef244 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -501,6 +501,9 @@ importers:
expo-doctor:
specifier: 1.21.4
version: 1.21.4
+ yoga-layout:
+ specifier: 3.2.1
+ version: 3.2.1
apps/presentations:
dependencies:
@@ -13388,6 +13391,9 @@ packages:
resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==}
engines: {node: '>=18'}
+ yoga-layout@3.2.1:
+ resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
@@ -27361,6 +27367,8 @@ snapshots:
yoctocolors@2.2.0: {}
+ yoga-layout@3.2.1: {}
+
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3