Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,21 @@ vi.mock("./components/SettingsEnvironmentFilterHeader", () => ({
}));
vi.mock("./components/DatabaseStorageNotice", () => ({ DatabaseStorageNotice: () => null }));
vi.mock("./components/SettingsSection", () => ({ SettingsSection: Children }));
vi.mock("../../components/ui/ListChoiceRow", () => ({ ListChoiceRow: () => null }));
vi.mock("../../components/ui/ListChoiceRow", () => ({
ListChoiceRow: ({
label,
disabled,
onPress,
}: {
label: string;
disabled?: boolean;
onPress: () => void;
}) => (
<button disabled={disabled} onClick={onPress}>
{label}
</button>
),
}));
vi.mock("./components/SettingsSwitchRow", () => ({
SettingsSwitchRow: ({
label,
Expand Down Expand Up @@ -158,7 +172,11 @@ function environment(environmentId: string, label: string) {
sidebarAutoSettleOnMerge: false,
},
environment: {
capabilities: { projectSettingsOverrides: true, threadAutoSettlement: true },
capabilities: {
projectSettingsOverrides: true,
threadAutoSettlement: true,
providerSessionIdleTimeout: true,
},
},
},
};
Expand Down Expand Up @@ -222,6 +240,25 @@ describe("SettingsServerControlsRouteScreen", () => {
await act(async () => pending.resolve(AsyncResult.success(undefined)));
});

it.each([false, true])(
"does not send idle timeout changes to an excluded target with idle capability %s",
async (idleCapability) => {
const remote = environment("remote", "Remote");
remote.connection.phase = "disconnected";
remote.serverConfig.environment.capabilities.providerSessionIdleTimeout = idleCapability;
mocks.targets = [environment("local", "Local"), remote];
act(() => root.render(<SettingsThreadsRouteScreen />));
const preset = [...container.querySelectorAll<HTMLButtonElement>("button")].find(
(button) => button.textContent === "5 minutes",
)!;
expect(preset).toBeDefined();
await act(async () => preset.click());
expect(mocks.updateSettings.mock.calls).toEqual([
[{ environmentId: "local", input: { patch: { providerSessionIdleTimeoutMinutes: 5 } } }],
]);
},
);

it("disables auto-settle when a selected environment cannot save settings", () => {
const remote = environment("remote", "Remote");
// Auto-settle rows skip a server without auto-settlement, but the page still saves to it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ function planServerSettingsWrites(
const capable = targets.filter((target) => supportsSharedSettingsSync(target.environment));
const others = targets.filter((target) => !supportsSharedSettingsSync(target.environment));
const othersPatch = Object.fromEntries(
Object.entries(patch).filter(([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key)),
Object.entries(patch).filter(
([key]) => !AUTO_SETTLE_SETTING_KEYS.has(key) && key !== "providerSessionIdleTimeoutMinutes",
),
) as ServerSettingsPatch;
return [
...planMobileScopedSettingsPatch(capable, projectSelected, patch),
Expand Down
29 changes: 28 additions & 1 deletion apps/mobile/src/features/settings/ThreadSettingsSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import type { ComponentProps } from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { DEFAULT_SERVER_SETTINGS, PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS } from "@t3tools/contracts";
import { supportsSharedSettingsSync } from "@t3tools/client-runtime/state/shared-settings";
import { AppText as Text } from "../../components/ui/AppText";
import { useEnvironments } from "../../state/environments";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsSection } from "./components/SettingsSection";
import { ListChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsScreen } from "./components/SettingsScreen";
import { planAutoSettleSettingsSync, type AutoSettleSettings } from "./autoSettleSettingsSync";
Expand Down Expand Up @@ -101,6 +102,7 @@ export function AutoSettleSettingsRows(props: {
patch: Partial<AutoSettleSettings> & {
autoResumeLimitedThreads?: boolean;
snoozeLimitedThreads?: boolean;
providerSessionIdleTimeoutMinutes?: number | null;
},
) => props.write(patch);

Expand Down Expand Up @@ -145,6 +147,31 @@ export function AutoSettleSettingsRows(props: {
/>
</SettingsSection>
) : null}
{!projectSelected &&
displayTargets.every(
(target) =>
target.environment.serverConfig.environment.capabilities.providerSessionIdleTimeout ===
true,
Comment thread
avillagran marked this conversation as resolved.
Resolved
) ? (
<SettingsSection title="Release idle agents">
<Text className="px-4 text-sm text-foreground-muted">
Free memory while idle. Conversations resume when you send a message. Active turns and
background work delay release.
</Text>
{PROVIDER_SESSION_IDLE_TIMEOUT_PRESETS.map((option, index) => (
<ListChoiceRow
key={option.value ?? "never"}
label={option.label}
separated={index > 0}
disabled={disabled}
selected={displayTargets.every(
(target) => target.settings.providerSessionIdleTimeoutMinutes === option.value,
)}
onPress={() => writeToAll({ providerSessionIdleTimeoutMinutes: option.value })}
/>
))}
</SettingsSection>
) : null}
<SettingsSection title="Auto-settle">
<SettingsSwitchRow
icon="arrow.triangle.branch"
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export const make = Effect.gen(function* () {
requiredWorktreeBootstrap: true,
threadSettlement: true,
threadAutoSettlement: true,
providerSessionIdleTimeout: true,
storageCleanup: true,
storageCleanupRun: true,
databaseStorage: true,
Expand Down
Loading
Loading