| author | |
| committer | |
| log | 0a91b9a116117dd5df619fb7c1248cba9cf6f43c |
| tree | e26abd2f08686538452004aeb3240267d0e17801 |
| parent | 20a8f1de3668e0ff4da236d75258e2184a68d727 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Code <noreply@anthropic.com>8 files changed, 350 insertions(+), 0 deletions(-)
apps/mobile/src/Stack.tsx+8| ... | ... | @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 53 | 53 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 54 | 54 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| 55 | 55 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; |
| 56 | import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; | |
| 56 | 57 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 57 | 58 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 58 | 59 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| ... | ... | @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({ |
| 198 | 199 | title: "Client Storage", |
| 199 | 200 | }, |
| 200 | 201 | }), |
| 202 | SettingsDiagnostics: createNativeStackScreen({ | |
| 203 | screen: SettingsDiagnosticsRouteScreen, | |
| 204 | linking: "diagnostics", | |
| 205 | options: { | |
| 206 | title: "Diagnostics", | |
| 207 | }, | |
| 208 | }), | |
| 201 | 209 | SettingsUsageAccount: createNativeStackScreen({ |
| 202 | 210 | screen: UsageLimitAccountScreen, |
| 203 | 211 | options: { title: "Account" }, |
apps/mobile/src/components/AppSymbol.tsx+2| ... | ... | @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer"; |
| 79 | 79 | import IconSettings from "@tabler/icons-react-native/IconSettings"; |
| 80 | 80 | import IconSparkles from "@tabler/icons-react-native/IconSparkles"; |
| 81 | 81 | import IconStack2 from "@tabler/icons-react-native/IconStack2"; |
| 82 | import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; | |
| 82 | 83 | import IconSun from "@tabler/icons-react-native/IconSun"; |
| 83 | 84 | import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; |
| 84 | 85 | import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; |
| ... | ... | @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = { |
| 164 | 165 | "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, |
| 165 | 166 | safari: IconExternalLink, |
| 166 | 167 | "server.rack": IconServer, |
| 168 | stethoscope: IconStethoscope, | |
| 167 | 169 | "sidebar.left": IconLayoutSidebar, |
| 168 | 170 | "sidebar.right": IconLayoutSidebarRight, |
| 169 | 171 | "slider.horizontal.3": IconAdjustmentsHorizontal, |
apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx created+175| ... | ... | @@ -0,0 +1,175 @@ |
| 1 | import Constants from "expo-constants"; | |
| 2 | import * as Updates from "expo-updates"; | |
| 3 | import { useEffect, useState } from "react"; | |
| 4 | import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 6 | ||
| 7 | import { SymbolView } from "../../components/AppSymbol"; | |
| 8 | import { AppText as Text } from "../../components/AppText"; | |
| 9 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | |
| 10 | import { SettingsSection } from "../settings/components/SettingsSection"; | |
| 11 | import { | |
| 12 | formatStartupCrashReport, | |
| 13 | parseStartupCrashRecords, | |
| 14 | type StartupCrashRecord, | |
| 15 | } from "./crash-log-model"; | |
| 16 | ||
| 17 | // expo-updates keeps its persistent log this long. Reading any further back | |
| 18 | // returns nothing, so this is the whole available window. | |
| 19 | const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; | |
| 20 | ||
| 21 | type CrashLogState = | |
| 22 | | { readonly status: "loading" } | |
| 23 | | { readonly status: "unavailable" } | |
| 24 | | { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> }; | |
| 25 | ||
| 26 | function appIdentity() { | |
| 27 | return { | |
| 28 | version: Constants.expoConfig?.version ?? "0.0.0", | |
| 29 | build: | |
| 30 | (Platform.OS === "ios" | |
| 31 | ? Constants.platform?.ios?.buildNumber | |
| 32 | : Constants.platform?.android?.versionCode?.toString()) ?? "dev", | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Startup crashes that TestFlight and the stores strip from their reports. | |
| 38 | * expo-updates' ErrorRecovery writes the JS error and component stack to its | |
| 39 | * own log before aborting the process, so the next launch can show it here. | |
| 40 | */ | |
| 41 | export function SettingsDiagnosticsRouteScreen() { | |
| 42 | const insets = useSafeAreaInsets(); | |
| 43 | const [state, setState] = useState<CrashLogState>(() => | |
| 44 | Updates.isEnabled ? { status: "loading" } : { status: "unavailable" }, | |
| 45 | ); | |
| 46 | const [copied, setCopied] = useState(false); | |
| 47 | ||
| 48 | useEffect(() => { | |
| 49 | if (!Updates.isEnabled) return; | |
| 50 | let cancelled = false; | |
| 51 | Updates.readLogEntriesAsync(LOG_WINDOW_MS) | |
| 52 | .then((entries) => { | |
| 53 | if (cancelled) return; | |
| 54 | setState({ status: "ready", records: parseStartupCrashRecords(entries) }); | |
| 55 | }) | |
| 56 | .catch((error: unknown) => { | |
| 57 | console.warn("[diagnostics] could not read the expo-updates log", error); | |
| 58 | if (!cancelled) setState({ status: "unavailable" }); | |
| 59 | }); | |
| 60 | return () => { | |
| 61 | cancelled = true; | |
| 62 | }; | |
| 63 | }, []); | |
| 64 | ||
| 65 | const records = state.status === "ready" ? state.records : []; | |
| 66 | const copyReport = async () => { | |
| 67 | const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), { | |
| 68 | target: "crash report", | |
| 69 | }); | |
| 70 | if (ok) setCopied(true); | |
| 71 | }; | |
| 72 | ||
| 73 | return ( | |
| 74 | <View collapsable={false} className="flex-1 bg-sheet"> | |
| 75 | <ScrollView | |
| 76 | contentInsetAdjustmentBehavior="automatic" | |
| 77 | contentInset={{ bottom: Math.max(insets.bottom, 18) }} | |
| 78 | showsVerticalScrollIndicator={false} | |
| 79 | className="flex-1" | |
| 80 | contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]" | |
| 81 | > | |
| 82 | <SettingsSection title="Startup crashes"> | |
| 83 | {state.status === "loading" ? ( | |
| 84 | <View className="items-center gap-3 px-6 py-8"> | |
| 85 | <ActivityIndicator /> | |
| 86 | <Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text> | |
| 87 | </View> | |
| 88 | ) : state.status === "unavailable" ? ( | |
| 89 | <EmptyState | |
| 90 | icon="exclamationmark.triangle" | |
| 91 | title="Crash log unavailable" | |
| 92 | detail="Startup crash records are only kept in store and TestFlight builds." | |
| 93 | /> | |
| 94 | ) : records.length === 0 ? ( | |
| 95 | <EmptyState | |
| 96 | icon="checkmark.circle" | |
| 97 | title="No startup crashes" | |
| 98 | detail="Nothing has taken the app down during launch in the last 7 days." | |
| 99 | /> | |
| 100 | ) : ( | |
| 101 | records.map((record, index) => ( | |
| 102 | <CrashRow key={record.timestamp} record={record} first={index === 0} /> | |
| 103 | )) | |
| 104 | )} | |
| 105 | </SettingsSection> | |
| 106 | ||
| 107 | <View className="gap-3"> | |
| 108 | <SettingsSection title="Actions"> | |
| 109 | <Pressable | |
| 110 | accessibilityRole="button" | |
| 111 | disabled={state.status !== "ready"} | |
| 112 | onPress={() => void copyReport()} | |
| 113 | className="flex-row items-center gap-4 p-4 disabled:opacity-40" | |
| 114 | > | |
| 115 | <SymbolView | |
| 116 | name={copied ? "checkmark" : "doc.on.doc"} | |
| 117 | size={22} | |
| 118 | tintColorClassName={"accent-icon"} | |
| 119 | type="monochrome" | |
| 120 | weight="regular" | |
| 121 | /> | |
| 122 | <Text className="flex-1 text-lg text-foreground"> | |
| 123 | {copied ? "Copied" : "Copy crash report"} | |
| 124 | </Text> | |
| 125 | </Pressable> | |
| 126 | </SettingsSection> | |
| 127 | <Text className="px-2 text-sm leading-normal text-foreground-muted"> | |
| 128 | Paste the report into a GitHub issue. It contains the app version, the JavaScript error | |
| 129 | message, and the component stack. Error messages can quote values from the app, so read | |
| 130 | it over before sharing. | |
| 131 | </Text> | |
| 132 | </View> | |
| 133 | </ScrollView> | |
| 134 | </View> | |
| 135 | ); | |
| 136 | } | |
| 137 | ||
| 138 | function EmptyState(props: { | |
| 139 | readonly icon: "exclamationmark.triangle" | "checkmark.circle"; | |
| 140 | readonly title: string; | |
| 141 | readonly detail: string; | |
| 142 | }) { | |
| 143 | return ( | |
| 144 | <View className="items-center gap-2 px-6 py-8"> | |
| 145 | <SymbolView | |
| 146 | name={props.icon} | |
| 147 | size={28} | |
| 148 | tintColorClassName={"accent-icon"} | |
| 149 | type="monochrome" | |
| 150 | weight="regular" | |
| 151 | /> | |
| 152 | <Text className="text-center text-base text-foreground">{props.title}</Text> | |
| 153 | <Text className="text-center text-sm text-foreground-muted">{props.detail}</Text> | |
| 154 | </View> | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 158 | function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) { | |
| 159 | const { record } = props; | |
| 160 | return ( | |
| 161 | <View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}> | |
| 162 | <Text className="text-xs text-foreground-muted"> | |
| 163 | {new Date(record.timestamp).toLocaleString()} | |
| 164 | </Text> | |
| 165 | <Text selectable className="text-base leading-snug text-danger-foreground"> | |
| 166 | {record.description} | |
| 167 | </Text> | |
| 168 | {record.frames.length > 0 ? ( | |
| 169 | <Text selectable className="font-mono text-xs leading-snug text-foreground-muted"> | |
| 170 | {record.frames.slice(0, 4).join("\n")} | |
| 171 | </Text> | |
| 172 | ) : null} | |
| 173 | </View> | |
| 174 | ); | |
| 175 | } |
apps/mobile/src/features/diagnostics/crash-log-model.test.ts created+74| ... | ... | @@ -0,0 +1,74 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model"; | |
| 4 | ||
| 5 | // Verbatim shape of the entry expo-updates wrote for the build 56 launch crash. | |
| 6 | const BUNDLE = | |
| 7 | "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle"; | |
| 8 | const FATAL = { | |
| 9 | timestamp: 1789277752000, | |
| 10 | level: "error", | |
| 11 | code: "JSRuntimeError", | |
| 12 | message: [ | |
| 13 | "ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930", | |
| 14 | "Domain: RCTErrorDomain", | |
| 15 | "Code: 0", | |
| 16 | "Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null", | |
| 17 | "", | |
| 18 | "This error is located at:", | |
| 19 | ` at NewTaskFlowProvider (${BUNDLE}:590585:51)`, | |
| 20 | ` at NavigationProvider (${BUNDLE}:139067:3)`, | |
| 21 | " at RNSSafeAreaView (<anonymous>)", | |
| 22 | ].join("\n"), | |
| 23 | }; | |
| 24 | ||
| 25 | describe("parseStartupCrashRecords", () => { | |
| 26 | it("extracts the JS error and a compact component stack from a fatal entry", () => { | |
| 27 | const [record] = parseStartupCrashRecords([FATAL]); | |
| 28 | expect(record?.description).toBe( | |
| 29 | "TypeError: Cannot read property 'defaultModelSelection' of null", | |
| 30 | ); | |
| 31 | expect(record?.frames).toEqual([ | |
| 32 | "NewTaskFlowProvider (main.jsbundle:590585:51)", | |
| 33 | "NavigationProvider (main.jsbundle:139067:3)", | |
| 34 | "RNSSafeAreaView", | |
| 35 | ]); | |
| 36 | expect(record?.detail).toContain("Domain: RCTErrorDomain"); | |
| 37 | }); | |
| 38 | ||
| 39 | it("ignores update-check noise and orders crashes newest first", () => { | |
| 40 | const records = parseStartupCrashRecords([ | |
| 41 | { timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" }, | |
| 42 | { ...FATAL, timestamp: 10 }, | |
| 43 | { timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" }, | |
| 44 | { ...FATAL, timestamp: 20 }, | |
| 45 | ]); | |
| 46 | expect(records.map((record) => record.timestamp)).toEqual([20, 10]); | |
| 47 | }); | |
| 48 | ||
| 49 | it("skips a runtime error entry without a description line", () => { | |
| 50 | expect( | |
| 51 | parseStartupCrashRecords([ | |
| 52 | { ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" }, | |
| 53 | ]), | |
| 54 | ).toEqual([]); | |
| 55 | }); | |
| 56 | }); | |
| 57 | ||
| 58 | describe("formatStartupCrashReport", () => { | |
| 59 | it("writes the app version and each crash's full detail", () => { | |
| 60 | const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), { | |
| 61 | version: "1.1.1", | |
| 62 | build: "56", | |
| 63 | }); | |
| 64 | expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true); | |
| 65 | expect(report).toContain("2026-09-13T05:35:52.000Z"); | |
| 66 | expect(report).toContain("at NewTaskFlowProvider"); | |
| 67 | }); | |
| 68 | ||
| 69 | it("says so when nothing was recorded", () => { | |
| 70 | expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe( | |
| 71 | "T3 Code 1.1.1 (56)\nNo startup crashes recorded.", | |
| 72 | ); | |
| 73 | }); | |
| 74 | }); |
apps/mobile/src/features/diagnostics/crash-log-model.ts created+83| ... | ... | @@ -0,0 +1,83 @@ |
| 1 | /** | |
| 2 | * The shape of an expo-updates log entry we care about. Mirrors | |
| 3 | * `UpdatesLogEntry` structurally so the model needs no native module at test | |
| 4 | * time. | |
| 5 | */ | |
| 6 | export interface UpdatesLogEntryLike { | |
| 7 | readonly timestamp: number; | |
| 8 | readonly message: string; | |
| 9 | readonly code: string; | |
| 10 | readonly level: string; | |
| 11 | } | |
| 12 | ||
| 13 | /** One fatal JavaScript error that took the app down at startup. */ | |
| 14 | export interface StartupCrashRecord { | |
| 15 | readonly timestamp: number; | |
| 16 | /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */ | |
| 17 | readonly description: string; | |
| 18 | /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */ | |
| 19 | readonly frames: ReadonlyArray<string>; | |
| 20 | /** Everything after the message header, verbatim, for copying. */ | |
| 21 | readonly detail: string; | |
| 22 | } | |
| 23 | ||
| 24 | const FATAL_PREFIX = "ErrorRecovery fatal exception: "; | |
| 25 | const DESCRIPTION_PREFIX = "Description: "; | |
| 26 | const UNHANDLED_PREFIX = "Unhandled JS Exception: "; | |
| 27 | ||
| 28 | /** | |
| 29 | * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while | |
| 30 | * aborting the process. Everything else in that log (update checks, asset | |
| 31 | * loads) is noise for a "why did the app crash" question. | |
| 32 | */ | |
| 33 | export function parseStartupCrashRecords( | |
| 34 | entries: ReadonlyArray<UpdatesLogEntryLike>, | |
| 35 | ): ReadonlyArray<StartupCrashRecord> { | |
| 36 | const records: StartupCrashRecord[] = []; | |
| 37 | for (const entry of entries) { | |
| 38 | if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue; | |
| 39 | const body = entry.message.slice(FATAL_PREFIX.length); | |
| 40 | const lines = body.split("\n"); | |
| 41 | const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX)); | |
| 42 | if (descriptionLine === undefined) continue; | |
| 43 | let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim(); | |
| 44 | if (description.startsWith(UNHANDLED_PREFIX)) { | |
| 45 | description = description.slice(UNHANDLED_PREFIX.length); | |
| 46 | } | |
| 47 | const frames = lines | |
| 48 | .map((line) => line.trim()) | |
| 49 | .filter((line) => line.startsWith("at ")) | |
| 50 | .map(compactFrame); | |
| 51 | records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() }); | |
| 52 | } | |
| 53 | // Newest first; the log appends in time order. | |
| 54 | return records.sort((left, right) => right.timestamp - left.timestamp); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as | |
| 59 | * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is | |
| 60 | * the same on every frame and says nothing. | |
| 61 | */ | |
| 62 | function compactFrame(line: string): string { | |
| 63 | const match = /^at (.+?) \((.*)\)$/.exec(line); | |
| 64 | if (!match) return line.slice("at ".length); | |
| 65 | const [, name = "", location = ""] = match; | |
| 66 | const file = location.slice(location.lastIndexOf("/") + 1); | |
| 67 | return location === "<anonymous>" ? name : `${name} (${file})`; | |
| 68 | } | |
| 69 | ||
| 70 | /** The report a user pastes into an issue: every recorded crash, newest first. */ | |
| 71 | export function formatStartupCrashReport( | |
| 72 | records: ReadonlyArray<StartupCrashRecord>, | |
| 73 | app: { readonly version: string; readonly build: string }, | |
| 74 | ): string { | |
| 75 | const header = `T3 Code ${app.version} (${app.build})`; | |
| 76 | if (records.length === 0) return `${header}\nNo startup crashes recorded.`; | |
| 77 | return [ | |
| 78 | header, | |
| 79 | ...records.map( | |
| 80 | (record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`, | |
| 81 | ), | |
| 82 | ].join("\n"); | |
| 83 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+1| ... | ... | @@ -821,6 +821,7 @@ function AppSettingsSection() { |
| 821 | 821 | return ( |
| 822 | 822 | <SettingsSection title="App"> |
| 823 | 823 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 824 | <SettingsRow icon="stethoscope" label="Diagnostics" target="SettingsDiagnostics" /> | |
| 824 | 825 | <SettingsRow |
| 825 | 826 | icon="doc.on.doc" |
| 826 | 827 | label="Open source licenses" |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | ... | @@ -4,6 +4,7 @@ export type SettingsSheetTarget = |
| 4 | 4 | | "SettingsAppearance" |
| 5 | 5 | | "SettingsProjectGrouping" |
| 6 | 6 | | "SettingsClientStorage" |
| 7 | | "SettingsDiagnostics" | |
| 7 | 8 | | "SettingsOpenSourceLicenses" |
| 8 | 9 | | "SettingsUsage"; |
| 9 | 10 |
docs/user/install.md+6| ... | ... | @@ -60,6 +60,12 @@ Install T3 Code from the |
| 60 | 60 | The phone connects to a server on another machine. Follow |
| 61 | 61 | [remote access](./remote-access.md) to link it through T3 Connect or a pairing URL. |
| 62 | 62 | |
| 63 | If the app crashes during launch, open Settings → Diagnostics on the next launch | |
| 64 | that succeeds. It lists startup crashes from the last 7 days with the error and | |
| 65 | component stack that store crash reports leave out. Copy the report and paste it | |
| 66 | into a GitHub issue. Error messages can quote values from the app, so read it over | |
| 67 | before sharing. | |
| 68 | ||
| 63 | 69 | ## Providers |
| 64 | 70 | |
| 65 | 71 | Open **Settings → Providers** in the web or desktop app, select the environment, |