diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index bfdb97da39..510558b7e7 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -40,7 +40,7 @@ The cycle covers the original bound `ab099178a7b7f9728843e90fc95ed90bb61d710d` ( | Sync status timing and sorting | `ab70c8943f48c08e67e3597f62941c169bc58c16`, `6f97b0f66a51fbafabd54a5616da8827ebdf6020` | Adopted: 400ms show/minimum-visible sync status with raw phase kept for gating; precomputed sort keys shared by web and mobile. | [#935](https://github.com/pylon-code/pylon/pull/935); 289 focused tests. | | Mobile feed, composer, Connect removal copy | `ecd3237b184e87af5d744a9dc28422cc1eb68e1a`, `dab9561cae8817bea24442c1dc7e28996de9c52e`, `ba79610d166dcc7a9b8beb013d18304bc6261307`, `525af2d1ad4b6de847ea658c37a081fd7afe8ef6`, `595a1e1f537f403eba408817c2aba35e61c2ac29` | First three adopted/adapted with Pylon Connect copy. `525af2d1ad` deferred and `595a1e1f53` not applicable: both need upstream's LegendList v2 Home (`151324b2c0`, deferred in #736). | [#928](https://github.com/pylon-code/pylon/pull/928); 184 focused tests. | | Android usage widget, text-size scaling, native autolinks | `59abcd67ac2c54bd63a422e92fa2e31b2c667b6d`, `7a12aff471ffe2b22b9fee495b04b32c43f45a37`, `fe6388f01d5ae9f9db43079c6f5b2c31869067ca`, `81e0491d2bdec153bf1ee12d4e1deab3909b7428` | Widget adapted (scrolling list on 12L+, older Android keeps the fitted layout). Scaling partial: Pylon sizing helper only; `Material*` hunks need `e106fe95cc` (skipped as a Pylon product choice in #736). Autolink patch adopted on registry 0.5.8 — the `deps/` tarball override is npm-only and unused by pnpm; needs a native build. | [#930](https://github.com/pylon-code/pylon/pull/930), [#933](https://github.com/pylon-code/pylon/pull/933); 403 mobile tests; Kotlin not compiled locally. | -| Per-thread auto-settle switch | `0109670411d13b9e380766e98b386b2f756ecf5b` | Adapted: migration 067, `threadAutoSettleOptOut` capability, default unchanged on replay. | [#931](https://github.com/pylon-code/pylon/pull/931); 87 server tests, six typechecks. | +| Per-thread auto-settle switch | `0109670411d13b9e380766e98b386b2f756ecf5b` | Adapted: migration 067, `threadAutoSettleOptOut` capability, default unchanged on replay. This follow-up adds the capability-gated switch to Pylon’s retained legacy mobile Home/sidebar and v2 fallback menus; native menu evidence remains pending. | [#931](https://github.com/pylon-code/pylon/pull/931); 87 server tests, six typechecks. | | node-pty upgrade, Windows terminal startup, Linux URL handler | `4923ff417f15fe1930fbb679e548706b55f7cde8`, `ea7d46ac107e6a3461b81065ebc86dfd5483095f`, `b528a701102f95089544596856a3f208f4404613`, `b21f3b71913370a1a650a61fd9082f992d40e657`, `7733bc839e23e19467a70b11a0bcedf6ffe8f1a3` | node-pty chain adopted; prebuild validation already covered (test added). URL handler partial: MIME cache refresh adopted, icon copy excluded because Pylon installs a per-channel icon. | [#929](https://github.com/pylon-code/pylon/pull/929), [#932](https://github.com/pylon-code/pylon/pull/932); Windows not run locally. | | Bitbucket credentials | `5da55957d48dbd79c1a05b72bdf2479ae0956b1c` | Adapted: stored in `ServerSecretStore`, redacted to clients, `bitbucketCredentials` capability, unusable tokens refused on save. | [#936](https://github.com/pylon-code/pylon/pull/936); 112 focused tests plus RPC redaction test. | | Transcript usage and OpenCode Go limits | `adfc9240eab7c5db376a87591b497d8144289257`, `1a553d0f5dd2aa39a64895ec770dc51ef1448b1a` | Adopted; credential fingerprint uses Pylon's JSON key format. | [#940](https://github.com/pylon-code/pylon/pull/940); 132 focused tests. | diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 631cefb741..7ad73ef123 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -69,6 +69,7 @@ import { } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; +import { useLegacyHomeListExtraData } from "./home-list-extra-data"; import { buildHomeListLayout, DEFAULT_GROUP_DISPLAY_STATE, @@ -1034,15 +1035,14 @@ export function HomeScreen(props: HomeScreenProps) { ], ); - const extraData = useMemo( - () => ({ - projectCwdByKey, - savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, - threadSearchMatchByKey, - }), - [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], - ); + const extraData = useLegacyHomeListExtraData({ + projectCwdByKey, + savedConnectionsById: props.savedConnectionsById, + searchQuery: props.searchQuery, + threadSearchMatchByKey, + autoSettleOptOutEnvironmentIds, + titleRegenerationEnvironmentIds, + }); const renderItem = useCallback( ({ item }: LegendListRenderItemProps) => { @@ -1110,6 +1110,8 @@ export function HomeScreen(props: HomeScreenProps) { onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + onSetThreadAutoSettle={handleSetThreadAutoSettle} onSelectThread={props.onSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1132,6 +1134,8 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleRegenerateThreadTitle, + handleSetThreadAutoSettle, + autoSettleOptOutEnvironmentIds, handleRenameThread, machineByEnvironmentId, projectCwdByKey, diff --git a/apps/mobile/src/features/home/home-list-extra-data.test.tsx b/apps/mobile/src/features/home/home-list-extra-data.test.tsx new file mode 100644 index 0000000000..a22258cdfc --- /dev/null +++ b/apps/mobile/src/features/home/home-list-extra-data.test.tsx @@ -0,0 +1,62 @@ +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import { EnvironmentId } from "@t3tools/contracts"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it } from "vite-plus/test"; + +import { useLegacyHomeListExtraData } from "./home-list-extra-data"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +type Input = Parameters[0]; + +const environmentId = EnvironmentId.make("environment-a"); +const baseInput: Input = { + projectCwdByKey: new Map(), + savedConnectionsById: {}, + searchQuery: "", + threadSearchMatchByKey: new Map(), + autoSettleOptOutEnvironmentIds: new Set(), + titleRegenerationEnvironmentIds: new Set(), +}; + +function renderExtraData(initial: Input) { + const seen: unknown[] = []; + function Probe(props: { readonly input: Input }) { + seen.push(useLegacyHomeListExtraData(props.input)); + return null; + } + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(); + }); + return { + seen, + rerender(input: Input) { + act(() => renderer?.update()); + }, + }; +} + +describe("useLegacyHomeListExtraData", () => { + it("keeps its identity across renders with unchanged inputs", () => { + const probe = renderExtraData(baseInput); + probe.rerender({ ...baseInput }); + expect(probe.seen).toHaveLength(2); + expect(probe.seen[1]).toBe(probe.seen[0]); + }); + + // A recycled row only re-renders on an item or extraData change, so a + // capability that lands (server configs after threads) or disappears + // (server downgrade) must change the identity or the row menu goes stale. + it("changes identity when the auto-settle capability set changes", () => { + const probe = renderExtraData(baseInput); + probe.rerender({ ...baseInput, autoSettleOptOutEnvironmentIds: new Set([environmentId]) }); + expect(probe.seen[1]).not.toBe(probe.seen[0]); + }); + + it("changes identity when the title regeneration capability set changes", () => { + const probe = renderExtraData(baseInput); + probe.rerender({ ...baseInput, titleRegenerationEnvironmentIds: new Set([environmentId]) }); + expect(probe.seen[1]).not.toBe(probe.seen[0]); + }); +}); diff --git a/apps/mobile/src/features/home/home-list-extra-data.ts b/apps/mobile/src/features/home/home-list-extra-data.ts new file mode 100644 index 0000000000..bc93790cef --- /dev/null +++ b/apps/mobile/src/features/home/home-list-extra-data.ts @@ -0,0 +1,49 @@ +import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useMemo } from "react"; + +import type { SavedRemoteConnection } from "../../lib/connection"; + +/** + * The legacy Home list's `extraData`. A recycled LegendList cell re-renders + * only when its item changes or this identity changes — a new render closure + * alone does not — so every non-item input a row reads must be listed here. + * The capability sets gate row menu items (title regeneration, auto-settle) + * and arrive with server configs, which can land after the threads or change + * when a server is upgraded or downgraded. + */ +export function useLegacyHomeListExtraData(input: { + readonly projectCwdByKey: ReadonlyMap; + readonly savedConnectionsById: Readonly>; + readonly searchQuery: string; + readonly threadSearchMatchByKey: ReadonlyMap; + readonly autoSettleOptOutEnvironmentIds: ReadonlySet; + readonly titleRegenerationEnvironmentIds: ReadonlySet; +}) { + const { + projectCwdByKey, + savedConnectionsById, + searchQuery, + threadSearchMatchByKey, + autoSettleOptOutEnvironmentIds, + titleRegenerationEnvironmentIds, + } = input; + return useMemo( + () => ({ + projectCwdByKey, + savedConnectionsById, + searchQuery, + threadSearchMatchByKey, + autoSettleOptOutEnvironmentIds, + titleRegenerationEnvironmentIds, + }), + [ + projectCwdByKey, + savedConnectionsById, + searchQuery, + threadSearchMatchByKey, + autoSettleOptOutEnvironmentIds, + titleRegenerationEnvironmentIds, + ], + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 5d4628e076..cde6f24fa9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1105,6 +1105,8 @@ function ThreadNavigationSidebarPane( onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} + autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)} + onSetThreadAutoSettle={setThreadAutoSettle} onSelectThread={handleSelectThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} diff --git a/apps/mobile/src/features/threads/thread-auto-settle-menu.ts b/apps/mobile/src/features/threads/thread-auto-settle-menu.ts new file mode 100644 index 0000000000..3d6c8399ee --- /dev/null +++ b/apps/mobile/src/features/threads/thread-auto-settle-menu.ts @@ -0,0 +1,28 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +export function buildThreadAutoSettleMenuItems(input: { + readonly supported: boolean; + readonly autoSettleDisabledAt: string | null | undefined; +}): MenuAction[] { + if (!input.supported) return []; + + return [ + { + id: "auto-settle", + title: "Auto-settle behavior", + image: "timer", + subactions: [ + { + id: "auto-settle:enabled", + title: "Enabled", + state: input.autoSettleDisabledAt == null ? "on" : "off", + }, + { + id: "auto-settle:disabled", + title: "Disabled", + state: input.autoSettleDisabledAt == null ? "off" : "on", + }, + ], + }, + ]; +} diff --git a/apps/mobile/src/features/threads/thread-list-items.test.tsx b/apps/mobile/src/features/threads/thread-list-items.test.tsx new file mode 100644 index 0000000000..fd5b37c8fd --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-items.test.tsx @@ -0,0 +1,151 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import type { ComponentProps, ReactNode } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + Pressable: "Pressable", + View: "View", + useWindowDimensions: () => ({ width: 390 }), +})); +vi.mock("@legendapp/list/react-native", () => ({ useRecyclingState: () => [false, vi.fn()] })); +vi.mock("react-native-svg", () => ({ default: "Svg", Circle: "Circle", Path: "Path" })); +vi.mock("../../components/AppText", () => ({ AppText: "Text" })); +vi.mock("../../components/AppSymbol", () => ({ SymbolView: "SymbolView" })); +vi.mock("../../components/ControlPill", () => ({ ControlPillMenu: "ControlPillMenu" })); +vi.mock("../../components/EnvironmentMachineSymbol", () => ({ + EnvironmentMachineSymbol: "EnvironmentMachineSymbol", +})); +vi.mock("../../components/ProjectFavicon", () => ({ ProjectFavicon: "ProjectFavicon" })); +vi.mock("../../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); +vi.mock("../../lib/useUniwindTheme", () => ({ useUniwindTheme: () => ({}) })); +vi.mock("../../state/use-thread-pr", () => ({ useThreadPr: () => null })); +vi.mock("./queued-message-icon", () => ({ QueuedMessageIcon: "QueuedMessageIcon" })); +vi.mock("./thread-search-match", () => ({ ThreadSearchMatchExcerpt: "ThreadSearchMatchExcerpt" })); +vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ + useAppearancePreferences: () => ({ + themeAppearance: "light", + materialYouStyleLayoutActive: false, + }), +})); +vi.mock("../home/thread-swipe-actions", () => ({ + ThreadSwipeable: (props: { readonly children: (close: () => void) => ReactNode }) => + props.children(() => {}), +})); + +import { ControlPillMenu } from "../../components/ControlPill"; +import { ThreadListRow } from "./thread-list-items"; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const thread: EnvironmentThreadShell = { + environmentId: EnvironmentId.make("environment-a"), + id: ThreadId.make("thread-a"), + projectId: ProjectId.make("project-a"), + title: "Thread A", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}; + +function menu(renderer: ReactTestRenderer) { + // The renderer's native interop props are untyped; recover the component contract. + return renderer.root.findByType(ControlPillMenu).props as ComponentProps; +} + +function checkedOptions(actions: MenuAction[]) { + return actions + .find((action) => action.id === "auto-settle") + ?.subactions?.map((action) => ({ + id: action.id, + state: action.state, + })); +} + +describe("legacy thread auto-settle menu", () => { + it.each(["compact", "sidebar"] as const)( + "updates the %s row's checked setting and dispatches for its current environment/thread", + async (variant) => { + const setAutoSettle = vi.fn(); + const archive = vi.fn(); + const baseProps = { + variant, + environmentLabel: null, + projectCwd: null, + isLast: true, + onSelectThread: vi.fn(), + onArchiveThread: archive, + onDeleteThread: vi.fn(), + onNewThreadOnBranch: vi.fn(), + onRenameThread: vi.fn(), + onRegenerateThreadTitle: vi.fn(), + titleRegenerationSupported: false, + autoSettleOptOutSupported: true, + onSetThreadAutoSettle: setAutoSettle, + onSwipeableWillOpen: vi.fn(), + onSwipeableClose: vi.fn(), + } satisfies Omit, "thread">; + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create(); + }); + expect(checkedOptions(menu(renderer!).actions)).toEqual([ + { id: "auto-settle:enabled", state: "on" }, + { id: "auto-settle:disabled", state: "off" }, + ]); + await act(async () => { + menu(renderer!).onPressAction?.({ nativeEvent: { event: "auto-settle:disabled" } }); + }); + expect(setAutoSettle).toHaveBeenLastCalledWith(thread, false); + const held = { ...thread, autoSettleDisabledAt: "2026-09-30T00:00:00.000Z" }; + await act(async () => renderer!.update()); + expect(checkedOptions(menu(renderer!).actions)).toEqual([ + { id: "auto-settle:enabled", state: "off" }, + { id: "auto-settle:disabled", state: "on" }, + ]); + const next = { + ...held, + environmentId: EnvironmentId.make("environment-b"), + id: ThreadId.make("thread-b"), + }; + await act(async () => renderer!.update()); + await act(async () => { + menu(renderer!).onPressAction?.({ nativeEvent: { event: "auto-settle:enabled" } }); + menu(renderer!).onPressAction?.({ nativeEvent: { event: "archive" } }); + }); + expect(setAutoSettle).toHaveBeenLastCalledWith(next, true); + expect(archive).toHaveBeenLastCalledWith(next); + await act(async () => { + renderer!.update( + , + ); + }); + expect(checkedOptions(menu(renderer!).actions)).toBeUndefined(); + expect(menu(renderer!).actions.map((action) => action.id)).toContain("archive"); + // Reconnecting to an updated environment restores the persisted checked state. + await act(async () => renderer!.update()); + expect(checkedOptions(menu(renderer!).actions)?.[1]?.state).toBe("on"); + } finally { + await act(async () => renderer?.unmount()); + } + }, + ); +}); diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index ff2d3a922d..ad3952235e 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -32,6 +32,7 @@ import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-p import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; +import { buildThreadAutoSettleMenuItems } from "./thread-auto-settle-menu"; import { QueuedMessageIcon } from "./queued-message-icon"; import { resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -484,6 +485,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; + readonly autoSettleOptOutSupported: boolean; + readonly onSetThreadAutoSettle: (thread: EnvironmentThreadShell, enabled: boolean) => void; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly simultaneousSwipeGesture?: ComponentProps< @@ -516,6 +519,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, + onSetThreadAutoSettle, } = props; const status = resolveThreadStatus(thread); const pr = useThreadPr(thread, props.projectCwd); @@ -582,9 +586,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ...buildThreadAutoSettleMenuItems({ + supported: props.autoSettleOptOutSupported, + autoSettleDisabledAt: thread.autoSettleDisabledAt, + }), THREAD_ROW_MENU_ACTIONS[2]!, ], - [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], + [ + props.autoSettleOptOutSupported, + props.titleRegenerationSupported, + thread.autoSettleDisabledAt, + thread.branch, + thread.titleRegeneration, + ], ); const primaryAction = useMemo( () => ({ @@ -604,9 +618,19 @@ export const ThreadListRow = memo(function ThreadListRow(props: { copyTextWithHaptic(thread.id, { target: "thread-id" }); } if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); + if (nativeEvent.event === "auto-settle:enabled") onSetThreadAutoSettle(thread, true); + if (nativeEvent.event === "auto-settle:disabled") onSetThreadAutoSettle(thread, false); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], + [ + handleArchive, + handleDelete, + handleRegenerateTitle, + handleRename, + onNewThreadOnBranch, + onSetThreadAutoSettle, + thread, + ], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2e285ec565..160bacde3c 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -35,6 +35,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; +import { buildThreadAutoSettleMenuItems } from "./thread-auto-settle-menu"; import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -596,29 +597,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); // A submenu with the current option checked, matching web. This is a // per-thread setting, not a lifecycle verb. - const autoSettleMenuItems = useMemo( + const autoSettleMenuItems = useMemo( () => - props.autoSettleOptOutSupported - ? [ - { - id: "auto-settle", - title: "Auto-settle behavior", - image: "timer", - subactions: [ - { - id: "auto-settle:enabled", - title: "Enabled", - state: thread.autoSettleDisabledAt == null ? "on" : "off", - }, - { - id: "auto-settle:disabled", - title: "Disabled", - state: thread.autoSettleDisabledAt == null ? "off" : "on", - }, - ], - } satisfies MenuAction, - ] - : [], + buildThreadAutoSettleMenuItems({ + supported: props.autoSettleOptOutSupported, + autoSettleDisabledAt: thread.autoSettleDisabledAt, + }), [props.autoSettleOptOutSupported, thread.autoSettleDisabledAt], ); const titleMenuItems = useMemo( @@ -685,9 +669,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { LEGACY_MENU_ACTIONS[0]!, ...arrangementMenuItems, ...titleMenuItems, + ...autoSettleMenuItems, LEGACY_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleMenuItems], + [arrangementMenuItems, autoSettleMenuItems, titleMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {