Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,65 @@
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
import {
resolveAgentAwarenessPlatformPresentation,
resolveAutoSettleReferenceEnvironmentId,
} from "./SettingsRouteScreen.logic";

describe("resolveAutoSettleReferenceEnvironmentId", () => {
const firstId = EnvironmentId.make("first");
const secondId = EnvironmentId.make("second");

it("waits for an earlier grant before exposing a later writable reference", () => {
const second = { environmentId: secondId, canWriteSettings: true };
expect(
resolveAutoSettleReferenceEnvironmentId([
{ environmentId: firstId, canWriteSettings: null },
second,
]),
).toBeNull();
expect(
resolveAutoSettleReferenceEnvironmentId([
{ environmentId: firstId, canWriteSettings: false },
second,
]),
).toBe(secondId);
expect(
resolveAutoSettleReferenceEnvironmentId([
{ environmentId: firstId, canWriteSettings: true },
second,
]),
).toBe(firstId);
});

it("does not wait for later grants after finding the first writable reference", () => {
expect(
resolveAutoSettleReferenceEnvironmentId([
{ environmentId: firstId, canWriteSettings: true },
{ environmentId: secondId, canWriteSettings: null },
]),
).toBe(firstId);
});

it("waits before showing a read-only fallback until all grants are resolved", () => {
expect(
resolveAutoSettleReferenceEnvironmentId([
{ environmentId: firstId, canWriteSettings: false },
{ environmentId: secondId, canWriteSettings: null },
]),
).toBeNull();
expect(
resolveAutoSettleReferenceEnvironmentId([
{ environmentId: firstId, canWriteSettings: false },
{ environmentId: secondId, canWriteSettings: false },
]),
).toBe(firstId);
});

it("has no reference when no environment supports synchronization", () => {
expect(resolveAutoSettleReferenceEnvironmentId([])).toBeNull();
});
});

describe("resolveAgentAwarenessPlatformPresentation", () => {
it("explains that agent awareness settings are unavailable on Android", () => {
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
import type { EnvironmentId } from "@t3tools/contracts";

/** Wait for earlier grants before choosing the settings that edits and synchronization use. */
export function resolveAutoSettleReferenceEnvironmentId(
environments: ReadonlyArray<{
readonly environmentId: EnvironmentId;
readonly canWriteSettings: boolean | null;
}>,
): EnvironmentId | null {
for (const environment of environments) {
if (environment.canWriteSettings === null) return null;
if (environment.canWriteSettings) return environment.environmentId;
}
return environments[0]?.environmentId ?? null;
}

export function resolveAgentAwarenessPlatformPresentation(platform: string): {
readonly supported: boolean;
readonly subtitle: string | undefined;
Expand Down
76 changes: 66 additions & 10 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useNavigation } from "@react-navigation/native";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { SymbolView } from "../../components/AppSymbol";
import * as Effect from "effect/Effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand Down Expand Up @@ -35,9 +35,11 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { runtime } from "../../lib/runtime";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { serverEnvironment } from "../../state/server";
import { environmentSession, readEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { useEnvironments } from "../../state/environments";
import {
AuthSettingsWriteScope,
DEFAULT_SERVER_SETTINGS,
MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
Expand All @@ -59,7 +61,10 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
import { SettingsRow } from "./components/SettingsRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic";
import {
resolveAgentAwarenessPlatformPresentation,
resolveAutoSettleReferenceEnvironmentId,
} from "./SettingsRouteScreen.logic";

type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
Expand Down Expand Up @@ -554,9 +559,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD

/**
* Auto-settlement is a user preference that every server has to hold. Mobile
* has no primary environment, so the first eligible sync target provides the
* reference value. Edits fan out to every eligible target, and a mismatch row
* lets the user push the reference out.
* has no primary environment, so the first writable sync target provides the
* reference value. Edits fan out to writable targets, and a mismatch row lets
* the user push the reference out. Read-only connections still show settings.
*/
function AutoSettleSettingsRows() {
const { environments } = useEnvironments();
Expand All @@ -565,18 +570,54 @@ function AutoSettleSettingsRows() {
reportFailure: true,
});

const syncTargets = environments.filter(supportsSharedSettingsSync);
const reference = syncTargets[0] ?? null;
const settingsAccessAtom = useMemo(
() =>
Atom.make((get) =>
environments.filter(supportsSharedSettingsSync).map((environment) => {
const result = get(environmentSession.sessionStateAtom(environment.environmentId));
const session = result._tag === "Success" ? result.value : null;
return {
environmentId: environment.environmentId,
canWriteSettings:
result._tag === "Initial"
? null
: session?.authenticated === true &&
session.scopes?.includes(AuthSettingsWriteScope) === true,
};
}),
),
[environments],
);
const settingsAccess = useAtomValue(settingsAccessAtom);
const writableEnvironmentIds = new Set(
settingsAccess.flatMap((environment) =>
environment.canWriteSettings ? [environment.environmentId] : [],
),
);
const availableTargets = environments.filter(supportsSharedSettingsSync);
const syncTargets = availableTargets.filter((environment) =>
writableEnvironmentIds.has(environment.environmentId),
);
const canWriteSettings = syncTargets.length > 0;
const referenceEnvironmentId = resolveAutoSettleReferenceEnvironmentId(settingsAccess);
const reference =
availableTargets.find((environment) => environment.environmentId === referenceEnvironmentId) ??
null;
const referenceSettings = reference?.serverConfig?.settings ?? null;

const [daysDraft, setDaysDraft] = useState<string | null>(null);

if (reference === null || referenceSettings === null) {
return null;
return availableTargets.length > 0 ? (
<View className="p-4">
<Text className="text-sm text-foreground-muted">Loading auto-settle settings…</Text>
</View>
) : null;
}

const writeToAll = (patch: ServerSettingsPatch) => {
for (const environment of syncTargets) {
if (!readEnvironmentScope(environment.environmentId, AuthSettingsWriteScope)) continue;
void updateSettings({ environmentId: environment.environmentId, input: { patch } });
}
};
Expand All @@ -587,7 +628,7 @@ function AutoSettleSettingsRows() {
environments: environments.map((environment) => ({
environmentId: environment.environmentId,
label: environment.label,
syncEligible: supportsSharedSettingsSync(environment),
syncEligible: writableEnvironmentIds.has(environment.environmentId),
settings: environment.serverConfig?.settings ?? null,
})),
});
Expand All @@ -612,12 +653,14 @@ function AutoSettleSettingsRows() {
return (
<>
<SettingsSwitchRow
disabled={!canWriteSettings}
icon="arrow.triangle.branch"
label="Auto-settle merged threads"
value={referenceSettings.sidebarAutoSettleOnMerge}
onValueChange={(value) => writeToAll({ sidebarAutoSettleOnMerge: value })}
/>
<SettingsSwitchRow
disabled={!canWriteSettings}
icon="clock"
label="Auto-settle inactive threads"
subtitle={afterDays === null ? undefined : `After ${afterDays} days without activity`}
Expand All @@ -630,6 +673,7 @@ function AutoSettleSettingsRows() {
<View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
<Text className="flex-1 text-lg text-foreground">Days before auto-settle</Text>
<TextInput
editable={canWriteSettings}
className="min-h-10 w-20 rounded-xl px-3 py-2 text-center text-base"
keyboardType="number-pad"
returnKeyType="done"
Expand All @@ -641,6 +685,15 @@ function AutoSettleSettingsRows() {
/>
</View>
) : null}
{syncTargets.length < availableTargets.length ? (
<View className="border-t border-border-subtle p-4">
<Text className="text-sm text-foreground-muted">
{canWriteSettings
? "Changes apply only to environments this connection can configure."
: "This connection cannot change environment settings."}
</Text>
</View>
) : null}
{mismatches.length > 0 ? (
<View className="flex-row items-center gap-4 border-t border-border-subtle p-4">
<View className="min-w-0 flex-1">
Expand All @@ -654,6 +707,7 @@ function AutoSettleSettingsRows() {
onPress={() => {
const patch = pickSharedServerSettings(referenceSettings);
for (const mismatch of mismatches) {
if (!readEnvironmentScope(mismatch.environmentId, AuthSettingsWriteScope)) continue;
void updateSettings({
environmentId: mismatch.environmentId,
input: { patch },
Expand All @@ -662,7 +716,9 @@ function AutoSettleSettingsRows() {
}}
className="rounded-full bg-subtle px-4 py-2 active:opacity-70"
>
<Text className="text-base font-t3-medium text-foreground">Apply to all</Text>
<Text className="text-base font-t3-medium text-foreground">
{syncTargets.length < availableTargets.length ? "Apply settings" : "Apply to all"}
</Text>
</Pressable>
</View>
) : null}
Expand Down
34 changes: 22 additions & 12 deletions apps/mobile/src/features/usage/UsageLimitsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useAtomValue } from "@effect/atom-react";
import type {
EnvironmentId,
ProviderConsumeResetCreditOutcome,
ProviderInstanceId,
ServerProvider,
ServerProviderResetCredits,
ServerProviderUsageWindow,
UsageLimitSourceAccount,
UsageProviderKind,
import {
AuthProvidersManageScope,
type EnvironmentId,
type ProviderConsumeResetCreditOutcome,
type ProviderInstanceId,
type ServerProvider,
type ServerProviderResetCredits,
type ServerProviderUsageWindow,
type UsageLimitSourceAccount,
type UsageProviderKind,
} from "@t3tools/contracts";
import {
collectLimitSources,
Expand All @@ -26,6 +27,7 @@ import { AppText as Text } from "../../components/AppText";
import { ProviderIcon } from "../../components/ProviderIcon";
import { environmentPresentations } from "../../state/presentation";
import { serverEnvironment } from "../../state/server";
import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsSection } from "../settings/components/SettingsSection";
import { useProviderColors } from "./usageProviders";
Expand Down Expand Up @@ -164,6 +166,7 @@ function ResetCredits(props: {
readonly now: number;
}) {
const { environmentId, instanceId, credits, now } = props;
const canManageProviders = useEnvironmentScope(environmentId, AuthProvidersManageScope);
const consume = useAtomCommand(serverEnvironment.consumeResetCredit, {
reportFailure: false,
});
Expand All @@ -182,6 +185,7 @@ function ResetCredits(props: {
}`;

const redeem = async () => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
setBusy(true);
setStatus(null);
const result = await consume({ environmentId, input: { instanceId } });
Expand All @@ -198,6 +202,7 @@ function ResetCredits(props: {
};

const confirm = () => {
if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
Alert.alert(
"Use a reset credit?",
"This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.",
Expand All @@ -214,16 +219,21 @@ function ResetCredits(props: {
{credits.availableCount > 0 ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
disabled={busy}
accessibilityState={{ disabled: busy || !canManageProviders }}
disabled={busy || !canManageProviders}
onPress={confirm}
className="rounded-full bg-subtle-strong px-3 py-1.5"
className="rounded-full bg-subtle-strong px-3 py-1.5 disabled:opacity-[0.45]"
>
<Text className="text-sm font-t3-medium text-foreground">
{busy ? "Using credit…" : "Use a reset credit"}
</Text>
</Pressable>
) : null}
{!canManageProviders ? (
<Text className="text-xs text-foreground-tertiary">
This connection cannot manage provider accounts.
</Text>
) : null}
{status ? <Text className="text-sm text-foreground">{status}</Text> : null}
</View>
);
Expand Down
38 changes: 36 additions & 2 deletions apps/mobile/src/state/session.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
import { useAtomValue } from "@effect/atom-react";
import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session";
import type { EnvironmentId } from "@t3tools/contracts";
import type { AuthEnvironmentScope, AuthSessionState, EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { Atom } from "effect/unstable/reactivity";
import { AsyncResult, Atom } from "effect/unstable/reactivity";

import { connectionAtomRuntime } from "../connection/runtime";
import { appAtomRegistry } from "./atom-registry";

export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime);

const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial<AuthSessionState>());

/** Uses the selected environment's grant, including cached scopes during a refresh. */
export function useEnvironmentScope(
environmentId: EnvironmentId | null,
scope: AuthEnvironmentScope,
): boolean {
const result = useAtomValue(
environmentId === null
? EMPTY_SESSION_STATE_ATOM
: environmentSession.sessionStateAtom(environmentId),
);
const session = Option.getOrNull(AsyncResult.value(result));
return (
result._tag !== "Failure" &&
session?.authenticated === true &&
session.scopes?.includes(scope) === true
);
}

export function readEnvironmentScope(
environmentId: EnvironmentId,
scope: AuthEnvironmentScope,
): boolean {
const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId));
const session = Option.getOrNull(AsyncResult.value(result));
return (
result._tag !== "Failure" &&
session?.authenticated === true &&
session.scopes?.includes(scope) === true
);
}

const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe(
Atom.withLabel("mobile-prepared-connection:empty"),
);
Expand Down
Loading
Loading