Skip to content
Merged
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
7 changes: 6 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"expo-system-ui": "~6.0.9",
"expo-updates": "~29.0.20",
"js-base64": "3.7.8",
"markdown-it": "14.1.1",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
Expand All @@ -56,13 +57,17 @@
"@jest/globals": "29.7.0",
"@testing-library/react-native": "13.3.3",
"@types/jest": "29.5.14",
"@types/markdown-it": "14.1.2",
"@types/react": "~19.1.17",
"expo-doctor": "1.20.2",
"jest-expo": "~54.0.18",
"react-test-renderer": "19.1.0",
"typescript": "~5.9.3"
},
"jest": {
"preset": "jest-expo"
"preset": "jest-expo",
"setupFilesAfterEnv": [
"<rootDir>/test/setup.ts"
]
}
}
135 changes: 91 additions & 44 deletions apps/mobile/src/components/modal-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useState } from "react";
import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react";
import {
AccessibilityInfo,
findNodeHandle,
KeyboardAvoidingView,
Modal,
Platform,
Expand All @@ -11,7 +12,7 @@ import {
useWindowDimensions,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";

import { palette, space, typeRamp, typography, usesLargeTextLayout } from "../theme";

Expand All @@ -22,17 +23,40 @@ export function ModalSheet({
subtitle,
title,
visible,
size = "page",
returnFocusRef,
}: {
children: ReactNode;
onClose: () => void;
scrollable?: boolean;
subtitle?: string;
title: string;
visible: boolean;
size?: "page" | "full" | "compact";
returnFocusRef?: RefObject<View | null> | undefined;
}) {
const [reducedMotion, setReducedMotion] = useState(false);
const { fontScale } = useWindowDimensions();
const { fontScale, width } = useWindowDimensions();
const largeText = usesLargeTextLayout(fontScale);
const compact = size === "compact" && !largeText;
const overlay = compact || (size !== "page" && width >= 700);
const headingRef = useRef<Text>(null);
const wasVisible = useRef(visible);
const restoreFocus = () => {
if (!returnFocusRef?.current) return;
const handle = findNodeHandle(returnFocusRef.current);
if (handle) AccessibilityInfo.setAccessibilityFocus(handle);
};
useEffect(() => {
const closed = wasVisible.current && !visible;
wasVisible.current = visible;
if (!closed || Platform.OS === "ios" || !returnFocusRef) return;
const frame = requestAnimationFrame(() => {
const handle = returnFocusRef.current ? findNodeHandle(returnFocusRef.current) : null;
if (handle) AccessibilityInfo.setAccessibilityFocus(handle);
});
return () => cancelAnimationFrame(frame);
}, [visible, returnFocusRef]);

useEffect(() => {
let active = true;
Expand All @@ -55,58 +79,81 @@ export function ModalSheet({
<Modal
animationType={reducedMotion ? "none" : "slide"}
onRequestClose={onClose}
presentationStyle="pageSheet"
onDismiss={restoreFocus}
onShow={() => {
const handle = findNodeHandle(headingRef.current);
if (handle) AccessibilityInfo.setAccessibilityFocus(handle);
}}
presentationStyle={overlay ? "overFullScreen" : size === "page" ? "pageSheet" : "fullScreen"}
transparent={overlay}
visible={visible}
>
<SafeAreaView edges={["top", "bottom"]} style={styles.safeArea}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.keyboardView}
{/* Native modals need safe-area measurements from their own presentation root. */}
<SafeAreaProvider
style={[styles.frame, overlay && styles.overlay, compact && width < 700 && styles.bottom]}
>
<SafeAreaView
edges={["top", "bottom"]}
style={[styles.safeArea, overlay && styles.panel, compact && styles.compactPanel]}
>
<View style={[styles.header, largeText && styles.headerLargeText]}>
<View style={[styles.heading, largeText && styles.headingLargeText]}>
<Text
accessibilityRole="header"
dynamicTypeRamp={typeRamp.subheading}
style={styles.title}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={[styles.keyboardView, compact && styles.compactKeyboardView]}
>
<View style={[styles.header, largeText && styles.headerLargeText]}>
<View style={[styles.heading, largeText && styles.headingLargeText]}>
<Text
accessibilityRole="header"
ref={headingRef}
dynamicTypeRamp={typeRamp.subheading}
style={styles.title}
>
{title}
</Text>
{subtitle ? (
<Text dynamicTypeRamp={typeRamp.control} style={styles.subtitle}>
{subtitle}
</Text>
) : null}
</View>
<Pressable
accessibilityLabel={`Close ${title}`}
accessibilityRole="button"
onPress={onClose}
style={({ pressed }) => [styles.closeButton, pressed && styles.pressed]}
>
{title}
</Text>
{subtitle ? (
<Text dynamicTypeRamp={typeRamp.control} style={styles.subtitle}>
{subtitle}
<Text dynamicTypeRamp={typeRamp.control} style={styles.closeLabel}>
Done
</Text>
) : null}
</Pressable>
</View>
<Pressable
accessibilityLabel={`Close ${title}`}
accessibilityRole="button"
onPress={onClose}
style={({ pressed }) => [styles.closeButton, pressed && styles.pressed]}
>
<Text dynamicTypeRamp={typeRamp.control} style={styles.closeLabel}>
Done
</Text>
</Pressable>
</View>
{scrollable ? (
<ScrollView
contentContainerStyle={styles.content}
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={styles.fixedContent}>{children}</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
{scrollable ? (
<ScrollView
contentContainerStyle={styles.content}
style={compact && styles.compactScroll}
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={styles.fixedContent}>{children}</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
</SafeAreaProvider>
</Modal>
);
}

const styles = StyleSheet.create({
frame: { flex: 1 },
overlay: { backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "center", alignItems: "center" },
bottom: { justifyContent: "flex-end" },
panel: { width: "100%", maxWidth: 640, maxHeight: "90%", borderRadius: 18, overflow: "hidden" },
compactPanel: { flex: 0, flexShrink: 1 },
compactKeyboardView: { flex: 0, flexShrink: 1 },
compactScroll: { flexGrow: 0, flexShrink: 1 },
closeButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.sm },
closeLabel: { ...typography.control, color: palette.signal },
content: { gap: space.md, padding: space.lg, paddingBottom: space.xl },
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/connections/connection-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ test("deletes server-scoped local state when an existing profile changes origin"
String(sql).includes("DELETE FROM followed_projects") && profileId === "profile-id",
),
).toBe(true);
expect(
runAsync.mock.calls.some(
([sql, profileId]) =>
String(sql).includes("DELETE FROM model_favorites") && profileId === "profile-id",
),
).toBe(true);
expect(
runAsync.mock.calls.some(
([sql, profileId]) =>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/connections/connection-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export async function saveConnectionProfile(db: SQLiteDatabase, input: SaveConne
await txn.runAsync("DELETE FROM session_drafts WHERE connection_id = ?", id);
await txn.runAsync("DELETE FROM unresolved_prompt_admissions WHERE connection_id = ?", id);
await txn.runAsync("DELETE FROM followed_projects WHERE connection_id = ?", id);
await txn.runAsync("DELETE FROM model_favorites WHERE connection_id = ?", id);
await txn.runAsync("DELETE FROM followed_project_preferences WHERE connection_id = ?", id);
}
});
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/navigation/root-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ export function RootNavigation() {
options={({ navigation }) => ({
...(customWorkspaceHeader
? {
header: () => (
header: ({ options }) => (
<WorkspaceHeader
navigate={(destination) => navigation.navigate(destination)}
onBack={() => {
if (navigation.canGoBack()) navigation.goBack();
else navigation.popTo("Workspace");
}}
title="Session"
title={options.title ?? "Session"}
/>
),
}
Expand Down
35 changes: 35 additions & 0 deletions apps/mobile/src/screens/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,41 @@ test("uses native navigation on phones and retains the tablet rail", () => {
}
});

test("session tabs replace healthy connection and server labels but retain connection problems", () => {
jest.mocked(useConnections).mockReturnValue({
profiles: [{ id: "connection-1", name: "Test server" }],
selectedProfileId: "connection-1",
} as never);
jest
.mocked(useConnectionRuntime)
.mockReturnValue({ reconnectAttempt: 0, status: "connected" } as never);
jest
.mocked(useWorkspaceSelection)
.mockReturnValue({ attentionCoverage: { completeness: "complete" }, pendingCount: 0 } as never);
const onSelect = jest.fn();
const navigate = jest.fn();
const element = () =>
createElement(ShellFrame, {
active: "Workspace",
navigate,
branch: { state: "known", name: "main" },
sessionTabs: { active: "session", onSelect },
});
const view = render(element());
expect(screen.queryByText("LIVE")).toBeNull();
expect(screen.queryByText("Test server")).toBeNull();
expect(screen.getByRole("tab", { name: "Session", selected: true })).toBeOnTheScreen();
expect(screen.getByRole("button", { name: "Current branch, main" })).toBeOnTheScreen();
fireEvent.press(screen.getByRole("tab", { name: "Changes" }));
expect(onSelect).toHaveBeenCalledWith("changes");
jest
.mocked(useConnectionRuntime)
.mockReturnValue({ reconnectAttempt: 0, status: "offline" } as never);
view.rerender(element());
fireEvent.press(screen.getByRole("button", { name: /Connection problem: OFFLINE/ }));
expect(navigate).toHaveBeenCalledWith("Settings");
});

test("reveals and copies the full session branch name", async () => {
jest.mocked(useConnections).mockReturnValue({
profiles: [{ id: "connection-1", name: "Test server" }],
Expand Down
Loading
Loading