Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,13 @@ export const RootStack = createNativeStackNavigator({
Thread: createNativeStackScreen({
screen: ThreadRouteScreen,
linking: THREAD_LINKING_PREFIX,
options: GLASS_HEADER_OPTIONS,
options: {
...GLASS_HEADER_OPTIONS,
// Android owns an in-flow thread header. Hide the native header before
// the route's loading state mounts so hydration cannot move the whole
// screen by one toolbar height when runtime options arrive.
...(Platform.OS === "android" ? { headerShown: false } : null),
},
}),
ThreadTerminal: createNativeStackScreen({
screen: ThreadTerminalRouteScreen,
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/components/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function AndroidHeaderIconButton(props: {
export function AndroidScreenHeader(props: {
readonly title: string;
readonly subtitle?: string | null;
readonly reserveSubtitleSpace?: boolean;
readonly actions?: ReadonlyArray<AndroidHeaderAction>;
readonly trailing?: ReactNode;
readonly onBack?: () => void;
Expand Down Expand Up @@ -85,12 +86,15 @@ export function AndroidScreenHeader(props: {
<Text numberOfLines={1} className="text-lg font-t3-bold text-foreground">
{props.title}
</Text>
{props.subtitle ? (
{props.subtitle || props.reserveSubtitleSpace ? (
<Text
numberOfLines={1}
className="mt-px text-[13px] font-t3-medium text-foreground-muted"
className={cn(
"mt-px text-[13px] font-t3-medium text-foreground-muted",
!props.subtitle && "opacity-0",
)}
>
{props.subtitle}
{props.subtitle || " "}
</Text>
) : null}
</View>
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/components/CopyTextButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
readonly iconSize?: number;
readonly buttonSize?: number;
}) {
const [copied, setCopied] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const copied = copiedText === props.text;
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(
Expand All @@ -36,12 +37,12 @@ export const CopyTextButton = memo(function CopyTextButton(props: {
hitSlop={8}
onPress={() => {
copyTextWithHaptic(props.text);
setCopied(true);
setCopiedText(props.text);
if (resetTimeoutRef.current) {
clearTimeout(resetTimeoutRef.current);
}
resetTimeoutRef.current = setTimeout(() => {
setCopied(false);
setCopiedText(null);
resetTimeoutRef.current = null;
}, COPY_FEEDBACK_DURATION_MS);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import {
} from "./reviewHighlighterEngine";

describe("resolveReviewHighlighterEnginePreference", () => {
it("defaults invalid values to native", () => {
expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("native");
expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("native");
it("defaults invalid values to javascript", () => {
expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("javascript");
expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("javascript");
});

it("accepts supported values", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/review/reviewHighlighterEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function resolveReviewHighlighterEnginePreference(
case "native":
return "native";
default:
return "native";
return "javascript";
}
}

Expand Down
3 changes: 1 addition & 2 deletions apps/mobile/src/features/review/shikiReviewHighlighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,7 @@ const SHIKI_THEME_NAME_BY_SCHEME = {
dark: "github-dark-default",
} as const;
const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE =
process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ??
(process.env.NODE_ENV === "test" ? "javascript" : "native");
process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript";
const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
);
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
readonly host: string;
readonly href: string;
}) {
const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host));
const [failedHost, setFailedHost] = useState<string | null>(() =>
failedMarkdownFaviconHosts.has(props.host) ? props.host : null,
);
const failed = failedHost === props.host || failedMarkdownFaviconHosts.has(props.host);

return (
<NativeText
Expand All @@ -296,7 +299,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: {
style={[markdownLinkStyles.inlineIcon, markdownLinkStyles.favicon]}
onError={() => {
failedMarkdownFaviconHosts.add(props.host);
setFailed(true);
setFailedHost(props.host);
}}
/>
) : (
Expand Down Expand Up @@ -1855,6 +1858,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
maintainVisibleContentPosition={maintainVisibleContentPosition}
data={presentedFeed}
recycleItems={Platform.OS === "android"}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
extraData={listAppearanceData}
renderItem={renderItem}
keyExtractor={(entry) => entry.id}
Expand Down
136 changes: 101 additions & 35 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ function OpeningThreadLoadingScreen() {
return <LoadingScreen message="Opening thread…" messagePlacement="above-spinner" />;
}

const ANDROID_THREAD_LOADING_ACTIONS: ReadonlyArray<AndroidHeaderAction> = [
{
accessibilityLabel: "Open files",
icon: "folder",
disabled: true,
onPress: () => undefined,
},
{
accessibilityLabel: "Open terminal",
icon: "terminal",
disabled: true,
onPress: () => undefined,
},
{
accessibilityLabel: "Open git controls",
icon: "point.topleft.down.curvedto.point.bottomright.up",
disabled: true,
onPress: () => undefined,
},
];

type ThreadRouteScreenRouteProps = StaticScreenProps<{
readonly environmentId: string;
readonly threadId: string;
Expand All @@ -106,27 +127,65 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps {
readonly renderInspector?: (headerInset: number) => ReactNode;
}

function ThreadUnavailableScreen() {
function ThreadRouteStateScreen(props: {
readonly children: ReactNode;
readonly onNavigateUp: () => void;
readonly title: string;
}) {
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 32,
}}
className="bg-screen flex-1"
>
<EmptyState
title="Thread unavailable"
detail="This thread is not available in the current mobile snapshot."
/>
</ScrollView>
<>
{Platform.OS === "android" ? (
<AndroidScreenHeader
title={props.title}
reserveSubtitleSpace
onBack={props.onNavigateUp}
actions={ANDROID_THREAD_LOADING_ACTIONS}
/>
) : null}
{props.children}
</>
);
}

function ThreadUnavailableScreen(props: { readonly onNavigateUp: () => void }) {
return (
<ThreadRouteStateScreen title="Thread unavailable" onNavigateUp={props.onNavigateUp}>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 32,
}}
className="bg-screen flex-1"
>
<EmptyState
title="Thread unavailable"
detail="This thread is not available in the current mobile snapshot."
/>
</ScrollView>
</ThreadRouteStateScreen>
);
}

function OpeningThreadRouteScreen(props: { readonly onNavigateUp: () => void }) {
return (
<ThreadRouteStateScreen title="Opening thread…" onNavigateUp={props.onNavigateUp}>
<OpeningThreadLoadingScreen />
</ThreadRouteStateScreen>
);
}

export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
const navigation = useNavigation();
const handleNavigateUp = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack();
return;
}
navigation.dispatch(StackActions.replace("Home"));
}, [navigation]);
const { state: workspaceState } = useWorkspaceState();
const { connectionState } = useRemoteConnectionStatus();
const { selectedThread } = useThreadSelection();
Expand All @@ -148,7 +207,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
const selectedThreadDetailState = useSelectedThreadDetailState();

if (environmentId === null || threadIdRaw === null) {
return <OpeningThreadLoadingScreen />;
return <OpeningThreadRouteScreen onNavigateUp={handleNavigateUp} />;
}

// Render the full thread chrome (header, feed, composer) as soon as the
Expand All @@ -165,10 +224,10 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) {
routeConnectionState === "reconnecting";

if (stillHydrating) {
return <OpeningThreadLoadingScreen />;
return <OpeningThreadRouteScreen onNavigateUp={handleNavigateUp} />;
}

return <ThreadUnavailableScreen />;
return <ThreadUnavailableScreen onNavigateUp={handleNavigateUp} />;
}

function ThreadRouteContent(
Expand Down Expand Up @@ -671,30 +730,29 @@ function ThreadRouteContent(
onPress: props.onReturnToThread,
});
}
if (selectedThreadCwd !== null) {
actions.push({
accessibilityLabel: "Open files",
icon: "folder",
onPress: handleOpenFilesInspector,
});
}
if (selectedThreadProject?.workspaceRoot) {
actions.push({
accessibilityLabel: "Open terminal",
icon: "terminal",
onPress: () => handleOpenTerminal(null),
});
}
actions.push({
accessibilityLabel: "Open files",
icon: "folder",
onPress: handleOpenFilesInspector,
disabled: selectedThreadCwd === null,
});
actions.push({
accessibilityLabel: "Open terminal",
icon: "terminal",
onPress: () => handleOpenTerminal(null),
disabled: !selectedThreadProject?.workspaceRoot,
});
actions.push({
accessibilityLabel: "Open git controls",
icon: "point.topleft.down.curvedto.point.bottomright.up",
onPress: handleOpenGitInspector,
});
if (fileInspector.supported && selectedThreadCwd !== null) {
if (fileInspector.supported) {
actions.push({
accessibilityLabel: "Toggle inspector",
icon: "sidebar.right",
onPress: handleToggleInspector,
disabled: selectedThreadCwd === null,
});
}
return actions;
Expand All @@ -713,6 +771,13 @@ function ThreadRouteContent(
// native back button does not render. Provide an explicit Home escape for
// that case; when history exists the native back button is used instead.
const canGoBack = navigation.canGoBack();
const handleNavigateUp = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack();
return;
}
navigation.dispatch(StackActions.replace("Home"));
}, [navigation]);
const compactHomeHeaderItems = useMemo<NativeHeaderItems>(
() => [
withNativeGlassHeaderItem({
Expand Down Expand Up @@ -837,7 +902,8 @@ function ThreadRouteContent(
<AndroidScreenHeader
title={selectedThread.title}
subtitle={headerSubtitle}
onBack={layout.usesSplitView ? undefined : () => navigation.goBack()}
reserveSubtitleSpace
onBack={layout.usesSplitView ? undefined : handleNavigateUp}
actions={androidHeaderActions}
/>
) : null}
Expand Down
Loading
Loading