Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,6 +32,7 @@ const clientSettings: ClientSettings = {
planModeEnabled: false,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
sidebarAutoSettleCompletedChangeRequests: 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 numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
Expand DownExpand Up@@ -206,6 +207,7 @@ export function HomeScreen(props: HomeScreenProps) {
>(() => new Map());
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
const listRef = useRef<LegendListRef | null>(null);
Expand DownExpand Up@@ -651,6 +653,7 @@ export function HomeScreen(props: HomeScreenProps) {
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -659,6 +662,7 @@ export function HomeScreen(props: HomeScreenProps) {
selectedThreadKey: null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "../threads/use-auto-settle-completed-change-requests";
import {
type AppUpdateCheckState,
registerHiddenUpdateTap,
Expand DownExpand Up@@ -544,6 +545,7 @@ function GeneralSettingsSection() {
function BetaSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();

return (
<View className="gap-3">
Expand All@@ -554,11 +556,26 @@ function BetaSettingsSection() {
value={threadListV2Enabled}
onValueChange={(value) => savePreferences({ threadListV2Enabled: value })}
/>
{threadListV2Enabled ? (
<SettingsSwitchRow
icon="checkmark.circle"
label="Auto-settle completed pull requests"
value={autoSettleCompletedChangeRequests}
onValueChange={(value) => savePreferences({ autoSettleCompletedChangeRequests: value })}
/>
) : null}
</SettingsSection>
<Text className="px-2 text-sm text-foreground-muted">
One flat thread list in creation order. Active work renders as cards; settled threads
collapse to compact rows. Switch back any time.
</Text>
{threadListV2Enabled ? (
<Text className="px-2 text-sm text-foreground-muted">
Completed pull-request settle is independent of the three-day inactivity window. Turn it
off to keep merged or closed PR threads active until inactivity settle applies, or until
you settle them yourself. A manual Un-settle stays active until you settle again.
</Text>
) : null}
</View>
);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,6 +31,7 @@ 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 { useAutoSettleCompletedChangeRequests } from "./use-auto-settle-completed-change-requests";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
import { useWorkspaceState } from "../../state/workspace";
Expand DownExpand Up@@ -213,6 +214,7 @@ function ThreadNavigationSidebarPane(
movePinnedThread,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
const autoSettleCompletedChangeRequests = useAutoSettleCompletedChangeRequests();
const pendingTasks = usePendingNewTasks();
const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
const environments = useMemo(
Expand DownExpand Up@@ -538,6 +540,7 @@ function ThreadNavigationSidebarPane(
changeRequestStateByKey,
settlementEnvironmentIds,
snoozeEnvironmentIds,
autoSettleCompletedChangeRequests,
settledLimit: settledVisibleCount,
now: `${nowMinute}:00.000Z`,
snoozeNow: new Date().toISOString(),
Expand All@@ -546,6 +549,7 @@ function ThreadNavigationSidebarPane(
selectedThreadKey: props.selectedThreadKey ?? null,
});
}, [
autoSettleCompletedChangeRequests,
changeRequestStateByKey,
nowMinute,
snoozeWakeTick,
Expand Down
68 changes: 68 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
buildThreadListV2Items,
buildThreadListV2ListItems,
resolveAutoSettleCompletedChangeRequests,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
Expand DownExpand Up@@ -121,6 +122,41 @@ describe("resolveThreadListV2Enabled", () => {
});
});

describe("resolveAutoSettleCompletedChangeRequests", () => {
it("defaults on when the device has never chosen", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: true,
}),
).toBe(true);
});

it("honors an explicit device opt-out", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: false,
preferencesLoaded: true,
}),
).toBe(false);
expect(
resolveAutoSettleCompletedChangeRequests({
preference: true,
preferencesLoaded: true,
}),
).toBe(true);
});

it("holds the default while preferences are still loading", () => {
expect(
resolveAutoSettleCompletedChangeRequests({
preference: undefined,
preferencesLoaded: false,
}),
).toBe(true);
});
});

