From d5cb4421b580a1a64fcbf2804462a72a0bbfd99f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 25 Jun 2026 23:49:16 +0700 Subject: [PATCH] fix(diagnostics): render sheet reports reliably Diagnostic sheets now share one themed code surface. String reports render line by line so large log lines do not turn the whole report into one oversized native text surface. --- .../src/components/app-diagnostic-sheet.tsx | 55 ++--- .../components/provider-diagnostic-sheet.tsx | 49 ++-- .../components/ui/scrollable-code-surface.tsx | 210 ++++++++++++++++++ 3 files changed, 240 insertions(+), 74 deletions(-) create mode 100644 packages/app/src/components/ui/scrollable-code-surface.tsx diff --git a/packages/app/src/components/app-diagnostic-sheet.tsx b/packages/app/src/components/app-diagnostic-sheet.tsx index 295910842..c82d7897c 100644 --- a/packages/app/src/components/app-diagnostic-sheet.tsx +++ b/packages/app/src/components/app-diagnostic-sheet.tsx @@ -1,19 +1,13 @@ import * as Clipboard from "expo-clipboard"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Platform, - Pressable, - ScrollView, - Text, - View, - type PressableStateCallbackType, -} from "react-native"; +import { Platform, Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Copy, RotateCw } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface"; import { useToast } from "@/contexts/toast-context"; import { getDesktopDaemonLogs, getDesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; import { @@ -24,7 +18,6 @@ import { redactAppDiagnosticReport, } from "@/diagnostics/app-diagnostic-report"; import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime"; -import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; @@ -230,16 +223,12 @@ export function AppDiagnosticSheet({ scrollable={false} testID="app-diagnostic-sheet" > - - {diagnostic ? ( - - - - {diagnostic} - - - - ) : ( + {diagnostic ? ( + + {diagnostic} + + ) : ( + {progress.length === 0 ? ( @@ -256,7 +245,7 @@ export function AppDiagnosticSheet({ /> ) : ( )} - )} - + + )} ); } @@ -374,22 +363,6 @@ function formatProgressStatus(status: ProgressStatus): string { } const styles = StyleSheet.create((theme) => ({ - diagnosticCard: { - overflow: "hidden", - }, - codeScroll: { - maxHeight: 520, - }, - codeContent: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - }, - codeText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - lineHeight: 18, - }, progressContent: { paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], @@ -431,9 +404,9 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.success, }, statusDotFailed: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.destructive, }, })); - -const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, styles.diagnosticCard]; -const FAILED_STATUS_DOT_STYLE = [styles.statusDot, styles.statusDotFailed]; diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 5d9c19978..9085801e0 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -7,7 +7,6 @@ import { ActivityIndicator, Pressable, type PressableStateCallbackType, - ScrollView, Text, View, } from "react-native"; @@ -19,6 +18,7 @@ import { } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ScrollableCodeSurface, SurfaceCard } from "@/components/ui/scrollable-code-surface"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useToast } from "@/contexts/toast-context"; @@ -363,26 +363,26 @@ function DiagnosticSubSheet({ let body: React.ReactNode; if (loading && !diagnostic) { body = ( - - - {t("settings.providers.diagnostic.running")} - + + + + {t("settings.providers.diagnostic.running")} + + ); } else if (diagnostic) { body = ( - - - - {diagnostic} - - - + + {diagnostic} + ); } else { body = ( - - {t("settings.providers.diagnostic.none")} - + + + {t("settings.providers.diagnostic.none")} + + ); } @@ -395,7 +395,7 @@ function DiagnosticSubSheet({ scrollable={false} testID="provider-diagnostic-sheet" > - {body} + {body} ); } @@ -862,22 +862,6 @@ const sheetStyles = StyleSheet.create((theme) => ({ justifyContent: "flex-end", gap: theme.spacing[2], }, - diagnosticCard: { - overflow: "hidden", - }, - codeScroll: { - maxHeight: 480, - }, - codeContent: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - }, - codeText: { - fontFamily: theme.fontFamily.mono, - fontSize: theme.fontSize.code, - color: theme.colors.foreground, - lineHeight: 18, - }, codeBlockLoading: { paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], @@ -893,4 +877,3 @@ const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFo const MAIN_SNAP_POINTS = ["65%", "92%"]; const ADD_SNAP_POINTS = ["40%"]; const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"]; -const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, sheetStyles.diagnosticCard]; diff --git a/packages/app/src/components/ui/scrollable-code-surface.tsx b/packages/app/src/components/ui/scrollable-code-surface.tsx new file mode 100644 index 000000000..ab900f9ac --- /dev/null +++ b/packages/app/src/components/ui/scrollable-code-surface.tsx @@ -0,0 +1,210 @@ +import { useMemo, type ReactNode } from "react"; +import { + ScrollView, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; +import { isWeb } from "@/constants/platform"; +import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; + +type CodeSurfaceTone = "surface0" | "surface1" | "surface2"; + +interface ScrollableCodeSurfaceProps { + children: ReactNode; + maxHeight?: number; + horizontal?: boolean; + selectable?: boolean; + tone?: CodeSurfaceTone; + bordered?: boolean; + style?: StyleProp; + scrollStyle?: StyleProp; + contentContainerStyle?: StyleProp; + textStyle?: StyleProp; + testID?: string; + accessibilityLabel?: string; +} + +interface SurfaceCardProps { + children: ReactNode; + tone?: CodeSurfaceTone; + bordered?: boolean; + style?: StyleProp; + testID?: string; + accessibilityLabel?: string; + codeSurface?: boolean; +} + +export function SurfaceCard({ + children, + tone = "surface1", + bordered = true, + style, + testID, + accessibilityLabel, + codeSurface = false, +}: SurfaceCardProps) { + const surfaceStyle = getSurfaceStyle(tone); + const containerStyle = useMemo( + () => [styles.container, surfaceStyle, bordered && styles.bordered, style], + [bordered, style, surfaceStyle], + ); + + const surface = ( + + {children} + + ); + return {surface}; +} + +export function ScrollableCodeSurface({ + children, + maxHeight, + horizontal = true, + selectable = true, + tone = "surface1", + bordered = true, + style, + scrollStyle, + contentContainerStyle, + textStyle, + testID, + accessibilityLabel, +}: ScrollableCodeSurfaceProps) { + const maxHeightStyle = useMemo( + () => (maxHeight == null ? null : inlineUnistylesStyle({ maxHeight })), + [maxHeight], + ); + const outerScrollStyle = useMemo( + () => [maxHeightStyle, scrollStyle], + [maxHeightStyle, scrollStyle], + ); + const contentStyle = useMemo( + () => [styles.content, contentContainerStyle], + [contentContainerStyle], + ); + const codeTextStyle = useMemo(() => [styles.text, textStyle], [textStyle]); + const codeContent = + typeof children === "string" ? ( + + ) : ( + + {children} + + ); + + return ( + + + {horizontal ? ( + + {codeContent} + + ) : ( + codeContent + )} + + + ); +} + +function PlainCodeText({ + text, + selectable, + textStyle, +}: { + text: string; + selectable: boolean; + textStyle: StyleProp; +}) { + const lines = useMemo(() => { + let offset = 0; + return text.split("\n").map((line) => { + const key = `${offset}:${line.length}`; + offset += line.length + 1; + return { key, line }; + }); + }, [text]); + return ( + + {lines.map(({ key, line }) => ( + + {line.length === 0 ? "\u200B" : line} + + ))} + + ); +} + +function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp { + switch (tone) { + case "surface0": + return styles.surface0; + case "surface2": + return styles.surface2; + case "surface1": + return styles.surface1; + } +} + +const styles = StyleSheet.create((theme) => ({ + container: { + overflow: "hidden", + }, + bordered: { + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + surface0: { + backgroundColor: theme.colors.surface0, + }, + surface1: { + backgroundColor: theme.colors.surface1, + }, + surface2: { + backgroundColor: theme.colors.surface2, + }, + content: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + text: { + backgroundColor: "transparent", + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + color: theme.colors.foreground, + lineHeight: 18, + ...(isWeb + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), + }, +}));