From 6ec26cdcf3e52b077042f2ec33e0171275d5da62 Mon Sep 17 00:00:00 2001 From: Leo Orel Date: Wed, 2 Sep 2026 17:11:20 -0400 Subject: [PATCH 1/3] fix(settings): remove obsolete thread controls Remove auto-settle preferences and automatic conversation naming from the bot-first workflow. Hide legacy thread controls from General settings while preserving active compatibility paths. --- .../settings/DesktopClientSettings.test.ts | 2 - apps/mobile/src/features/home/HomeScreen.tsx | 46 +-- .../src/features/home/useThreadListActions.ts | 4 +- .../features/settings/SettingsRouteScreen.tsx | 13 - .../threads/ThreadNavigationSidebar.tsx | 49 +-- .../features/threads/thread-list-v2-items.tsx | 26 +- .../src/features/threads/threadListV2.test.ts | 101 +----- .../src/features/threads/threadListV2.ts | 63 +--- apps/mobile/src/lib/projectThreadStartTurn.ts | 1 - .../src/persistence/mobile-preferences.ts | 5 - apps/server/src/git/GitManager.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 74 +--- .../Layers/ProviderCommandReactor.ts | 62 +--- .../Layers/ProviderRuntimeIngestion.test.ts | 30 +- .../Layers/ProviderRuntimeIngestion.ts | 12 - .../src/orchestration/decider.settled.test.ts | 7 +- apps/server/src/orchestration/decider.ts | 6 +- apps/server/src/orchestration/threadTitles.ts | 12 - .../server/src/routines/RuntimeAdapterLive.ts | 3 +- .../web/src/components/ChatView.logic.test.ts | 1 - apps/web/src/components/ChatView.logic.ts | 9 +- apps/web/src/components/Sidebar.logic.test.ts | 4 +- apps/web/src/components/Sidebar.logic.ts | 7 +- apps/web/src/components/Sidebar.tsx | 52 +-- .../components/ThreadStatusIndicators.test.ts | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 5 - .../roster/botThreadRuntime.logic.ts | 2 - .../components/roster/useBotThreadRuntime.ts | 8 +- .../roster/useGroupThreadRuntime.ts | 7 +- .../components/settings/SettingsPanels.tsx | 325 +----------------- .../src/components/settings/settingsSearch.ts | 38 +- apps/web/src/hooks/useThreadActionMenu.ts | 13 +- apps/web/src/hooks/useThreadActions.ts | 3 +- docs/user/thread-sidebar.md | 8 +- .../src/state/threadSettled.test.ts | 314 +---------------- .../client-runtime/src/state/threadSettled.ts | 113 +----- packages/contracts/src/orchestration.test.ts | 8 +- packages/contracts/src/orchestration.ts | 3 - packages/contracts/src/settings.test.ts | 29 +- packages/contracts/src/settings.ts | 16 - 40 files changed, 92 insertions(+), 1397 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 3304b2a87f1e..d8fba8cb7bd1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -40,8 +40,6 @@ const clientSettings: ClientSettings = { planModeEnabled: false, showSkillsInSlashMenu: false, providerModelPreferences: {}, - sidebarAutoSettleAfterDays: 3, - sidebarAutoSettleOnMerge: true, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3a92131bb2bc..d93fe05cc545 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -53,7 +53,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; @@ -209,9 +208,6 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -488,33 +484,6 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells — no snapshot merging or // optimistic holds. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule, matching web. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); const handleSettleThread = useCallback( (thread: EnvironmentThreadShell) => { void props.onSettleThread(thread); @@ -581,9 +550,8 @@ export function HomeScreen(props: HomeScreenProps) { toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now is quantized to the minute and ticks so the inactivity auto-settle - // boundary is actually crossed while the app stays open (mirrors web); - // without a clock dependency the partition memoizes a frozen "now". + // A minute clock lets a queued turn leave its short adoption grace period + // while the app stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -591,9 +559,7 @@ export function HomeScreen(props: HomeScreenProps) { const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -679,8 +645,6 @@ export function HomeScreen(props: HomeScreenProps) { projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -691,8 +655,6 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -864,7 +826,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -874,7 +835,6 @@ export function HomeScreen(props: HomeScreenProps) { ); }, [ - handleChangeRequestState, handleDeleteThread, arrangedPinnedKeys, handleMovePinnedThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 5c66944042ad..294177f75a8c 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -143,9 +143,7 @@ function useThreadActionExecutor( } const result = action === "unsettle" - ? // reason "user" pins the thread active: auto-settle stays - // suppressed until real activity clears the pin server-side. - await unsettleMutation({ + ? await unsettleMutation({ environmentId: thread.environmentId, input: { threadId: thread.id, reason: "user" }, }) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 9241a9bc667a..b9c685f1963b 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -217,21 +217,8 @@ function EnvironmentPrivacySettingsSection({ } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; - return ( - - savePreferences({ autoSettleOnMerge: value })} - /> ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 0e74e27f8743..90085e11e6e5 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -9,7 +9,6 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; import type { EnvironmentId } from "@t3tools/contracts"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -29,7 +28,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; @@ -83,7 +81,6 @@ import { buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, - type ThreadListV2ChangeRequestState, type ThreadListV2ListItem, } from "./threadListV2"; @@ -173,10 +170,6 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const autoSettleOnMerge = - !AsyncResult.isSuccess(preferencesResult) || - preferencesResult.value.autoSettleOnMerge !== false; const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -374,33 +367,6 @@ function ThreadNavigationSidebarPane( // Thread List v2 (beta) support — same model as the compact Home list // (HomeScreen.tsx): flat creation-order card block + settled recency tail. - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestByKey, setChangeRequestByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, changeRequest: ThreadListV2ChangeRequestState | null) => { - setChangeRequestByKey((current) => { - const existing = current.get(threadKey) ?? null; - if ( - (existing?.state ?? null) === (changeRequest?.state ?? null) && - (existing?.updatedAt ?? null) === (changeRequest?.updatedAt ?? null) && - (existing?.linkedPullRequestKey ?? null) === (changeRequest?.linkedPullRequestKey ?? null) - ) { - return current; - } - const next = new Map(current); - if (changeRequest === null) { - next.delete(threadKey); - } else { - next.set(threadKey, changeRequest); - } - return next; - }); - }, - [], - ); // The settled tail renders in pages; expansion resets when the filter // context changes so environment/search flips never inherit a deep page. const [settledVisibleCount, setSettledVisibleCount] = useState( @@ -423,9 +389,8 @@ function ThreadNavigationSidebarPane( toggleSettledShelf, toggleSnoozedShelf, } = useThreadListV2ShelfPreferences(); - // now ticks per minute so the inactivity auto-settle boundary is actually - // crossed while the pane stays open; without a clock dependency the - // partition memoizes a frozen "now". + // A minute clock lets a queued turn leave its short adoption grace period + // while the pane stays open. const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); // Snooze wake times are second-precise; a counter bumped exactly at the // next wake boundary re-runs the partition with a fresh clock so a woken @@ -433,9 +398,7 @@ function ThreadNavigationSidebarPane( const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { if (!threadListV2Enabled) return; - // Refresh immediately on enable: the mount-time value can be hours old - // by the time the beta is switched on, which would misclassify the - // inactivity auto-settle boundary until the first tick. + // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); @@ -518,8 +481,6 @@ function ThreadNavigationSidebarPane( projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, matchedThreadKeys, - changeRequestByKey, - autoSettleOnMerge, settlementEnvironmentIds, snoozeEnvironmentIds, settledLimit: settledVisibleCount, @@ -530,8 +491,6 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ - changeRequestByKey, - autoSettleOnMerge, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -944,7 +903,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} @@ -1071,7 +1029,6 @@ function ThreadNavigationSidebarPane( arrangedPinnedKeys, confirmDeletePendingTask, confirmDeleteThread, - handleChangeRequestState, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, 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 146779280003..30c027c7e04a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,12 +23,10 @@ import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { - resolveThreadListV2ChangeRequestState, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, - type ThreadListV2ChangeRequestState, type ThreadListV2Status, } from "./threadListV2"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -371,12 +369,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - /** Reports this row's live PR (state + last activity) for the partition's - merge and close rules. Mirrors web's onChangeRequestState. */ - readonly onChangeRequestState?: ( - threadKey: string, - changeRequest: ThreadListV2ChangeRequestState | null, - ) => void; readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; @@ -399,25 +391,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onPinThread, onUnpinThread, onMovePinnedThread, - onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); - const prState = pr?.state ?? null; - const prUpdatedAt = pr?.updatedAt ?? null; - const threadKey = `${thread.environmentId}:${thread.id}`; - useEffect(() => { - const changeRequest = resolveThreadListV2ChangeRequestState({ - linkedPullRequest: thread.linkedPullRequest, - state: prState, - updatedAt: prUpdatedAt, - }); - if (changeRequest === undefined) return; - onChangeRequestState?.(threadKey, changeRequest); - }, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]); - const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); @@ -454,9 +432,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - // Swipe: the v2 primary action is the lifecycle transition. Every settled - // row can un-settle — explicit settles clear the override, auto-settled - // rows get pinned active until real activity clears the pin. + // Swipe uses the primary lifecycle transition for the row. const canUnsettle = variant === "slim"; const [snoozeGateTick, bumpSnoozeGateTick] = useState(0); const snoozeGateExpiryMs = props.snoozeSupported diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 24c07eae6da1..cc5a12e6cfee 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -16,7 +16,6 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, - resolveThreadListV2ChangeRequestState, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -54,49 +53,6 @@ function makeThread( } const NOW = "2026-06-02T00:00:00.000Z"; -const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", -}; - -describe("resolveThreadListV2ChangeRequestState", () => { - it("preserves the previous state while a linked pull request reloads", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: null, - updatedAt: null, - }), - ).toBeUndefined(); - }); - - it("clears the previous state after a pull request is unlinked", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest: null, - state: null, - updatedAt: null, - }), - ).toBeNull(); - }); - - it("reports a loaded linked pull request", () => { - expect( - resolveThreadListV2ChangeRequestState({ - linkedPullRequest, - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - ).toEqual({ - state: "merged", - updatedAt: "2026-06-02T00:00:00.000Z", - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }); - }); -}); - describe("resolveThreadListV2SnoozeMenuSelection", () => { it("accepts a displayed evening preset while its wake time is still future", () => { const menuOpenedAt = new Date(2026, 4, 8, 16, 59, 30); @@ -319,25 +275,15 @@ describe("sortThreadsForListV2", () => { }); describe("buildThreadListV2Items", () => { - it("ignores the previous pull request state after a different pull request is linked", () => { + it("does not settle a linked pull request thread", () => { const thread = makeThread({ id: ThreadId.make("linked"), title: "Linked pull request", - linkedPullRequest, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",41]', - }, - ], - ]), now: NOW, }); @@ -345,42 +291,28 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]?.variant).toBe("card"); }); - it("settles a thread only when the cached pull request identity matches", () => { + it("keeps an active thread in the card list", () => { const thread = makeThread({ id: ThreadId.make("linked-merged"), title: "Linked merged pull request", - linkedPullRequest, }); const layout = buildThreadListV2Items({ threads: [thread], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [ - `${environmentId}:${thread.id}`, - { - state: "merged" as const, - linkedPullRequestKey: '["project-1","pingdotgg/t3code",42]', - }, - ], - ]), now: NOW, }); - expect(layout.settledCount).toBe(1); - expect(layout.items[0]?.variant).toBe("slim"); + expect(layout.settledCount).toBe(0); + expect(layout.items[0]?.variant).toBe("card"); }); - it("keeps a merged thread active when auto-settle on merge is off", () => { + it("keeps a merged thread active", () => { const merged = makeThread({ id: ThreadId.make("merged"), title: "Merged" }); const layout = buildThreadListV2Items({ threads: [merged], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([ - [`${environmentId}:${merged.id}`, { state: "merged" as const }], - ]), - autoSettleOnMerge: false, now: NOW, }); @@ -439,7 +371,7 @@ describe("buildThreadListV2Items", () => { expect(layout.settledCount).toBe(1); }); - it("moves pinned threads to the settled shelf when their pull request merges", () => { + it("keeps pinned threads pinned without an explicit settle", () => { const merged = makeThread({ id: ThreadId.make("pinned-merged"), title: "Pinned merged pull request", @@ -449,17 +381,16 @@ describe("buildThreadListV2Items", () => { threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); - expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); - expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); - expect(layout.settledCount).toBe(1); + expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-merged", "active"]); + expect(layout.items.map((item) => item.variant)).toEqual(["card", "card"]); + expect(layout.items[0]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); + expect(layout.settledCount).toBe(0); }); - it("moves inactive pinned threads to the settled shelf", () => { + it("keeps inactive pinned threads pinned", () => { const inactive = makeThread({ id: ThreadId.make("pinned-inactive"), title: "Pinned inactive thread", @@ -483,13 +414,13 @@ describe("buildThreadListV2Items", () => { expect(layout.items[0]).toMatchObject({ thread: { id: "pinned-inactive" }, - variant: "slim", - pinned: false, + variant: "card", + pinned: true, }); - expect(layout.settledCount).toBe(1); + expect(layout.settledCount).toBe(0); }); - it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { + it("keeps pinned merged threads pinned", () => { const merged = makeThread({ id: ThreadId.make("pinned-merged"), title: "Pinned merged pull request", @@ -499,8 +430,6 @@ describe("buildThreadListV2Items", () => { threads: [merged], environmentId: null, searchQuery: "", - changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), - autoSettleOnMerge: false, now: NOW, }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index be3343a21bad..7666d1e67125 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -6,17 +6,14 @@ import { resolveSnoozePresets, snoozeWakeLabel, } from "@t3tools/client-runtime/state/thread-settled"; -import type { - ChangeRequestSettleSource, - SnoozePreset, -} from "@t3tools/client-runtime/state/thread-settled"; +import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { activeThreadAnchorTimestampMs, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId, ThreadLinkedPullRequest } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -33,35 +30,6 @@ export { snoozeWakeLabel }; export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; export type ThreadListV2SwipeAction = "archive" | "settle" | "unsettle" | "snooze" | "unsnooze"; -export interface ThreadListV2ChangeRequestState extends ChangeRequestSettleSource { - readonly linkedPullRequestKey?: string | null; -} - -function linkedPullRequestKey( - linkedPullRequest: ThreadLinkedPullRequest | null | undefined, -): string | null { - if (linkedPullRequest == null) return null; - return JSON.stringify([ - linkedPullRequest.projectId, - linkedPullRequest.repository.toLowerCase(), - linkedPullRequest.number, - ]); -} - -/** Keep the previous linked PR state while its detail query reloads. */ -export function resolveThreadListV2ChangeRequestState(input: { - readonly linkedPullRequest: ThreadLinkedPullRequest | null | undefined; - readonly state: ChangeRequestSettleSource["state"] | null; - readonly updatedAt: string | null; -}): ThreadListV2ChangeRequestState | null | undefined { - if (input.state === null) return input.linkedPullRequest == null ? null : undefined; - return { - state: input.state, - updatedAt: input.updatedAt, - linkedPullRequestKey: linkedPullRequestKey(input.linkedPullRequest), - }; -} - export function resolveThreadListV2SnoozeMenuSelection(input: { readonly event: string; readonly displayedPresets: ReadonlyArray; @@ -347,8 +315,7 @@ export function buildThreadListV2ListItems(input: { /** * Partitions visible threads into the active card block (creation order) and - * the settled recency tail, matching the web v2 list. Mobile stores these - * auto-settle preferences per device. + * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; @@ -359,8 +326,6 @@ export function buildThreadListV2Items(input: { }> | null; readonly searchQuery: string; readonly matchedThreadKeys?: ReadonlySet; - /** Per-row PR reported up by visible rows ("env:threadId" keys). */ - readonly changeRequestByKey?: ReadonlyMap; /** Environments whose server supports thread.settle/unsettle. Threads on other environments never classify as settled — the user could neither un-settle nor pin them. Absent = no gating (tests). */ @@ -368,8 +333,6 @@ export function buildThreadListV2Items(input: { /** Environments whose server supports thread.snooze/unsnooze. Same contract as settlementEnvironmentIds. */ readonly snoozeEnvironmentIds?: ReadonlySet; - readonly autoSettleAfterDays?: number; - readonly autoSettleOnMerge?: boolean; /** Max settled rows to render; the rest are counted, not built. */ readonly settledLimit?: number; /** Injectable for tests; defaults to now. */ @@ -389,8 +352,6 @@ export function buildThreadListV2Items(input: { }): ThreadListV2Layout { const now = input.now ?? new Date().toISOString(); const snoozeNow = input.snoozeNow ?? now; - const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; - const autoSettleOnMerge = input.autoSettleOnMerge ?? true; const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -422,14 +383,6 @@ export function buildThreadListV2Items(input: { } const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; - const cachedChangeRequest = - input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - const changeRequest = - cachedChangeRequest !== null && - (cachedChangeRequest.linkedPullRequestKey ?? null) === - linkedPullRequestKey(thread.linkedPullRequest) - ? cachedChangeRequest - : null; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); @@ -442,15 +395,7 @@ export function buildThreadListV2Items(input: { } continue; } - if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + if (supportsSettlement && effectiveSettled(thread, { now })) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f5..bb850d59a916 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -58,7 +58,6 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe attachments: toUploadChatImageAttachments(spec.attachments), }, modelSelection: spec.modelSelection, - titleSeed: title, runtimeMode: spec.runtimeMode, interactionMode: spec.interactionMode, bootstrap: { diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index b12cbaac4e7d..14bbba55ad34 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -32,7 +32,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has * no client-settings sync, so the legacy grouped thread list is opted into @@ -103,7 +102,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; @@ -170,9 +168,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.autoSettleOnMerge === "boolean") { - preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; - } if (typeof parsed.legacyThreadListEnabled === "boolean") { preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; } diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 40f665024fda..cc23f738be1d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -961,7 +961,7 @@ export const make = Effect.gen(function* () { // `git worktree add -b feature origin/main` makes the new local branch // track origin/main. That upstream is the branch's base, not its // published PR head. Looking up PRs for it can attach an old reverse - // merge from main and auto-settle an unrelated feature thread. + // merge from main to an unrelated feature thread. if ( headContext.headBranch !== details.branch && upstreamHeadIsDefault && diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 4eb9a2a8ede5..478291254d3b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1040,12 +1040,10 @@ describe("ProviderCommandReactor", () => { }), ); - it("generates a thread title on the first turn", async () => { + it("keeps the existing title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; const seededTitle = "Please investigate reconnect failures after restar..."; - harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); - await Effect.runPromise( harness.engine.dispatch({ type: "thread.meta.update", @@ -1066,28 +1064,17 @@ describe("ProviderCommandReactor", () => { text: "Please investigate reconnect failures after restarting the session.", attachments: [], }, - titleSeed: seededTitle, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ - message: "Please investigate reconnect failures after restarting the session.", - }); - - await waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === - "Generated title" - ); - }); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Generated title"); + expect(thread?.title).toBe(seededTitle); }); it("regenerates a thread title from the current conversation", async () => { @@ -1746,7 +1733,6 @@ describe("ProviderCommandReactor", () => { it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - const seededTitle = "Please investigate reconnect failures after restar..."; await Effect.runPromise( harness.engine.dispatch({ @@ -1768,7 +1754,6 @@ describe("ProviderCommandReactor", () => { text: "Please investigate reconnect failures after restarting the session.", attachments: [], }, - titleSeed: seededTitle, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -1783,57 +1768,6 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Keep this custom title"); }); - it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => { - const harness = await createHarness(); - const now = "2026-01-01T00:00:00.000Z"; - const seededTitle = "Fix reconnect spinner on resume"; - harness.generateThreadTitle.mockReturnValue( - Effect.succeed({ - title: "Reconnect spinner resume bug", - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.meta.update", - commandId: CommandId.make("cmd-thread-title-formatted-seed"), - threadId: ThreadId.make("thread-1"), - title: seededTitle, - }), - ); - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-title-formatted"), - threadId: ThreadId.make("thread-1"), - message: { - messageId: asMessageId("user-message-title-formatted"), - role: "user", - text: "[effort:high]\\n\\nFix reconnect spinner on resume", - attachments: [], - }, - titleSeed: seededTitle, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt: now, - }), - ); - - await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); - await waitFor(async () => { - const readModel = await harness.readModel(); - return ( - readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === - "Reconnect spinner resume bug" - ); - }); - - const readModel = await harness.readModel(); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("Reconnect spinner resume bug"); - }); - it("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 27204b42186f..2563ffc947a4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -64,7 +64,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { forkParked, ServerActivation } from "../../serverActivation.ts"; -import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; +import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -1153,51 +1153,6 @@ const make = Effect.gen(function* () { ); }); - const maybeGenerateThreadTitleForFirstTurn = Effect.fn("maybeGenerateThreadTitleForFirstTurn")( - function* (input: { - readonly threadId: ThreadId; - readonly cwd: string; - readonly messageText: string; - readonly attachments?: ReadonlyArray; - readonly titleSeed?: string; - }) { - const attachments = input.attachments ?? []; - yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = - yield* serverSettingsService.getSettings; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: input.cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }); - if (!generated) return; - - const thread = yield* resolveThread(input.threadId); - if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { - return; - } - - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* serverCommandId("thread-title-rename"), - threadId: input.threadId, - title: generated.title, - }); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to generate or rename thread title", { - threadId: input.threadId, - cwd: input.cwd, - cause: Cause.pretty(cause), - }), - ), - ); - }, - ); - const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* ( event: Extract, requestId: CommandId, @@ -1407,16 +1362,9 @@ const make = Effect.gen(function* () { const isFirstUserMessageTurn = thread.messages.filter((entry) => entry.role === "user").length === 1; if (isFirstUserMessageTurn) { - const project = yield* resolveProject(thread.projectId); - const generationCwd = - resolveThreadWorkspaceCwd({ - thread, - projects: project ? [project] : [], - }) ?? process.cwd(); const generationInput = { messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), }; yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ @@ -1425,14 +1373,6 @@ const make = Effect.gen(function* () { worktreePath: thread.worktreePath, ...generationInput, }).pipe(Effect.forkScoped); - - if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - yield* maybeGenerateThreadTitleForFirstTurn({ - threadId: event.payload.threadId, - cwd: generationCwd, - ...generationInput, - }).pipe(Effect.forkScoped); - } } const handleTurnStartFailure = (cause: Cause.Cause) => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 3a396da57465..ab89b8a411b6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -51,7 +51,6 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; -import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -3534,8 +3533,8 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); - it("mirrors a provider title only while the thread still has the default title", async () => { - const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + it("does not replace the app title with provider metadata", async () => { + const harness = await createHarness({ threadTitle: "Bot conversation" }); const now = "2026-01-01T00:00:00.000Z"; harness.emit({ @@ -3550,33 +3549,10 @@ describe("ProviderRuntimeIngestion", () => { }, }); - const thread = await waitForThread( - harness.readModel, - (entry) => entry.title === "Renamed by provider", - ); - expect(thread.title).toBe("Renamed by provider"); - }); - - it("rejects a provider title once the thread has a real title", async () => { - const harness = await createHarness({ threadTitle: "User-set title" }); - const now = "2026-01-01T00:00:00.000Z"; - - harness.emit({ - type: "thread.metadata.updated", - eventId: asEventId("evt-thread-metadata-real"), - provider: ProviderDriverKind.make("codex"), - createdAt: now, - threadId: asThreadId("thread-1"), - payload: { - name: "Renamed by provider", - metadata: { source: "provider" }, - }, - }); - await harness.drain(); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.title).toBe("User-set title"); + expect(thread?.title).toBe("Bot conversation"); }); it("projects context window updates into normalized thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index e1aad7925fbd..611b97ba164e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -53,7 +53,6 @@ import { import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { canReplaceThreadTitle } from "../threadTitles.ts"; import { ServerConfig } from "../../config.ts"; import { BotInboxService } from "../../bot-inbox/service.ts"; import { BotUsageLedger } from "../../usage/BotUsageLedger.ts"; @@ -2158,17 +2157,6 @@ const make = Effect.gen(function* () { } } - if (event.type === "thread.metadata.updated" && event.payload.name) { - if (canReplaceThreadTitle(thread.title)) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); - } - } - if (event.type === "turn.diff.updated") { const turnId = toTurnId(event.turnId); const checkpointContext = turnId diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index 271dd1189496..4de726ceb542 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -503,8 +503,8 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { session: makeSession("running"), createdAt: NOW, }, - // A keep-active pin is also an override: real activity clears it - // back to neutral so auto-settle can apply again later. + // A keep-active state is also an override. Real activity clears it + // back to neutral. readModel: makeReadModel("active"), }); const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; @@ -535,8 +535,7 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { readModel: makeReadModel("active"), }); const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; - // The pin exists to suppress AUTO-settle, not to survive real work: - // activity resets it to neutral, restoring the default lifecycle. + // Activity resets the active override to neutral. expect(turnEvents.map((event) => event.type)).toEqual([ "thread.unsettled", "thread.message-sent", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 5c8ed2dca9c6..7750ef4d17ac 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2712,7 +2712,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), @@ -2721,9 +2720,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - // Real activity resets ANY override: it wakes an explicitly settled - // thread, and it clears a keep-active pin back to neutral so the - // thread can auto-settle again after this burst of work goes stale. + // Real activity resets any override. It wakes an explicitly settled + // thread and clears an active override back to neutral. // A snooze clears the same way — sending a message to a snoozed // thread is the user re-engaging, so the return ticket is spent. const lifecycleResetEvents: Array> = []; diff --git a/apps/server/src/orchestration/threadTitles.ts b/apps/server/src/orchestration/threadTitles.ts index c9a9c4f72830..fe604544d601 100644 --- a/apps/server/src/orchestration/threadTitles.ts +++ b/apps/server/src/orchestration/threadTitles.ts @@ -1,13 +1 @@ export const DEFAULT_THREAD_TITLE = "New thread"; - -export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; -} diff --git a/apps/server/src/routines/RuntimeAdapterLive.ts b/apps/server/src/routines/RuntimeAdapterLive.ts index ed8ed1c61c66..0b1f9a55dec3 100644 --- a/apps/server/src/routines/RuntimeAdapterLive.ts +++ b/apps/server/src/routines/RuntimeAdapterLive.ts @@ -1,4 +1,4 @@ -import { CommandId, MessageId, ThreadId, type OrchestrationCommand } from "@t3tools/contracts"; +import { CommandId, MessageId, type OrchestrationCommand } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -281,7 +281,6 @@ const make = Effect.gen(function* () { text: `Execute this routine now. Do not create or change a schedule.\n\n${routine.procedure}`, attachments: [], }, - titleSeed: routine.job, runtimeMode: routine.approvalPolicy, interactionMode: "default", createdAt, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a850de04a03e..4137baa3c6e0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -594,7 +594,6 @@ describe("first-send turn payload", () => { threadId, message, modelSelection, - titleSeed: "New thread", runtimeMode: "approval-required" as const, interactionMode: "default" as const, createdAt: now, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e0660fd98f20..d27174cfefc1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -385,13 +385,7 @@ export async function crossWorktreeSendBoundary(input: { type FirstSendTurnInput = Required< Pick< StartThreadTurnInput, - | "threadId" - | "message" - | "modelSelection" - | "titleSeed" - | "runtimeMode" - | "interactionMode" - | "createdAt" + "threadId" | "message" | "modelSelection" | "runtimeMode" | "interactionMode" | "createdAt" > > & Pick; @@ -402,7 +396,6 @@ export function buildFirstSendTurnInput(input: FirstSendTurnInput): StartThreadT threadId: input.threadId, message: input.message, modelSelection: input.modelSelection, - titleSeed: input.titleSeed, runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, ...(input.bootstrap === undefined ? {} : { bootstrap: input.bootstrap }), diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bba1fd5a2b76..9d3588339dad 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -981,7 +981,7 @@ describe("sortSettledThreadsForSidebar", () => { expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); }); - it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { + it("falls back to last activity for settled threads without a settledAt stamp", () => { const sorted = sortSettledThreadsForSidebar([ settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), @@ -991,7 +991,7 @@ describe("sortSettledThreadsForSidebar", () => { expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); }); - it("counts a turn completion as activity for auto-settled threads", () => { + it("counts a turn completion as activity for settled threads without a stamp", () => { // The message came in before the other thread's, but its turn finished // after: completion time is the real "work ended" moment. const sorted = sortSettledThreadsForSidebar([ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d0abcf49bb59..6c1879ef1fa3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -649,11 +649,8 @@ type SettledTimestampInput = Pick< "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" >; -/** The timestamp a settled row sorts and labels by: settledAt when stamped - (explicit settles), otherwise last activity — the same candidates - threadLastActivityAt feeds the auto-settle window (user message plus all - latestTurn stamps), so a thread whose last activity was a turn completion - doesn't sort by an older message time. updatedAt is the final net. */ +/** The timestamp a settled row sorts and labels by: settledAt when stamped, + otherwise the latest user or turn activity. updatedAt is the final net. */ export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { const settledAt = firstValidTimestamp(thread.settledAt); if (settledAt !== null) return settledAt; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4ab3d812799e..4ab1542fabf2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -19,7 +19,6 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- import { CSS } from "@dnd-kit/utilities"; import { canSnooze, - changeRequestAutoSettles, effectiveSettled, effectiveSnoozed, threadWokeAt, @@ -703,7 +702,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // False on environments whose server predates thread.settle/unsettle: // the lifecycle affordances hide entirely rather than fail on click. settlementSupported: boolean; - autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; // Pinned threads show the same pin marker in active, settled, and snoozed @@ -829,13 +827,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // counts as never-visited, so corrupt local data cannot eat the wake signal. const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); - const isWoke = - wokeAtDate !== null && - (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && - !changeRequestAutoSettles(pr, { - autoSettleOnMerge: props.autoSettleOnMerge, - thread, - }); + const isWoke = wokeAtDate !== null && (lastVisitedDate === null || lastVisitedDate < wokeAtDate); // In-flight rows (working, or waiting on approval/input) fade as a whole: // there is nothing for the user to do yet, so prominence is reserved for // rows that need a human — done (unread), read-but-unsettled, failed, and @@ -1738,8 +1730,6 @@ export default function Sidebar() { const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -1937,8 +1927,8 @@ export default function Sidebar() { [projectGroups], ); - // now is quantized to the minute so effectiveSettled memoization doesn't - // churn on every render; auto-settle thresholds are day-granular anyway. + // A minute clock lets a queued turn leave its short adoption grace period + // without making the thread partition churn on every render. const nowMinute = useNowMinute(); // Snooze wake times are second-precise, so classifying with the quantized // minute would hold a woken thread on the shelf for up to a minute. The @@ -2055,35 +2045,15 @@ export default function Sidebar() { for (const thread of visible) { // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. + // could neither un-settle nor pin them. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshot = changeRequestSnapshotByKey.get(threadKey); - const changeRequest = - snapshot != null && - (thread.linkedPullRequest == null - ? thread.worktreePath === null || snapshot.branch === thread.branch - : snapshot.linkedPullRequest?.projectId === thread.linkedPullRequest.projectId && - snapshot.linkedPullRequest.repository === thread.linkedPullRequest.repository && - snapshot.linkedPullRequest.number === thread.linkedPullRequest.number) - ? snapshot.pr - : null; // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { - now, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, - }) - ) { + } else if (supportsSettlement && effectiveSettled(thread, { now })) { settled.push(thread); } else if (thread.pinnedAt != null) { pinned.push(thread); @@ -2118,8 +2088,6 @@ export default function Sidebar() { snoozeNow: preciseNow, }; }, [ - autoSettleAfterDays, - autoSettleOnMerge, changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, @@ -3089,10 +3057,8 @@ export default function Sidebar() { thread.worktreePath ?? projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null; - // Un-settle works on every settled row: for explicit settles it - // clears the override, for auto-settled rows it pins the thread - // active until real activity clears the pin. Environments without - // the settlement capability get no lifecycle items at all. + // Environments without the settlement capability get no lifecycle + // items at all. const supportsSettlement = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; @@ -3720,8 +3686,7 @@ export default function Sidebar() { thread={thread} variant={rowVariant} // Snoozed rows wake; settled rows un-settle (explicit - // settles clear the override, auto-settled rows get - // pinned active); cards settle. + // settled rows return to the active list; cards settle. variantAction={ section === "snoozed" ? "unsnooze" @@ -3733,7 +3698,6 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } - autoSettleOnMerge={autoSettleOnMerge} snoozeSupported={ serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3710bcea8b8e..9ebca3c82520 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -495,7 +495,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { ).toEqual(mergedPr); }); - it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + it("does not settle a thread when its retained pull request merged", () => { const matchingStatus = status({ refName: featureBranch, pr: mergedPr, @@ -544,10 +544,8 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { expect( effectiveSettled(shell, { now: "2026-04-10T00:00:00.000Z", - autoSettleAfterDays: null, - changeRequest: displayed, }), - ).toBe(true); + ).toBe(false); }); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 961609f05657..e57637c4d0c9 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { ChangeRequestSettleSource } from "@t3tools/client-runtime/state/thread-settled"; import { ChevronDownIcon } from "lucide-react"; import { memo, @@ -56,8 +55,6 @@ interface ChatHeaderProps { activeThreadTitle: string; /** Drafts have no server thread yet, so the title carries no action menu. */ isServerThread: boolean; - /** PR feeding the settled classification, resolved by ChatView. */ - changeRequest: ChangeRequestSettleSource | null; activeProjectName: string | undefined; activeProjectCwd: string | null; activeProjectFaviconPath: string | null; @@ -128,7 +125,6 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, isServerThread, - changeRequest, activeProjectName, activeProjectCwd, activeProjectFaviconPath, @@ -208,7 +204,6 @@ export const ChatHeader = memo(function ChatHeader({ const { openMenu, closeMenu } = useThreadActionMenu({ threadRef: isServerThread ? activeThreadRef : null, projectCwd: activeProjectCwd, - changeRequest, onStartRename: startRename, }); const titleButtonRef = useRef(null); diff --git a/apps/web/src/components/roster/botThreadRuntime.logic.ts b/apps/web/src/components/roster/botThreadRuntime.logic.ts index 349afb6ad444..8e567a892486 100644 --- a/apps/web/src/components/roster/botThreadRuntime.logic.ts +++ b/apps/web/src/components/roster/botThreadRuntime.logic.ts @@ -58,7 +58,6 @@ export function buildBotTurnStartInput(input: { threadId: input.threadId, message: input.message, modelSelection: input.modelSelection, - titleSeed: input.title, runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, ...(input.createThread @@ -99,7 +98,6 @@ export function buildGroupTurnStartInput(input: { threadId: input.threadId, message: input.message, modelSelection: input.modelSelection, - titleSeed: input.title, runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, ...(input.respondingBotId ? { respondingBotId: input.respondingBotId } : {}), diff --git a/apps/web/src/components/roster/useBotThreadRuntime.ts b/apps/web/src/components/roster/useBotThreadRuntime.ts index 2bfdf6242662..f62db7c3261e 100644 --- a/apps/web/src/components/roster/useBotThreadRuntime.ts +++ b/apps/web/src/components/roster/useBotThreadRuntime.ts @@ -44,7 +44,6 @@ import { joinOrStartThreadCreate, resolveBotThreadTarget, } from "./botThreadRuntime.logic"; -import { parseChatPath } from "./roster.logic"; import { useRosterStore } from "./rosterStore"; const NO_ENVIRONMENT = "" as EnvironmentId; @@ -54,11 +53,6 @@ function errorMessage(result: Parameters[0]): s return error instanceof Error ? error.message : "Could not send the message."; } -function threadTitle(prompt: string, files: readonly File[]): string { - const seed = prompt || (files[0] ? `Image: ${files[0].name}` : "New thread"); - return seed.length > 80 ? `${seed.slice(0, 79)}…` : seed; -} - function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -294,7 +288,7 @@ export function useBotThreadRuntime(botId: string, effectiveModelSelection: Mode bot?.sandbox ?? null, settings.localExecutionMode, ); - const title = threadTitle(prompt, files); + const title = bot?.name ?? "Bot"; try { const attachments = await Promise.all( diff --git a/apps/web/src/components/roster/useGroupThreadRuntime.ts b/apps/web/src/components/roster/useGroupThreadRuntime.ts index 5bb3acacde28..8fb802b5c603 100644 --- a/apps/web/src/components/roster/useGroupThreadRuntime.ts +++ b/apps/web/src/components/roster/useGroupThreadRuntime.ts @@ -41,11 +41,6 @@ function errorMessage(result: Parameters[0]): s return error instanceof Error ? error.message : "Could not send the message."; } -function threadTitle(prompt: string, files: readonly File[]): string { - const seed = prompt || (files[0] ? `Image: ${files[0].name}` : "New thread"); - return seed.length > 80 ? `${seed.slice(0, 79)}…` : seed; -} - function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -208,7 +203,7 @@ export function useGroupThreadRuntime(groupId: string) { respondingBotId: BotId.make(respondingBot.id), threadId, projectId: activeProject.id, - title: threadTitle(prompt, files), + title: group.name, message: { messageId: newMessageId(), role: "user", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0792d01a1642..66aad3784d31 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -7,7 +7,6 @@ import { type BotSandboxBrowserSharing, ProviderDriverKind, type ScopedThreadRef, - type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -24,14 +23,12 @@ import { MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, - MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_APPEARANCE_CONTRAST, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, - MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, ProductFeedbackEndpoint, } from "@t3tools/contracts/settings"; @@ -132,10 +129,6 @@ import { normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, - isProjectGroupingEnabled, - projectGroupingModeFromToggle, - readLastEnabledProjectGroupingMode, - rememberEnabledProjectGroupingMode, resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { @@ -405,17 +398,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), - ...(settings.sidebarProjectGroupingMode !== - DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode - ? ["Project Grouping"] - : []), - ...(settings.sidebarAutoSettleAfterDays !== - DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays - ? ["Auto-settle inactive threads"] - : []), - ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge - ? ["Auto-settle merged threads"] - : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace @@ -439,22 +421,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.localExecutionMode !== DEFAULT_UNIFIED_SETTINGS.localExecutionMode ? ["Local execution"] : []), - ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode - ? ["New thread mode"] - : []), - ...(settings.newWorktreesStartFromOrigin !== - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin - ? ["New worktrees start from origin"] - : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), - ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive - ? ["Archive confirmation"] - : []), - ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete - ? ["Delete confirmation"] - : []), ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit confirmation"] : []), @@ -476,12 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableAgentBrowserAccess, settings.voice, settings.confirmQuit, - settings.confirmThreadArchive, - settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.localExecutionMode, - settings.defaultThreadEnvMode, - settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, @@ -496,9 +461,6 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.botSandboxBrowserSharing, - settings.sidebarAutoSettleAfterDays, - settings.sidebarAutoSettleOnMerge, - settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, @@ -582,9 +544,6 @@ export function useSettingsRestore(onRestored?: () => void) { environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, - sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, botSandboxBrowserSharing: DEFAULT_UNIFIED_SETTINGS.botSandboxBrowserSharing, @@ -593,11 +552,7 @@ export function useSettingsRestore(onRestored?: () => void) { automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, localExecutionMode: DEFAULT_UNIFIED_SETTINGS.localExecutionMode, - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, - confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, - confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, @@ -1613,49 +1568,6 @@ function FontFamilySettingsRow({ ); } -const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; - -function AutoSettleDaysInput({ - value, - onCommit, -}: { - value: number; - onCommit: (days: number) => void; -}) { - // Local draft so the field can be emptied mid-edit; the setting only moves - // on valid input and snaps back to the persisted value on blur. - const [draft, setDraft] = useState(String(value)); - useEffect(() => { - setDraft(String(value)); - }, [value]); - - return ( - { - setDraft(event.target.value); - // Number(), not parseInt: "3.5" must be rejected (not truncated to a - // committed 3 while the field shows 3.5) — commit only when the - // persisted value matches the displayed one. - const parsed = Number(event.target.value); - if ( - Number.isInteger(parsed) && - parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && - parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS - ) { - onCommit(parsed); - } - }} - onBlur={() => setDraft(String(value))} - aria-label="Days of inactivity before auto-settle" - /> - ); -} - // The legacy rows sit behind the fold, so a settings-search jump has to // expand the section before its target can mount and scroll. const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ @@ -1849,9 +1761,6 @@ export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); - const lastEnabledProjectGroupingMode = useRef( - readLastEnabledProjectGroupingMode(), - ); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); const diagnosticsDescription = formatDiagnosticsDescription({ @@ -1906,110 +1815,6 @@ export function GeneralSettingsPanel() { onChange={(value) => updateSettings({ botSandboxBrowserSharing: value })} /> - - updateSettings({ - sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - }) - } - /> - ) : null - } - control={ - { - if (!checked && settings.sidebarProjectGroupingMode !== "separate") { - lastEnabledProjectGroupingMode.current = settings.sidebarProjectGroupingMode; - rememberEnabledProjectGroupingMode(settings.sidebarProjectGroupingMode); - } - updateSettings({ - sidebarProjectGroupingMode: projectGroupingModeFromToggle( - checked, - lastEnabledProjectGroupingMode.current, - ), - }); - }} - aria-label="Project grouping" - /> - } - /> - - - updateSettings({ - sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, - }) - } - /> - ) : null - } - control={ - - updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) - } - aria-label="Auto-settle merged threads" - /> - } - /> - - - updateSettings({ - sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, - }) - } - aria-label="Auto-settle inactive threads" - /> - } - /> - {settings.sidebarAutoSettleAfterDays !== null ? ( - updateSettings({ sidebarAutoSettleAfterDays: days })} - /> - } - /> - ) : null} - - - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } - control={ - - } - /> - - {settings.defaultThreadEnvMode === "worktree" ? ( - - updateSettings({ - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } - control={ - - updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) - } - aria-label="Start new worktrees from origin by default" - /> - } - /> - ) : null} - - - updateSettings({ - confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, - }) - } - /> - ) : null - } - control={ - - updateSettings({ confirmThreadArchive: Boolean(checked) }) - } - aria-label="Confirm thread archiving" - /> - } - /> - - - updateSettings({ - confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, - }) - } - /> - ) : null - } - control={ - - updateSettings({ confirmThreadDelete: Boolean(checked) }) - } - aria-label="Confirm thread deletion" - /> - } - /> - {isElectron ? ( void; }) { - const { threadRef, projectCwd, changeRequest, onStartRename } = input; + const { threadRef, projectCwd, onStartRename } = input; const { settleThread, unsettleThread, @@ -80,8 +77,6 @@ export function useThreadActionMenu(input: { }); const handleNewThread = useNewThreadHandler(); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); - const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); - const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); @@ -134,9 +129,6 @@ export function useThreadActionMenu(input: { // can never disagree with the sidebar partition or ChatView's // parked-thread banner within the same minute. now: `${now.toISOString().slice(0, 16)}:00.000Z`, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, }), isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), @@ -310,9 +302,6 @@ export function useThreadActionMenu(input: { }, [ archiveThread, - autoSettleAfterDays, - autoSettleOnMerge, - changeRequest, confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index f40920779b0f..1d079e6c7003 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -527,8 +527,7 @@ export function useThreadActions() { ), ); } - // reason "user" pins the thread active: auto-settle (PR merged / - // inactivity) stays suppressed until real activity clears the pin. + // Keep the user action distinct from an activity-driven wake. return unsettleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, reason: "user" }, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 57a346e160a9..1e9be2d839cc 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -4,9 +4,8 @@ Use a thread's menu to settle, snooze, wake, archive, delete, pin, or unpin it. ## Active and settled threads -Akeru settles inactive threads after three days by default. An open pull request blocks inactivity -settlement. Closed pull requests settle their threads, and merged pull requests settle them when -**Auto-settle merged threads** is on. +Akeru settles a thread only when you select **Settle thread**. Inactivity and pull request state do +not move threads to the settled list. Settling a pinned thread also removes its pin. **Un-settle thread** returns the thread to the top of the active list without changing its timestamps. @@ -24,8 +23,7 @@ newest-first order until it is updated. ## Link a pull request Right-click a pull-request link and select **Link to thread**. Select **Unlink from thread** from the -same menu to remove it. Linked review state appears with the thread and can trigger automatic -settlement. +same menu to remove it. Linked review state appears with the thread. ## Regenerate a title diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 06a8bb32c793..ff32aa27d3e9 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -9,119 +9,15 @@ import { describe, expect, it } from "vite-plus/test"; import { canSettle, - changeRequestAutoSettles, effectiveSettled, hasQueuedTurnStart, threadLastActivityAt, - type ChangeRequestStateLike, } from "./threadSettled.ts"; const NOW = "2026-04-10T00:00:00.000Z"; const FRESH = "2026-04-09T00:00:00.000Z"; const STALE = "2026-04-06T23:59:59.999Z"; -describe("changeRequestAutoSettles", () => { - it.each([ - ["open", true, false], - ["merged", true, true], - ["merged", false, false], - ["closed", false, true], - [null, false, false], - ] as const)("state=%s autoSettleOnMerge=%s returns %s", (state, autoSettleOnMerge, expected) => { - expect(changeRequestAutoSettles(state === null ? null : { state }, { autoSettleOnMerge })).toBe( - expected, - ); - }); - - const THREAD_CREATED_AT = "2026-04-01T00:00:00.000Z"; - const idleThread = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: null, - latestTurn: null, - }; - - it("ignores a terminal change request last touched before the thread existed", () => { - for (const state of ["merged", "closed"] as const) { - expect( - changeRequestAutoSettles( - { state, updatedAt: "2026-03-31T23:59:59.999Z" }, - { thread: idleThread }, - ), - ).toBe(false); - } - }); - - it("settles on a terminal change request touched at or after the thread's latest event", () => { - for (const updatedAt of [THREAD_CREATED_AT, "2026-04-02T00:00:00.000Z"]) { - expect(changeRequestAutoSettles({ state: "merged", updatedAt }, { thread: idleThread })).toBe( - true, - ); - } - }); - - it("never re-settles a thread revived after the merge", () => { - // Settling on a merge happens once: a user message newer than the PR's - // last activity means the conversation outlived the PR. - const revived = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-05T00:00:00.000Z", - latestTurn: null, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-03T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(false); - // A merge landing after the revival still settles. - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-06T00:00:00.000Z" }, - { thread: revived }, - ), - ).toBe(true); - }); - - it("still settles when the merge lands during an in-flight turn", () => { - // Anchor is user-initiated activity only: the agent finishing a turn - // after the merge must not block the settle the merge earned. - const midTurnMerge = { - createdAt: THREAD_CREATED_AT, - latestUserMessageAt: "2026-04-02T00:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-mid"), - state: "completed" as const, - requestedAt: "2026-04-02T00:00:00.000Z", - startedAt: "2026-04-02T00:00:05.000Z", - completedAt: "2026-04-02T00:20:00.000Z", - assistantMessageId: null, - }, - }; - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "2026-04-02T00:10:00.000Z" }, - { thread: midTurnMerge }, - ), - ).toBe(true); - }); - - it("falls back to settling when either timestamp is missing or malformed", () => { - expect(changeRequestAutoSettles({ state: "merged" }, { thread: idleThread })).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: null }, { thread: idleThread }), - ).toBe(true); - expect( - changeRequestAutoSettles({ state: "merged", updatedAt: "2026-03-01T00:00:00.000Z" }, {}), - ).toBe(true); - expect( - changeRequestAutoSettles( - { state: "merged", updatedAt: "not-a-date" }, - { thread: idleThread }, - ), - ).toBe(true); - }); -}); - function makeShell(input: { readonly settledOverride?: "settled" | "active" | null; readonly activityAt: string | null; @@ -195,184 +91,25 @@ describe("threadLastActivityAt", () => { }); describe("effectiveSettled", () => { - const overrideCases = [null, "settled", "active"] as const; - const changeRequestStates = [undefined, "open", "merged"] as const; - const inactivityCases = [ - ["fresh", FRESH], - ["stale", STALE], - ["no-activity", null], - ] as const; - const runningCases = [false, true] as const; - const pendingCases = [undefined, "approval", "user-input"] as const; - const truthTable = overrideCases.flatMap((settledOverride) => - changeRequestStates.flatMap((changeRequestState) => - inactivityCases.flatMap(([inactivity, activityAt]) => - runningCases.flatMap((running) => - pendingCases.map((pending) => ({ - settledOverride, - changeRequestState, - inactivity, - activityAt, - running, - pending, - // Settled iff nothing blocks (pending work / live session) AND - // the override says settled, or (with no override) a merged PR - // or staleness auto-settles. The "active" pin suppresses both - // auto signals, and an open PR suppresses the inactivity path: - // a thread with a PR out for review is never done, however quiet. - expected: - pending === undefined && - !running && - (settledOverride === "settled" || - (settledOverride === null && - (changeRequestState === "merged" || - (changeRequestState !== "open" && inactivity === "stale")))), - })), - ), - ), - ), - ); - - it.each(truthTable)( - "override=$settledOverride pr=$changeRequestState inactivity=$inactivity running=$running pending=$pending", - ({ settledOverride, changeRequestState, activityAt, running, pending, expected }) => { - const shell = makeShell({ - settledOverride, - activityAt, - ...(running ? { sessionStatus: "running" as const } : {}), - ...(pending === undefined ? {} : { pending }), - }); - const changeRequestOptions = - changeRequestState === undefined - ? {} - : { changeRequest: { state: changeRequestState as ChangeRequestStateLike } }; - - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - ...changeRequestOptions, - }), - ).toBe(expected); - }, - ); - - it("treats closed change requests like merged ones", () => { - const shell = makeShell({ activityAt: null }); + it("uses only the explicit settled state", () => { expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("settles immediately when a change request merges or closes", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - for (const changeRequestState of ["merged", "closed"] as const) { - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: changeRequestState }, - }), - ).toBe(true); - } - }); - - it("ignores a change request that merged before the thread's latest event", () => { - // A new thread started at a worktree root inherits the branch's old - // merged PR, and a revived thread outlives its merge; neither settles - // the live conversation. - const fresh = makeShell({ activityAt: FRESH }); - for (const state of ["merged", "closed"] as const) { - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state, updatedAt: "2026-03-20T00:00:00.000Z" }, - }), - ).toBe(false); - } - // A merge during the thread's life still settles it. - expect( - effectiveSettled(fresh, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged", updatedAt: "2026-04-09T00:00:00.000Z" }, - }), - ).toBe(true); - }); - - it("can keep a merged change request active", () => { - const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" }); - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "merged" }, - }), + effectiveSettled(makeShell({ settledOverride: null, activityAt: STALE }), { now: NOW }), ).toBe(false); - expect( - effectiveSettled(recentlyActive, { - now: NOW, - autoSettleAfterDays: null, - autoSettleOnMerge: false, - changeRequest: { state: "closed" }, - }), - ).toBe(true); - }); - - it("never auto-settles a stale thread with an open change request", () => { - const stale = makeShell({ activityAt: STALE }); - expect( - effectiveSettled(stale, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), + effectiveSettled(makeShell({ settledOverride: "active", activityAt: STALE }), { now: NOW }), ).toBe(false); - // An explicit user settle still wins: open PR only blocks the auto path. - const settled = makeShell({ settledOverride: "settled", activityAt: STALE }); expect( - effectiveSettled(settled, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "open" }, - }), + effectiveSettled(makeShell({ settledOverride: "settled", activityAt: FRESH }), { now: NOW }), ).toBe(true); }); - it("keeps an explicitly un-settled merged-PR thread active", () => { - const shell = makeShell({ - settledOverride: "active", - activityAt: "2026-04-09T23:59:59.999Z", - }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: null, - changeRequest: { state: "merged" }, - }), - ).toBe(false); - }); - it("never settles a starting session, even with a settled override", () => { const shell = makeShell({ settledOverride: "settled", activityAt: STALE, sessionStatus: "starting", }); - expect( - effectiveSettled(shell, { - now: NOW, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); + expect(effectiveSettled(shell, { now: NOW })).toBe(false); }); it("keeps a new turn active from queued through starting and running", () => { @@ -410,25 +147,9 @@ describe("effectiveSettled", () => { }; for (const shell of [queued, starting, running]) { - expect( - effectiveSettled(shell, { - now: transitionNow, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); + expect(effectiveSettled(shell, { now: transitionNow })).toBe(false); } }); - - it("uses a strict inactivity boundary and honors a null threshold", () => { - const boundary = makeShell({ - activityAt: "2026-04-07T00:00:00.000Z", - }); - const stale = makeShell({ activityAt: STALE }); - - expect(effectiveSettled(boundary, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); - expect(effectiveSettled(stale, { now: NOW, autoSettleAfterDays: null })).toBe(false); - }); }); describe("hasQueuedTurnStart", () => { @@ -526,15 +247,7 @@ describe("canSettle", () => { }; const justAfter = "2026-04-09T12:00:30.000Z"; expect(canSettle(queued, { now: justAfter })).toBe(false); - // effectiveSettled must agree: queued work never auto-settles either, - // even with a merged PR. - expect( - effectiveSettled(queued, { - now: justAfter, - autoSettleAfterDays: 3, - changeRequest: { state: "merged" }, - }), - ).toBe(false); + expect(effectiveSettled(queued, { now: justAfter })).toBe(false); // Past the window the message is a failed/stale start: settleable again. expect(canSettle(queued, { now: NOW })).toBe(true); }); @@ -554,9 +267,7 @@ describe("canSettle", () => { settledAt: "2026-04-09T12:02:10.000Z", }; expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); - expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( - true, - ); + expect(effectiveSettled(settledAfterMessage, { now: flooredNow })).toBe(true); // A message NEWER than settledAt is genuinely new work: still blocked // until the server's auto-unsettle lands. @@ -565,12 +276,7 @@ describe("canSettle", () => { latestUserMessageAt: "2026-04-09T12:03:00.000Z", settledAt: "2026-04-09T12:02:10.000Z", }; - expect( - effectiveSettled(messageAfterSettle, { - now: "2026-04-09T12:03:30.000Z", - autoSettleAfterDays: 3, - }), - ).toBe(false); + expect(effectiveSettled(messageAfterSettle, { now: "2026-04-09T12:03:30.000Z" })).toBe(false); }); it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { @@ -582,6 +288,6 @@ describe("canSettle", () => { pending: "user-input", }); expect(canSettle(blocked, { now: NOW })).toBe(false); - expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + expect(effectiveSettled(blocked, { now: NOW })).toBe(false); }); }); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 8ccf0d230efd..41b4e84c6455 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -1,76 +1,6 @@ // @effect-diagnostics globalDate:off -- UI snooze presets use local calendar boundaries and Intl labels. import type { OrchestrationThreadShell } from "@t3tools/contracts"; -export type ChangeRequestStateLike = "open" | "closed" | "merged"; - -/** - * The slice of a change request the settle rules need. `updatedAt` is the - * provider's last-activity timestamp; for a merged/closed request it bounds - * when the terminal state landed. - */ -export interface ChangeRequestSettleSource { - readonly state: ChangeRequestStateLike; - readonly updatedAt?: string | null | undefined; -} - -/** What the settle rules need to know about the thread's own timeline. */ -export type ThreadActivitySource = Pick< - OrchestrationThreadShell, - "createdAt" | "latestUserMessageAt" | "latestTurn" ->; - -/** - * Latest USER-initiated activity: messages and the turn requests they start, - * deliberately not the agent-side started/completed stamps. The settle-on- - * merge anchor uses this so a merge landing mid-turn still settles the - * thread when that turn finishes, while a user re-engaging after the merge - * blocks it for good. Falls back to creation time for untouched threads. - */ -function threadUserActivityAnchorAt(thread: ThreadActivitySource): string { - const messageAt = thread.latestUserMessageAt; - const requestedAt = thread.latestTurn?.requestedAt; - let anchor = thread.createdAt; - for (const candidate of [messageAt, requestedAt]) { - if (candidate != null && Date.parse(candidate) > Date.parse(anchor)) { - anchor = candidate; - } - } - return anchor; -} - -/** - * Returns whether the change request settles the thread immediately. A - * terminal request settles the thread only while it postdates every user- - * initiated event in it: settling on a merge happens ONCE. A request last - * touched before the thread was created is inherited branch history (a new - * thread started at a worktree root whose PR already merged), and one older - * than the user's latest engagement was already adjudicated — re-engaging a - * thread whose PR merged is the user saying the conversation outlived the - * PR. Unknown timestamps keep the old always-settle behavior. - */ -export function changeRequestAutoSettles( - changeRequest: ChangeRequestSettleSource | null | undefined, - options: { - readonly autoSettleOnMerge?: boolean | undefined; - readonly thread?: ThreadActivitySource | null | undefined; - } = {}, -): boolean { - if (changeRequest == null) return false; - const terminal = - changeRequest.state === "closed" || - (changeRequest.state === "merged" && options.autoSettleOnMerge !== false); - if (!terminal) return false; - if (changeRequest.updatedAt == null || options.thread == null) return true; - const updatedAtMs = Date.parse(changeRequest.updatedAt); - const anchorAtMs = Date.parse(threadUserActivityAnchorAt(options.thread)); - // Malformed timestamps fall back to settling, matching servers that never - // report updatedAt. - if (Number.isNaN(updatedAtMs) || Number.isNaN(anchorAtMs)) return true; - return updatedAtMs >= anchorAtMs; -} - -const DAY_MS = 24 * 60 * 60 * 1_000; - export function threadLastActivityAt( shell: Pick, ): string | null { @@ -289,22 +219,13 @@ export function threadWokeAt( * queued turn) are checked first and hold a thread active regardless of any * override. Past the blockers, the explicit user override (thread.settle / * thread.unsettle commands, projected into settledOverride + settledAt) - * wins in both directions; without one, a thread can auto-settle on a - * merged PR or always on a closed PR (both only while the terminal state is - * the thread's latest event, see changeRequestAutoSettles), or settles on - * inactivity past the window. - * An open PR blocks the inactivity path entirely. The server + * wins in both directions. The server * un-settles on real activity (user message, session start, approval/ * user-input request), so an override never goes stale silently. */ export function effectiveSettled( shell: OrchestrationThreadShell, - options: { - readonly now: string; - readonly autoSettleAfterDays: number | null; - readonly autoSettleOnMerge?: boolean; - readonly changeRequest?: ChangeRequestSettleSource | null; - }, + options: { readonly now: string }, ): boolean { // Blocked work must remain visible even when a user explicitly settled it. if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; @@ -326,37 +247,11 @@ export function effectiveSettled( if (!serverAdjudicated) return false; } if (shell.settledOverride === "settled") return true; - // "active" is the explicit keep-active pin: it suppresses auto-settle - // until real activity clears it server-side. - if (shell.settledOverride === "active") return false; - if ( - changeRequestAutoSettles(options.changeRequest, { - autoSettleOnMerge: options.autoSettleOnMerge, - thread: shell, - }) - ) { - return true; - } - // An open PR is unfinished business regardless of how long the thread has - // been quiet: review can take days, and hiding the thread would bury the - // work waiting on it. A configured merge, a close, or an explicit user - // settle resolves it. - if (options.changeRequest?.state === "open") return false; - if (options.autoSettleAfterDays === null) return false; - - const lastActivityAt = threadLastActivityAt(shell); - if (lastActivityAt === null) return false; - - // threadLastActivityAt only returns candidates whose Date.parse beat - // -Infinity, so this parse is a real number; a malformed `now` yields NaN, - // the comparison is false, and the thread stays active (never a surprise - // auto-settle on bad input). - return ( - Date.parse(lastActivityAt) < Date.parse(options.now) - options.autoSettleAfterDays * DAY_MS - ); + return false; } const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; const EVENING_HOUR = 18; const MORNING_HOUR = 9; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d549d4e0d8ab..e760831e0e32 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1032,7 +1032,7 @@ it.effect( }), ); -it.effect("accepts a title seed in thread.turn.start", () => +it.effect("drops the retired title seed in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ type: "thread.turn.start", @@ -1047,7 +1047,7 @@ it.effect("accepts a title seed in thread.turn.start", () => titleSeed: "Investigate reconnect failures", createdAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures"); + assert.ok(!("titleSeed" in parsed)); }), ); @@ -1179,7 +1179,7 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); -it.effect("decodes thread.turn-start-requested title seed when present", () => +it.effect("drops the retired title seed from thread.turn-start-requested events", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartRequestedPayload({ threadId: "thread-2", @@ -1187,7 +1187,7 @@ it.effect("decodes thread.turn-start-requested title seed when present", () => titleSeed: "Investigate reconnect failures", createdAt: "2026-01-01T00:00:00.000Z", }); - assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures"); + assert.ok(!("titleSeed" in parsed)); }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 617c7be5045a..ef732bf7e8a4 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1478,7 +1478,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ channelOrigin: Schema.optional(ChannelMessageOrigin), }), modelSelection: Schema.optional(ModelSelection), - titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_LOCAL_EXECUTION_MODE)), ), @@ -1506,7 +1505,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(Schema.Union([UploadChatAttachment, ChatAttachment])), }), modelSelection: Schema.optional(ModelSelection), - titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), @@ -2210,7 +2208,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), - titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index df03c44c2d67..de6ad18c4edf 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -211,11 +211,9 @@ describe("ClientSettings environment identification", () => { }); describe("ClientSettings sidebar", () => { - it("defaults to the current sidebar with automatic merge and inactivity settling", () => { + it("defaults to the current sidebar", () => { const settings = decodeClientSettings({}); expect(settings.legacySidebarEnabled).toBe(false); - expect(settings.sidebarAutoSettleAfterDays).toBe(3); - expect(settings.sidebarAutoSettleOnMerge).toBe(true); }); it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => { @@ -235,24 +233,13 @@ describe("ClientSettings sidebar", () => { ); }); - it("allows auto-settle by inactivity to be disabled", () => { - expect( - decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, - ).toBeNull(); - }); - - it("allows auto-settle on merge to be disabled", () => { - expect(decodeClientSettings({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge).toBe( - false, - ); - expect( - decodeClientSettingsPatch({ sidebarAutoSettleOnMerge: false }).sidebarAutoSettleOnMerge, - ).toBe(false); - }); - - it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => { - expect(() => decodeClientSettings({ sidebarAutoSettleAfterDays: value })).toThrow(); - expect(() => decodeClientSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow(); + it("drops retired auto-settle settings", () => { + const settings = decodeClientSettings({ + sidebarAutoSettleAfterDays: 3, + sidebarAutoSettleOnMerge: true, + }); + expect(settings).not.toHaveProperty("sidebarAutoSettleAfterDays"); + expect(settings).not.toHaveProperty("sidebarAutoSettleOnMerge"); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3a3212ea1db8..8032ca2060ee 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -77,16 +77,6 @@ export const SidebarThreadPreviewCount = Schema.Int.check( ); export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; -export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; -export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; -export const SidebarAutoSettleAfterDays = Schema.Number.check( - Schema.isBetween({ - minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, - }), -); -export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type; -export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3; export const MIN_GLASS_OPACITY = 40; export const MAX_GLASS_OPACITY = 100; export const GlassOpacity = Schema.Int.check( @@ -267,10 +257,6 @@ export const ClientSettingsSchema = Schema.Struct({ // old keys, so everyone, including prior beta opt-outs, resets to the new // default sidebar. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), - sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), - ), - sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -1124,8 +1110,6 @@ export const ClientSettingsPatch = Schema.Struct({ planModeEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), - sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), - sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From c5871076e72c73406d66af72c649dccc8c67a4fa Mon Sep 17 00:00:00 2001 From: Leo Orel Date: Wed, 2 Sep 2026 17:24:17 -0400 Subject: [PATCH 2/3] fix(mobile): keep project grouping reachable --- apps/mobile/src/features/settings/SettingsRouteScreen.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b9c685f1963b..3b2da83458f0 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -219,6 +219,7 @@ function EnvironmentPrivacySettingsSection({ function GeneralSettingsSection() { return ( + ); From 9583fe61ced8e7949cff7338cfe937de85bcdd14 Mon Sep 17 00:00:00 2001 From: Leo Orel Date: Thu, 3 Sep 2026 10:34:39 -0400 Subject: [PATCH 3/3] test(server): wait for complete failure projection --- .../Layers/ProviderCommandReactor.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 478291254d3b..d64bb63a40ac 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -3460,8 +3460,10 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); if (!thread) return false; - return thread.activities.some( - (activity) => activity.kind === "provider.approval.respond.failed", + return ( + thread.activities.some( + (activity) => activity.kind === "provider.approval.respond.failed", + ) && thread.session?.status === "error" ); }); @@ -3579,8 +3581,10 @@ describe("ProviderCommandReactor", () => { const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); if (!thread) return false; - return thread.activities.some( - (activity) => activity.kind === "provider.user-input.respond.failed", + return ( + thread.activities.some( + (activity) => activity.kind === "provider.user-input.respond.failed", + ) && thread.session?.status === "error" ); });