Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleOnPullRequestCompletion: true,
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
"environment-1:/tmp/project-a": "separate",
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { SavedRemoteConnection } from "../../lib/connection";
import { scopedProjectKey } from "../../lib/scopedEntities";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useMobileThreadAutoSettlePolicy } from "../../state/thread-auto-settle";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
Expand Down Expand Up @@ -205,6 +206,7 @@ export function HomeScreen(props: HomeScreenProps) {
ReadonlyMap<string, HomeGroupDisplayState>
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const autoSettlePolicy = useMobileThreadAutoSettlePolicy();
const threadListV2Enabled = useThreadListV2Enabled();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
Expand Down Expand Up @@ -649,6 +651,7 @@ export function HomeScreen(props: HomeScreenProps) {
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
autoSettlePolicy,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -660,6 +663,7 @@ export function HomeScreen(props: HomeScreenProps) {
});
}, [
changeRequestStateByKey,
autoSettlePolicy,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down
50 changes: 50 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import { useNavigation } from "@react-navigation/native";
import type { MenuAction } from "@react-native-menu/menu";
import {
DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
THREAD_AUTO_SETTLE_MODES,
isThreadAutoSettleMode,
type ThreadAutoSettleMode,
} from "@t3tools/contracts";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { SymbolView } from "../../components/AppSymbol";
import * as Effect from "effect/Effect";
Expand All @@ -19,6 +26,7 @@ import {
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { ControlPillMenu } from "../../components/ControlPill";
import { AppText as Text } from "../../components/AppText";
import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities";
import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences";
Expand All @@ -35,6 +43,10 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import {
mobileThreadAutoSettleModePatch,
useMobileThreadAutoSettlePolicy,
} from "../../state/thread-auto-settle";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
type AppUpdateCheckState,
Expand All @@ -50,6 +62,13 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";

const AUTO_SETTLE_MODE_LABELS: Record<ThreadAutoSettleMode, string> = {
never: "Never",
inactive: `Inactivity (${DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS} days)`,
"pull-request": "PR merge/close",
"inactive-or-pull-request": "Inactivity or PR merge/close",
};

// Reflects whether the relay actually accepted this device's registration.
// The notification and Live Activity switches are gated on this so they can
// never read as enabled when the device cannot receive anything (e.g. the
Expand Down Expand Up @@ -522,9 +541,40 @@ function ConfiguredSettingsRouteScreen() {
}

function GeneralSettingsSection() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
const autoSettlePolicy = useMobileThreadAutoSettlePolicy();
const autoSettleActions: MenuAction[] = THREAD_AUTO_SETTLE_MODES.map((mode) => ({
id: mode,
title: AUTO_SETTLE_MODE_LABELS[mode],
state: mode === autoSettlePolicy.mode ? "on" : "off",
}));
const autoSettleRow = (
<SettingsRow
disabled={!preferencesReady}
icon="checkmark.circle"
label="Auto-settle Threads"
value={AUTO_SETTLE_MODE_LABELS[autoSettlePolicy.mode]}
/>
);

return (
<SettingsSection title="General">
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
{preferencesReady ? (
<ControlPillMenu
actions={autoSettleActions}
onPressAction={({ nativeEvent }) => {
if (!isThreadAutoSettleMode(nativeEvent.event)) return;
savePreferences(mobileThreadAutoSettleModePatch(nativeEvent.event));
}}
>
{autoSettleRow}
</ControlPillMenu>
) : (
autoSettleRow
)}
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
</SettingsSection>
);
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
import { environmentServerConfigsAtom } from "../../state/server";
import { useMobileThreadAutoSettlePolicy } from "../../state/thread-auto-settle";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
Expand Down Expand Up @@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettlePolicy = useMobileThreadAutoSettlePolicy();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand Down Expand Up @@ -536,6 +538,7 @@ function ThreadNavigationSidebarPane(
searchQuery: props.searchQuery,
matchedThreadKeys,
changeRequestStateByKey,
autoSettlePolicy,
settlementEnvironmentIds,
snoozeEnvironmentIds,
settledLimit: settledVisibleCount,
Expand All @@ -547,6 +550,7 @@ function ThreadNavigationSidebarPane(
});
}, [
changeRequestStateByKey,
autoSettlePolicy,
nowMinute,
snoozeWakeTick,
snoozedShelfExpanded,
Expand Down
50 changes: 48 additions & 2 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import {
resolveSnoozePresets,
threadAutoSettlePolicy,
} from "@t3tools/client-runtime/state/thread-settled";
import {
CommandId,
EnvironmentId,
Expand All @@ -14,7 +17,7 @@ import { describe, expect, it } from "vite-plus/test";

import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2Items as buildThreadListV2ItemsWithRequiredPolicy,
buildThreadListV2ListItems,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
Expand All @@ -24,6 +27,20 @@ import {
sortThreadsForListV2,
} from "./threadListV2";

const DEFAULT_AUTO_SETTLE_POLICY = threadAutoSettlePolicy({
mode: "inactive-or-pull-request",
afterDays: 3,
});
type ThreadListV2Input = Parameters<typeof buildThreadListV2ItemsWithRequiredPolicy>[0];
function buildThreadListV2Items(
input: Omit<ThreadListV2Input, "autoSettlePolicy"> & {
readonly autoSettlePolicy?: ThreadListV2Input["autoSettlePolicy"];
},
) {
const { autoSettlePolicy = DEFAULT_AUTO_SETTLE_POLICY, ...rest } = input;
return buildThreadListV2ItemsWithRequiredPolicy({ ...rest, autoSettlePolicy });
}

const environmentId = EnvironmentId.make("environment-1");

function makeThread(
Expand Down Expand Up @@ -263,6 +280,35 @@ describe("sortThreadsForListV2", () => {
});

describe("buildThreadListV2Items", () => {
it("keeps completed-pull-request threads active when automatic settling is disabled", () => {
const thread = makeThread({
id: ThreadId.make("completed-pr"),
title: "Completed pull request",
latestUserMessageAt: "2026-06-01T12:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:${thread.id}`, "merged" as const]]);

const automatic = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
const manual = buildThreadListV2Items({
threads: [thread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettlePolicy: threadAutoSettlePolicy({ mode: "inactive", afterDays: 3 }),
now: NOW,
});

expect(automatic.settledCount).toBe(1);
expect(manual.settledCount).toBe(0);
expect(manual.items.map((item) => item.thread.id)).toEqual([thread.id]);
});

it("hides snoozed threads and counts them — visibility parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
15 changes: 9 additions & 6 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
QUEUED_TURN_START_GRACE_MS,
resolveSnoozePresets,
snoozeWakeLabel,
type ThreadAutoSettlePolicy,
} from "@t3tools/client-runtime/state/thread-settled";
import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
Expand Down Expand Up @@ -306,9 +307,8 @@ export function buildThreadListV2ListItems(input: {

/**
* Partitions visible threads into the active card block (creation order) and
* the settled recency tail, matching the web v2 list. `autoSettleAfterDays`
* mirrors the web default of 3 — mobile has no client-settings sync yet, so
* the default is fixed here rather than user-configurable.
* the settled recency tail, matching the web v2 list. Mobile resolves its
* device-local mode into the same canonical policy used by web.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All @@ -328,7 +328,7 @@ export function buildThreadListV2Items(input: {
/** Environments whose server supports thread.snooze/unsnooze. Same
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
readonly autoSettlePolicy: ThreadAutoSettlePolicy;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All @@ -348,7 +348,6 @@ export function buildThreadListV2Items(input: {
}): ThreadListV2Layout {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand Down Expand Up @@ -405,7 +404,11 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettlePolicy: input.autoSettlePolicy,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
import {
isThreadAutoSettleMode,
type SidebarProjectGroupingMode,
type ThreadAutoSettleMode,
} from "@t3tools/contracts";

import * as MobileDatabase from "./mobile-database";
import * as MobileSecureStorage from "./mobile-secure-storage";
Expand Down Expand Up @@ -34,6 +38,8 @@ export interface Preferences {
* default flat list — see `resolveThreadListV2Enabled`.
*/
readonly legacyThreadListEnabled?: boolean;
/** Device-local because mobile does not sync web client settings. */
readonly threadAutoSettleMode?: ThreadAutoSettleMode;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand Down Expand Up @@ -86,6 +92,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
legacyThreadListEnabled?: boolean;
threadAutoSettleMode?: ThreadAutoSettleMode;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand Down Expand Up @@ -125,6 +132,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
if (isThreadAutoSettleMode(parsed.threadAutoSettleMode)) {
preferences.threadAutoSettleMode = parsed.threadAutoSettleMode;
}
return preferences;
}

Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/state/thread-auto-settle.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vite-plus/test";

import {
mobileThreadAutoSettleModePatch,
resolveMobileThreadAutoSettlePolicy,
} from "./thread-auto-settle.logic";

describe("mobile thread auto-settle policy", () => {
it("defaults to the combined three-day policy", () => {
expect(resolveMobileThreadAutoSettlePolicy({})).toEqual({
mode: "inactive-or-pull-request",
afterDays: 3,
});
});

it.each([
["never", { mode: "never" }],
["inactive", { mode: "inactive", afterDays: 3 }],
["pull-request", { mode: "pull-request" }],
["inactive-or-pull-request", { mode: "inactive-or-pull-request", afterDays: 3 }],
] as const)("resolves the %s preference", (mode, expected) => {
expect(resolveMobileThreadAutoSettlePolicy({ threadAutoSettleMode: mode })).toEqual(expected);
expect(mobileThreadAutoSettleModePatch(mode)).toEqual({ threadAutoSettleMode: mode });
});
});
26 changes: 26 additions & 0 deletions apps/mobile/src/state/thread-auto-settle.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { threadAutoSettlePolicy } from "@t3tools/client-runtime/state/thread-settled";
import {
DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
DEFAULT_THREAD_AUTO_SETTLE_MODE,
type ThreadAutoSettleMode,
} from "@t3tools/contracts";

import type { Preferences } from "../persistence/mobile-preferences";

export const DEFAULT_MOBILE_THREAD_AUTO_SETTLE_POLICY = threadAutoSettlePolicy({
mode: DEFAULT_THREAD_AUTO_SETTLE_MODE,
afterDays: DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
});

export function resolveMobileThreadAutoSettlePolicy(preferences: Preferences) {
return threadAutoSettlePolicy({
mode: preferences.threadAutoSettleMode ?? DEFAULT_THREAD_AUTO_SETTLE_MODE,
afterDays: DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
});
}

export function mobileThreadAutoSettleModePatch(
mode: ThreadAutoSettleMode,
): Pick<Preferences, "threadAutoSettleMode"> {
return { threadAutoSettleMode: mode };
}
17 changes: 17 additions & 0 deletions apps/mobile/src/state/thread-auto-settle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "./preferences";
import {
DEFAULT_MOBILE_THREAD_AUTO_SETTLE_POLICY,
resolveMobileThreadAutoSettlePolicy,
} from "./thread-auto-settle.logic";

export * from "./thread-auto-settle.logic";

export function useMobileThreadAutoSettlePolicy() {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
return AsyncResult.isSuccess(preferencesResult)
? resolveMobileThreadAutoSettlePolicy(preferencesResult.value)
: DEFAULT_MOBILE_THREAD_AUTO_SETTLE_POLICY;
}
Loading
Loading