From 2585df2686abf0376ef38ad924f1548815dd545a Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:34:56 +0800 Subject: [PATCH 1/2] feat(clients): add PR auto-settle toggle (#4982) --- .../settings/DesktopClientSettings.test.ts | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 8 +++- .../features/settings/SettingsRouteScreen.tsx | 30 ++++++++++++- .../threads/ThreadNavigationSidebar.tsx | 8 +++- .../src/features/threads/threadListV2.test.ts | 43 +++++++++++++++++++ .../src/features/threads/threadListV2.ts | 42 +++++++++++++++--- .../threads/use-thread-list-v2-enabled.ts | 27 +++++++++++- .../src/persistence/mobile-preferences.ts | 12 ++++++ apps/web/src/components/ChatView.tsx | 5 +++ apps/web/src/components/SidebarV2.tsx | 11 ++++- .../components/settings/BetaSettingsPanel.tsx | 41 +++++++++++++----- .../settings/settingsSearch.test.ts | 7 +++ .../src/components/settings/settingsSearch.ts | 6 +++ docs/user/source-control.md | 14 ++++++ .../src/state/threadSettled.test.ts | 39 +++++++++++++++++ .../client-runtime/src/state/threadSettled.ts | 18 +++++--- packages/contracts/src/settings.test.ts | 12 ++++++ packages/contracts/src/settings.ts | 4 ++ 18 files changed, 300 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33..4f47136dee7 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -23,6 +23,7 @@ const clientSettings: ClientSettings = { glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleOnChangeRequestCompletion: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", @@ -153,6 +154,7 @@ describe("DesktopClientSettings", () => { assert.isTrue(Option.isSome(persisted)); if (Option.isSome(persisted)) { assert.equal(persisted.value.timestampFormat, "24-hour"); + assert.isTrue(persisted.value.sidebarAutoSettleOnChangeRequestCompletion); } }), ), diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d54fb7d4890..fcf8b7a68eb 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -32,7 +32,10 @@ import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { + useThreadListV2Enabled, + useThreadListV2SettlementPreferences, +} from "../threads/use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -202,6 +205,7 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const threadListV2SettlementPreferences = useThreadListV2SettlementPreferences(); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -597,6 +601,7 @@ export function HomeScreen(props: HomeScreenProps) { changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, + ...threadListV2SettlementPreferences, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), @@ -613,6 +618,7 @@ export function HomeScreen(props: HomeScreenProps) { settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, + threadListV2SettlementPreferences, props.searchQuery, props.selectedEnvironmentId, props.threads, diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 49adfe75cb2..dbd37b48a6a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -37,7 +37,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 { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { + useThreadListV2Enabled, + useThreadListV2SettlementPreferences, +} from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, registerHiddenUpdateTap, @@ -555,6 +558,8 @@ function GeneralSettingsSection() { function BetaSettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const { autoSettleAfterDays, autoSettleOnChangeRequestCompletion } = + useThreadListV2SettlementPreferences(); return ( @@ -565,10 +570,31 @@ function BetaSettingsSection() { value={threadListV2Enabled} onValueChange={(value) => savePreferences({ threadListV2Enabled: value })} /> + {threadListV2Enabled ? ( + <> + savePreferences({ threadListV2AutoSettleInactive: value })} + /> + + savePreferences({ + threadListV2AutoSettleOnChangeRequestCompletion: value, + }) + } + /> + + ) : null} One flat thread list in creation order. Active work renders as cards; settled threads - collapse to compact rows. Switch back any time. + collapse to compact rows. Inactive threads use a three-day window when automatic settling is + on. Switch back any time. ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 8a7fc2ed6df..63eee1a2315 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -29,7 +29,10 @@ import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; -import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; +import { + useThreadListV2Enabled, + useThreadListV2SettlementPreferences, +} from "./use-thread-list-v2-enabled"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -207,6 +210,7 @@ function ThreadNavigationSidebarPane( unsettleThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); + const threadListV2SettlementPreferences = useThreadListV2SettlementPreferences(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -501,6 +505,7 @@ function ThreadNavigationSidebarPane( changeRequestStateByKey, settlementEnvironmentIds, snoozeEnvironmentIds, + ...threadListV2SettlementPreferences, settledLimit: settledVisibleCount, now: `${nowMinute}:00.000Z`, snoozeNow: new Date().toISOString(), @@ -521,6 +526,7 @@ function ThreadNavigationSidebarPane( settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, + threadListV2SettlementPreferences, threadListV2Enabled, threads, selectedProjectScope, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 99b5700f7b0..1db4c14691b 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -17,6 +17,7 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, resolveThreadListV2Enabled, + resolveThreadListV2SettlementPreferences, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, @@ -121,6 +122,27 @@ describe("resolveThreadListV2Enabled", () => { }); }); +describe("resolveThreadListV2SettlementPreferences", () => { + it("defaults both automatic triggers on", () => { + expect(resolveThreadListV2SettlementPreferences({})).toEqual({ + autoSettleAfterDays: 3, + autoSettleOnChangeRequestCompletion: true, + }); + }); + + it("supports fully manual settlement", () => { + expect( + resolveThreadListV2SettlementPreferences({ + autoSettleInactive: false, + autoSettleOnChangeRequestCompletion: false, + }), + ).toEqual({ + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + }); + }); +}); + describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ @@ -259,6 +281,27 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { + it("keeps a stale merged-review thread active when both auto-settle triggers are disabled", () => { + const thread = makeThread({ + id: ThreadId.make("manual-only"), + title: "Manual only", + latestUserMessageAt: "2026-05-01T00:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + changeRequestStateByKey: new Map([[`${environmentId}:${thread.id}`, "merged"]]), + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + now: NOW, + }); + + expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["manual-only", "card"], + ]); + }); + it("hides snoozed threads and counts them — visibility parity with web", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c88aff4ec02..287c9611e51 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,11 @@ import { import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { + DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + type EnvironmentId, + type ProjectId, +} from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -26,6 +30,20 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; +export function resolveThreadListV2SettlementPreferences(input: { + readonly autoSettleInactive?: boolean; + readonly autoSettleOnChangeRequestCompletion?: boolean; +}): { + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnChangeRequestCompletion: boolean; +} { + return { + autoSettleAfterDays: + input.autoSettleInactive === false ? null : DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + autoSettleOnChangeRequestCompletion: input.autoSettleOnChangeRequestCompletion !== false, + }; +} + export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -303,9 +321,9 @@ 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. Automatic-settlement + * preferences are device-local on mobile and default to the existing + * three-day inactivity window plus terminal-review settlement. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -325,7 +343,8 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; + readonly autoSettleAfterDays?: number | null; + readonly autoSettleOnChangeRequestCompletion?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -345,7 +364,11 @@ export function buildThreadListV2Items(input: { }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const autoSettleAfterDays = + input.autoSettleAfterDays === undefined + ? DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + : input.autoSettleAfterDays; + const autoSettleOnChangeRequestCompletion = input.autoSettleOnChangeRequestCompletion !== false; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -394,7 +417,12 @@ export function buildThreadListV2Items(input: { } if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnChangeRequestCompletion, + changeRequestState, + }) ) { settled.push(thread); } else { diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts index 266bda944ae..28f665d2624 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -1,8 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; +import { useMemo } from "react"; import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveThreadListV2Enabled } from "./threadListV2"; +import { + resolveThreadListV2Enabled, + resolveThreadListV2SettlementPreferences, +} from "./threadListV2"; /** * Resolved Thread List v2 state: the device-local preference if the user has @@ -17,3 +21,24 @@ export function useThreadListV2Enabled(): boolean { preferencesLoaded: loaded, }); } + +export function useThreadListV2SettlementPreferences(): { + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnChangeRequestCompletion: boolean; +} { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const preferences = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value + : undefined; + const autoSettleInactive = preferences?.threadListV2AutoSettleInactive; + const autoSettleOnChangeRequestCompletion = + preferences?.threadListV2AutoSettleOnChangeRequestCompletion; + return useMemo( + () => + resolveThreadListV2SettlementPreferences({ + autoSettleInactive, + autoSettleOnChangeRequestCompletion, + }), + [autoSettleInactive, autoSettleOnChangeRequestCompletion], + ); +} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6b1018e2a0a..ceaae0752bb 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -30,6 +30,9 @@ export interface Preferences { * see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; + /** Device-local counterparts of the web Sidebar v2 auto-settle settings. */ + readonly threadListV2AutoSettleInactive?: boolean; + readonly threadListV2AutoSettleOnChangeRequestCompletion?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -81,6 +84,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; threadListV2Enabled?: boolean; + threadListV2AutoSettleInactive?: boolean; + threadListV2AutoSettleOnChangeRequestCompletion?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -113,6 +118,13 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } + if (typeof parsed.threadListV2AutoSettleInactive === "boolean") { + preferences.threadListV2AutoSettleInactive = parsed.threadListV2AutoSettleInactive; + } + if (typeof parsed.threadListV2AutoSettleOnChangeRequestCompletion === "boolean") { + preferences.threadListV2AutoSettleOnChangeRequestCompletion = + parsed.threadListV2AutoSettleOnChangeRequestCompletion; + } return preferences; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..a96c71677b7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3962,6 +3962,9 @@ function ChatViewContent(props: ChatViewProps) { // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); + const autoSettleOnChangeRequestCompletion = useClientSettings( + (settings) => settings.sidebarAutoSettleOnChangeRequestCompletion, + ); const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, @@ -3990,12 +3993,14 @@ function ChatViewContent(props: ChatViewProps) { return effectiveSettled(activeThreadShell, { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, + autoSettleOnChangeRequestCompletion, changeRequestState: activeThreadPr?.state ?? null, }); }, [ activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, + autoSettleOnChangeRequestCompletion, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ee73b570514..34b451fe9b0 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1176,6 +1176,9 @@ export default function SidebarV2() { const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const autoSettleOnChangeRequestCompletion = useClientSettings( + (s) => s.sidebarAutoSettleOnChangeRequestCompletion, + ); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1592,7 +1595,12 @@ export default function SidebarV2() { snoozed.push(thread); } else if ( supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + effectiveSettled(thread, { + now, + autoSettleAfterDays, + autoSettleOnChangeRequestCompletion, + changeRequestState, + }) ) { settled.push(thread); } else { @@ -1612,6 +1620,7 @@ export default function SidebarV2() { }; }, [ autoSettleAfterDays, + autoSettleOnChangeRequestCompletion, changeRequestStateByKey, nowMinute, scopedProjectKeys, diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 740d3048f0e..a9ac87d21d8 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -1,4 +1,9 @@ import { useEffect, useState } from "react"; +import { + DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, +} from "@t3tools/contracts"; import { useClientSettings, @@ -10,10 +15,6 @@ import { Switch } from "../ui/switch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; -const AUTO_SETTLE_MIN_DAYS = 1; -const AUTO_SETTLE_MAX_DAYS = 90; -const AUTO_SETTLE_DEFAULT_DAYS = 3; - function AutoSettleDaysInput({ value, onCommit, @@ -31,8 +32,8 @@ function AutoSettleDaysInput({ return ( { @@ -43,8 +44,8 @@ function AutoSettleDaysInput({ const parsed = Number(event.target.value); if ( Number.isInteger(parsed) && - parsed >= AUTO_SETTLE_MIN_DAYS && - parsed <= AUTO_SETTLE_MAX_DAYS + parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS ) { onCommit(parsed); } @@ -60,6 +61,9 @@ export function BetaSettingsPanel() { const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); + const sidebarAutoSettleOnChangeRequestCompletion = useClientSettings( + (settings) => settings.sidebarAutoSettleOnChangeRequestCompletion, + ); const updateSettings = useUpdateClientSettings(); return ( @@ -87,13 +91,15 @@ export function BetaSettingsPanel() { <> updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + sidebarAutoSettleAfterDays: checked + ? DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS + : null, }) } aria-label="Auto-settle inactive threads" @@ -112,6 +118,21 @@ export function BetaSettingsPanel() { } /> ) : null} + + updateSettings({ + sidebarAutoSettleOnChangeRequestCompletion: Boolean(checked), + }) + } + aria-label="Auto-settle merged or closed PR threads" + /> + } + /> ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 464f92547e5..d932f59e792 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -64,6 +64,13 @@ describe("searchSettings", () => { expect(new Set(ids).size).toBe(ids.length); }); + it("indexes both Sidebar v2 auto-settle controls", () => { + expect(searchSettings("auto-settle").map((item) => item.id)).toEqual([ + "auto-settle-inactive-threads", + "auto-settle-merged-or-closed-pr-threads", + ]); + }); + it("serves anchor props to panels from the catalog", () => { expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" }); expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 4ead6eff4d7..98b037f6f04 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -157,6 +157,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/beta", targetId: "sidebar-v2", }, + { + id: "auto-settle-merged-or-closed-pr-threads", + title: "Auto-settle merged or closed PR threads", + to: "/settings/beta", + targetId: "sidebar-v2", + }, { id: "archive", title: "Archived threads", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 6d81d2b33ab..3e2bfb38b36 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,6 +41,20 @@ T3 Code works with the platforms your team already uses: - Open the review directly in your browser with one click - Check out a teammate's branch to review code locally +### Control When Finished Threads Settle + +Sidebar v2 (Thread List v2 on mobile) keeps completed work close at hand without forcing every +thread out of your active list. Open **Settings → Beta** to control its two automatic settle +triggers independently: + +- **Auto-settle inactive threads** moves threads after the configured inactivity window. On mobile, + this window is three days. +- **Auto-settle merged or closed PR threads** moves threads when their pull request or merge request + reaches a terminal state. + +Turn both options off if you want threads to settle only when you do it manually. Existing installs +keep both options on by default. Mobile stores these choices per device. + ### Know Your Setup at a Glance The **Source Control settings** page shows you exactly what's connected: diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index a7dd4b1eab8..937edd8266f 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -178,6 +178,45 @@ describe("effectiveSettled", () => { } }); + it("keeps merged and closed change request threads active when their auto-settle is disabled", () => { + const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); + for (const changeRequestState of ["merged", "closed"] as const) { + expect( + effectiveSettled(recentlyActive, { + now: NOW, + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + changeRequestState, + }), + ).toBe(false); + } + }); + + it("keeps inactivity and manual settlement independent from change request auto-settle", () => { + const stale = makeShell({ activityAt: STALE }); + expect( + effectiveSettled(stale, { + now: NOW, + autoSettleAfterDays: 3, + autoSettleOnChangeRequestCompletion: false, + changeRequestState: "merged", + }), + ).toBe(true); + + const manuallySettled = makeShell({ + settledOverride: "settled", + activityAt: FRESH, + }); + expect( + effectiveSettled(manuallySettled, { + now: NOW, + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + changeRequestState: "merged", + }), + ).toBe(true); + }); + it("never auto-settles a stale thread with an open change request", () => { const stale = makeShell({ activityAt: STALE }); expect( diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 595b1303bea..3971c342380 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -221,17 +221,20 @@ export function threadWokeAt( * queued turn) are checked first and hold a thread active regardless of any * override. Past the blockers, the explicit user override (thread.settle / * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread auto-settles on a - * merged/closed PR immediately or on inactivity past the window — except - * that an open PR blocks the inactivity path entirely. The server - * un-settles on real activity (user message, session start, approval/ - * user-input request), so an override never goes stale silently. + * wins in both directions; without one, a thread may auto-settle on a + * merged/closed PR or on inactivity past the window, according to the two + * independent client preferences. An open PR blocks the inactivity path + * entirely. The server un-settles on real activity (user message, session + * start, approval/user-input request), so an override never goes stale + * silently. */ export function effectiveSettled( shell: OrchestrationThreadShell, options: { readonly now: string; readonly autoSettleAfterDays: number | null; + /** Defaults to true for callers that omit this preference. */ + readonly autoSettleOnChangeRequestCompletion?: boolean; readonly changeRequestState?: ChangeRequestStateLike | null; }, ): boolean { @@ -258,7 +261,10 @@ export function effectiveSettled( // "active" is the explicit keep-active pin: it suppresses auto-settle // until real activity clears it server-side. if (shell.settledOverride === "active") return false; - if (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + if ( + options.autoSettleOnChangeRequestCompletion !== false && + (options.changeRequestState === "merged" || options.changeRequestState === "closed") + ) { return true; } // An open PR is unfinished business regardless of how long the thread has diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2bc61d72f21..4bb3b7d7fbf 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -72,6 +72,7 @@ describe("ClientSettings sidebar v2", () => { const settings = decodeClientSettings({}); expect(settings.sidebarV2Enabled).toBe(false); expect(settings.sidebarAutoSettleAfterDays).toBe(3); + expect(settings.sidebarAutoSettleOnChangeRequestCompletion).toBe(true); }); it("treats settings written before the beta had a per-channel default as unconfigured", () => { @@ -105,6 +106,17 @@ describe("ClientSettings sidebar v2", () => { ).toBeNull(); }); + it("allows auto-settle on merged or closed change requests to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleOnChangeRequestCompletion: false }) + .sidebarAutoSettleOnChangeRequestCompletion, + ).toBe(false); + expect( + decodeClientSettingsPatch({ sidebarAutoSettleOnChangeRequestCompletion: false }) + .sidebarAutoSettleOnChangeRequestCompletion, + ).toBe(false); + }); + it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7edda2e52e5..352a499d8a0 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -104,6 +104,9 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), + sidebarAutoSettleOnChangeRequestCompletion: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -702,6 +705,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), ), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleOnChangeRequestCompletion: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 26894525dec9e71e154b75fa4044be5adbb98f69 Mon Sep 17 00:00:00 2001 From: 137 <113233555+caezium@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:42:04 +0800 Subject: [PATCH 2/2] fix(clients): wait for auto-settle preferences --- .../src/features/threads/threadListV2.test.ts | 10 +++++- .../src/features/threads/threadListV2.ts | 12 ++++--- .../threads/use-thread-list-v2-enabled.ts | 13 +++---- apps/web/src/components/ChatView.tsx | 8 ++--- apps/web/src/components/SidebarV2.tsx | 12 ++++--- apps/web/src/hooks/useSettings.test.ts | 34 +++++++++++++++++- apps/web/src/hooks/useSettings.ts | 35 +++++++++++++++++++ 7 files changed, 100 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1db4c14691b..cbcc52e2666 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -124,17 +124,25 @@ describe("resolveThreadListV2Enabled", () => { describe("resolveThreadListV2SettlementPreferences", () => { it("defaults both automatic triggers on", () => { - expect(resolveThreadListV2SettlementPreferences({})).toEqual({ + expect(resolveThreadListV2SettlementPreferences({ preferencesLoaded: true })).toEqual({ autoSettleAfterDays: 3, autoSettleOnChangeRequestCompletion: true, }); }); + it("holds both automatic triggers off while preferences are loading", () => { + expect(resolveThreadListV2SettlementPreferences({ preferencesLoaded: false })).toEqual({ + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + }); + }); + it("supports fully manual settlement", () => { expect( resolveThreadListV2SettlementPreferences({ autoSettleInactive: false, autoSettleOnChangeRequestCompletion: false, + preferencesLoaded: true, }), ).toEqual({ autoSettleAfterDays: null, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 287c9611e51..5b61a9b7f88 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -33,10 +33,14 @@ export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooz export function resolveThreadListV2SettlementPreferences(input: { readonly autoSettleInactive?: boolean; readonly autoSettleOnChangeRequestCompletion?: boolean; -}): { - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnChangeRequestCompletion: boolean; -} { + readonly preferencesLoaded: boolean; +}) { + if (!input.preferencesLoaded) { + return { + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + }; + } return { autoSettleAfterDays: input.autoSettleInactive === false ? null : DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts index 28f665d2624..58eb0cd1e1d 100644 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts @@ -22,14 +22,10 @@ export function useThreadListV2Enabled(): boolean { }); } -export function useThreadListV2SettlementPreferences(): { - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnChangeRequestCompletion: boolean; -} { +export function useThreadListV2SettlementPreferences() { const preferencesResult = useAtomValue(mobilePreferencesAtom); - const preferences = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value - : undefined; + const preferencesLoaded = AsyncResult.isSuccess(preferencesResult); + const preferences = preferencesLoaded ? preferencesResult.value : undefined; const autoSettleInactive = preferences?.threadListV2AutoSettleInactive; const autoSettleOnChangeRequestCompletion = preferences?.threadListV2AutoSettleOnChangeRequestCompletion; @@ -38,7 +34,8 @@ export function useThreadListV2SettlementPreferences(): { resolveThreadListV2SettlementPreferences({ autoSettleInactive, autoSettleOnChangeRequestCompletion, + preferencesLoaded, }), - [autoSettleInactive, autoSettleOnChangeRequestCompletion], + [autoSettleInactive, autoSettleOnChangeRequestCompletion, preferencesLoaded], ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a96c71677b7..59f6a308c84 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -166,7 +166,7 @@ import { import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; -import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; +import { useClientThreadSettlementPreferences, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -3961,10 +3961,8 @@ function ChatViewContent(props: ChatViewProps) { // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); - const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); - const autoSettleOnChangeRequestCompletion = useClientSettings( - (settings) => settings.sidebarAutoSettleOnChangeRequestCompletion, - ); + const { autoSettleAfterDays, autoSettleOnChangeRequestCompletion } = + useClientThreadSettlementPreferences(); const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 34b451fe9b0..80b56dc43c2 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -87,7 +87,11 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { + useClientSettings, + useClientThreadSettlementPreferences, + useUpdateClientSettings, +} from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; @@ -1175,10 +1179,8 @@ export default function SidebarV2() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnChangeRequestCompletion = useClientSettings( - (s) => s.sidebarAutoSettleOnChangeRequestCompletion, - ); + const { autoSettleAfterDays, autoSettleOnChangeRequestCompletion } = + useClientThreadSettlementPreferences(); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 741579661e7..49b82456022 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -6,7 +6,39 @@ import { import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; -import { mergeEnvironmentSettings, resolveEnvironmentIdentificationMode } from "./useSettings"; +import { + mergeEnvironmentSettings, + resolveClientThreadSettlementPreferences, + resolveEnvironmentIdentificationMode, +} from "./useSettings"; + +describe("resolveClientThreadSettlementPreferences", () => { + it("holds both automatic triggers off until client settings hydrate", () => { + expect( + resolveClientThreadSettlementPreferences({ + autoSettleAfterDays: 3, + autoSettleOnChangeRequestCompletion: true, + settingsHydrated: false, + }), + ).toEqual({ + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + }); + }); + + it("uses the persisted preferences after hydration", () => { + expect( + resolveClientThreadSettlementPreferences({ + autoSettleAfterDays: 7, + autoSettleOnChangeRequestCompletion: false, + settingsHydrated: true, + }), + ).toEqual({ + autoSettleAfterDays: 7, + autoSettleOnChangeRequestCompletion: false, + }); + }); +}); describe("resolveEnvironmentIdentificationMode", () => { it("keeps identification hidden until client settings hydrate", () => { diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e58876b19f7..97b7ac7a958 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -223,6 +223,41 @@ export function useClientSettings( return useMemo(() => (selector ? selector(settings) : (settings as T)), [selector, settings]); } +export function resolveClientThreadSettlementPreferences(input: { + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnChangeRequestCompletion: boolean; + readonly settingsHydrated: boolean; +}) { + if (!input.settingsHydrated) { + return { + autoSettleAfterDays: null, + autoSettleOnChangeRequestCompletion: false, + }; + } + return { + autoSettleAfterDays: input.autoSettleAfterDays, + autoSettleOnChangeRequestCompletion: input.autoSettleOnChangeRequestCompletion, + }; +} + +export function useClientThreadSettlementPreferences() { + const settingsHydrated = useClientSettingsHydrated(); + const settings = useClientSettingsValue(); + return useMemo( + () => + resolveClientThreadSettlementPreferences({ + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnChangeRequestCompletion: settings.sidebarAutoSettleOnChangeRequestCompletion, + settingsHydrated, + }), + [ + settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleOnChangeRequestCompletion, + settingsHydrated, + ], + ); +} + export function resolveEnvironmentIdentificationMode(input: { mode: EnvironmentIdentificationMode; settingsHydrated: boolean;