Skip to content

Commit 2632c92

Browse files
Refine mobile environment connection flows
- Rename backends to environments across mobile UI - Add richer loading and empty states for connections, home, and new task flows - Improve review section loading handling and terminal keyboard layout
1 parent 89672b3 commit 2632c92

27 files changed

Lines changed: 724 additions & 177 deletions

apps/mobile/src/app/connections/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default function ConnectionsRouteScreen() {
3333
<View collapsable={false} className="flex-1 bg-sheet">
3434
<Stack.Screen
3535
options={{
36-
title: "Backends",
36+
title: "Environments",
3737
headerRight: () => (
3838
<Link href="/connections/new" asChild>
3939
<Pressable className="h-10 w-10 items-center justify-center rounded-full bg-primary active:opacity-70">
@@ -53,10 +53,10 @@ export default function ConnectionsRouteScreen() {
5353
contentInsetAdjustmentBehavior="automatic"
5454
showsVerticalScrollIndicator={false}
5555
style={{ flex: 1 }}
56+
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
5657
contentContainerStyle={{
5758
paddingHorizontal: 20,
5859
paddingTop: 16,
59-
paddingBottom: Math.max(insets.bottom, 18) + 18,
6060
}}
6161
>
6262
{hasEnvironments ? (
@@ -92,7 +92,7 @@ export default function ConnectionsRouteScreen() {
9292
/>
9393
</View>
9494
<Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
95-
No backends connected yet.{"\n"}Tap{" "}
95+
No environments connected yet.{"\n"}Tap{" "}
9696
<Text className="font-t3-bold text-foreground">+</Text> to add one.
9797
</Text>
9898
</View>

apps/mobile/src/app/connections/new.tsx

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pair
1616

1717
export default function ConnectionsNewRouteScreen() {
1818
const {
19-
connectionError,
2019
connectionPairingUrl,
21-
connectionState,
2220
onChangeConnectionPairingUrl,
2321
onConnectPress,
22+
pairingConnectionError,
2423
} = useRemoteConnections();
2524
const router = useRouter();
2625
const params = useLocalSearchParams<{ mode?: string }>();
@@ -35,8 +34,7 @@ export default function ConnectionsNewRouteScreen() {
3534
const textColor = useThemeColor("--color-icon");
3635
const placeholderColor = useThemeColor("--color-placeholder");
3736

38-
const connectDisabled =
39-
isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
37+
const connectDisabled = isSubmitting || hostInput.trim().length === 0;
4038

4139
useEffect(() => {
4240
const { host, code } = parsePairingUrl(connectionPairingUrl);
@@ -45,10 +43,10 @@ export default function ConnectionsNewRouteScreen() {
4543
}, [connectionPairingUrl]);
4644

4745
useEffect(() => {
48-
if (connectionError) {
46+
if (pairingConnectionError) {
4947
setIsSubmitting(false);
5048
}
51-
}, [connectionError]);
49+
}, [pairingConnectionError]);
5250

5351
const handleHostChange = useCallback((value: string) => {
5452
setHostInput(value);
@@ -72,7 +70,10 @@ export default function ConnectionsNewRouteScreen() {
7270
return;
7371
}
7472

75-
Alert.alert("Camera access needed", "Allow camera access to scan a backend pairing QR code.");
73+
Alert.alert(
74+
"Camera access needed",
75+
"Allow camera access to scan an environment pairing QR code.",
76+
);
7677
}, [cameraPermission?.granted, requestCameraPermission]);
7778

7879
const closeScanner = useCallback(() => {
@@ -126,7 +127,7 @@ export default function ConnectionsNewRouteScreen() {
126127
<View collapsable={false} className="flex-1 bg-sheet">
127128
<Stack.Screen
128129
options={{
129-
title: showScanner ? "Scan QR Code" : "Add Backend",
130+
title: showScanner ? "Scan QR Code" : "Add Environment",
130131
headerRight: () => (
131132
<Pressable
132133
className="h-10 w-10 items-center justify-center rounded-full border border-border bg-secondary"
@@ -154,10 +155,10 @@ export default function ConnectionsNewRouteScreen() {
154155
contentInsetAdjustmentBehavior="automatic"
155156
showsVerticalScrollIndicator={false}
156157
style={{ flex: 1 }}
158+
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
157159
contentContainerStyle={{
158160
paddingHorizontal: 20,
159161
paddingTop: 16,
160-
paddingBottom: Math.max(insets.bottom, 18) + 18,
161162
}}
162163
>
163164
<View collapsable={false} className="gap-5">
@@ -231,13 +232,11 @@ export default function ConnectionsNewRouteScreen() {
231232
/>
232233
</View>
233234

234-
{connectionError ? <ErrorBanner message={connectionError} /> : null}
235+
{pairingConnectionError ? <ErrorBanner message={pairingConnectionError} /> : null}
235236

236237
<ConnectionSheetButton
237238
icon="plus"
238-
label={
239-
isSubmitting || connectionState === "connecting" ? "Pairing..." : "Add backend"
240-
}
239+
label={isSubmitting ? "Pairing..." : "Add environment"}
241240
disabled={connectDisabled}
242241
tone="primary"
243242
onPress={() => {

apps/mobile/src/app/index.tsx

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,50 @@
11
import { Stack, useRouter } from "expo-router";
2-
import { useState } from "react";
3-
import { Text as RNText, View, useColorScheme } from "react-native";
2+
import { type ComponentProps, useState } from "react";
3+
import { Pressable, Text as RNText, View, useColorScheme } from "react-native";
4+
import { SymbolView } from "expo-symbols";
45
import { useThemeColor } from "../lib/useThemeColor";
56

67
import { buildThreadRoutePath } from "../lib/routes";
78
import { useRemoteCatalog } from "../state/use-remote-catalog";
89
import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
910
import { HomeScreen } from "../features/home/HomeScreen";
11+
import type { RemoteCatalogState } from "../state/use-remote-catalog";
12+
13+
function resolveHeaderStatus(state: RemoteCatalogState): {
14+
readonly icon: ComponentProps<typeof SymbolView>["name"];
15+
readonly color: string;
16+
readonly label: string;
17+
} {
18+
if (state.isLoadingSavedConnections) {
19+
return { icon: "hourglass", color: "#737373", label: "Loading environments" };
20+
}
21+
if (state.connectionError) {
22+
return { icon: "exclamationmark.triangle.fill", color: "#ef4444", label: "Environment error" };
23+
}
24+
if (state.connectionState === "ready") {
25+
return { icon: "checkmark.circle.fill", color: "#22c55e", label: "Environment online" };
26+
}
27+
if (state.connectionState === "connecting" || state.connectionState === "reconnecting") {
28+
return {
29+
icon: "arrow.triangle.2.circlepath",
30+
color: "#f59e0b",
31+
label: "Environment connecting",
32+
};
33+
}
34+
return { icon: "wifi.slash", color: "#ef4444", label: "Environment offline" };
35+
}
1036

1137
/* ─── Route screen ───────────────────────────────────────────────────── */
1238

1339
export default function HomeRouteScreen() {
14-
const { projects, threads } = useRemoteCatalog();
40+
const { projects, state: catalogState, threads } = useRemoteCatalog();
1541
const { savedConnectionsById } = useRemoteEnvironmentState();
1642
const router = useRouter();
1743
const [searchQuery, setSearchQuery] = useState("");
1844

1945
const isDark = useColorScheme() === "dark";
2046
const iconColor = String(useThemeColor("--color-icon"));
47+
const status = resolveHeaderStatus(catalogState);
2148

2249
return (
2350
<>
@@ -78,11 +105,15 @@ export default function HomeRouteScreen() {
78105
</Stack.Toolbar>
79106

80107
<Stack.Toolbar placement="right">
81-
<Stack.Toolbar.Button
82-
icon="network"
83-
onPress={() => router.push("/connections")}
84-
separateBackground
85-
/>
108+
<Stack.Toolbar.View hidesSharedBackground>
109+
<Pressable
110+
accessibilityLabel={status.label}
111+
className="h-11 w-11 items-center justify-center rounded-full bg-card active:opacity-70"
112+
onPress={() => router.push("/connections")}
113+
>
114+
<SymbolView name={status.icon} size={21} tintColor={status.color} type="monochrome" />
115+
</Pressable>
116+
</Stack.Toolbar.View>
86117
</Stack.Toolbar>
87118

88119
{/* Bottom toolbar: search + compose, visually split like iMessage */}
@@ -99,8 +130,10 @@ export default function HomeRouteScreen() {
99130
<HomeScreen
100131
projects={projects}
101132
threads={threads}
133+
catalogState={catalogState}
102134
savedConnectionsById={savedConnectionsById}
103135
searchQuery={searchQuery}
136+
onAddConnection={() => router.push("/connections/new")}
104137
onSelectThread={(thread) => {
105138
router.push(buildThreadRoutePath(thread));
106139
}}

apps/mobile/src/app/new/index.tsx

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,74 @@
1-
import { Link, Stack } from "expo-router";
1+
import { Link, Stack, useRouter } from "expo-router";
22
import { SymbolView } from "expo-symbols";
33
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
44
import { useMemo } from "react";
5-
import { Pressable, ScrollView, View } from "react-native";
5+
import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
66
import { useSafeAreaInsets } from "react-native-safe-area-context";
77
import { useThemeColor } from "../../lib/useThemeColor";
88

99
import { AppText as Text } from "../../components/AppText";
1010
import { ProjectFavicon } from "../../components/ProjectFavicon";
1111
import { groupProjectsByRepository } from "../../lib/repositoryGroups";
12-
import { useRemoteCatalog } from "../../state/use-remote-catalog";
12+
import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog";
1313
import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
1414

15+
function deriveProjectEmptyState(catalogState: RemoteCatalogState): {
16+
readonly title: string;
17+
readonly detail: string;
18+
readonly loading: boolean;
19+
} {
20+
if (catalogState.isLoadingSavedConnections) {
21+
return {
22+
title: "Loading environments",
23+
detail: "Checking saved environments on this device.",
24+
loading: true,
25+
};
26+
}
27+
28+
if (!catalogState.hasSavedConnections) {
29+
return {
30+
title: "No environments connected",
31+
detail: "Add an environment before creating a task.",
32+
loading: false,
33+
};
34+
}
35+
36+
if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) {
37+
return {
38+
title: "Environment unavailable",
39+
detail:
40+
catalogState.connectionError ??
41+
"The saved environment is offline. Check the URL or start the environment, then retry.",
42+
loading: false,
43+
};
44+
}
45+
46+
if (
47+
catalogState.hasConnectingEnvironment &&
48+
!catalogState.hasLoadedShellSnapshot &&
49+
catalogState.connectionError === null
50+
) {
51+
return {
52+
title: "Connecting to environment",
53+
detail: "Loading projects from the saved environment.",
54+
loading: true,
55+
};
56+
}
57+
58+
return {
59+
title: "No projects found",
60+
detail: "The connected environment did not report any projects.",
61+
loading: false,
62+
};
63+
}
64+
1565
export default function NewTaskRoute() {
16-
const { projects, threads } = useRemoteCatalog();
66+
const { projects, state: catalogState, threads } = useRemoteCatalog();
1767
const { savedConnectionsById } = useRemoteEnvironmentState();
68+
const router = useRouter();
1869
const insets = useSafeAreaInsets();
1970
const chevronColor = useThemeColor("--color-chevron");
71+
const accentColor = useThemeColor("--color-icon-muted");
2072
const borderSubtleColor = useThemeColor("--color-border-subtle");
2173
const repositoryGroups = useMemo(
2274
() => groupProjectsByRepository({ projects, threads }),
@@ -45,6 +97,7 @@ export default function NewTaskRoute() {
4597
}
4698
return nextItems;
4799
}, [repositoryGroups]);
100+
const projectEmptyState = deriveProjectEmptyState(catalogState);
48101

49102
return (
50103
<View collapsable={false} className="flex-1 bg-sheet">
@@ -65,17 +118,31 @@ export default function NewTaskRoute() {
65118
<ScrollView
66119
showsVerticalScrollIndicator={false}
67120
style={{ flex: 1 }}
121+
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
68122
contentContainerStyle={{
69123
paddingHorizontal: 20,
70124
paddingTop: 8,
71-
paddingBottom: Math.max(insets.bottom, 18) + 18,
72125
}}
73126
>
74127
{items.length === 0 ? (
75-
<View collapsable={false} className="items-center rounded-[24px] bg-card px-6 py-8">
76-
<Text className="text-[16px] font-medium text-foreground-muted">
77-
Loading projects...
128+
<View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
129+
{projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null}
130+
<Text className="text-center text-[17px] font-t3-bold text-foreground">
131+
{projectEmptyState.title}
132+
</Text>
133+
<Text className="text-center text-[14px] leading-[20px] text-foreground-muted">
134+
{projectEmptyState.detail}
78135
</Text>
136+
{!catalogState.hasReadyEnvironment ? (
137+
<Pressable
138+
className="mt-1 rounded-full bg-primary px-4 py-2.5 active:opacity-70"
139+
onPress={() => router.push("/connections/new")}
140+
>
141+
<Text className="text-[13px] font-t3-bold text-primary-foreground">
142+
Add environment
143+
</Text>
144+
</Pressable>
145+
) : null}
79146
</View>
80147
) : (
81148
<View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
1-
import { View } from "react-native";
1+
import { Pressable, View } from "react-native";
22

33
import { AppText as Text } from "./AppText";
44

5-
export function EmptyState(props: { readonly title: string; readonly detail: string }) {
5+
export function EmptyState(props: {
6+
readonly title: string;
7+
readonly detail: string;
8+
readonly actionLabel?: string;
9+
readonly onAction?: () => void;
10+
}) {
611
return (
712
<View className="rounded-[22px] border border-border bg-card p-5">
813
<Text className="font-t3-bold text-lg text-foreground">{props.title}</Text>
914
<Text className="mt-2 font-sans text-sm leading-[21px] text-foreground-muted">
1015
{props.detail}
1116
</Text>
17+
{props.actionLabel && props.onAction ? (
18+
<Pressable
19+
className="mt-4 self-start rounded-full bg-primary px-4 py-2.5 active:opacity-70"
20+
onPress={props.onAction}
21+
>
22+
<Text className="text-[13px] font-t3-bold text-primary-foreground">
23+
{props.actionLabel}
24+
</Text>
25+
</Pressable>
26+
) : null}
1227
</View>
1328
);
1429
}

0 commit comments

Comments
 (0)