Skip to content

Commit 65a138f

Browse files
authored
feat(settings): control completed pull request auto-settlement (#2)
Add a dedicated setting for completed pull request auto-settlement across web, desktop, and mobile while preserving existing defaults and independent inactivity behavior.
1 parent 75288e8 commit 65a138f

27 files changed

Lines changed: 563 additions & 20 deletions

apps/desktop/src/settings/DesktopClientSettings.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const clientSettings: ClientSettings = {
3333
planModeEnabled: false,
3434
providerModelPreferences: {},
3535
sidebarAutoSettleAfterDays: 3,
36+
sidebarAutoSettleCompletedChangeRequests: false,
3637
sidebarProjectGroupingMode: "repository_path",
3738
sidebarProjectGroupingOverrides: {
3839
"environment-1:/tmp/project-a": "separate",
@@ -201,7 +202,9 @@ describe("DesktopClientSettings", () => {
201202
yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
202203
yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n");
203204

204-
assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}")));
205+
const persisted = yield* settings.get;
206+
assert.deepEqual(persisted, Option.some(yield* decodeClientSettingsJson("{}")));
207+
assert.isTrue(Option.getOrThrow(persisted).sidebarAutoSettleCompletedChangeRequests);
205208
}),
206209
),
207210
);

apps/mobile/src/features/home/HomeScreen.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { scopedProjectKey } from "../../lib/scopedEntities";
3333
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3434
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3535
import { useThreadSearch } from "../../state/queries";
36+
import { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
3637
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
3738
import { environmentServerConfigsAtom } from "../../state/server";
3839
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
@@ -207,6 +208,7 @@ export function HomeScreen(props: HomeScreenProps) {
207208
>(() => new Map());
208209
const preferencesResult = useAtomValue(mobilePreferencesAtom);
209210
const threadListV2Enabled = useThreadListV2Enabled();
211+
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
210212
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
211213
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
212214
const listRef = useRef<LegendListRef | null>(null);
@@ -665,6 +667,7 @@ export function HomeScreen(props: HomeScreenProps) {
665667
searchQuery: props.searchQuery,
666668
matchedThreadKeys,
667669
changeRequestStateByKey,
670+
autoSettleCompletedChangeRequests,
668671
settlementEnvironmentIds,
669672
snoozeEnvironmentIds,
670673
settledLimit: settledVisibleCount,
@@ -675,6 +678,7 @@ export function HomeScreen(props: HomeScreenProps) {
675678
selectedThreadKey: null,
676679
});
677680
}, [
681+
autoSettleCompletedChangeRequests,
678682
changeRequestStateByKey,
679683
nowMinute,
680684
snoozeWakeTick,

apps/mobile/src/features/settings/SettingsRouteScreen.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
3535
import { runtime } from "../../lib/runtime";
3636
import { useThemeColor } from "../../lib/useThemeColor";
3737
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
38+
import { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
3839
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
3940
import {
4041
type AppUpdateCheckState,
@@ -46,6 +47,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
4647
import { SettingsRow } from "./components/SettingsRow";
4748
import { SettingsSection } from "./components/SettingsSection";
4849
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
50+
import { shouldShowAutoSettleCompletedChangeRequestsSetting } from "./autoSettleCompletedChangeRequests";
4951

5052
type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported";
5153
type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking";
@@ -522,10 +524,27 @@ function ConfiguredSettingsRouteScreen() {
522524
}
523525

524526
function GeneralSettingsSection() {
527+
const preferencesResult = useAtomValue(mobilePreferencesAtom);
528+
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
529+
const threadListV2Enabled = useThreadListV2Enabled();
530+
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
531+
const preferencesLoaded = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
532+
525533
return (
526534
<SettingsSection title="General">
527535
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
528536
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
537+
{shouldShowAutoSettleCompletedChangeRequestsSetting({
538+
preferencesLoaded,
539+
threadListV2Enabled,
540+
}) ? (
541+
<SettingsSwitchRow
542+
icon="checkmark.circle"
543+
label="Auto-settle completed pull requests"
544+
value={autoSettleCompletedChangeRequests}
545+
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
546+
/>
547+
) : null}
529548
</SettingsSection>
530549
);
531550
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { shouldShowAutoSettleCompletedChangeRequestsSetting } from "./autoSettleCompletedChangeRequests";
4+
5+
describe("shouldShowAutoSettleCompletedChangeRequestsSetting", () => {
6+
it("shows the control only after preferences load for the list it changes", () => {
7+
expect(
8+
shouldShowAutoSettleCompletedChangeRequestsSetting({
9+
preferencesLoaded: false,
10+
threadListV2Enabled: true,
11+
}),
12+
).toBe(false);
13+
expect(
14+
shouldShowAutoSettleCompletedChangeRequestsSetting({
15+
preferencesLoaded: true,
16+
threadListV2Enabled: false,
17+
}),
18+
).toBe(false);
19+
expect(
20+
shouldShowAutoSettleCompletedChangeRequestsSetting({
21+
preferencesLoaded: true,
22+
threadListV2Enabled: true,
23+
}),
24+
).toBe(true);
25+
});
26+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function shouldShowAutoSettleCompletedChangeRequestsSetting(input: {
2+
readonly preferencesLoaded: boolean;
3+
readonly threadListV2Enabled: boolean;
4+
}): boolean {
5+
return input.preferencesLoaded && input.threadListV2Enabled;
6+
}

apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
3030
import { useThemeColor } from "../../lib/useThemeColor";
3131
import { useProjects, useThreadShells } from "../../state/entities";
3232
import { useThreadSearch } from "../../state/queries";
33+
import { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
3334
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
3435
import { environmentServerConfigsAtom } from "../../state/server";
3536
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
@@ -214,6 +215,7 @@ function ThreadNavigationSidebarPane(
214215
regenerateThreadTitle,
215216
} = useThreadListActions();
216217
const threadListV2Enabled = useThreadListV2Enabled();
218+
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
217219
const pendingTasks = usePendingNewTasks();
218220
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
219221
const environments = useMemo(
@@ -546,6 +548,7 @@ function ThreadNavigationSidebarPane(
546548
searchQuery: props.searchQuery,
547549
matchedThreadKeys,
548550
changeRequestStateByKey,
551+
autoSettleCompletedChangeRequests,
549552
settlementEnvironmentIds,
550553
snoozeEnvironmentIds,
551554
settledLimit: settledVisibleCount,
@@ -556,6 +559,7 @@ function ThreadNavigationSidebarPane(
556559
selectedThreadKey: props.selectedThreadKey ?? null,
557560
});
558561
}, [
562+
autoSettleCompletedChangeRequests,
559563
changeRequestStateByKey,
560564
nowMinute,
561565
snoozeWakeTick,

apps/mobile/src/features/threads/threadListV2.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc
33
import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
44
import {
55
CommandId,
6+
DEFAULT_SIDEBAR_AUTO_SETTLE_COMPLETED_CHANGE_REQUESTS,
67
EnvironmentId,
78
MessageId,
89
ProjectId,
@@ -16,6 +17,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1617
import {
1718
buildThreadListV2Items,
1819
buildThreadListV2ListItems,
20+
resolveAutoSettleCompletedChangeRequests,
1921
resolveThreadListV2Enabled,
2022
resolveThreadListV2SnoozeMenuSelection,
2123
resolveThreadListV2SnoozeGateExpiryMs,
@@ -125,6 +127,29 @@ describe("resolveThreadListV2Enabled", () => {
125127
});
126128
});
127129

130+
describe("resolveAutoSettleCompletedChangeRequests", () => {
131+
it("preserves an explicit disabled preference", () => {
132+
expect(
133+
resolveAutoSettleCompletedChangeRequests({ preference: false, preferencesLoaded: true }),
134+
).toBe(false);
135+
});
136+
137+
it("uses the shared completed-PR auto-settle default when the preference is absent", () => {
138+
expect(DEFAULT_SIDEBAR_AUTO_SETTLE_COMPLETED_CHANGE_REQUESTS).toBe(true);
139+
expect(
140+
resolveAutoSettleCompletedChangeRequests({ preference: undefined, preferencesLoaded: true }),
141+
).toBe(DEFAULT_SIDEBAR_AUTO_SETTLE_COMPLETED_CHANGE_REQUESTS);
142+
});
143+
144+
it("keeps a saved disabled preference disabled through hydration", () => {
145+
const resolvedStates = [false, true].map((preferencesLoaded) =>
146+
resolveAutoSettleCompletedChangeRequests({ preference: false, preferencesLoaded }),
147+
);
148+
149+
expect(resolvedStates).toEqual([false, false]);
150+
});
151+
});
152+
128153
describe("resolveThreadListV2Status", () => {
129154
it("prioritizes approval over a running session", () => {
130155
const thread = makeThread({
@@ -315,6 +340,107 @@ describe("buildThreadListV2Items", () => {
315340
expect(layout.settledCount).toBe(0);
316341
});
317342

343+
it("keeps fresh completed-PR threads active when completed-PR auto-settle is off", () => {
344+
const fresh = makeThread({
345+
id: ThreadId.make("fresh-completed-pr"),
346+
title: "Fresh completed PR",
347+
latestUserMessageAt: "2026-06-01T12:00:00.000Z",
348+
latestTurn: {
349+
turnId: TurnId.make("fresh-completed-pr-turn"),
350+
state: "completed",
351+
requestedAt: "2026-06-01T12:00:00.000Z",
352+
startedAt: "2026-06-01T12:00:00.000Z",
353+
completedAt: "2026-06-01T12:10:00.000Z",
354+
assistantMessageId: null,
355+
},
356+
});
357+
const layout = buildThreadListV2Items({
358+
threads: [fresh],
359+
environmentId: null,
360+
searchQuery: "",
361+
now: NOW,
362+
autoSettleAfterDays: 3,
363+
autoSettleCompletedChangeRequests: false,
364+
changeRequestStateByKey: new Map([[`${environmentId}:${fresh.id}`, "merged"]]),
365+
});
366+
367+
expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([
368+
["fresh-completed-pr", "card"],
369+
]);
370+
expect(layout.settledCount).toBe(0);
371+
});
372+
373+
it("does not briefly settle a saved-disabled completed pull request while preferences hydrate", () => {
374+
const fresh = makeThread({
375+
id: ThreadId.make("saved-disabled-completed-pr"),
376+
title: "Saved disabled completed PR",
377+
});
378+
const layouts = [false, true].map((preferencesLoaded) =>
379+
buildThreadListV2Items({
380+
threads: [fresh],
381+
environmentId: null,
382+
searchQuery: "",
383+
now: NOW,
384+
autoSettleCompletedChangeRequests: resolveAutoSettleCompletedChangeRequests({
385+
preference: false,
386+
preferencesLoaded,
387+
}),
388+
changeRequestStateByKey: new Map([[`${environmentId}:${fresh.id}`, "closed"]]),
389+
}),
390+
);
391+
392+
expect(layouts.map((layout) => layout.items[0]?.variant)).toEqual(["card", "card"]);
393+
expect(layouts.map((layout) => layout.settledCount)).toEqual([0, 0]);
394+
});
395+
396+
it("defaults completed-PR auto-settle on for existing mobile installs", () => {
397+
const fresh = makeThread({
398+
id: ThreadId.make("default-completed-pr"),
399+
title: "Default completed PR",
400+
});
401+
const layout = buildThreadListV2Items({
402+
threads: [fresh],
403+
environmentId: null,
404+
searchQuery: "",
405+
now: NOW,
406+
changeRequestStateByKey: new Map([[`${environmentId}:${fresh.id}`, "merged"]]),
407+
});
408+
409+
expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([
410+
["default-completed-pr", "slim"],
411+
]);
412+
});
413+
414+
it("keeps inactivity settlement independent when completed-PR auto-settle is off", () => {
415+
const stale = makeThread({
416+
id: ThreadId.make("stale-completed-pr"),
417+
title: "Stale completed PR",
418+
latestUserMessageAt: "2026-05-28T12:00:00.000Z",
419+
latestTurn: {
420+
turnId: TurnId.make("stale-completed-pr-turn"),
421+
state: "completed",
422+
requestedAt: "2026-05-28T12:00:00.000Z",
423+
startedAt: "2026-05-28T12:00:00.000Z",
424+
completedAt: "2026-05-28T12:10:00.000Z",
425+
assistantMessageId: null,
426+
},
427+
});
428+
const layout = buildThreadListV2Items({
429+
threads: [stale],
430+
environmentId: null,
431+
searchQuery: "",
432+
now: NOW,
433+
autoSettleAfterDays: 3,
434+
autoSettleCompletedChangeRequests: false,
435+
changeRequestStateByKey: new Map([[`${environmentId}:${stale.id}`, "closed"]]),
436+
});
437+
438+
expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([
439+
["stale-completed-pr", "slim"],
440+
]);
441+
expect(layout.settledCount).toBe(1);
442+
});
443+
318444
it("snooze hides a pinned thread and wake restores it to the pinned block", () => {
319445
const snoozedInput = {
320446
threads: [

apps/mobile/src/features/threads/threadListV2.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
1010
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1111
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1212
import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
13-
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
13+
import {
14+
DEFAULT_SIDEBAR_AUTO_SETTLE_COMPLETED_CHANGE_REQUESTS,
15+
type EnvironmentId,
16+
type ProjectId,
17+
} from "@t3tools/contracts";
1418

1519
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1620

@@ -122,6 +126,20 @@ export function resolveThreadListV2Enabled(input: {
122126
return input.legacyPreference !== true;
123127
}
124128

129+
/**
130+
* Existing installs had completed-PR auto-settlement before this preference
131+
* existed, so an absent loaded value preserves that behavior. Until mobile
132+
* preferences load, keep every thread visible rather than temporarily settling
133+
* a thread whose stored device-local preference is false.
134+
*/
135+
export function resolveAutoSettleCompletedChangeRequests(input: {
136+
readonly preference: boolean | undefined;
137+
readonly preferencesLoaded: boolean;
138+
}): boolean {
139+
if (!input.preferencesLoaded) return false;
140+
return input.preference ?? DEFAULT_SIDEBAR_AUTO_SETTLE_COMPLETED_CHANGE_REQUESTS;
141+
}
142+
125143
export function resolveThreadListV2Status(
126144
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
127145
): ThreadListV2Status {
@@ -307,8 +325,8 @@ export function buildThreadListV2ListItems(input: {
307325
/**
308326
* Partitions visible threads into the active card block (creation order) and
309327
* the settled recency tail, matching the web v2 list. `autoSettleAfterDays`
310-
* mirrors the web default of 3 — mobile has no client-settings sync yet, so
311-
* the default is fixed here rather than user-configurable.
328+
* mirrors the web default of 3. Completed-change-request auto-settlement is
329+
* configurable through a device-local mobile preference.
312330
*/
313331
export function buildThreadListV2Items(input: {
314332
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
@@ -329,6 +347,7 @@ export function buildThreadListV2Items(input: {
329347
contract as settlementEnvironmentIds. */
330348
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
331349
readonly autoSettleAfterDays?: number;
350+
readonly autoSettleCompletedChangeRequests?: boolean;
332351
/** Max settled rows to render; the rest are counted, not built. */
333352
readonly settledLimit?: number;
334353
/** Injectable for tests; defaults to now. */
@@ -349,6 +368,9 @@ export function buildThreadListV2Items(input: {
349368
const now = input.now ?? new Date().toISOString();
350369
const snoozeNow = input.snoozeNow ?? now;
351370
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
371+
const autoSettleCompletedChangeRequests =
372+
input.autoSettleCompletedChangeRequests ??
373+
DEFAULT_SIDEBAR_AUTO_SETTLE_COMPLETED_CHANGE_REQUESTS;
352374
const query = input.searchQuery.trim().toLocaleLowerCase();
353375
const projectKeys = input.projectRefs
354376
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
@@ -405,7 +427,12 @@ export function buildThreadListV2Items(input: {
405427
}
406428
if (
407429
supportsSettlement &&
408-
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
430+
effectiveSettled(thread, {
431+
now,
432+
autoSettleAfterDays,
433+
autoSettleCompletedChangeRequests,
434+
changeRequestState,
435+
})
409436
) {
410437
settled.push(thread);
411438
} else {

0 commit comments

Comments
 (0)