authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 09:52:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 09:52:01-07:00
log9eb4d71681dc7d002082db2f8b4bacf7614412f4
tree0390c8fe63f8601100b427b5c469a5aade969fa0
parent120fab18d84f1eb993ecc7fa858a8dd212747079
signature Signed by PGP key B5690EEEBB952194

fix(mobile): remove provider setup (#9721)


19 files changed, 32 insertions(+), 1095 deletions(-)

apps/mobile/src/Stack.tsx-8
...@@ -56,7 +56,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc...@@ -56,7 +56,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
57import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";57import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
58import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";58import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
59import { SettingsProviderSetupRouteScreen } from "./features/settings/SettingsProviderSetupRouteScreen";
60import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";59import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
61import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";60import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
62import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";61import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
...@@ -165,13 +164,6 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -165,13 +164,6 @@ const SettingsContentStack = createNativeStackNavigator({
165 title: "Add Environment",164 title: "Add Environment",
166 },165 },
167 }),166 }),
168 SettingsProviderSetup: createNativeStackScreen({
169 screen: SettingsProviderSetupRouteScreen,
170 linking: "providers/:environmentId/:instanceId",
171 options: {
172 title: "Antigravity",
173 },
174 }),
175 SettingsArchive: createNativeStackScreen({167 SettingsArchive: createNativeStackScreen({
176 screen: ArchivedThreadsRouteScreen,168 screen: ArchivedThreadsRouteScreen,
177 linking: "archive",169 linking: "archive",
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx-6
...@@ -111,12 +111,6 @@ function ConfiguredConnectOnboardingRouteScreen() {...@@ -111,12 +111,6 @@ function ConfiguredConnectOnboardingRouteScreen() {
111 <CloudEnvironmentRows111 <CloudEnvironmentRows
112 connectedCloudEnvironments={connectedCloudEnvironments}112 connectedCloudEnvironments={connectedCloudEnvironments}
113 onReconnectEnvironment={onReconnectEnvironment}113 onReconnectEnvironment={onReconnectEnvironment}
114 onSetupProvider={(params) =>
115 navigation.navigate("SettingsSheet", {
116 screen: "SettingsContent",
117 params: { screen: "SettingsProviderSetup", params },
118 })
119 }
120 showHeader={false}114 showHeader={false}
121 />115 />
122 ) : (116 ) : (
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx-22
...@@ -26,8 +26,6 @@ import { cn } from "../../lib/cn";...@@ -26,8 +26,6 @@ import { cn } from "../../lib/cn";
26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
27import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";27import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
28import { serverEnvironment } from "../../state/server";28import { serverEnvironment } from "../../state/server";
29import { ProviderSetupLink } from "../settings/ProviderSetupLink";
30import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen";
31import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";29import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
32import { hasCloudPublicConfig } from "../cloud/publicConfig";30import { hasCloudPublicConfig } from "../cloud/publicConfig";
33import { ConnectionStatusDot } from "./ConnectionStatusDot";31import { ConnectionStatusDot } from "./ConnectionStatusDot";
...@@ -36,7 +34,6 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect...@@ -36,7 +34,6 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
36interface CloudEnvironmentRowsProps {34interface CloudEnvironmentRowsProps {
37 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;35 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
38 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;36 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
39 readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void;
40 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;37 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
41 readonly showcaseSignedIn?: boolean;38 readonly showcaseSignedIn?: boolean;
42 /**39 /**
...@@ -151,7 +148,6 @@ function CloudEnvironmentRowsContent(...@@ -151,7 +148,6 @@ function CloudEnvironmentRowsContent(
151 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}148 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
152 errorExpanded={expandedErrorId === environment.environmentId}149 errorExpanded={expandedErrorId === environment.environmentId}
153 onToggleError={() => handleToggleCloudError(environment.environmentId)}150 onToggleError={() => handleToggleCloudError(environment.environmentId)}
154 onSetupProvider={props.onSetupProvider}
155 />151 />
156 ))}152 ))}
157 {availableCloudEnvironments.map((environment, index) => (153 {availableCloudEnvironments.map((environment, index) => (
...@@ -215,7 +211,6 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -215,7 +211,6 @@ function ConnectedCloudEnvironmentRow(props: {
215 readonly onConnect: () => void;211 readonly onConnect: () => void;
216 readonly onDisconnect: () => void;212 readonly onDisconnect: () => void;
217 readonly onToggleError: () => void;213 readonly onToggleError: () => void;
218 readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void;
219}) {214}) {
220 const serverConfig = useAtomValue(215 const serverConfig = useAtomValue(
221 serverEnvironment.configValueAtom(props.environment.environmentId),216 serverEnvironment.configValueAtom(props.environment.environmentId),
...@@ -240,23 +235,6 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -240,23 +235,6 @@ function ConnectedCloudEnvironmentRow(props: {
240 onToggleError={props.onToggleError}235 onToggleError={props.onToggleError}
241 value={props.environment.connectionState !== "available"}236 value={props.environment.connectionState !== "available"}
242 />237 />
243 {props.onSetupProvider
244 ? serverConfig?.providers
245 .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall)
246 .map((provider) => (
247 <ProviderSetupLink
248 key={provider.instanceId}
249 provider={provider}
250 disabled={props.environment.connectionState !== "connected"}
251 onPress={() =>
252 props.onSetupProvider?.({
253 environmentId: props.environment.environmentId,
254 instanceId: provider.instanceId,
255 })
256 }
257 />
258 ))
259 : null}
260 </View>238 </View>
261 );239 );
262}240}
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx-19
...@@ -15,8 +15,6 @@ import { cn } from "../../lib/cn";...@@ -15,8 +15,6 @@ import { cn } from "../../lib/cn";
15import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";15import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
16import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";16import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
17import { serverEnvironment } from "../../state/server";17import { serverEnvironment } from "../../state/server";
18import { ProviderSetupLink } from "../settings/ProviderSetupLink";
19import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen";
20import { ConnectionStatusDot } from "./ConnectionStatusDot";18import { ConnectionStatusDot } from "./ConnectionStatusDot";
2119
22function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {20function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
...@@ -33,7 +31,6 @@ export function ConnectionEnvironmentRow(props: {...@@ -33,7 +31,6 @@ export function ConnectionEnvironmentRow(props: {
33 readonly onToggle: () => void;31 readonly onToggle: () => void;
34 readonly onReconnect: (environmentId: EnvironmentId) => void;32 readonly onReconnect: (environmentId: EnvironmentId) => void;
35 readonly onRemove: (environmentId: EnvironmentId) => void;33 readonly onRemove: (environmentId: EnvironmentId) => void;
36 readonly onSetupProvider: (target: ProviderSetupRouteParams) => void;
37 readonly onUpdate: (34 readonly onUpdate: (
38 environmentId: EnvironmentId,35 environmentId: EnvironmentId,
39 updates: { readonly label: string; readonly displayUrl: string },36 updates: { readonly label: string; readonly displayUrl: string },
...@@ -182,22 +179,6 @@ export function ConnectionEnvironmentRow(props: {...@@ -182,22 +179,6 @@ export function ConnectionEnvironmentRow(props: {
182 </>179 </>
183 )}180 )}
184181
185 {serverConfig?.providers
186 .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall)
187 .map((provider) => (
188 <ProviderSetupLink
189 key={provider.instanceId}
190 provider={provider}
191 disabled={props.environment.connectionState !== "connected"}
192 onPress={() =>
193 props.onSetupProvider({
194 environmentId: props.environment.environmentId,
195 instanceId: provider.instanceId,
196 })
197 }
198 />
199 ))}
200
201 <View className="flex-row justify-end gap-2">182 <View className="flex-row justify-end gap-2">
202 {props.environment.isRelayManaged ? null : (183 {props.environment.isRelayManaged ? null : (
203 <Pressable184 <Pressable
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx-6
...@@ -75,12 +75,6 @@ export function ConnectionsRouteScreen() {...@@ -75,12 +75,6 @@ export function ConnectionsRouteScreen() {
75 onReconnect={onReconnectEnvironment}75 onReconnect={onReconnectEnvironment}
76 onRemove={onRemoveEnvironmentPress}76 onRemove={onRemoveEnvironmentPress}
77 onUpdate={onUpdateEnvironment}77 onUpdate={onUpdateEnvironment}
78 onSetupProvider={(params) =>
79 navigation.navigate("SettingsSheet", {
80 screen: "SettingsContent",
81 params: { screen: "SettingsProviderSetup", params },
82 })
83 }
84 />78 />
85 </View>79 </View>
86 ))}80 ))}
apps/mobile/src/features/settings/ProviderSetupLink.tsx deleted-29
...@@ -1,29 +0,0 @@
1import type { ServerProvider } from "@t3tools/contracts";
2import { Pressable } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { SymbolView } from "../../components/AppSymbol";
6import { ProviderIcon } from "../../components/ProviderIcon";
7import { providerNeedsSetup } from "./provider-setup-state";
8
9export function ProviderSetupLink(props: {
10 readonly provider: ServerProvider;
11 readonly disabled?: boolean;
12 readonly onPress: () => void;
13}) {
14 const label = props.provider.displayName ?? props.provider.driver;
15 return (
16 <Pressable
17 accessibilityRole="button"
18 disabled={props.disabled}
19 onPress={props.onPress}
20 className="min-h-12 flex-row items-center gap-3 px-4 py-3 active:opacity-70 disabled:opacity-40"
21 >
22 <ProviderIcon provider={props.provider.driver} size={18} />
23 <Text className="min-w-0 flex-1 text-base text-foreground">
24 {providerNeedsSetup(props.provider) ? `Set up ${label}` : `Manage ${label}`}
25 </Text>
26 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-foreground" />
27 </Pressable>
28 );
29}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx-12
...@@ -137,12 +137,6 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -137,12 +137,6 @@ export function SettingsEnvironmentsRouteScreen() {
137 onReconnect={onReconnectEnvironment}137 onReconnect={onReconnectEnvironment}
138 onRemove={onRemoveEnvironmentPress}138 onRemove={onRemoveEnvironmentPress}
139 onUpdate={handleUpdateEnvironment}139 onUpdate={handleUpdateEnvironment}
140 onSetupProvider={(params) =>
141 navigation.navigate("SettingsSheet", {
142 screen: "SettingsContent",
143 params: { screen: "SettingsProviderSetup", params },
144 })
145 }
146 />140 />
147 </View>141 </View>
148 ))}142 ))}
...@@ -170,12 +164,6 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -170,12 +164,6 @@ export function SettingsEnvironmentsRouteScreen() {
170 <CloudEnvironmentRows164 <CloudEnvironmentRows
171 connectedCloudEnvironments={connectedCloudEnvironments}165 connectedCloudEnvironments={connectedCloudEnvironments}
172 onReconnectEnvironment={onReconnectEnvironment}166 onReconnectEnvironment={onReconnectEnvironment}
173 onSetupProvider={(params) =>
174 navigation.navigate("SettingsSheet", {
175 screen: "SettingsContent",
176 params: { screen: "SettingsProviderSetup", params },
177 })
178 }
179 {...(SHOWCASE_ENABLED167 {...(SHOWCASE_ENABLED
180 ? {168 ? {
181 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,169 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx deleted-574
...@@ -1,574 +0,0 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import {
5 ANTIGRAVITY_AUTH_METHODS,
6 AuthOrchestrationOperateScope,
7 EnvironmentId,
8 ProviderInstanceId,
9} from "@t3tools/contracts";
10import * as Schema from "effect/Schema";
11import { AsyncResult } from "effect/unstable/reactivity";
12import { useCallback, useEffect, useRef, useState } from "react";
13import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15
16import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { ProviderIcon } from "../../components/ProviderIcon";
19import { cn } from "../../lib/cn";
20import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
22import { NativeStackScreenOptions } from "../../native/StackHeader";
23import { appAtomRegistry } from "../../state/atom-registry";
24import { useEnvironments } from "../../state/environments";
25import { useEnvironmentQuery } from "../../state/query";
26import { serverEnvironment } from "../../state/server";
27import { environmentSession } from "../../state/session";
28import { useAtomCommand } from "../../state/use-atom-command";
29import {
30 antigravityEnabledPatch,
31 readAntigravityAuthMethod,
32 resolveProviderSignInPresentation,
33} from "./provider-setup-state";
34
35export type ProviderSetupRouteParams = {
36 readonly environmentId: EnvironmentId;
37 readonly instanceId: ProviderInstanceId;
38};
39
40const PRIVATE_COMMAND_OPTIONS = { reportFailure: false, reportDefect: false } as const;
41const isEnvironmentId = Schema.is(EnvironmentId);
42const isProviderInstanceId = Schema.is(ProviderInstanceId);
43
44function SetupButton(props: {
45 readonly label: string;
46 readonly disabled?: boolean;
47 readonly primary?: boolean;
48 readonly destructive?: boolean;
49 readonly onPress: () => void;
50}) {
51 return (
52 <Pressable
53 accessibilityRole="button"
54 disabled={props.disabled}
55 onPress={props.onPress}
56 className={cn(
57 "min-h-11 items-center justify-center border border-white/25 px-4 py-3 active:opacity-70 disabled:opacity-40",
58 props.primary && "bg-white",
59 )}
60 >
61 <Text
62 className={cn(
63 "text-base font-t3-medium text-white",
64 props.primary && "text-black",
65 props.destructive && "text-red-400",
66 )}
67 >
68 {props.label}
69 </Text>
70 </Pressable>
71 );
72}
73
74/** The same screen stays inside Settings or the current model picker. */
75export function SettingsProviderSetupRouteScreen({
76 route,
77}: StaticScreenProps<ProviderSetupRouteParams>) {
78 if (
79 !isEnvironmentId(route.params?.environmentId) ||
80 !isProviderInstanceId(route.params?.instanceId)
81 ) {
82 return (
83 <View className="flex-1 bg-black p-5">
84 <Text className="text-base text-white">This provider link is not valid.</Text>
85 </View>
86 );
87 }
88 return (
89 <ProviderSetupScreen
90 key={`${route.params.environmentId}:${route.params.instanceId}`}
91 {...route.params}
92 />
93 );
94}
95
96function ProviderSetupScreen({ environmentId, instanceId }: ProviderSetupRouteParams) {
97 const navigation = useNavigation();
98 const insets = useSafeAreaInsets();
99 const { presentationById } = useEnvironments();
100 const environment = presentationById.get(environmentId);
101 const environmentLabel = environment?.entry.target.label ?? "this environment";
102 const isConnected = environment?.connection.phase === "connected";
103 const config = useAtomValue(serverEnvironment.configValueAtom(environmentId));
104 const provider = config?.providers.find((item) => item.instanceId === instanceId);
105 const access = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId));
106 const canOperate =
107 access.data?.authenticated === true &&
108 access.data.scopes?.includes(AuthOrchestrationOperateScope) === true;
109 const target = { environmentId, input: { instanceId } };
110 const authQuery = useEnvironmentQuery(
111 canOperate && provider?.setup?.canAuthenticate
112 ? serverEnvironment.providerAuthState(target)
113 : null,
114 );
115 const installQuery = useEnvironmentQuery(
116 canOperate && provider?.setup?.canInstall
117 ? serverEnvironment.providerInstallState(target)
118 : null,
119 );
120 const auth = authQuery.data;
121 const installation = installQuery.data;
122 const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, PRIVATE_COMMAND_OPTIONS);
123 const completeAuth = useAtomCommand(
124 serverEnvironment.completeProviderAuth,
125 PRIVATE_COMMAND_OPTIONS,
126 );
127 const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, PRIVATE_COMMAND_OPTIONS);
128 const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, PRIVATE_COMMAND_OPTIONS);
129 const startInstall = useAtomCommand(
130 serverEnvironment.startProviderInstall,
131 PRIVATE_COMMAND_OPTIONS,
132 );
133 const cancelInstall = useAtomCommand(
134 serverEnvironment.cancelProviderInstall,
135 PRIVATE_COMMAND_OPTIONS,
136 );
137 const removeInstall = useAtomCommand(
138 serverEnvironment.removeProviderInstallation,
139 PRIVATE_COMMAND_OPTIONS,
140 );
141 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, PRIVATE_COMMAND_OPTIONS);
142 const [callbackUrl, setCallbackUrl] = useState("");
143 const [error, setError] = useState<string | null>(null);
144 const [busy, setBusy] = useState(false);
145 const busyRef = useRef(false);
146 const authActive =
147 auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying";
148 const installActive =
149 installation?.phase === "downloading" ||
150 installation?.phase === "extracting" ||
151 installation?.phase === "verifying";
152 const controlsDisabled = busy || !isConnected || !canOperate;
153 const {
154 signedIn,
155 showSignOut,
156 message: authMessage,
157 } = resolveProviderSignInPresentation(provider, auth);
158 const authMethod = readAntigravityAuthMethod(
159 config?.settings.providerInstances[instanceId]?.config ??
160 (instanceId === "antigravity" ? config?.settings.providers.antigravity : undefined),
161 );
162 const usesBrowser = authMethod === "oauth-personal" || authMethod === "oauth-business";
163 const methodLabel =
164 ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === authMethod)?.label ??
165 "Google account";
166
167 // Return URLs never enter saved drafts, navigation params, or diagnostics.
168 useEffect(() => {
169 setCallbackUrl("");
170 }, [auth?.flowId, auth?.phase]);
171
172 const perform = useCallback(
173 async (command: () => Promise<AtomCommandResult<unknown, unknown>>, failureMessage: string) => {
174 if (busyRef.current || !isConnected || !canOperate) return false;
175 busyRef.current = true;
176 setBusy(true);
177 setError(null);
178 try {
179 const result = await command();
180 if (!AsyncResult.isSuccess(result)) {
181 setError(failureMessage);
182 return false;
183 }
184 return true;
185 } catch {
186 setError(failureMessage);
187 return false;
188 } finally {
189 busyRef.current = false;
190 setBusy(false);
191 }
192 },
193 [canOperate, isConnected],
194 );
195
196 const setEnabled = (enabled: boolean) => {
197 const currentConfig = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId));
198 const currentProvider = currentConfig?.providers.find((item) => item.instanceId === instanceId);
199 if (!currentConfig || !currentProvider) return;
200 const patch = antigravityEnabledPatch(currentConfig.settings, currentProvider, enabled);
201 if (!patch) return;
202 void perform(
203 () => updateSettings({ environmentId, input: { patch } }),
204 "Could not change this provider. Reconnect and try again.",
205 );
206 };
207
208 const title = provider?.displayName ?? "Antigravity";
209 return (
210 <View className="flex-1 bg-black">
211 <NativeStackScreenOptions
212 options={{
213 title,
214 headerShown: Platform.OS !== "android",
215 headerTransparent: false,
216 headerStyle: { backgroundColor: "#000" },
217 headerTintColor: "#fff",
218 contentStyle: { backgroundColor: "#000" },
219 }}
220 />
221 {Platform.OS === "android" ? (
222 <AndroidScreenHeader title={title} onBack={() => navigation.goBack()} />
223 ) : null}
224 <ScrollView
225 className="flex-1"
226 contentInsetAdjustmentBehavior="automatic"
227 contentContainerClassName="gap-5 px-5 pt-5"
228 contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
229 keyboardShouldPersistTaps="handled"
230 showsVerticalScrollIndicator={false}
231 >
232 <View className="flex-row items-center gap-3">
233 <ProviderIcon provider="antigravity" size={28} />
234 <Text className="min-w-0 flex-1 text-base text-white">{environmentLabel}</Text>
235 </View>
236 {!isConnected ? (
237 <Text className="text-base text-white">
238 Reconnect to this environment to continue setup.
239 </Text>
240 ) : access.error ? (
241 <View className="gap-3">
242 <Text className="text-base text-white">Could not check your environment access.</Text>
243 <SetupButton label="Retry" onPress={access.refresh} />
244 </View>
245 ) : !access.data ? (
246 <Text className="text-base text-white">Checking environment access.</Text>
247 ) : !canOperate ? (
248 <Text className="text-base text-white">
249 This connection cannot manage providers. Pair again with permission to operate this
250 environment.
251 </Text>
252 ) : null}
253 {!config ? (
254 <Text className="text-base text-white">Loading provider settings.</Text>
255 ) : !provider || provider.driver !== "antigravity" ? (
256 <Text className="text-base text-white">
257 This provider is not available on this environment.
258 </Text>
259 ) : (
260 <>
261 <View className="gap-3">
262 <Text className="text-base text-white">
263 {provider.enabled ? "Enabled" : "Disabled"}
264 {provider.installed
265 ? `. Installed${provider.version ? ` ${provider.version}` : ""}`
266 : ". Not installed"}
267 </Text>
268 <SetupButton
269 label={provider.enabled ? "Disable Antigravity" : "Enable Antigravity"}
270 disabled={controlsDisabled}
271 onPress={() => {
272 if (!provider.enabled) {
273 setEnabled(true);
274 return;
275 }
276 Alert.alert(
277 "Disable Antigravity?",
278 `This stops Antigravity sessions on ${environmentLabel}. Google credentials stay saved.`,
279 [
280 { text: "Cancel", style: "cancel" },
281 { text: "Disable", style: "destructive", onPress: () => setEnabled(false) },
282 ],
283 );
284 }}
285 />
286 </View>
287 {provider.setup?.canInstall ? (
288 <View className="gap-3 border-t border-white/20 pt-4">
289 {installActive ? (
290 <>
291 <Text accessibilityLiveRegion="polite" className="text-base text-white">
292 {installation.phase === "downloading"
293 ? `Downloading ${Math.round(installation.downloadedBytes / 1_048_576)}${installation.totalBytes === null ? "" : ` of ${Math.round(installation.totalBytes / 1_048_576)}`} MB`
294 : installation.phase === "extracting"
295 ? "Extracting Antigravity files."
296 : "Checking the Antigravity installation."}
297 </Text>
298 <SetupButton
299 label="Cancel install"
300 disabled={controlsDisabled || installation.operationId === null}
301 onPress={() => {
302 const operationId = installation.operationId;
303 if (!operationId) return;
304 void perform(
305 () =>
306 cancelInstall({ environmentId, input: { instanceId, operationId } }),
307 "Could not cancel the install. Try again.",
308 );
309 }}
310 />
311 </>
312 ) : !provider.installed ? (
313 <SetupButton
314 label={
315 installation?.phase === "failed" || installation?.phase === "cancelled"
316 ? "Retry install"
317 : "Install Antigravity"
318 }
319 primary
320 disabled={controlsDisabled || authActive}
321 onPress={() =>
322 void perform(
323 () => startInstall(target),
324 "Could not start the install. Try again.",
325 )
326 }
327 />
328 ) : null}
329 {installation?.message ? (
330 <Text className="text-sm text-white">{installation.message}</Text>
331 ) : null}
332 {installation?.canRemove && !installActive ? (
333 <SetupButton
334 label="Remove managed install"
335 destructive
336 disabled={controlsDisabled || authActive}
337 onPress={() => {
338 Alert.alert(
339 "Remove the Antigravity install?",
340 `This removes T3's managed install from ${environmentLabel}. Providers that use it will need it installed again. Google credentials and threads stay.`,
341 [
342 { text: "Cancel", style: "cancel" },
343 {
344 text: "Remove",
345 style: "destructive",
346 onPress: () =>
347 void perform(
348 () => removeInstall(target),
349 "Could not remove the managed install. Try again.",
350 ),
351 },
352 ],
353 );
354 }}
355 />
356 ) : null}
357 {installQuery.error ? (
358 <Text className="text-sm text-white">
359 Install status is unavailable. Reconnect and try again.
360 </Text>
361 ) : null}
362 </View>
363 ) : !provider.installed ? (
364 <Text className="text-base text-white">
365 Antigravity cannot be installed on this environment. Use a supported server host or
366 set an executable path in provider settings.
367 </Text>
368 ) : null}
369 {provider.setup?.canAuthenticate ? (
370 <View className="gap-3 border-t border-white/20 pt-4">
371 <Text className="text-lg font-t3-medium text-white">{methodLabel}</Text>
372 <Text className="text-base text-white">
373 {signedIn
374 ? "Signed in. Credentials stay on this environment."
375 : provider.auth.status === "unknown"
376 ? "Sign-in has not been checked."
377 : usesBrowser
378 ? "Sign in with the Google account you use for Antigravity."
379 : "Connect with the credentials set in provider settings on web or desktop."}
380 </Text>
381 {authActive ? (
382 <>
383 <Text accessibilityLiveRegion="polite" className="text-base text-white">
384 {auth.phase === "starting"
385 ? usesBrowser
386 ? "Starting Google sign-in."
387 : "Checking credentials."
388 : auth.phase === "verifying"
389 ? usesBrowser
390 ? "Checking Google sign-in."
391 : "Checking credentials."
392 : auth.authorizationUrl
393 ? "Complete sign-in in your browser."
394 : "Sign-in is open on another client. Complete it there or wait for it to expire."}
395 </Text>
396 {auth.authorizationUrl && auth.phase === "waiting" ? (
397 <>
398 <SetupButton
399 label="Open Google sign-in"
400 primary
401 disabled={controlsDisabled}
402 onPress={() => {
403 if (!auth.authorizationUrl) return;
404 void tryOpenExternalUrl(auth.authorizationUrl, "provider-auth").then(
405 (opened) => {
406 if (!opened)
407 setError(
408 "Could not open Google sign-in. Copy the link and open it in your browser.",
409 );
410 },
411 );
412 }}
413 />
414 <SetupButton
415 label="Copy sign-in link"
416 disabled={controlsDisabled}
417 onPress={() => {
418 if (!auth.authorizationUrl) return;
419 void tryCopyTextWithHaptic(auth.authorizationUrl, {
420 target: "provider-sign-in-link",
421 }).then((copied) => {
422 if (!copied) setError("Could not copy the sign-in link.");
423 });
424 }}
425 />
426 <Text className="text-sm leading-5 text-white">
427 After sign-in, the browser will open a 127.0.0.1 address that cannot load
428 on your phone. Copy that full address and paste it here.
429 </Text>
430 <TextInput
431 accessibilityLabel="Google sign-in return URL"
432 autoCapitalize="none"
433 autoComplete="off"
434 autoCorrect={false}
435 className="min-h-12 rounded-none border border-white/25 bg-black px-3 py-3 text-base text-white"
436 editable={!controlsDisabled}
437 keyboardType="url"
438 onChangeText={setCallbackUrl}
439 placeholder="http://127.0.0.1:.../"
440 textContentType="none"
441 value={callbackUrl}
442 />
443 <SetupButton
444 label="Complete sign-in"
445 primary
446 disabled={
447 controlsDisabled || callbackUrl.trim().length === 0 || !auth.flowId
448 }
449 onPress={() => {
450 const flowId = auth.flowId;
451 if (!flowId) return;
452 const submittedUrl = callbackUrl.trim();
453 setCallbackUrl("");
454 void perform(
455 () =>
456 completeAuth({
457 environmentId,
458 input: { instanceId, flowId, callbackUrl: submittedUrl },
459 }),
460 "Could not complete Google sign-in. Check the return URL and try again.",
461 );
462 }}
463 />
464 </>
465 ) : null}
466 {auth.expiresAt ? (
467 <Text className="text-sm text-white">
468 Sign-in expires at{" "}
469 {new Date(auth.expiresAt).toLocaleTimeString([], {
470 hour: "numeric",
471 minute: "2-digit",
472 })}
473 .
474 </Text>
475 ) : null}
476 {auth.flowId ? (
477 <SetupButton
478 label="Cancel sign-in"
479 disabled={controlsDisabled}
480 onPress={() => {
481 const flowId = auth.flowId;
482 if (!flowId) return;
483 setCallbackUrl("");
484 void perform(
485 () => cancelAuth({ environmentId, input: { instanceId, flowId } }),
486 "Could not cancel sign-in. Try again.",
487 );
488 }}
489 />
490 ) : null}
491 </>
492 ) : showSignOut ? (
493 <SetupButton
494 label={usesBrowser ? "Sign out of Google" : "Disconnect"}
495 destructive
496 disabled={controlsDisabled}
497 onPress={() => {
498 Alert.alert(
499 usesBrowser ? "Sign out of Google?" : "Disconnect Antigravity?",
500 `This stops Antigravity sessions for ${title} on ${environmentLabel}. Threads and files stay.`,
501 [
502 { text: "Cancel", style: "cancel" },
503 {
504 text: "Sign out",
505 style: "destructive",
506 onPress: () =>
507 void perform(
508 () => logout(target),
509 "Could not sign out of Google. Try again.",
510 ),
511 },
512 ],
513 );
514 }}
515 />
516 ) : (
517 <SetupButton
518 label={
519 usesBrowser
520 ? auth?.phase === "failed" || auth?.phase === "cancelled"
521 ? "Retry Google sign-in"
522 : "Sign in with Google"
523 : auth?.phase === "failed" || auth?.phase === "cancelled"
524 ? "Retry connection"
525 : "Connect"
526 }
527 primary
528 disabled={
529 controlsDisabled ||
530 !provider.enabled ||
531 !provider.installed ||
532 installActive ||
533 authQuery.isPending
534 }
535 onPress={() =>
536 void perform(
537 () => startAuth(target),
538 "Could not start Google sign-in. Try again.",
539 )
540 }
541 />
542 )}
543 {!provider.enabled && !signedIn ? (
544 <Text className="text-sm text-white">Enable Antigravity to sign in.</Text>
545 ) : !provider.installed && !signedIn ? (
546 <Text className="text-sm text-white">Install Antigravity to sign in.</Text>
547 ) : null}
548 {authMessage ? <Text className="text-sm text-white">{authMessage}</Text> : null}
549 {authQuery.error ? (
550 <Text className="text-sm text-white">
551 Sign-in status is unavailable. Reconnect and try again.
552 </Text>
553 ) : null}
554 </View>
555 ) : null}
556 {provider.message ? (
557 <Text className="text-sm text-white">{provider.message}</Text>
558 ) : null}
559 </>
560 )}
561 {error ? (
562 <Text accessibilityRole="alert" className="text-base text-red-400">
563 {error}
564 </Text>
565 ) : null}
566 {busy ? (
567 <Text accessibilityLiveRegion="polite" className="text-sm text-white">
568 Waiting for the environment.
569 </Text>
570 ) : null}
571 </ScrollView>
572 </View>
573 );
574}
apps/mobile/src/features/settings/provider-setup-state.test.ts deleted-159
...@@ -1,159 +0,0 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 ServerProvider,
6 resolveProviderInstanceEnabled,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9import { describe, expect, it } from "vite-plus/test";
10
11import { antigravityEnabledPatch, resolveProviderSignInPresentation } from "./provider-setup-state";
12
13const provider = Schema.decodeSync(ServerProvider)({
14 instanceId: "antigravity",
15 driver: "antigravity",
16 enabled: false,
17 installed: false,
18 version: null,
19 status: "disabled",
20 auth: { status: "unauthenticated" },
21 checkedAt: "2026-09-02T00:00:00.000Z",
22 models: [],
23});
24
25describe("resolveProviderSignInPresentation", () => {
26 const completedFlow = { phase: "succeeded", message: "Google sign-in is complete." } as const;
27
28 it("offers sign-in after saved credentials expire, even if the old flow succeeded", () => {
29 expect(
30 resolveProviderSignInPresentation(
31 {
32 enabled: true,
33 auth: { status: "authenticated" },
34 },
35 completedFlow,
36 ),
37 ).toEqual({
38 signedIn: true,
39 showSignOut: true,
40 message: completedFlow.message,
41 });
42
43 expect(
44 resolveProviderSignInPresentation(
45 {
46 enabled: true,
47 auth: { status: "unauthenticated" },
48 },
49 completedFlow,
50 ),
51 ).toEqual({
52 signedIn: false,
53 showSignOut: false,
54 message: null,
55 });
56 });
57
58 it("allows credential cleanup without claiming a disabled unknown account is signed in", () => {
59 expect(
60 resolveProviderSignInPresentation(
61 {
62 enabled: false,
63 auth: { status: "unknown" },
64 },
65 completedFlow,
66 ),
67 ).toEqual({
68 signedIn: false,
69 showSignOut: true,
70 message: null,
71 });
72 });
73
74 it("keeps progress while Google sign-in is still pending", () => {
75 const flow = { phase: "verifying", message: "Checking Google sign-in." } as const;
76 expect(
77 resolveProviderSignInPresentation(
78 {
79 enabled: true,
80 auth: { status: "unauthenticated" },
81 },
82 flow,
83 ),
84 ).toEqual({
85 signedIn: false,
86 showSignOut: false,
87 message: flow.message,
88 });
89 });
90});
91
92describe("antigravityEnabledPatch", () => {
93 it("enables a legacy instance without losing its executable path or models", () => {
94 const settings = {
95 ...DEFAULT_SERVER_SETTINGS,
96 providers: {
97 ...DEFAULT_SERVER_SETTINGS.providers,
98 antigravity: {
99 ...DEFAULT_SERVER_SETTINGS.providers.antigravity,
100 enabled: false,
101 binaryPath: "/opt/google/agy-acp",
102 customModels: ["gemini-native"],
103 },
104 },
105 };
106 const patch = antigravityEnabledPatch(settings, provider, true);
107 const instance = patch?.providerInstances?.[provider.instanceId];
108
109 expect(instance).toMatchObject({
110 enabled: true,
111 config: { binaryPath: "/opt/google/agy-acp", customModels: ["gemini-native"] },
112 });
113 expect(instance?.config).not.toHaveProperty("enabled");
114 expect(instance && resolveProviderInstanceEnabled(instance)).toBe(true);
115 expect(settings.providers.antigravity.enabled).toBe(false);
116 });
117
118 it("keeps separate accounts and environment overrides when enabling a custom instance", () => {
119 const workId = ProviderInstanceId.make("google_work");
120 const personalId = ProviderInstanceId.make("google_personal");
121 const personal = { driver: ProviderDriverKind.make("antigravity"), enabled: true };
122 const settings = {
123 ...DEFAULT_SERVER_SETTINGS,
124 providerInstances: {
125 [personalId]: personal,
126 [workId]: {
127 driver: ProviderDriverKind.make("antigravity"),
128 displayName: "Work Google",
129 enabled: false,
130 config: { enabled: false, binaryPath: "/work/agy-acp", futureSetting: "keep" },
131 environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }],
132 },
133 },
134 };
135 const patch = antigravityEnabledPatch(settings, { ...provider, instanceId: workId }, true);
136
137 expect(patch?.providerInstances?.[personalId]).toBe(personal);
138 expect(patch?.providerInstances?.[workId]).toMatchObject({
139 displayName: "Work Google",
140 enabled: true,
141 config: { binaryPath: "/work/agy-acp", futureSetting: "keep" },
142 environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }],
143 });
144 expect(patch?.providers).toBeUndefined();
145 });
146
147 it("does not change a different driver", () => {
148 expect(
149 antigravityEnabledPatch(
150 DEFAULT_SERVER_SETTINGS,
151 {
152 ...provider,
153 driver: ProviderDriverKind.make("codex"),
154 },
155 true,
156 ),
157 ).toBeNull();
158 });
159});
apps/mobile/src/features/settings/provider-setup-state.ts deleted-76
...@@ -1,76 +0,0 @@
1import {
2 ANTIGRAVITY_AUTH_METHODS,
3 type AntigravityAuthMethod,
4 DEFAULT_SERVER_SETTINGS,
5 type ProviderAuthState,
6 type ServerProvider,
7 type ServerSettings,
8 type ServerSettingsPatch,
9} from "@t3tools/contracts";
10
11/** Read the configured method from an instance config. Unknown values fall back to personal. */
12export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMethod {
13 const value =
14 config !== null && typeof config === "object" && "authMethod" in config
15 ? config.authMethod
16 : undefined;
17 return (
18 ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === value)?.value ?? "oauth-personal"
19 );
20}
21
22/** A completed sign-in flow does not prove that saved credentials are still valid. */
23export function resolveProviderSignInPresentation(
24 provider: Pick<ServerProvider, "enabled" | "auth"> | undefined,
25 flow: Pick<ProviderAuthState, "phase" | "message"> | null,
26) {
27 const signedIn = provider?.auth.status === "authenticated";
28 return {
29 signedIn,
30 showSignOut: signedIn || (provider?.enabled === false && provider.auth.status === "unknown"),
31 message: flow?.phase === "succeeded" && !signedIn ? null : (flow?.message ?? null),
32 };
33}
34
35/** Keep one enabled flag when a legacy provider becomes an explicit instance. */
36export function antigravityEnabledPatch(
37 settings: ServerSettings,
38 provider: ServerProvider,
39 enabled: boolean,
40): ServerSettingsPatch | null {
41 if (provider.driver !== "antigravity") return null;
42
43 const { enabled: _legacyEnabled, ...legacyConfig } = settings.providers.antigravity;
44 const instance = settings.providerInstances[provider.instanceId] ?? {
45 driver: provider.driver,
46 config: legacyConfig,
47 };
48 const config =
49 instance.config !== null &&
50 typeof instance.config === "object" &&
51 !Array.isArray(instance.config)
52 ? Object.fromEntries(Object.entries(instance.config).filter(([key]) => key !== "enabled"))
53 : instance.config;
54
55 return {
56 ...(provider.instanceId === "antigravity"
57 ? { providers: { antigravity: DEFAULT_SERVER_SETTINGS.providers.antigravity } }
58 : {}),
59 providerInstances: {
60 ...settings.providerInstances,
61 [provider.instanceId]: { ...instance, enabled, config },
62 },
63 };
64}
65
66/** Setup remains available when the provider has no selectable models. */
67export function providerNeedsSetup(provider: ServerProvider): boolean {
68 return (
69 (provider.setup?.canAuthenticate === true || provider.setup?.canInstall === true) &&
70 (!provider.enabled ||
71 !provider.installed ||
72 provider.auth.status !== "authenticated" ||
73 provider.availability === "unavailable" ||
74 provider.models.length === 0)
75 );
76}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1-1
...@@ -904,7 +904,7 @@ export function NewTaskDraftScreen(props: {...@@ -904,7 +904,7 @@ export function NewTaskDraftScreen(props: {
904 ) {904 ) {
905 Alert.alert(905 Alert.alert(
906 "Antigravity model unavailable",906 "Antigravity model unavailable",
907 "Open model settings to finish setup or choose another model.",907 "Set up Antigravity on web or desktop, or choose another model.",
908 );908 );
909 return;909 return;
910 }910 }
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+6-53
...@@ -5,9 +5,7 @@ import type {...@@ -5,9 +5,7 @@ import type {
5 ProviderOptionDescriptor,5 ProviderOptionDescriptor,
6 ProviderOptionSelection,6 ProviderOptionSelection,
7 RuntimeMode,7 RuntimeMode,
8 ServerProvider,
9} from "@t3tools/contracts";8} from "@t3tools/contracts";
10import { useAtomValue } from "@effect/atom-react";
11import type { LegendListRenderItemProps } from "@legendapp/list/react-native";9import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
12import { AnimatedLegendList } from "@legendapp/list/reanimated";10import { AnimatedLegendList } from "@legendapp/list/reanimated";
13import { HeaderHeightContext } from "@react-navigation/elements";11import { HeaderHeightContext } from "@react-navigation/elements";
...@@ -52,11 +50,6 @@ import {...@@ -52,11 +50,6 @@ import {
52} from "../../native/StackHeader";50} from "../../native/StackHeader";
53import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";51import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
54import { serverEnvironment } from "../../state/server";52import { serverEnvironment } from "../../state/server";
55import { ProviderSetupLink } from "../settings/ProviderSetupLink";
56import {
57 SettingsProviderSetupRouteScreen,
58 type ProviderSetupRouteParams,
59} from "../settings/SettingsProviderSetupRouteScreen";
60import { useAtomCommand } from "../../state/use-atom-command";53import { useAtomCommand } from "../../state/use-atom-command";
61import { useNewTaskFlow } from "./new-task-flow-provider";54import { useNewTaskFlow } from "./new-task-flow-provider";
62import {55import {
...@@ -73,7 +66,6 @@ import {...@@ -73,7 +66,6 @@ import {
73 canCommitPendingModel,66 canCommitPendingModel,
74 modelMatchesCatalogQuery,67 modelMatchesCatalogQuery,
75 pendingModelAfterPress,68 pendingModelAfterPress,
76 providerSetupCandidates,
77 providerSectionIsCollapsed,69 providerSectionIsCollapsed,
78} from "./thread-settings-sheet-state";70} from "./thread-settings-sheet-state";
7971
...@@ -446,7 +438,10 @@ function ThreadSettingsSessionProvider(...@@ -446,7 +438,10 @@ function ThreadSettingsSessionProvider(
446 const commitPendingModel = useCallback(() => {438 const commitPendingModel = useCallback(() => {
447 if (pendingModel) {439 if (pendingModel) {
448 if (!canCommitPendingModel(pendingModel, props.providerGroups)) {440 if (!canCommitPendingModel(pendingModel, props.providerGroups)) {
449 Alert.alert("Model unavailable", "Complete provider setup or select another model.");441 Alert.alert(
442 "Model unavailable",
443 "Set up this provider on web or desktop, or select another model.",
444 );
450 return false;445 return false;
451 }446 }
452 void Haptics.selectionAsync();447 void Haptics.selectionAsync();
...@@ -581,11 +576,6 @@ type ThreadSettingsCatalogItem =...@@ -581,11 +576,6 @@ type ThreadSettingsCatalogItem =
581 readonly isFirst: boolean;576 readonly isFirst: boolean;
582 readonly isLast: boolean;577 readonly isLast: boolean;
583 }578 }
584 | {
585 readonly kind: "setup";
586 readonly key: string;
587 readonly provider: ServerProvider;
588 }
589 | {579 | {
590 readonly kind: "empty";580 readonly kind: "empty";
591 readonly key: "empty";581 readonly key: "empty";
...@@ -795,37 +785,20 @@ function ThreadSettingsOptionsItem(props: {...@@ -795,37 +785,20 @@ function ThreadSettingsOptionsItem(props: {
795/** One native scroll owner for the model catalog and its related settings. */785/** One native scroll owner for the model catalog and its related settings. */
796function ThreadSettingsMainContent(props: {786function ThreadSettingsMainContent(props: {
797 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;787 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
798 readonly onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
799}) {788}) {
800 const session = useThreadSettingsSession();789 const session = useThreadSettingsSession();
801 const config = useAtomValue(serverEnvironment.configValueAtom(session.environmentId));
802 const catalogItems = useThreadSettingsCatalogItems(session);790 const catalogItems = useThreadSettingsCatalogItems(session);
803 const [animationsReady, setAnimationsReady] = useState(false);791 const [animationsReady, setAnimationsReady] = useState(false);
804 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;792 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
805 const hasActiveCatalogFilter =793 const hasActiveCatalogFilter =
806 session.providerFilter !== null || session.searchQuery.trim().length > 0;794 session.providerFilter !== null || session.searchQuery.trim().length > 0;
807 const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;795 const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;
808 const setupProviders = useMemo(
809 () =>
810 providerSetupCandidates({
811 providers: config?.providers ?? [],
812 instanceId: session.providerInstanceId,
813 providerFilter: session.providerFilter,
814 query: session.searchQuery,
815 }),
816 [config?.providers, session.providerInstanceId, session.providerFilter, session.searchQuery],
817 );
818 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(796 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(
819 () => [797 () => [
820 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),798 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
821 ...setupProviders.map((provider) => ({
822 kind: "setup" as const,
823 key: `setup:${provider.instanceId}`,
824 provider,
825 })),
826 { kind: "options", key: "options" },799 { kind: "options", key: "options" },
827 ],800 ],
828 [catalogItems, setupProviders],801 [catalogItems],
829 );802 );
830 const renderCatalogItem = useCallback(803 const renderCatalogItem = useCallback(
831 (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => {804 (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => {
...@@ -842,13 +815,6 @@ function ThreadSettingsMainContent(props: {...@@ -842,13 +815,6 @@ function ThreadSettingsMainContent(props: {
842 option={item.option}815 option={item.option}
843 />816 />
844 );817 );
845 } else if (item.kind === "setup") {
846 content = (
847 <ProviderSetupLink
848 provider={item.provider}
849 onPress={() => props.onOpenProviderSetup(item.provider.instanceId)}
850 />
851 );
852 } else if (item.kind === "empty") {818 } else if (item.kind === "empty") {
853 content = (819 content = (
854 <View className="items-center px-8 py-14">820 <View className="items-center px-8 py-14">
...@@ -876,7 +842,7 @@ function ThreadSettingsMainContent(props: {...@@ -876,7 +842,7 @@ function ThreadSettingsMainContent(props: {
876 </Animated.View>842 </Animated.View>
877 );843 );
878 },844 },
879 [animationsReady, hasActiveCatalogFilter, props.onOpenProviderSetup, props.onOpenSubmenu],845 [animationsReady, hasActiveCatalogFilter, props.onOpenSubmenu],
880 );846 );
881847
882 return (848 return (
...@@ -1002,7 +968,6 @@ function ThreadSettingsChoiceContent(props: {...@@ -1002,7 +968,6 @@ function ThreadSettingsChoiceContent(props: {
1002type ThreadSettingsPickerStackParams = {968type ThreadSettingsPickerStackParams = {
1003 ThreadSettingsModels: undefined;969 ThreadSettingsModels: undefined;
1004 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };970 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
1005 ThreadSettingsProviderSetup: ProviderSetupRouteParams;
1006};971};
1007972
1008type ThreadSettingsPickerPresentation = {973type ThreadSettingsPickerPresentation = {
...@@ -1146,13 +1111,6 @@ function ThreadSettingsModelsScreen() {...@@ -1146,13 +1111,6 @@ function ThreadSettingsModelsScreen() {
1146 }}1111 }}
1147 />1112 />
1148 <ThreadSettingsMainContent1113 <ThreadSettingsMainContent
1149 onOpenProviderSetup={(instanceId) => {
1150 if (!session.environmentId) return;
1151 navigation.navigate("ThreadSettingsProviderSetup", {
1152 environmentId: session.environmentId,
1153 instanceId,
1154 });
1155 }}
1156 onOpenSubmenu={(submenu) => {1114 onOpenSubmenu={(submenu) => {
1157 const title =1115 const title =
1158 submenu.kind === "runtime"1116 submenu.kind === "runtime"
...@@ -1287,11 +1245,6 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)...@@ -1287,11 +1245,6 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
1287 component={ThreadSettingsChoiceScreen}1245 component={ThreadSettingsChoiceScreen}
1288 options={({ route }) => ({ title: route.params.title })}1246 options={({ route }) => ({ title: route.params.title })}
1289 />1247 />
1290 <ThreadSettingsPickerStack.Screen
1291 name="ThreadSettingsProviderSetup"
1292 component={SettingsProviderSetupRouteScreen}
1293 options={{ title: "Antigravity" }}
1294 />
1295 </ThreadSettingsPickerStack.Navigator>1248 </ThreadSettingsPickerStack.Navigator>
1296 </ThreadSettingsPickerPresentationContext.Provider>1249 </ThreadSettingsPickerPresentationContext.Provider>
1297 );1250 );
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+1-81
...@@ -1,18 +1,12 @@...@@ -1,18 +1,12 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
4 ProviderInstanceId,
5 ServerProvider,
6 type ProviderOptionSelection,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
94
10import type { ModelOption } from "../../lib/modelOptions";5import type { ModelOption } from "../../lib/modelOptions";
11import {6import {
12 canCommitPendingModel,7 canCommitPendingModel,
13 modelMatchesCatalogQuery,8 modelMatchesCatalogQuery,
14 pendingModelAfterPress,9 pendingModelAfterPress,
15 providerSetupCandidates,
16} from "./thread-settings-sheet-state";10} from "./thread-settings-sheet-state";
1711
18function modelOption(12function modelOption(
...@@ -123,77 +117,3 @@ describe("thread settings sheet state", () => {...@@ -123,77 +117,3 @@ describe("thread settings sheet state", () => {
123 ).toBe(false);117 ).toBe(false);
124 });118 });
125});119});
126
127const decodeServerProvider = Schema.decodeSync(ServerProvider);
128
129function setupProvider(overrides: Partial<ServerProvider> = {}): ServerProvider {
130 return decodeServerProvider({
131 instanceId: "antigravity",
132 driver: "antigravity",
133 displayName: "Antigravity",
134 enabled: false,
135 installed: false,
136 version: null,
137 status: "disabled",
138 auth: { status: "unauthenticated" },
139 checkedAt: "2026-09-02T00:00:00.000Z",
140 setup: { canAuthenticate: true, canInstall: true },
141 models: [],
142 ...overrides,
143 });
144}
145
146describe("providerSetupCandidates", () => {
147 const unfiltered = { providerFilter: null, query: "" };
148
149 it("offers setup without a selectable model and after sign-out", () => {
150 const disabled = setupProvider();
151 const signedOut = setupProvider({ enabled: true, installed: true });
152
153 expect(providerSetupCandidates({ providers: [disabled], ...unfiltered })).toEqual([disabled]);
154 expect(providerSetupCandidates({ providers: [signedOut], ...unfiltered })).toEqual([signedOut]);
155 });
156
157 it("uses the selected environment's status for identical instance IDs", () => {
158 const offlineAccount = setupProvider();
159 const readyAccount = setupProvider({
160 enabled: true,
161 installed: true,
162 auth: { status: "authenticated" },
163 models: [{ slug: "gemini-native", name: "Gemini", isCustom: false, capabilities: null }],
164 });
165
166 expect(providerSetupCandidates({ providers: [offlineAccount], ...unfiltered })).toHaveLength(1);
167 expect(providerSetupCandidates({ providers: [readyAccount], ...unfiltered })).toEqual([]);
168 });
169
170 it("limits existing threads to their provider and respects search", () => {
171 const personal = setupProvider();
172 const work = setupProvider({
173 instanceId: ProviderInstanceId.make("google_work"),
174 displayName: "Work Google",
175 });
176
177 expect(
178 providerSetupCandidates({
179 providers: [personal, work],
180 ...unfiltered,
181 instanceId: work.instanceId,
182 }),
183 ).toEqual([work]);
184 expect(
185 providerSetupCandidates({
186 providers: [personal, work],
187 providerFilter: work.instanceId,
188 query: "work",
189 }),
190 ).toEqual([work]);
191 expect(
192 providerSetupCandidates({
193 providers: [personal, work],
194 providerFilter: null,
195 query: "no-match",
196 }),
197 ).toEqual([]);
198 });
199});
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+1-23
...@@ -1,26 +1,4 @@...@@ -1,26 +1,4 @@
1import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
2import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";1import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
3import { providerNeedsSetup } from "../settings/provider-setup-state";
4
5/** Read setup choices from this environment, not the selectable model list. */
6export function providerSetupCandidates(input: {
7 readonly providers: ReadonlyArray<ServerProvider>;
8 readonly instanceId?: ProviderInstanceId;
9 readonly providerFilter: string | null;
10 readonly query: string;
11}): ReadonlyArray<ServerProvider> {
12 const query = input.query.trim().toLocaleLowerCase();
13 return input.providers.filter(
14 (provider) =>
15 providerNeedsSetup(provider) &&
16 (input.instanceId === undefined || provider.instanceId === input.instanceId) &&
17 (input.providerFilter === null || provider.instanceId === input.providerFilter) &&
18 (query.length === 0 ||
19 [provider.displayName ?? "", provider.driver, provider.instanceId].some((label) =>
20 label.toLocaleLowerCase().includes(query),
21 )),
22 );
23}
242
25/** Match the terms a user can actually see or recognize in the model picker. */3/** Match the terms a user can actually see or recognize in the model picker. */
26export function modelMatchesCatalogQuery(input: {4export function modelMatchesCatalogQuery(input: {
...@@ -53,7 +31,7 @@ export function pendingModelAfterPress(input: {...@@ -53,7 +31,7 @@ export function pendingModelAfterPress(input: {
53 return input.current?.key === input.pressed.key ? input.current : input.pressed;31 return input.current?.key === input.pressed.key ? input.current : input.pressed;
54}32}
5533
56/** A model can disappear while its setup page is open inside the picker. */34/** A model can disappear while the picker is open. */
57export function canCommitPendingModel(35export function canCommitPendingModel(
58 pending: ModelOption,36 pending: ModelOption,
59 groups: ReadonlyArray<ProviderGroup>,37 groups: ReadonlyArray<ProviderGroup>,
apps/mobile/src/features/threads/use-project-actions.ts+1-1
...@@ -118,7 +118,7 @@ export function useCreateProjectThread() {...@@ -118,7 +118,7 @@ export function useCreateProjectThread() {
118 const providerError = !serverConfig118 const providerError = !serverConfig
119 ? "Provider settings are still loading. Try again."119 ? "Provider settings are still loading. Try again."
120 : isModelSelectionUnavailable(serverConfig, input.modelSelection)120 : isModelSelectionUnavailable(serverConfig, input.modelSelection)
121 ? "Antigravity model unavailable. Open model settings to finish setup or choose another model."121 ? "Antigravity model unavailable. Set it up on web or desktop, or choose another model."
122 : null;122 : null;
123 if (providerError !== null) {123 if (providerError !== null) {
124 setPendingConnectionError(providerError);124 setPendingConnectionError(providerError);
apps/mobile/src/state/use-thread-composer-state.ts+1-1
...@@ -276,7 +276,7 @@ export function useThreadComposerState() {...@@ -276,7 +276,7 @@ export function useThreadComposerState() {
276 ) {276 ) {
277 Alert.alert(277 Alert.alert(
278 "Antigravity model unavailable",278 "Antigravity model unavailable",
279 "Open model settings to finish setup or choose another model.",279 "Set up Antigravity on web or desktop, or choose another model.",
280 );280 );
281 return null;281 return null;
282 }282 }
apps/mobile/src/state/use-thread-outbox-drain.ts+4-4
...@@ -636,7 +636,7 @@ export function useThreadOutboxDrain(): void {...@@ -636,7 +636,7 @@ export function useThreadOutboxDrain(): void {
636 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {636 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
637 return restoreQueuedMessage(637 return restoreQueuedMessage(
638 queuedMessage,638 queuedMessage,
639 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",639 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
640 );640 );
641 }641 }
642 const { reportFailure } = makeDeliveryHelpers(queuedMessage);642 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
...@@ -729,7 +729,7 @@ export function useThreadOutboxDrain(): void {...@@ -729,7 +729,7 @@ export function useThreadOutboxDrain(): void {
729 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {729 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
730 return restoreQueuedMessage(730 return restoreQueuedMessage(
731 persistedMessage,731 persistedMessage,
732 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",732 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
733 );733 );
734 }734 }
735 const sendSettings = resolveQueuedThreadSettings(735 const sendSettings = resolveQueuedThreadSettings(
...@@ -811,7 +811,7 @@ export function useThreadOutboxDrain(): void {...@@ -811,7 +811,7 @@ export function useThreadOutboxDrain(): void {
811 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {811 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
812 return restoreQueuedMessage(812 return restoreQueuedMessage(
813 queuedMessage,813 queuedMessage,
814 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",814 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
815 );815 );
816 }816 }
817 let prepared: PreparedTurnAttachments;817 let prepared: PreparedTurnAttachments;
...@@ -855,7 +855,7 @@ export function useThreadOutboxDrain(): void {...@@ -855,7 +855,7 @@ export function useThreadOutboxDrain(): void {
855 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {855 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
856 return restoreQueuedMessage(856 return restoreQueuedMessage(
857 persistedMessage,857 persistedMessage,
858 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",858 "Antigravity model unavailable. Set it up on web or desktop, or choose another model.",
859 );859 );
860 }860 }
861 const sendSettings = resolveQueuedThreadSettings(861 const sendSettings = resolveQueuedThreadSettings(
docs/user/providers-antigravity.md+12-14
...@@ -12,16 +12,13 @@ not sign in this T3 Code provider....@@ -12,16 +12,13 @@ not sign in this T3 Code provider.
12## Set up Antigravity12## Set up Antigravity
1313
14On web or desktop, open **Settings** > **Providers**, select the device that runs your project,14On web or desktop, open **Settings** > **Providers**, select the device that runs your project,
15then select **Antigravity**. On mobile, open **Settings** > **Environments**, expand the15then select **Antigravity**. Provider setup is not available in the mobile app.
16environment, then choose **Set up Antigravity**. An existing setup shows **Manage Antigravity**.
17The model picker can open setup for the selected environment too.
1816
191. Choose **Enable Antigravity**. Antigravity is off by default.171. Choose **Enable Antigravity**. Antigravity is off by default.
202. Choose **Install Antigravity**. T3 Code downloads the official runtime from Google to that182. Choose **Install Antigravity**. T3 Code downloads the official runtime from Google to that
21 environment. Installation continues if you leave the page or reconnect.19 environment. Installation continues if you leave the page or reconnect.
223. Choose **Sign in with Google**.203. Choose **Sign in with Google**.
234. Choose **Open sign-in page** on web or desktop, or **Open Google sign-in** on mobile. You can214. Choose **Open sign-in page**. You can use **Copy sign-in link** to open it in another browser.
24 use **Copy sign-in link** to open it in another browser.
255. Complete Google sign-in. Use the account you use for Antigravity.225. Complete Google sign-in. Use the account you use for Antigravity.
266. Wait for T3 Code to confirm sign-in and load the model choices. Select an Antigravity model236. Wait for T3 Code to confirm sign-in and load the model choices. Select an Antigravity model
27 in the thread's model picker.24 in the thread's model picker.
...@@ -36,8 +33,8 @@ machine as the T3 Code environment, sign-in can finish directly....@@ -36,8 +33,8 @@ machine as the T3 Code environment, sign-in can finish directly.
3633
37On a phone or another computer, the final page will usually fail to load. This is expected.34On a phone or another computer, the final page will usually fail to load. This is expected.
38Copy the full address from the browser, including everything after `?`, and paste it into the35Copy the full address from the browser, including everything after `?`, and paste it into the
39return URL field in T3 Code. Choose **Continue** on web or desktop, or **Complete sign-in** on36return URL field in the web or desktop client where you started setup. Choose **Continue**. Do not
40mobile. Do not change the address to your server's hostname.37change the address to your server's hostname.
4138
42Return to the same T3 Code client and environment where you started sign-in. Another client39Return to the same T3 Code client and environment where you started sign-in. Another client
43can see that sign-in is in progress, but cannot complete that attempt. The setup screen shows40can see that sign-in is in progress, but cannot complete that attempt. The setup screen shows
...@@ -50,7 +47,7 @@ callback page alone does not prove account access....@@ -50,7 +47,7 @@ callback page alone does not prove account access.
50### Other sign-in methods47### Other sign-in methods
5148
52**Sign-in method** in the Antigravity provider settings on web or desktop selects how the49**Sign-in method** in the Antigravity provider settings on web or desktop selects how the
53agent authenticates. Mobile shows the selected method and its connect controls.50agent authenticates.
5451
55| Method | What you enter | How it signs in |52| Method | What you enter | How it signs in |
56| -------------------------- | ---------------------------------------- | ------------------------------- |53| -------------------------- | ---------------------------------------- | ------------------------------- |
...@@ -159,11 +156,11 @@ Each Antigravity provider instance has its own Google sign-in on its environment...@@ -159,11 +156,11 @@ Each Antigravity provider instance has its own Google sign-in on its environment
159**Add provider** in web or desktop provider settings to create a separate instance for another156**Add provider** in web or desktop provider settings to create a separate instance for another
160account. To replace an instance's account, sign out first, then sign in again.157account. To replace an instance's account, sign out first, then sign in again.
161158
162| Action | Result |159| Action | Result |
163| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |160| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
164| Disable Antigravity | Stops the instance's sessions. Keeps Google sign-in, thread history, and files. |161| Disable Antigravity | Stops the instance's sessions. Keeps Google sign-in, thread history, and files. |
165| Sign out of Google | Stops the instance's sessions and removes its saved Google credentials. Keeps thread history and files. |162| Sign out of Google | Stops the instance's sessions and removes its saved Google credentials. Keeps thread history and files. |
166| Remove downloaded runtime on web or desktop, or Remove managed install on mobile | Removes the runtime shared by Antigravity instances on that environment. Keeps Google credentials, thread history, and files. |163| Remove downloaded runtime | Removes the runtime shared by Antigravity instances on that environment. Keeps Google credentials, thread history, and files. |
167164
168Send `/logout` by itself in an Antigravity thread to sign out that provider instance. This has165Send `/logout` by itself in an Antigravity thread to sign out that provider instance. This has
169the same effect as **Sign out of Google**, including stopping its other sessions.166the same effect as **Sign out of Google**, including stopping its other sessions.
...@@ -186,7 +183,8 @@ To check account access and reload models on web or desktop, open **Settings** >...@@ -186,7 +183,8 @@ To check account access and reload models on web or desktop, open **Settings** >
186and select the circular arrow beside **Checked** at the top of the page. Its tooltip says183and select the circular arrow beside **Checked** at the top of the page. Its tooltip says
187**Refresh provider status**. On mobile, use **Refresh models** in the model picker.184**Refresh provider status**. On mobile, use **Refresh models** in the model picker.
188Refresh uses saved Google sign-in and does not open a login page. If sign-in is required,185Refresh uses saved Google sign-in and does not open a login page. If sign-in is required,
189use the provider's setup controls. Automatic status checks verify the installation only.186use the provider's setup controls on web or desktop. Automatic status checks verify the
187installation only.
190188
191The packaged runtime can be slow to start, especially on Windows. Health checks, model refresh,189The packaged runtime can be slow to start, especially on Windows. Health checks, model refresh,
192and sign-out each allow up to 90 seconds before reporting a timeout.190and sign-out each allow up to 90 seconds before reporting a timeout.
docs/user/remote-access.md+5-6
...@@ -206,15 +206,14 @@ If reconnecting after an app update fails, retry the SSH launch once. The launch...@@ -206,15 +206,14 @@ If reconnecting after an app update fails, retry the SSH launch once. The launch
206## Antigravity Google sign-in206## Antigravity Google sign-in
207207
208Antigravity runs and saves its Google credentials on the selected environment. You can install208Antigravity runs and saves its Google credentials on the selected environment. You can install
209it and sign in from a remote web, desktop, or mobile client without an SSH login.209it and sign in from a remote web or desktop client without an SSH login.
210210
211Start in **Settings** > **Providers** on web or desktop. On mobile, open **Settings** >211Start in **Settings** > **Providers**, select the environment, then choose **Antigravity**.
212**Environments**, expand the environment, then choose **Set up Antigravity**.212Provider setup is not available in the mobile app.
213213
214After Google sign-in, a remote browser usually reaches a `127.0.0.1` page that cannot load.214After Google sign-in, a remote browser usually reaches a `127.0.0.1` page that cannot load.
215Copy that full address into the return URL field in the same T3 Code client. Choose215Copy that full address into the return URL field in the same T3 Code client and choose
216**Continue** on web or desktop, or **Complete sign-in** on mobile. Keep the address unchanged.216**Continue**. Keep the address unchanged. Do not paste the return URL into a thread or bug report.
217Do not paste the return URL into a thread or bug report.
218217
219See [Antigravity setup](./providers-antigravity.md) for installation, expiry, and account changes.218See [Antigravity setup](./providers-antigravity.md) for installation, expiry, and account changes.
220219