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.
This commit is contained in:
Mohamed Boudra
2026-06-25 23:49:16 +07:00
parent 26f169866f
commit d5cb4421b5
3 changed files with 240 additions and 74 deletions
@@ -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"
>
<View style={DIAGNOSTIC_CARD_STYLE}>
{diagnostic ? (
<ScrollView style={styles.codeScroll} contentContainerStyle={styles.codeContent}>
<ScrollView horizontal showsHorizontalScrollIndicator>
<Text style={styles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
{diagnostic ? (
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={520}>
{diagnostic}
</ScrollableCodeSurface>
) : (
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={styles.progressContent}>
{progress.length === 0 ? (
<View style={styles.progressRow}>
@@ -256,7 +245,7 @@ export function AppDiagnosticSheet({
/>
) : (
<View
style={row.status === "failed" ? FAILED_STATUS_DOT_STYLE : styles.statusDot}
style={row.status === "failed" ? styles.statusDotFailed : styles.statusDot}
/>
)}
<Text
@@ -266,8 +255,8 @@ export function AppDiagnosticSheet({
))
)}
</View>
)}
</View>
</SurfaceCard>
)}
</AdaptiveModalSheet>
);
}
@@ -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];
@@ -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 = (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
</View>
</SurfaceCard>
);
} else if (diagnostic) {
body = (
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
<ScrollView horizontal showsHorizontalScrollIndicator>
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
<ScrollableCodeSurface key={visible ? "visible" : "hidden"} maxHeight={480}>
{diagnostic}
</ScrollableCodeSurface>
);
} else {
body = (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
<SurfaceCard key={visible ? "visible" : "hidden"}>
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
</View>
</SurfaceCard>
);
}
@@ -395,7 +395,7 @@ function DiagnosticSubSheet({
scrollable={false}
testID="provider-diagnostic-sheet"
>
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
{body}
</AdaptiveModalSheet>
);
}
@@ -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];
@@ -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<ViewStyle>;
scrollStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
testID?: string;
accessibilityLabel?: string;
}
interface SurfaceCardProps {
children: ReactNode;
tone?: CodeSurfaceTone;
bordered?: boolean;
style?: StyleProp<ViewStyle>;
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 = (
<View
style={containerStyle}
dataSet={codeSurface ? CODE_SURFACE_DATASET : undefined}
testID={testID}
accessible={accessibilityLabel != null}
accessibilityLabel={accessibilityLabel}
>
{children}
</View>
);
return <AppearanceStyleBoundary>{surface}</AppearanceStyleBoundary>;
}
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" ? (
<PlainCodeText text={children} selectable={selectable} textStyle={codeTextStyle} />
) : (
<Text selectable={selectable} style={codeTextStyle} dataSet={CODE_SURFACE_DATASET}>
{children}
</Text>
);
return (
<SurfaceCard
tone={tone}
bordered={bordered}
style={style}
testID={testID}
accessibilityLabel={accessibilityLabel}
codeSurface
>
<ScrollView
style={outerScrollStyle}
contentContainerStyle={contentStyle}
nestedScrollEnabled
showsVerticalScrollIndicator
>
{horizontal ? (
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
{codeContent}
</ScrollView>
) : (
codeContent
)}
</ScrollView>
</SurfaceCard>
);
}
function PlainCodeText({
text,
selectable,
textStyle,
}: {
text: string;
selectable: boolean;
textStyle: StyleProp<TextStyle>;
}) {
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 (
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map(({ key, line }) => (
<Text key={key} selectable={selectable} style={textStyle} dataSet={CODE_SURFACE_DATASET}>
{line.length === 0 ? "\u200B" : line}
</Text>
))}
</View>
);
}
function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
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),
},
}));