describe("resolveThreadListV2Status", () => {
it("prioritizes approval over a running session", () => {
const thread = makeThread({
Expand DownExpand Up@@ -288,6 +324,38 @@ describe("buildThreadListV2Items", () => {
expect(layout.snoozedCount).toBe(1);
});

it("settles merged PRs by default and keeps them active when completed-PR settle is off", () => {
const mergedThread = makeThread({
id: ThreadId.make("merged"),
title: "Merged",
latestUserMessageAt: "2026-06-01T20:00:00.000Z",
});
const changeRequestStateByKey = new Map([[`${environmentId}:merged`, "merged" as const]]);

const defaultLayout = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
now: NOW,
});
expect(defaultLayout.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(defaultLayout.settledCount).toBe(1);
expect(defaultLayout.items[0]?.variant).toBe("slim");

const optedOut = buildThreadListV2Items({
threads: [mergedThread],
environmentId: null,
searchQuery: "",
changeRequestStateByKey,
autoSettleCompletedChangeRequests: false,
now: NOW,
});
expect(optedOut.settledCount).toBe(0);
expect(optedOut.items.map((item) => item.thread.id)).toEqual(["merged"]);
expect(optedOut.items[0]?.variant).toBe("card");
});

it("renders pinned threads first and exempts them from auto-settle — parity with web", () => {
const layout = buildThreadListV2Items({
threads: [
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -122,6 +122,20 @@ export function resolveThreadListV2Enabled(input: {
return input.preference ?? true;
}

/**
* Device-local completed-PR auto-settle preference. Defaults on (matching web
* ClientSettings) so older installs keep historical always-settle behavior.
*/
export function resolveAutoSettleCompletedChangeRequests(input: {
readonly preference: boolean | undefined;
readonly preferencesLoaded: boolean;
}): boolean {
if (!input.preferencesLoaded) {
return true;
}
return input.preference ?? true;
}

export function resolveThreadListV2Status(
thread: Pick<EnvironmentThreadShell, "hasPendingApprovals" | "hasPendingUserInput" | "session">,
): ThreadListV2Status {
Expand DownExpand Up@@ -307,8 +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.
* mirrors the web default of 3 — mobile has no synced inactivity setting yet,
* so that default is fixed here. Completed-PR auto-settle is a separate
* device-local preference.
*/
export function buildThreadListV2Items(input: {
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All@@ -329,6 +344,8 @@ export function buildThreadListV2Items(input: {
contract as settlementEnvironmentIds. */
readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
readonly autoSettleAfterDays?: number;
/** Defaults to true so callers that omit it keep historical behavior. */
readonly autoSettleCompletedChangeRequests?: boolean;
/** Max settled rows to render; the rest are counted, not built. */
readonly settledLimit?: number;
/** Injectable for tests; defaults to now. */
Expand All@@ -349,6 +366,7 @@ export function buildThreadListV2Items(input: {
const now = input.now ?? new Date().toISOString();
const snoozeNow = input.snoozeNow ?? now;
const autoSettleAfterDays = input.autoSettleAfterDays ?? 3;
const autoSettleCompletedChangeRequests = input.autoSettleCompletedChangeRequests !== false;
const query = input.searchQuery.trim().toLocaleLowerCase();
const projectKeys = input.projectRefs
? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
Expand DownExpand Up@@ -405,7 +423,12 @@ export function buildThreadListV2Items(input: {
}
if (
supportsSettlement &&
effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState })
effectiveSettled(thread, {
now,
autoSettleAfterDays,
autoSettleCompletedChangeRequests,
changeRequestState,
})
) {
settled.push(thread);
} else {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";

import { mobilePreferencesAtom } from "../../state/preferences";
import { resolveAutoSettleCompletedChangeRequests } from "./threadListV2";

/**
* Resolved completed-PR auto-settle preference: the device-local choice if
* set, otherwise the default (on). Matches web ClientSettings semantics.
*/
export function useAutoSettleCompletedChangeRequests(): boolean {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const loaded = AsyncResult.isSuccess(preferencesResult);
return resolveAutoSettleCompletedChangeRequests({
preference: loaded ? preferencesResult.value.autoSettleCompletedChangeRequests : undefined,
preferencesLoaded: loaded,
});
}
38 changes: 38 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vite-plus/test";

vi.mock("expo-secure-store", () => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
}));

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
}));

import { sanitizePreferences } from "./mobile-preferences";

describe("sanitizePreferences completed-PR auto-settle", () => {
it("drops an absent preference so resolvers can default on", () => {
expect(sanitizePreferences({}).autoSettleCompletedChangeRequests).toBeUndefined();
});

it.each([true, false])("keeps an explicit completed-PR auto-settle choice: %s", (value) => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
}),
).toEqual({
autoSettleCompletedChangeRequests: value,
threadListV2Enabled: true,
});
});

it("ignores non-boolean completed-PR auto-settle values", () => {
expect(
sanitizePreferences({
autoSettleCompletedChangeRequests: "yes" as unknown as boolean,
}).autoSettleCompletedChangeRequests,
).toBeUndefined();
});
});
11 changes: 10 additions & 1 deletion apps/mobile/src/persistence/mobile-preferences.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,11 @@ export interface Preferences {
* see `resolveThreadListV2Enabled`.
*/
readonly threadListV2Enabled?: boolean;
/**
* Device-local mirror of web's `sidebarAutoSettleCompletedChangeRequests`.
* Undefined means never chosen, which resolves to on (historical default).
*/
readonly autoSettleCompletedChangeRequests?: boolean;
}

export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
Expand DownExpand Up@@ -72,7 +77,7 @@ export class MobilePreferencesStore extends Context.Service<
}
>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}

function sanitizePreferences(parsed: Preferences): Preferences {
export function sanitizePreferences(parsed: Preferences): Preferences {
const preferences: {
liveActivitiesEnabled?: boolean;
baseFontSize?: number;
Expand All@@ -85,6 +90,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingEnabled?: boolean;
projectGroupingMode?: SidebarProjectGroupingMode;
threadListV2Enabled?: boolean;
autoSettleCompletedChangeRequests?: boolean;
} = {};

if (typeof parsed.liveActivitiesEnabled === "boolean") {
Expand DownExpand Up@@ -124,6 +130,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.threadListV2Enabled === "boolean") {
preferences.threadListV2Enabled = parsed.threadListV2Enabled;
}
if (typeof parsed.autoSettleCompletedChangeRequests === "boolean") {
preferences.autoSettleCompletedChangeRequests = parsed.autoSettleCompletedChangeRequests;
}
return preferences;
}

Expand Down
Loading
Loading