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