diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ea7ec6e1512..47800f3192d 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, favorites: [], providerModelPreferences: {}, + sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", @@ -27,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, + sidebarV2Enabled: false, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 6cd530ab37d..697d13e7c47 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 49cf06d85ec..502d2bab1b5 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -28,7 +28,8 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); - const { archiveThread, confirmDeleteThread } = useThreadListActions(); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = + useThreadListActions(); const pendingTasks = usePendingNewTasks(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( @@ -111,6 +112,8 @@ export function HomeRouteScreen() { } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onSettleThread={settleThread} + onUnsettleThread={unsettleThread} onEnvironmentChange={setSelectedEnvironmentId} onOpenEnvironments={() => navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) @@ -120,6 +123,8 @@ export function HomeRouteScreen() { onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { + // Settled threads are live shells: opening one is plain + // navigation, and sending a message un-settles server-side. navigation.navigate("Thread", { environmentId: thread.environmentId, threadId: thread.id, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index dcc56fd77c5..d1339a9bb91 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -14,18 +14,22 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; +import { cn } from "../../lib/cn"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -33,6 +37,8 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; +import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { buildThreadListV2Items, type ThreadListV2Item } from "../threads/threadListV2"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; import { buildHomeListLayout, @@ -74,6 +80,9 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + /** Resolves true iff the settle was dispatched and succeeded. */ + readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -82,6 +91,10 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 settled-tail paging: recent history is the common lookup; the deep +// tail stays behind an explicit Show more. +const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; +const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -156,6 +169,80 @@ function HomeTopContentSpacer() { return ; } +function ThreadListV2ProjectScope(props: { + readonly projects: ReadonlyArray; + readonly selectedKey: string | null; + readonly onChange: (key: string | null) => void; +}) { + if (props.projects.length === 0) return null; + + return ( + + {props.projects.length > 1 ? ( + props.onChange(null)} + className={cn( + "min-h-8 items-center justify-center rounded-lg border px-3", + props.selectedKey === null + ? "border-border bg-subtle-strong" + : "border-black/15 dark:border-white/15", + )} + > + All + + ) : null} + {props.projects.map((project) => { + const key = scopedProjectKey(project.environmentId, project.id); + const selected = props.selectedKey === key; + return ( + props.onChange(selected ? null : key)} + className={cn( + "min-h-8 flex-row items-center gap-1.5 rounded-lg border py-1 pl-2 pr-3", + selected ? "border-border bg-subtle-strong" : "border-black/15 dark:border-white/15", + )} + > + + + {project.title} + + + ); + })} + + ); +} + /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { @@ -163,6 +250,9 @@ export function HomeScreen(props: HomeScreenProps) { ReadonlyMap >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const threadListV2Enabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true; const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -267,6 +357,193 @@ export function HomeScreen(props: HomeScreenProps) { return map; }, [props.projects]); + const projectByKey = useMemo(() => { + const map = new Map(); + for (const project of props.projects) { + map.set(scopedProjectKey(project.environmentId, project.id), project); + } + return map; + }, [props.projects]); + + const [v2ProjectScopeKey, setV2ProjectScopeKey] = useState(null); + const v2ScopeProjects = useMemo( + () => + props.selectedEnvironmentId === null + ? props.projects + : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId), + [props.projects, props.selectedEnvironmentId], + ); + const v2ScopedProject = useMemo( + () => + v2ProjectScopeKey === null + ? null + : (v2ScopeProjects.find( + (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey, + ) ?? null), + [v2ProjectScopeKey, v2ScopeProjects], + ); + useEffect(() => { + if (v2ProjectScopeKey !== null && v2ScopedProject === null) { + setV2ProjectScopeKey(null); + } + }, [v2ProjectScopeKey, v2ScopedProject]); + + // Thread List v2 (beta): one flat list in creation order, no grouping. + // Settled threads collapse into a recency tail below the card block. + // 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 (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition (mirrors web). + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + const handleSettleThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onSettleThread(thread); + }, + [props.onSettleThread], + ); + const handleDeleteThread = props.onDeleteThread; + const handleUnsettleThread = props.onUnsettleThread; + // 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( + THREAD_LIST_V2_SETTLED_INITIAL_COUNT, + ); + const settledResetKey = `${props.selectedEnvironmentId ?? "all"}:${v2ProjectScopeKey ?? "all"}:${props.searchQuery.trim()}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT); + } + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT), + [], + ); + // 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". + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + if (!threadListV2Enabled) return; + const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); + return () => clearInterval(id); + }, [threadListV2Enabled]); + // Threads on servers without the settlement capability never classify as + // settled (the user could neither un-settle nor pin them). + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const settlementEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadSettlement === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); + const threadListV2Layout = useMemo(() => { + if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0 }; + // Settled threads are live shells; archived threads keep their original + // "hidden from lists" meaning. + return buildThreadListV2Items({ + threads: props.threads.filter((thread) => thread.archivedAt === null), + environmentId: props.selectedEnvironmentId, + projectRef: + v2ScopedProject === null + ? null + : { + environmentId: v2ScopedProject.environmentId, + projectId: v2ScopedProject.id, + }, + searchQuery: props.searchQuery, + changeRequestStateByKey, + settlementEnvironmentIds, + settledLimit: settledVisibleCount, + now: `${nowMinute}:00.000Z`, + }); + }, [ + changeRequestStateByKey, + nowMinute, + settledVisibleCount, + settlementEnvironmentIds, + props.searchQuery, + props.selectedEnvironmentId, + props.threads, + threadListV2Enabled, + v2ScopedProject, + ]); + const threadListV2Items = threadListV2Layout.items; + + const renderV2Item = useCallback( + ({ item }: { readonly item: ThreadListV2Item }) => ( + + provider.instanceId === + (item.thread.session?.providerInstanceId ?? item.thread.modelSelection.instanceId), + )?.driver ?? null + } + onSelectThread={props.onSelectThread} + onDeleteThread={handleDeleteThread} + onArchiveThread={props.onArchiveThread} + settlementSupported={settlementEnvironmentIds.has(item.thread.environmentId)} + onSettleThread={handleSettleThread} + onUnsettleThread={handleUnsettleThread} + onChangeRequestState={handleChangeRequestState} + projectCwd={ + projectCwdByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? + null + } + onSwipeableClose={handleSwipeableClose} + onSwipeableWillOpen={handleSwipeableWillOpen} + /> + ), + [ + handleChangeRequestState, + handleDeleteThread, + handleSettleThread, + handleSwipeableClose, + handleSwipeableWillOpen, + handleUnsettleThread, + projectByKey, + projectCwdByKey, + props.onArchiveThread, + props.onSelectThread, + serverConfigs, + settlementEnvironmentIds, + ], + ); + const v2KeyExtractor = useCallback( + (item: ThreadListV2Item) => `${item.thread.environmentId}:${item.thread.id}`, + [], + ); + const extraData = useMemo( () => ({ savedConnectionsById: props.savedConnectionsById, projectCwdByKey }), [props.savedConnectionsById, projectCwdByKey], @@ -360,6 +637,10 @@ export function HomeScreen(props: HomeScreenProps) { const keyExtractor = useCallback((item: HomeListItem) => item.key, []); /* Empty states */ + // The signal must ignore the search/environment filters: an active query + // that matches nothing needs the in-list "No results" state, not the + // full-page "No threads yet". Settled threads are unarchived live shells, + // so the v1 check already covers v2. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; const hasResults = projectGroups.length > 0; @@ -436,6 +717,44 @@ export function HomeScreen(props: HomeScreenProps) { ); + // v2 renders queued offline tasks above the thread cards — they are not + // thread shells, so the v2 item builder never sees them, but they must + // stay visible and deletable while their environment is offline. They + // respect the same environment scope and search filter as the list. + const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase(); + const v2PendingTasks = props.pendingTasks.filter( + (pendingTask) => + (props.selectedEnvironmentId === null || + pendingTask.message.environmentId === props.selectedEnvironmentId) && + (v2ScopedProject === null || + (pendingTask.message.environmentId === v2ScopedProject.environmentId && + pendingTask.creation.projectId === v2ScopedProject.id)) && + (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), + ); + const v2ListHeader = ( + <> + {listHeader} + + {v2PendingTasks.map((pendingTask, index) => ( + + ))} + + ); + const listEmpty = !hasResults ? ( hasSearchQuery ? ( @@ -448,6 +767,69 @@ export function HomeScreen(props: HomeScreenProps) { ) ) : null; + // Self-contained: v1's listEmpty keys off projectGroups, which ignores the + // v2 project scope, so it can be null (results elsewhere) while this list + // is empty. Search outranks the scope — "No results" names the actionable + // fact when a query is active. Pending tasks render in the header, so the + // list showing them isn't empty in the user's eyes. + const v2ListEmpty = + v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( + + ) : v2ScopedProject !== null ? ( + + ) : ( + listEmpty + ); + + if (threadListV2Enabled) { + return ( + + + 0 ? ( + ({ opacity: pressed ? 0.6 : 1 })} + > + + Show more ({threadListV2Layout.hiddenSettledCount} settled hidden) + + + ) : null + } + ListEmptyComponent={v2ListEmpty} + style={{ flex: 1 }} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + {...scrollGateHandlers} + scrollEventThrottle={16} + contentContainerStyle={{ + paddingBottom: + Platform.OS === "ios" + ? Math.max(insets.bottom, 24) + 96 + : Math.max(insets.bottom, 16) + 88, + }} + /> + + {connectionStatus} + + ); + } return ( diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 36e98ef129f..c5a9f2c6bbc 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -47,6 +47,8 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 8f13000b9e2..46d1173b0dd 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -41,6 +41,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 84a9f32ee5e..666169f3039 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -167,6 +167,13 @@ export function ThreadSwipeable(props: { /** Disables NEW swipe activations (e.g. while the list scrolls). */ readonly enabled?: boolean; readonly enableTrackpadSwipe?: boolean; + /** + * What a full swipe commits: "delete" (default, v1 behavior — the Delete + * button stretches) or "primary" — the advertised primary action fires and + * its button stretches instead. A full swipe must always match the action + * the stretching button advertises. + */ + readonly fullSwipeAction?: "delete" | "primary"; readonly fullSwipeWidth: number; readonly onDelete: () => void; readonly onSwipeableClose?: (methods: SwipeableMethods) => void; @@ -239,7 +246,11 @@ export function ThreadSwipeable(props: { if (fullSwipeArmedRef.current) { fullSwipeArmedRef.current = false; methods.close(); - props.onDelete(); + if (props.fullSwipeAction === "primary") { + props.primaryAction.onPress(); + } else { + props.onDelete(); + } } }} overshootFriction={1} @@ -247,6 +258,7 @@ export function ThreadSwipeable(props: { renderRightActions={(_progress, translation, methods) => ( void; readonly onFullSwipeArmedChange: (armed: boolean) => void; @@ -430,6 +443,7 @@ export function ThreadSwipeActions(props: { readonly threadTitle: string; readonly translation: SharedValue; }) { + const fullSwipeIsPrimary = props.fullSwipeAction === "primary"; useAnimatedReaction( () => -props.translation.value >= props.fullSwipeThreshold, (armed, previous) => { @@ -457,7 +471,7 @@ export function ThreadSwipeActions(props: { icon={props.primaryAction.icon} label={props.primaryAction.label} onPress={props.primaryAction.onPress} - stretchesOnFullSwipe={false} + stretchesOnFullSwipe={fullSwipeIsPrimary} translation={props.translation} /> diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 8effdc942d9..e200eb7acde 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -1,4 +1,5 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; @@ -6,19 +7,37 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentServerConfigsAtom } from "../../state/server"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; -type ThreadListAction = "archive" | "unarchive" | "delete"; +/** Version skew: never send settle/unsettle to a server that predates them + (capability defaults false on decode for older servers). */ +function environmentSupportsSettlement(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + +type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; + +const ACTION_VERBS: Record = { + archive: "archived", + unarchive: "unarchived", + delete: "deleted", + settle: "settled", + unsettle: "un-settled", +}; function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause): string { const error = Cause.squash(cause); if (error instanceof Error && error.message.trim().length > 0) { return error.message; } - const verb = - action === "archive" ? "archived" : action === "unarchive" ? "unarchived" : "deleted"; - return `The thread could not be ${verb}.`; + return `The thread could not be ${ACTION_VERBS[action]}.`; } function selectionHaptic(): void { @@ -28,54 +47,115 @@ function selectionHaptic(): void { function actionFailureTitle(action: ThreadListAction): string { if (action === "archive") return "Could not archive thread"; if (action === "unarchive") return "Could not unarchive thread"; + if (action === "settle") return "Could not settle thread"; + if (action === "unsettle") return "Could not un-settle thread"; return "Could not delete thread"; } +/** Resolves to true iff the action was dispatched and succeeded. */ function useThreadActionExecutor( onCompleted?: (action: ThreadListAction, thread: EnvironmentThreadShell) => void, ) { const archiveMutation = useAtomCommand(threadEnvironment.archive, { reportFailure: false }); const unarchiveMutation = useAtomCommand(threadEnvironment.unarchive, { reportFailure: false }); const deleteMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const settleMutation = useAtomCommand(threadEnvironment.settle, { reportFailure: false }); + const unsettleMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false }); const inFlightThreadKeys = useRef(new Set()); const executeAction = useCallback( async (action: ThreadListAction, thread: EnvironmentThreadShell) => { const key = scopedThreadKey(thread.environmentId, thread.id); if (inFlightThreadKeys.current.has(key)) { - return; + return false; } inFlightThreadKeys.current.add(key); selectionHaptic(); try { - const mutation = - action === "archive" - ? archiveMutation - : action === "unarchive" - ? unarchiveMutation - : deleteMutation; - const result = await mutation({ - environmentId: thread.environmentId, - input: { threadId: thread.id }, - }); + if ( + (action === "settle" || action === "unsettle") && + !environmentSupportsSettlement(thread.environmentId) + ) { + Alert.alert( + actionFailureTitle(action), + "This environment's server does not support settling yet. Update the server to use Settle.", + ); + return false; + } + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (action === "settle" && !canSettle(thread, { now: new Date().toISOString() })) { + Alert.alert( + actionFailureTitle(action), + "This thread still needs attention. Resolve or interrupt it first, then try again.", + ); + return false; + } + // Archive keeps its original, narrower guard: never interrupt a + // thread mid-turn. + if ( + action === "archive" && + thread.session?.status === "running" && + thread.session.activeTurnId != null + ) { + Alert.alert( + actionFailureTitle(action), + "This thread is working. Interrupt it first, then try again.", + ); + return false; + } + const result = + action === "unsettle" + ? // reason "user" pins the thread active: auto-settle stays + // suppressed until real activity clears the pin server-side. + await unsettleMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id, reason: "user" }, + }) + : await ( + action === "settle" + ? settleMutation + : action === "archive" + ? archiveMutation + : action === "unarchive" + ? unarchiveMutation + : deleteMutation + )({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); if (result._tag === "Failure") { Alert.alert(actionFailureTitle(action), actionFailureMessage(action, result.cause)); - return; + return false; + } + // Settled threads stay in the live shell stream; only the archive + // lifecycle still feeds the archived-snapshot surface. + if (action === "archive" || action === "unarchive" || action === "delete") { + refreshArchivedThreadsForEnvironment(thread.environmentId); } onCompleted?.(action, thread); + return true; } finally { inFlightThreadKeys.current.delete(key); } }, - [archiveMutation, deleteMutation, onCompleted, unarchiveMutation], + [ + archiveMutation, + deleteMutation, + onCompleted, + settleMutation, + unarchiveMutation, + unsettleMutation, + ], ); return executeAction; } function useConfirmDeleteThread( - executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, + executeAction: (action: ThreadListAction, thread: EnvironmentThreadShell) => Promise, ) { return useCallback( (thread: EnvironmentThreadShell) => { @@ -111,6 +191,8 @@ function useConfirmDeleteThread( export function useThreadListActions(): { readonly archiveThread: (thread: EnvironmentThreadShell) => void; readonly confirmDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly settleThread: (thread: EnvironmentThreadShell) => Promise; + readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -120,10 +202,18 @@ export function useThreadListActions(): { }, [executeAction], ); + const settleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("settle", thread)) === true, + [executeAction], + ); + const unsettleThread = useCallback( + async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, + [executeAction], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); - return { archiveThread, confirmDeleteThread }; + return { archiveThread, confirmDeleteThread, settleThread, unsettleThread }; } export function useArchivedThreadListActions( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 6c67a4d89e8..9aea392555a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -123,6 +123,8 @@ function LocalSettingsRouteScreen() { + + @@ -506,6 +508,8 @@ function ConfiguredSettingsRouteScreen() { + + @@ -514,6 +518,35 @@ function ConfiguredSettingsRouteScreen() { ); } +/** + * Device-local beta toggles. Mobile has no client-settings sync, so this is + * the counterpart of web's Settings → Beta backed by mobile preferences. + */ +function BetaSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const threadListV2Enabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.threadListV2Enabled === true + : false; + + return ( + + + savePreferences({ threadListV2Enabled: value })} + /> + + + One flat thread list in creation order. Active work renders as cards; settled threads + collapse to compact rows. Switch back any time. + + + ); +} + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx new file mode 100644 index 00000000000..9dd41f3cd60 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -0,0 +1,329 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { MenuAction } from "@react-native-menu/menu"; +import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react"; +import { Platform, Pressable, useWindowDimensions, View } from "react-native"; +import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; + +import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { relativeTime } from "../../lib/time"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useThreadPr } from "../../state/use-thread-pr"; +import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { resolveThreadListV2Status, type ThreadListV2Status } from "./threadListV2"; + +/** + * Thread List v2 rows mirror the web sidebar's compact tonal cards and + * receded settled tail while retaining native swipe and long-press actions. + */ + +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + +const STATUS_LABEL_BY_STATUS: Partial< + Record +> = { + approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" }, + input: { label: "Input", className: "text-amber-700 dark:text-amber-300" }, + working: { label: "Working", className: "text-blue-600 dark:text-blue-400" }, + failed: { label: "Failed", className: "text-red-700 dark:text-red-300" }, +}; + +function threadTimeLabel(thread: EnvironmentThreadShell): string { + return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); +} + +// Menus stay lifecycle-focused: settle/un-settle plus delete. Archive keeps +// its own surface (thread screen / settings) rather than crowding the row. +const CARD_MENU_ACTIONS: MenuAction[] = [ + { id: "settle", title: "Settle", image: "checkmark" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +const SLIM_MENU_ACTIONS: MenuAction[] = [ + { id: "unsettle", title: "Un-settle", image: "arrow.uturn.backward" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +// Pre-settlement servers: no lifecycle items, archive fills the gap. +const LEGACY_MENU_ACTIONS: MenuAction[] = [ + { id: "archive", title: "Archive", image: "archivebox" }, + { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, +]; + +export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivider() { + const borderColor = useThemeColor("--color-border"); + return ( + + Settled + + + ); +}); + +export const ThreadListV2Row = memo(function ThreadListV2Row(props: { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + readonly showSettledDivider: boolean; + readonly project: EnvironmentProject | null; + readonly providerDriver: string | null; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; + readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onSettleThread: (thread: EnvironmentThreadShell) => void; + readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + /** False on environments whose server predates thread.settle/unsettle: + swipe + menu fall back to Archive instead of failing on use. */ + readonly settlementSupported: boolean; + readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; + readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** Reports this row's live PR state up so the partition can auto-settle + merged/closed work (mirrors web's onChangeRequestState). */ + readonly onChangeRequestState?: ( + threadKey: string, + state: "open" | "closed" | "merged" | null, + ) => void; + readonly projectCwd?: string | null; + readonly simultaneousSwipeGesture?: ComponentProps< + typeof ThreadSwipeable + >["simultaneousWithExternalGesture"]; +}) { + const { width: windowWidth } = useWindowDimensions(); + const { + thread, + variant, + onSelectThread, + onDeleteThread, + onSettleThread, + onUnsettleThread, + onArchiveThread, + onChangeRequestState, + } = props; + + const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const prState = pr?.state ?? null; + const threadKey = `${thread.environmentId}:${thread.id}`; + useEffect(() => { + onChangeRequestState?.(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const screenColor = useThemeColor("--color-screen"); + + const status = resolveThreadListV2Status(thread); + const statusLabel = STATUS_LABEL_BY_STATUS[status]; + const timeLabel = threadTimeLabel(thread); + + const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); + const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleMenuAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "settle") handleSettle(); + if (nativeEvent.event === "unsettle") handleUnsettle(); + if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "delete") handleDelete(); + }, + [handleArchive, handleDelete, handleSettle, handleUnsettle], + ); + + // 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. + const canUnsettle = variant === "slim"; + const primaryAction = useMemo(() => { + // Pre-settlement server: archive is the swipe action, as in v1. (Slim + // rows cannot occur here — unsupported environments never classify as + // settled.) + if (!props.settlementSupported) { + return { + accessibilityLabel: `Archive ${thread.title}`, + icon: "archivebox" as const, + label: "Archive", + onPress: handleArchive, + }; + } + return canUnsettle + ? { + accessibilityLabel: `Un-settle ${thread.title}`, + icon: "arrow.uturn.backward" as const, + label: "Un-settle", + onPress: handleUnsettle, + } + : { + accessibilityLabel: `Settle ${thread.title}`, + icon: "checkmark" as const, + label: "Settle", + onPress: handleSettle, + }; + }, [ + canUnsettle, + handleArchive, + handleSettle, + handleUnsettle, + props.settlementSupported, + thread.title, + ]); + + const rowContent = (close: () => void) => + variant === "card" ? ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + + + + + {props.project ? ( + + ) : null} + + {props.project?.title ?? ""} + + + {statusLabel?.label ?? timeLabel} + + + + {thread.title} + + + {status === "failed" && thread.session?.lastError ? ( + + {thread.session.lastError} + + ) : thread.branch ? ( + + {thread.branch} + + ) : ( + + )} + {props.providerDriver ? ( + + + + ) : null} + + + + + + ) : ( + { + close(); + onSelectThread(thread); + }} + style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })} + > + {/* Settled history recedes: dimmed favicon + muted title. */} + + {props.project ? ( + + + + ) : null} + + {thread.title} + + + {relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)} + + + + ); + + return ( + <> + {props.showSettledDivider ? : null} + + {(close) => ( + + {rowContent(close)} + + )} + + + ); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts new file mode 100644 index 00000000000..80edc86124b --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -0,0 +1,220 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadListV2Items, + resolveThreadListV2Status, + sortThreadsForListV2, +} from "./threadListV2"; + +const environmentId = EnvironmentId.make("environment-1"); + +function makeThread( + input: Partial & Pick, +): EnvironmentThreadShell { + return { + environmentId, + projectId: ProjectId.make("project-1"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +const NOW = "2026-06-02T00:00:00.000Z"; + +describe("resolveThreadListV2Status", () => { + it("prioritizes approval over a running session", () => { + const thread = makeThread({ + id: ThreadId.make("t"), + title: "t", + hasPendingApprovals: true, + session: { + threadId: ThreadId.make("t"), + status: "running", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + expect(resolveThreadListV2Status(thread)).toBe("approval"); + }); + + it("resolves ready for quiescent threads", () => { + expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe( + "ready", + ); + }); +}); + +describe("sortThreadsForListV2", () => { + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForListV2([ + { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, + { id: "newest", createdAt: "2026-06-01T12:00:00.000Z" }, + { id: "middle", createdAt: "2026-06-01T10:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); +}); + +describe("buildThreadListV2Items", () => { + it("partitions settled threads into a slim tail with one divider", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Settled", + settledOverride: "settled", + settledAt: NOW, + }), + makeThread({ + id: ThreadId.make("settled-2"), + title: "Settled 2", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["active", "card"], + ["settled", "slim"], + ["settled-2", "slim"], + ]); + expect(items.map((item) => item.showSettledDivider)).toEqual([false, true, false]); + expect(items.map((item) => item.isLast)).toEqual([false, false, true]); + }); + + it("keeps cards in creation order while settled sorts by recency", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("older-created"), + title: "Older", + createdAt: "2026-06-01T08:00:00.000Z", + updatedAt: NOW, // recent activity must NOT promote it + }), + makeThread({ + id: ThreadId.make("newer-created"), + title: "Newer", + createdAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["newer-created", "older-created"]); + }); + + it("keeps settled threads in the tail and filters by search query", () => { + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("match"), title: "Fix login bug" }), + makeThread({ id: ThreadId.make("miss"), title: "Greeting" }), + makeThread({ + id: ThreadId.make("settled"), + title: "Fix login again", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "login", + now: NOW, + }); + + expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ + ["match", "card"], + ["settled", "slim"], + ]); + }); + + it("scopes the flat list to one project", () => { + const otherProjectId = ProjectId.make("project-2"); + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("included"), title: "Included" }), + makeThread({ + id: ThreadId.make("excluded"), + projectId: otherProjectId, + title: "Excluded", + }), + ], + environmentId: null, + projectRef: { environmentId, projectId: ProjectId.make("project-1") }, + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["included"]); + }); +}); + +describe("buildThreadListV2Items settled paging", () => { + it("caps the settled tail at settledLimit and reports the hidden count", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + ...Array.from({ length: 4 }, (_, index) => + makeThread({ + id: ThreadId.make(`settled-${index}`), + title: `Settled ${index}`, + settledOverride: "settled", + settledAt: NOW, + latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`, + // A turn adopted the message (same requestedAt): without it the + // thread reads as a queued turn start, which never settles. + latestTurn: { + turnId: TurnId.make(`turn-${index}`), + state: "completed", + requestedAt: `2026-06-01T0${index}:00:00.000Z`, + startedAt: `2026-06-01T0${index}:00:00.000Z`, + completedAt: `2026-06-01T0${index}:10:00.000Z`, + assistantMessageId: null, + }, + }), + ), + ]; + + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + settledLimit: 2, + now: NOW, + }); + + expect(layout.hiddenSettledCount).toBe(2); + expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + // Most recent settled first — the hidden ones are the oldest. + expect(layout.items.map((item) => item.thread.id)).toEqual([ + "active", + "settled-3", + "settled-2", + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts new file mode 100644 index 00000000000..2a1df18309c --- /dev/null +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -0,0 +1,167 @@ +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + +/** + * Thread List v2 model, ported from the web sidebar v2 + * (apps/web/src/components/Sidebar.logic.ts + SidebarV2.tsx). + * + * Four visual states, three colors: color is reserved for "act now" + * (approval), "in motion" (working), and "broken" (failed). Ready is the + * unlabeled resting state. + */ +export type ThreadListV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +export function resolveThreadListV2Status( + thread: Pick, +): ThreadListV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: a present-yet-malformed string falls through + to the next candidate rather than sinking the row to the epoch. */ +function firstValidTimestampMs(...candidates: ReadonlyArray): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +/** + * v2 sort: static creation order, newest thread on top. Activity NEVER + * reorders the list — a row holds its position from open until settled, so + * the screen only moves at lifecycle transitions. Mirrors web's + * sortThreadsForSidebarV2. + */ +export function sortThreadsForListV2( + threads: readonly T[], +): T[] { + // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 + // change-by-copy array methods. + return [...threads].sort( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + +export interface ThreadListV2Item { + readonly thread: EnvironmentThreadShell; + readonly variant: "card" | "slim"; + /** First settled row after the card block draws the SETTLED divider. */ + readonly showSettledDivider: boolean; + readonly isLast: boolean; +} + +export interface ThreadListV2Layout { + readonly items: ThreadListV2Item[]; + /** Settled threads beyond the render limit (behind "Show more"). */ + readonly hiddenSettledCount: number; +} + +/** + * Partitions visible threads into the active card block (creation order) and + * the settled recency tail, matching the web v2 list. `autoSettleAfterDays` + * mirrors the web default of 3 — mobile has no client-settings sync yet, so + * the default is fixed here rather than user-configurable. + */ +export function buildThreadListV2Items(input: { + readonly threads: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectRef?: { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + } | null; + readonly searchQuery: string; + /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ + readonly changeRequestStateByKey?: 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). */ + readonly settlementEnvironmentIds?: ReadonlySet; + readonly autoSettleAfterDays?: number; + /** Max settled rows to render; the rest are counted, not built. */ + readonly settledLimit?: number; + /** Injectable for tests; defaults to now. */ + readonly now?: string; +}): ThreadListV2Layout { + const now = input.now ?? new Date().toISOString(); + const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; + const query = input.searchQuery.trim().toLocaleLowerCase(); + + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of input.threads) { + // Callers pass live (unarchived) shells; settled threads are among them + // and partition into the tail via effectiveSettled. + if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; + if ( + input.projectRef != null && + (thread.environmentId !== input.projectRef.environmentId || + thread.projectId !== input.projectRef.projectId) + ) { + continue; + } + if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; + const supportsSettlement = input.settlementEnvironmentIds?.has(thread.environmentId) ?? true; + const changeRequestState = + input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + + const orderedActive = sortThreadsForListV2(active); + const orderedSettled = [...settled].sort( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ); + const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY; + const visibleSettled = + orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled; + + const items: ThreadListV2Item[] = []; + for (const thread of orderedActive) { + items.push({ thread, variant: "card", showSettledDivider: false, isLast: false }); + } + for (const [index, thread] of visibleSettled.entries()) { + items.push({ + thread, + variant: "slim", + showSettledDivider: index === 0, + isLast: false, + }); + } + const last = items.at(-1); + if (last) { + items[items.length - 1] = { ...last, isLast: true }; + } + return { items, hiddenSettledCount: orderedSettled.length - visibleSettled.length }; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 8cea5df2307..ab4311524ce 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -38,6 +38,8 @@ function makeThread( hasPendingUserInput: false, hasActionableProposedPlan: false, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 23b47fc625f..5f9f099a46c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -50,6 +50,8 @@ function makeThread( checkpoints: [], session: null, ...input, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? null, }; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6971570b0e6..1138ad2b655 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,12 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + /** + * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no + * client-settings sync, so the flat v2 thread list is opted into per + * device. + */ + readonly threadListV2Enabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -71,6 +77,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; + threadListV2Enabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -97,6 +104,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { (key): key is string => typeof key === "string", ); } + if (typeof parsed.threadListV2Enabled === "boolean") { + preferences.threadListV2Enabled = parsed.threadListV2Enabled; + } return preferences; } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index d369fbc4377..80bc1916b11 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -58,6 +58,8 @@ function threadDetailToShell( createdAt: thread.createdAt, updatedAt: thread.updatedAt, archivedAt: thread.archivedAt, + settledOverride: thread.settledOverride, + settledAt: thread.settledAt, session: thread.session, latestUserMessageAt: latestUserMessageAt(thread), hasPendingApprovals: false, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1c0d34ea5bc..01567b98d32 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + threadSettlement: true, }, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index b00c00e0d3f..731002ea783 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,8 @@ describe("OrchestrationEngine", () => { createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..a9d7317999a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -171,6 +171,70 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { for (const row of stateRows) { assert.equal(row.lastAppliedSequence, 3); } + + // Settled lifecycle through the DB pipeline: thread.settled writes the + // override + timestamp, thread.unsettled(user) flips to the active pin. + yield* eventStore.append({ + type: "thread.settled", + eventId: EventId.make("evt-settle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:01.000Z", + commandId: CommandId.make("cmd-settle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-settle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + settledAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const settledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(settledRows, [ + { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z" }, + ]); + + yield* eventStore.append({ + type: "thread.unsettled", + eventId: EventId.make("evt-unsettle-1"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:02.000Z", + commandId: CommandId.make("cmd-unsettle-1"), + causationEventId: null, + correlationId: CommandId.make("cmd-unsettle-1"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-1"), + reason: "user", + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const unsettledRows = yield* sql<{ + readonly settledOverride: string | null; + readonly settledAt: string | null; + }>` + SELECT + settled_override AS "settledOverride", + settled_at AS "settledAt" + FROM projection_threads + WHERE thread_id = 'thread-1' + `; + assert.deepEqual(unsettledRows, [{ settledOverride: "active", settledAt: null }]); }), ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..3ceae0ea43b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -607,6 +607,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -645,6 +647,38 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.settled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unsettled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9a136b06872..15ded458e22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -308,6 +308,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [ { @@ -418,6 +420,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId: ThreadId.make("thread-1"), status: "running", @@ -562,6 +566,115 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("keeps settled threads in the shell snapshot with non-null settlement fields", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-settled-test', + 'Settled Test', + '/tmp/settled-test', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-04-06T00:00:00.000Z', + '2026-04-06T00:00:01.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + archived_at, + settled_override, + settled_at, + deleted_at + ) + VALUES ( + 'thread-settled', + 'project-settled-test', + 'Settled Thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + NULL, + NULL, + 0, + 0, + 0, + '2026-04-06T00:00:02.000Z', + '2026-04-06T00:00:05.000Z', + NULL, + 'settled', + '2026-04-06T00:00:04.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES + (${ORCHESTRATION_PROJECTOR_NAMES.projects}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threads}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadMessages}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') + `; + + // Settled ≠ archived: the thread must appear in the LIVE shell + // snapshot, carrying its settlement fields through the row aliases. + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); + assert.deepEqual( + shellSnapshot.threads.map((thread) => thread.id), + [ThreadId.make("thread-settled")], + ); + assert.equal(shellSnapshot.threads[0]?.settledOverride, "settled"); + assert.equal(shellSnapshot.threads[0]?.settledAt, "2026-04-06T00:00:04.000Z"); + + // And the full command read model carries them too. + const readModel = yield* snapshotQuery.getCommandReadModel(); + const thread = readModel.threads.find( + (candidate) => candidate.id === ThreadId.make("thread-settled"), + ); + assert.equal(thread?.settledOverride, "settled"); + assert.equal(thread?.settledAt, "2026-04-06T00:00:04.000Z"); + }), + ); + it.effect( "reads targeted project, thread, and count queries without hydrating the full snapshot", () => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 32210436e67..155e9ab0013 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -334,6 +334,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -362,6 +364,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -392,6 +396,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -754,6 +760,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -1186,6 +1194,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1384,6 +1394,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, deletedAt: row.deletedAt, messages: [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], @@ -1513,6 +1525,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1647,6 +1661,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: row.createdAt, updatedAt: row.updatedAt, archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, hasPendingApprovals: row.pendingApprovalCount > 0, @@ -1887,6 +1903,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, @@ -1981,6 +1999,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, archivedAt: threadRow.value.archivedAt, + settledOverride: threadRow.value.settledOverride, + settledAt: threadRow.value.settledAt, deletedAt: null, messages: messageRows.map((row) => { const message = { diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..0d0d7bdd5e4 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,11 +4,13 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, + ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadInteractionModeSetPayload as ContractsThreadInteractionModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, + ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -29,11 +31,13 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; +export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadInteractionModeSetPayload = ContractsThreadInteractionModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; +export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9c6c8bd2a18..9531cd5c3af 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -68,6 +68,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -91,6 +93,8 @@ const readModel: OrchestrationReadModel = { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts new file mode 100644 index 00000000000..73f1cbf9127 --- /dev/null +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -0,0 +1,521 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSession, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const SETTLED_AT = "2025-12-30T00:00:00.000Z"; + +function makeReadModel( + settledOverride: OrchestrationThread["settledOverride"], + archivedAt: string | null = null, + session: OrchestrationSession | null = null, + activities: OrchestrationThread["activities"] = [], + messages: OrchestrationThread["messages"] = [], +): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + settledOverride, + settledAt: settledOverride === "settled" ? SETTLED_AT : null, + deletedAt: null, + messages, + proposedPlans: [], + activities, + checkpoints: [], + session, + }, + ], + updatedAt: NOW, + }; +} + +function makeSession(status: OrchestrationSession["status"]): OrchestrationSession { + return { + threadId: ThreadId.make("thread-1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("settled thread decider", (it) => { + it.effect("settles active threads and re-emits idempotently for settled ones", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.settled"); + if (events[0]?.type === "thread.settled") { + expect(events[0].payload.settledAt).toBe(events[0].payload.updatedAt); + } + + // Already settled: the engine rejects zero-event commands, so idempotency + // is by re-emission — preserving the original settledAt. + const reEmit = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel("settled"), + }); + const reEmitEvents = Array.isArray(reEmit) ? reEmit : [reEmit]; + expect(reEmitEvents).toHaveLength(1); + expect(reEmitEvents[0]?.type).toBe("thread.settled"); + if (reEmitEvents[0]?.type === "thread.settled") { + expect(reEmitEvents[0].payload.settledAt).toBe(SETTLED_AT); + // updatedAt must NOT rewind to the historical settledAt: sorting and + // relative-time labels key on it. + expect(reEmitEvents[0].payload.updatedAt).not.toBe(SETTLED_AT); + } + }), + ); + + it.effect("rejects settling a thread with a live session", () => + Effect.gen(function* () { + for (const status of ["starting", "running"] as const) { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make(`cmd-settle-live-${status}`), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession(status)), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + } + // Stopped/error sessions are settleable — only live work is protected. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stopped"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession("stopped")), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling a thread with an open approval or user-input request", () => + Effect.gen(function* () { + const requestActivity = (kind: string, requestId: string, at: string) => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId }, + turnId: null, + createdAt: at, + }) as OrchestrationThread["activities"][number]; + + // Open approval request: settle rejected. + const openError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + ]), + }).pipe(Effect.flip); + expect(openError._tag).toBe("OrchestrationCommandInvariantError"); + + // Same request later resolved: settleable again. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-resolved"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("approval.requested", "req-1", NOW), + requestActivity("approval.resolved", "req-1", NOW), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // Open user-input request: also rejected. + const inputError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pending-input"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + requestActivity("user-input.requested", "req-2", NOW), + ]), + }).pipe(Effect.flip); + expect(inputError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("clears an open request when its respond failure marks it stale", () => + Effect.gen(function* () { + const activity = ( + kind: string, + requestId: string, + payload: Record, + ): OrchestrationThread["activities"][number] => + ({ + id: EventId.make(`activity-${requestId}-${kind}`), + tone: "approval" as const, + kind, + summary: kind, + payload: { requestId, ...payload }, + turnId: null, + createdAt: NOW, + }) as OrchestrationThread["activities"][number]; + + // Stale-failure detail clears the request — mirrors the projection's + // pending accounting, which is what the client's canSettle sees. + const settled = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stale-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-1", {}), + activity("provider.approval.respond.failed", "req-1", { + detail: "Unknown pending approval request req-1", + }), + activity("user-input.requested", "req-2", {}), + activity("provider.user-input.respond.failed", "req-2", { + detail: "stale pending user-input request req-2", + }), + ]), + }); + const settledEvents = Array.isArray(settled) ? settled : [settled]; + expect(settledEvents[0]?.type).toBe("thread.settled"); + + // A non-stale respond failure (transient provider error) keeps the + // request open: the user can retry, so it is still blocked-on-you. + const stillOpen = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-transient-failed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [ + activity("approval.requested", "req-3", {}), + activity("provider.approval.respond.failed", "req-3", { + detail: "provider connection reset", + }), + ]), + }).pipe(Effect.flip); + expect(stillOpen._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("bounds the queued-turn grace window against client clock skew", () => + Effect.gen(function* () { + const userMessage = (createdAt: string): OrchestrationThread["messages"][number] => ({ + id: MessageId.make("message-queued"), + role: "user", + text: "Continue", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }); + + // The decider's clock is the Effect test clock, pinned to the epoch: + // timestamps here are relative to 1970-01-01T00:00:00.000Z. + + // Within the grace window: genuinely queued, settle rejected. + const queuedError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-queued"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1969-12-31T23:59:30.000Z")]), + }).pipe(Effect.flip); + expect(queuedError._tag).toBe("OrchestrationCommandInvariantError"); + + // Message timestamp far in the FUTURE (client clock ahead of server): + // a negative age must not read as queued forever — past the grace + // bound in either direction the thread is settleable. + const skewed = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-skewed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, null, [], [userMessage("1970-01-01T01:00:00.000Z")]), + }); + const skewedEvents = Array.isArray(skewed) ? skewed : [skewed]; + expect(skewedEvents[0]?.type).toBe("thread.settled"); + }), + ); + + it.effect("rejects settling and unsettling archived threads", () => + Effect.gen(function* () { + const settleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, NOW), + }).pipe(Effect.flip); + expect(settleError._tag).toBe("OrchestrationCommandInvariantError"); + + const unsettleError = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-archived"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled", NOW), + }).pipe(Effect.flip); + expect(unsettleError._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("maps unsettle reasons to overrides and re-emits idempotently", () => + Effect.gen(function* () { + const userEvent = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("settled"), + }); + const userEvents = Array.isArray(userEvent) ? userEvent : [userEvent]; + expect(userEvents).toHaveLength(1); + expect(userEvents[0]?.type).toBe("thread.unsettled"); + if (userEvents[0]?.type === "thread.unsettled") { + expect(userEvents[0].payload.reason).toBe("user"); + } + + // Re-dispatching against the already-reached state re-emits rather than + // producing zero events (the engine rejects empty commands). + const userAgain = yield* decideOrchestrationCommand({ + command: { + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-user-again"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }, + readModel: makeReadModel("active"), + }); + const userAgainEvents = Array.isArray(userAgain) ? userAgain : [userAgain]; + expect(userAgainEvents).toHaveLength(1); + expect(userAgainEvents[0]?.type).toBe("thread.unsettled"); + }), + ); + + it.effect("prepends activity unsets for turn starts and live session updates", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const turnEvents = Array.isArray(turnResult) ? turnResult : [turnResult]; + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const sessionResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set"), + threadId: ThreadId.make("thread-1"), + 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. + readModel: makeReadModel("active"), + }); + const sessionEvents = Array.isArray(sessionResult) ? sessionResult : [sessionResult]; + expect(sessionEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.session-set", + ]); + }), + ); + + it.effect("clears a keep-active pin on real activity", () => + Effect.gen(function* () { + const turnResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-active-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-active"), + role: "user", + text: "Continue", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + 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. + expect(turnEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.message-sent", + "thread.turn-start-requested", + ]); + + const activityResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-active-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-active"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("active"), + }); + const activityEvents = Array.isArray(activityResult) ? activityResult : [activityResult]; + expect(activityEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + }), + ); + + it.effect("does not unsettle for session stop/error status writes", () => + Effect.gen(function* () { + for (const status of ["stopped", "error", "ready", "idle"] as const) { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.session.set", + commandId: CommandId.make(`cmd-session-${status}`), + threadId: ThreadId.make("thread-1"), + session: makeSession(status), + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.session-set"]); + } + }), + ); + + it.effect("unsettles for approval and user-input activities but not others", () => + Effect.gen(function* () { + const approvalResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-approval"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-1"), + tone: "approval", + kind: "approval.requested", + summary: "Command approval requested", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const approvalEvents = Array.isArray(approvalResult) ? approvalResult : [approvalResult]; + expect(approvalEvents.map((event) => event.type)).toEqual([ + "thread.unsettled", + "thread.activity-appended", + ]); + + const routineResult = yield* decideOrchestrationCommand({ + command: { + type: "thread.activity.append", + commandId: CommandId.make("cmd-activity-routine"), + threadId: ThreadId.make("thread-1"), + activity: { + id: EventId.make("activity-2"), + tone: "info", + kind: "tool.completed", + summary: "Tool completed", + payload: null, + turnId: null, + createdAt: NOW, + }, + createdAt: NOW, + }, + readModel: makeReadModel("settled"), + }); + const routineEvents = Array.isArray(routineResult) ? routineResult : [routineResult]; + expect(routineEvents.map((event) => event.type)).toEqual(["thread.activity-appended"]); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1730494ecc6..cba967afc7c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -24,6 +24,61 @@ import { projectEvent } from "./projector.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +// Session adoption takes seconds; a user message still unadopted after this +// window is a failed/stale start, not pending work. Mirrors the client's +// QUEUED_TURN_START_GRACE_MS in client-runtime threadSettled.ts. +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * Blocked-on-you work derived from the thread's retained activities: an + * approval or user-input request with no later resolution for the same + * requestId. The server-side twin of the shell's hasPendingApprovals / + * hasPendingUserInput flags, which the decider read model does not carry. + * The clearing rules MUST match ProjectionPipeline's pending accounting — + * resolved activities always clear, respond.failed clears only when the + * failure detail marks the request stale/unknown — or settle would be + * rejected on threads whose shell flags read as clear. + */ +function isStaleRequestFailureDetail(payload: Record | null): boolean { + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; + if (detail === null) return false; + return ( + detail.includes("stale pending approval request") || + detail.includes("unknown pending approval request") || + detail.includes("unknown pending permission request") || + detail.includes("stale pending user-input request") || + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request") + ); +} + +function hasOpenBlockingRequest(thread: { + readonly activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>; +}): boolean { + const openRequestIds = new Set(); + for (const activity of thread.activities) { + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as Record) + : null; + const requestId = typeof payload?.requestId === "string" ? payload.requestId : null; + if (requestId === null) continue; + if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") { + openRequestIds.add(requestId); + } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") { + openRequestIds.delete(requestId); + } else if ( + (activity.kind === "provider.approval.respond.failed" || + activity.kind === "provider.user-input.respond.failed") && + isStaleRequestFailureDetail(payload) + ) { + openRequestIds.delete(requestId); + } + } + return openRequestIds.size > 0; +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -327,6 +382,132 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.settle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Server-side twin of the client's canSettle session check: a stale + // or raced client must not settle a thread whose session is coming + // alive or working. + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has an active session and cannot be settled`, + }), + ); + } + // Pending approval / user-input requests are blocked-on-you work: a + // raced or stale client must not park them behind a settled override + // that would surface only after the request resolves. + if (hasOpenBlockingRequest(thread)) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be settled`, + }), + ); + } + const occurredAt = yield* nowIso; + // A queued turn start — a user message no turn has picked up yet — is + // work in flight even though session is still null (turn.start emits + // message-sent + turn-start-requested; the session arrives later). + // Settling in that window would hide just-requested work. Detection + // mirrors the client's hasQueuedTurnStart: the newest user message is + // strictly newer than every latestTurn timestamp (adoption stamps the + // new turn's requestedAt with the message time, clearing this), and + // only within the adoption grace window — historical threads whose + // last user message postdates their turn timestamps (older-server + // data, mid-turn messages) must stay settleable. A failed session + // start (status "error") clears the block immediately. + const latestUserMessageAtMs = thread.messages.reduce( + (latest, message) => + message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, + Number.NEGATIVE_INFINITY, + ); + const latestTurnAtMs = + thread.latestTurn === null + ? Number.NEGATIVE_INFINITY + : Math.max( + ...[ + thread.latestTurn.requestedAt, + thread.latestTurn.startedAt, + thread.latestTurn.completedAt, + ].map((candidate) => + candidate == null ? Number.NEGATIVE_INFINITY : Date.parse(candidate), + ), + ); + // The age check is bounded on BOTH sides: message timestamps are + // client-supplied, so a client clock ahead of the server yields a + // negative age. Without the lower bound that negative age satisfies + // `<= grace` for as long as the skew lasts, extending the settle + // block far past the intended two minutes. + const queuedAgeMs = Date.parse(occurredAt) - latestUserMessageAtMs; + const hasQueuedTurnStart = + thread.session?.status !== "error" && + Number.isFinite(latestUserMessageAtMs) && + latestUserMessageAtMs > latestTurnAtMs && + Math.abs(queuedAgeMs) <= QUEUED_TURN_START_GRACE_MS; + if (hasQueuedTurnStart) { + return yield* Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be settled`, + }), + ); + } + // Settling an already-settled thread re-emits with the original + // settledAt: the engine rejects zero-event commands, and bulk-settle / + // double-click must stay silent no-ops rather than surface errors. + const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt: alreadySettled ? thread.settledAt : occurredAt, + // A re-emission is a projected no-op: keep the existing updatedAt + // so duplicate settles neither rewind nor churn ordering. A fresh + // settle stamps the command time. + updatedAt: alreadySettled ? thread.updatedAt : occurredAt, + }, + }; + } + + case "thread.unsettle": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): reducing the event a + // second time lands on the same override state. A re-emission keeps + // the existing updatedAt so duplicates do not churn ordering. + const alreadyPinnedActive = thread.settledOverride === "active"; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: command.reason, + updatedAt: alreadyPinnedActive ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, @@ -479,7 +660,27 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" createdAt: command.createdAt, }, }; - return [userMessageEvent, turnStartRequestedEvent]; + // 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. + if (targetThread.settledOverride === null) { + return [userMessageEvent, turnStartRequestedEvent]; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, userMessageEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { @@ -600,12 +801,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.session.set": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); - return { + const sessionSetEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -619,6 +820,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" session: command.session, }, }; + // Only a session coming alive is activity worth waking a settled thread + // for — status writes like ready/stopped/error arrive after the fact and + // must not fight a user's explicit settle. + const isSessionActivity = + command.session.status === "starting" || command.session.status === "running"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !isSessionActivity) { + return sessionSetEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, sessionSetEvent]; } case "thread.message.assistant.delta": { @@ -745,7 +970,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.activity.append": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, @@ -758,7 +983,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? ((command.activity.payload as { requestId: string }) .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; - return { + const activityAppendedEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -772,6 +997,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" activity: command.activity, }, }; + // An approval or user-input request is blocked-on-you work — it must + // never stay hidden inside a settled slim row. + const wakesSettledThread = + command.activity.kind === "approval.requested" || + command.activity.kind === "user-input.requested"; + // Real activity resets ANY override (settled wakes, active unpins). + if (thread.settledOverride === null || !wakesSettledThread) { + return activityAppendedEvent; + } + const unsettledEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "activity", + updatedAt: command.createdAt, + }, + }; + return [unsettledEvent, activityAppendedEvent]; } default: { diff --git a/apps/server/src/orchestration/projector.settled.test.ts b/apps/server/src/orchestration/projector.settled.test.ts new file mode 100644 index 00000000000..2070c44418a --- /dev/null +++ b/apps/server/src/orchestration/projector.settled.test.ts @@ -0,0 +1,88 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects settled lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const settled = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.settled", + payload: { threadId: ThreadId.make("thread-1"), settledAt: now, updatedAt: now }, + }), + ); + expect(settled.threads[0]?.settledOverride).toBe("settled"); + expect(settled.threads[0]?.settledAt).toBe(now); + + const userUnsettled = yield* projectEvent( + settled, + makeEvent({ + sequence: 3, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "user", updatedAt: now }, + }), + ); + expect(userUnsettled.threads[0]?.settledOverride).toBe("active"); + expect(userUnsettled.threads[0]?.settledAt).toBeNull(); + + const activityUnsettled = yield* projectEvent( + userUnsettled, + makeEvent({ + sequence: 4, + type: "thread.unsettled", + payload: { threadId: ThreadId.make("thread-1"), reason: "activity", updatedAt: now }, + }), + ); + expect(activityUnsettled.threads[0]?.settledOverride).toBeNull(); + expect(activityUnsettled.threads[0]?.settledAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index fadd5078026..e9a55c0796b 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -89,6 +89,8 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fc6ab8f6fcf..c8f47dcebbf 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -22,7 +22,9 @@ import { ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, + ThreadSettledPayload, ThreadUnarchivedPayload, + ThreadUnsettledPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -286,6 +288,8 @@ export function projectEvent( createdAt: payload.createdAt, updatedAt: payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], activities: [], @@ -337,6 +341,30 @@ export function projectEvent( })), ); + case "thread.settled": + return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: "settled", + settledAt: payload.settledAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unsettled": + return decodeForEvent(ThreadUnsettledPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + settledOverride: payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index a2069e62a14..497aa8b7d2c 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -91,6 +91,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -128,4 +130,58 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("round-trips non-null settlement values through the thread row", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-settled"), + projectId: ProjectId.make("project-1"), + title: "Settled thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + archivedAt: null, + settledOverride: "settled", + settledAt: "2026-03-25T00:00:00.000Z", + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const row = Option.getOrNull(persisted); + if (!row) { + return yield* Effect.die("Expected settled projection_threads row to exist."); + } + assert.strictEqual(row.settledOverride, "settled"); + assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); + + // Un-settle to the keep-active pin and confirm the flip persists. + yield* threads.upsert({ + ...row, + settledOverride: "active", + settledAt: null, + }); + const repersisted = yield* threads.getById({ + threadId: ThreadId.make("thread-settled"), + }); + const updated = Option.getOrNull(repersisted); + assert.strictEqual(updated?.settledOverride, "active"); + assert.strictEqual(updated?.settledAt, null); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1baeb375c15..e0c85e91494 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -43,6 +43,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at, updated_at, archived_at, + settled_override, + settled_at, latest_user_message_at, pending_approval_count, pending_user_input_count, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.createdAt}, ${row.updatedAt}, ${row.archivedAt}, + ${row.settledOverride}, + ${row.settledAt}, ${row.latestUserMessageAt}, ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, @@ -81,6 +85,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, + settled_override = excluded.settled_override, + settled_at = excluded.settled_at, latest_user_message_at = excluded.latest_user_message_at, pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, @@ -107,6 +113,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", @@ -135,6 +143,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt", + settled_override AS "settledOverride", + settled_at AS "settledAt", latest_user_message_at AS "latestUserMessageAt", pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d468838d5d4..cacb2c85b83 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -45,6 +45,7 @@ import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexe import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; +import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; /** * Migration loader with all migrations defined inline. @@ -89,6 +90,7 @@ export const migrationEntries = [ [30, "ProjectionThreadShellArchiveIndexes", Migration0030], [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], + [33, "ProjectionThreadsSettled", Migration0033], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts new file mode 100644 index 00000000000..e93407defe2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/033_ProjectionThreadsSettled.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "settled_override")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_override TEXT + `; + } + + if (!columns.some((column) => column.name === "settled_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN settled_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 44fdc147a4a..8057d434950 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -36,6 +36,8 @@ export const ProjectionThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])), + settledAt: Schema.NullOr(IsoDateTime), latestUserMessageAt: Schema.NullOr(IsoDateTime), pendingApprovalCount: NonNegativeInt, pendingUserInputCount: NonNegativeInt, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 166a88ef17b..3843c8acbcd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -100,6 +100,8 @@ function makeReadModel( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 0c261c8f21e..74a4de594a1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -305,6 +305,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -451,6 +453,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", @@ -607,6 +611,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: { threadId, status: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 6122c498145..8a5e0b713e6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -164,6 +164,8 @@ const makeDefaultOrchestrationReadModel = () => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, @@ -193,6 +195,8 @@ const makeDefaultOrchestrationThreadShell = ( createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, @@ -5499,6 +5503,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, latestTurn: null, messages: [], session: null, diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6c692dc3de8..8a713fd9026 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -8,7 +8,9 @@ import { getLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { useClientSettings } from "../hooks/useSettings"; import ThreadSidebar from "./Sidebar"; +import ThreadSidebarV2 from "./SidebarV2"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { resolveInitialThreadSidebarWidth, @@ -98,7 +100,12 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + // Settings routes render the settings nav, which lives in the v1 component + // and is identical for both sidebars — so v1 stays mounted there. const pathname = useLocation({ select: (location) => location.pathname }); + const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); + const useSidebarV2 = sidebarV2Enabled && !isOnSettings; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(window.innerWidth); @@ -157,7 +164,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - + {useSidebarV2 ? : } {children} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc7487cee29..c578e69c450 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -49,6 +49,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: now, updatedAt: now, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 325f9afa90a..6b74eae9ff4 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -45,6 +45,8 @@ export function buildLocalDraftThread( createdAt: draftThread.createdAt, updatedAt: draftThread.createdAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: draftThread.branch, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..f3145df4500 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5220,6 +5220,7 @@ function ChatViewContent(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.title} + activeProjectCwd={activeProject?.workspaceRoot ?? null} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..6609017f91c 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -24,6 +24,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1dd4e340125..a9b32a887c0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,8 +18,10 @@ import { resolveSidebarNewThreadEnvMode, resolveSidebarStageBadgeLabel, resolveThreadRowClassName, + resolveSidebarV2Status, resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown, + sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -634,6 +636,100 @@ describe("isContextMenuPointerDown", () => { }); }); +describe("resolveSidebarV2Status", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + + const idle = { hasPendingApprovals: false, hasPendingUserInput: false }; + + it("prioritizes approval over a running session", () => { + expect(resolveSidebarV2Status({ ...idle, hasPendingApprovals: true, session })).toBe( + "approval", + ); + }); + + it("prioritizes awaiting input over a running session, below approval", () => { + expect(resolveSidebarV2Status({ ...idle, hasPendingUserInput: true, session })).toBe("input"); + expect( + resolveSidebarV2Status({ + ...idle, + hasPendingApprovals: true, + hasPendingUserInput: true, + session, + }), + ).toBe("approval"); + }); + + it("reports working for running and starting sessions", () => { + expect(resolveSidebarV2Status({ ...idle, session })).toBe("working"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "starting" as const }, + }), + ).toBe("working"); + }); + + it("reports failed only while the session status is error", () => { + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "error" as const, lastError: "boom" }, + }), + ).toBe("failed"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "stopped" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + expect( + resolveSidebarV2Status({ + ...idle, + session: { ...session, status: "ready" as const, lastError: "persisted" }, + }), + ).toBe("ready"); + }); + + it("defaults to ready with no session", () => { + expect(resolveSidebarV2Status({ ...idle, session: null })).toBe("ready"); + }); +}); + +describe("sortThreadsForSidebarV2", () => { + const sortable = (input: { id: string; createdAt: string }) => ({ + id: input.id, + createdAt: input.createdAt, + }); + + it("orders by creation time, newest first, ignoring activity", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }), + sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }), + sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]); + }); + + it("breaks creation-time ties by id so the order is stable", () => { + const sorted = sortThreadsForSidebarV2([ + sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }), + sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, @@ -900,6 +996,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 1a565efd878..b8ae19c3e5f 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -417,6 +417,71 @@ export function resolveThreadRowClassName(input: { return cn(baseClassName, "text-muted-foreground hover:bg-accent hover:text-foreground"); } +// ── Sidebar v2 status model ───────────────────────────────────────── +// Five visual states, three colors: color is reserved for "act now" +// (approval), "in motion" (working), and "broken" (failed). Ready is the +// unlabeled resting state — the agent stopped and is waiting on the user, +// whether it finished, asked a question, or proposed a plan. +// Unread completion is tracked separately: it describes whether a ready +// thread needs attention, not what the thread is currently doing. +export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready"; + +type SidebarV2StatusInput = Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "session" +>; + +export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status { + if (thread.hasPendingApprovals) { + return "approval"; + } + if (thread.hasPendingUserInput) { + return "input"; + } + if (thread.session?.status === "running" || thread.session?.status === "starting") { + return "working"; + } + if (thread.session?.status === "error") { + return "failed"; + } + return "ready"; +} + +/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not + poison the whole ordering, so it sinks to the epoch instead. */ +export function parseTimestampMs(isoDate: string): number { + const parsed = Date.parse(isoDate); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** First VALID timestamp wins: `a ?? b` falls through on null, but a present- + yet-malformed string must also fall through to the next candidate rather + than sink the row to the epoch. */ +export function firstValidTimestampMs( + ...candidates: ReadonlyArray +): number { + for (const candidate of candidates) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +// v2 sort: static creation order, newest thread on top. Activity NEVER +// reorders the list — a row holds its position from open until settled, so +// the screen only moves at lifecycle transitions. Status (including pending +// approval) is carried by each card's edge strip, not by position. +export function sortThreadsForSidebarV2< + T extends { readonly id: string; readonly createdAt: string }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted( + (left, right) => + parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || + left.id.localeCompare(right.id), + ); +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4b4b1e52a94..cf3fa30cf8d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { Globe2Icon, LoaderIcon, SearchIcon, - SettingsIcon, SquarePenIcon, TerminalIcon, TriangleAlertIcon, @@ -63,7 +62,7 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, MIN_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -74,10 +73,9 @@ import { import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { cn, isMacPlatform } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { readThreadShell, useProject, @@ -126,7 +124,6 @@ import { import { stackedThreadToast, toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { Kbd } from "./ui/kbd"; import { getArm64IntelBuildWarningDescription, @@ -169,9 +166,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, - SidebarFooter, SidebarGroup, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -179,7 +174,6 @@ import { SidebarMenuSubButton, SidebarMenuSubItem, SidebarSeparator, - SidebarTrigger, useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; @@ -194,7 +188,6 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, - resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, @@ -204,12 +197,12 @@ import { ThreadStatusPill, } from "./Sidebar.logic"; import { sortThreads } from "../lib/threadSort"; -import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -223,7 +216,6 @@ import { type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { SidebarProviderUpdatePill } from "./sidebar/SidebarProviderUpdatePill"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -2787,112 +2779,6 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { - const stageLabel = useSidebarStageLabel(); - const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); - - return ( - - {backdropVariant ? : null} - - - - ); -}); - -function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { - return ( - - - - Code - - - ); -} - -function useSidebarStageLabel() { - const primaryServerVersion = - useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - - return resolveSidebarStageBadgeLabel({ - primaryServerVersion, - fallbackStageLabel: APP_STAGE_LABEL, - }); -} - -function T3Wordmark() { - return ( - - - - ); -} - -const SidebarChromeFooter = memo(function SidebarChromeFooter() { - const navigate = useNavigate(); - const { isMobile, setOpenMobile } = useSidebar(); - const handleSettingsClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/settings" }); - }, [isMobile, navigate, setOpenMobile]); - - return ( - - - - - - - - Settings - - - - - ); -}); - interface SidebarProjectsContentProps { showArm64IntelBuildWarning: boolean; arm64IntelBuildWarningDescription: string | null; diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx new file mode 100644 index 00000000000..a62e09a3b8e --- /dev/null +++ b/apps/web/src/components/SidebarV2.tsx @@ -0,0 +1,1707 @@ +import { autoAnimate } from "@formkit/auto-animate"; +import { useAtomValue } from "@effect/atom-react"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + scopeProjectRef, + scopeThreadRef, + scopedThreadKey, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + CheckIcon, + CircleCheckIcon, + CircleDashedIcon, + CloudIcon, + FolderPlusIcon, + PlusIcon, + SearchIcon, + SquarePenIcon, + Undo2Icon, +} from "lucide-react"; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useParams, useRouter } from "@tanstack/react-router"; + +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { isElectron } from "../env"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHintsForModifiers, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; +import { useShortcutModifierState } from "../shortcutModifierState"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isMacPlatform } from "~/lib/utils"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { readLocalApi } from "../localApi"; +import { useUiStateStore } from "../uiStateStore"; +import { useThreadSelectionStore } from "../threadSelectionStore"; +import { useThreadActions } from "../hooks/useThreadActions"; +import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; +import { onOpenNewThreadPicker } from "../newThreadPickerBus"; +import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "./ui/dialog"; +import { + resolveThreadActionProjectRef, + startNewThreadFromContext, + startNewThreadInProjectFromContext, +} from "../lib/chatThreadActions"; +import { useClientSettings } from "../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; +import { threadEnvironment } from "../state/threads"; +import { useEnvironmentQuery } from "../state/query"; +import { useAtomCommand } from "../state/use-atom-command"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; +import { formatRelativeTimeLabel } from "../timestampFormat"; +import type { SidebarThreadSummary } from "../types"; +import { cn } from "~/lib/utils"; +import { + firstValidTimestampMs, + hasUnseenCompletion, + isTrailingDoubleClick, + resolveAdjacentThreadId, + resolveSidebarV2Status, + sortThreadsForSidebarV2, +} from "./Sidebar.logic"; +import { prStatusIndicator, resolveThreadPr } from "./ThreadStatusIndicators"; +import { ProjectFavicon } from "./ProjectFavicon"; +import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { primaryServerProvidersAtom } from "../state/server"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { CommandDialogTrigger } from "./ui/command"; +import { Kbd } from "./ui/kbd"; +import { + SidebarContent, + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, + useSidebar, +} from "./ui/sidebar"; +import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +// Settled-tail paging: recent history is the common lookup; the deep tail +// stays behind an explicit Show more. +const SETTLED_TAIL_INITIAL_COUNT = 10; +const SETTLED_TAIL_PAGE_COUNT = 25; + +function compactSidebarTimeLabel(label: string): string { + if (label === "just now") return "now"; + return label.endsWith(" ago") ? label.slice(0, -4) : label; +} + +function threadTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = thread.latestUserMessageAt ?? thread.updatedAt; + return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} + +const SidebarV2Row = memo(function SidebarV2Row(props: { + thread: SidebarThreadSummary; + variant: "card" | "slim"; + // Slim rows are either settled (action: un-settle) or merely quiet + // (seen Ready threads — action: settle). + variantAction: "settle" | "unsettle"; + // False on environments whose server predates thread.settle/unsettle: + // the lifecycle affordances hide entirely rather than fail on click. + settlementSupported: boolean; + // Marks where active work transitions into history: a quiet labeled + // rule above the first settled row, so the tail reads as a named zone + // rather than an unexplained gap. + showSettledGap?: boolean; + isActive: boolean; + jumpLabel: string | null; + currentEnvironmentId: string | null; + environmentLabel: string | null; + projectCwd: string | null; + projectTitle: string | null; + providerEntryByInstanceId: ReadonlyMap; + onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; + onThreadActivate: (threadRef: ScopedThreadRef) => void; + onStartRename: (threadRef: ScopedThreadRef, title: string) => void; + onRenameTitleChange: (title: string) => void; + onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; + onCancelRename: () => void; + isRenaming: boolean; + renamingTitle: string; + onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onSettle: (threadRef: ScopedThreadRef) => void; + onUnsettle: (threadRef: ScopedThreadRef) => void; + onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; +}) { + const { + isRenaming, + onChangeRequestState, + onCancelRename, + onCommitRename, + onContextMenu, + onRenameTitleChange, + onSettle, + onStartRename, + onThreadActivate, + onThreadClick, + onUnsettle, + renamingTitle, + thread, + variant, + variantAction, + } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const openPrLink = useOpenPrLink(); + + // Same semantics as v1 (never-visited counts as read): flipping the beta + // flag must not light up every historical thread as unread. + const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); + const status = resolveSidebarV2Status(thread); + const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + const topStatus = + status === "working" + ? { + label: "Working", + icon: "working" as const, + className: + "animate-sidebar-working-text font-semibold text-blue-600 motion-reduce:animate-none dark:text-blue-400", + } + : status === "approval" + ? { + label: "Approval", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "input" + ? { + label: "Input", + icon: null, + className: "font-semibold text-amber-700 dark:text-amber-300", + } + : status === "failed" + ? { + label: "Failed", + icon: null, + className: "font-semibold text-red-700 dark:text-red-300", + } + : isUnread + ? { + label: "Done", + icon: "done" as const, + className: "font-semibold text-emerald-700 dark:text-emerald-300", + } + : null; + + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + // Report the PR state up: the parent partitions rows with effectiveSettled, + // and a merged/closed PR auto-settles a thread — data only rows have. + const prState = pr?.state ?? null; + useEffect(() => { + onChangeRequestState(threadKey, prState); + }, [onChangeRequestState, prState, threadKey]); + + const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const driverKind = props.providerEntryByInstanceId.get(modelInstanceId)?.driverKind ?? null; + + const isRemote = + props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + + const handleClick = useCallback( + (event: ReactMouseEvent) => { + onThreadClick(event, threadRef); + }, + [onThreadClick, threadRef], + ); + const handleContextMenu = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + }, + [onContextMenu, threadRef], + ); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onThreadActivate(threadRef); + }, + [onThreadActivate, threadRef], + ); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (isRenaming || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + if ((event.target as HTMLElement).closest("button, a, input")) return; + event.preventDefault(); + onStartRename(threadRef, thread.title); + }, + [isRenaming, onStartRename, thread.title, threadRef], + ); + const renameCommittedRef = useRef(false); + useEffect(() => { + if (isRenaming) renameCommittedRef.current = false; + }, [isRenaming]); + const handleRenameKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renameCommittedRef.current = true; + onCommitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renameCommittedRef.current = true; + onCancelRename(); + } + }, + [onCancelRename, onCommitRename, renamingTitle, thread.title, threadRef], + ); + const handleRenameBlur = useCallback(() => { + if (!renameCommittedRef.current) { + onCommitRename(threadRef, renamingTitle, thread.title); + } + }, [onCommitRename, renamingTitle, thread.title, threadRef]); + const handleSettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onSettle(threadRef); + }, + [onSettle, threadRef], + ); + const handleUnsettleClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onUnsettle(threadRef); + }, + [onUnsettle, threadRef], + ); + const handlePrClick = useCallback( + (event: ReactMouseEvent) => { + if (pr?.url) openPrLink(event, pr.url); + }, + [openPrLink, pr], + ); + + const rowClassName = cn( + "group/v2-row relative w-full cursor-pointer select-none rounded-md text-left", + props.isActive + ? "bg-foreground/[0.11] text-foreground dark:bg-white/[0.11]" + : isSelected + ? "bg-foreground/[0.07] text-foreground dark:bg-white/[0.07]" + : "hover:bg-accent/65", + ); + + const title = isRenaming ? ( + onRenameTitleChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-[13px] text-foreground outline-none focus:border-foreground" + /> + ) : ( + + {thread.title} + + ); + + const prBadge = + prStatus && pr ? ( + + ) : null; + + if (variant === "slim") { + return ( +
  • + {props.showSettledGap ? ( +
    + Settled + +
    + ) : null} +
    + {/* Settled history recedes: dimmed favicon at rest, restored on + hover so the tail stays scannable when you're hunting. */} + + + + {title} + {/* The PR badge stays outside the hover-fading slot: it must + remain visible AND clickable while the row is hovered. Only + the time/jump label yields to the settle affordance. */} + {prBadge} + + + + {props.jumpLabel ?? + compactSidebarTimeLabel( + formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), + )} + + + {!props.settlementSupported ? null : variantAction === "unsettle" ? ( + + ) : ( + + )} + +
    +
  • + ); + } + + const diff = latestTurnDiff(thread); + + return ( +
  • +
    +
    +
    + + {props.projectTitle ? ( + + {props.projectTitle} + + ) : ( + + )} + + + {props.jumpLabel ? ( + props.jumpLabel + ) : topStatus ? ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {topStatus.label} + + ) : ( + threadTimeLabel(thread) + )} + + {props.settlementSupported ? ( + + ) : null} + +
    +
    {title}
    +
    + {thread.branch ? ( + + {thread.branch} + + ) : ( + + )} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + −{diff.deletions} + + ) : null} + + {driverKind ? ( + + } + > + + + {thread.modelSelection.model} + + ) : null} + {isRemote ? ( + + + } + > + + + + Running on {props.environmentLabel ?? "a remote environment"} + + + ) : null} + +
    + {status === "failed" && thread.session?.lastError ? ( +
    + {thread.session.lastError} +
    + ) : null} +
    +
    +
  • + ); +}); + +function latestTurnDiff( + thread: SidebarThreadSummary, +): { insertions: number; deletions: number } | null { + // Shells don't carry checkpoint summaries; diff stats render only when the + // shell projection grows them. Kept as a seam so the row layout is ready. + void thread; + return null; +} + +export default function SidebarV2() { + const projects = useProjects(); + const threads = useThreadShells(); + const router = useRouter(); + const { isMobile, setOpenMobile } = useSidebar(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); + const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const { settleThread, unsettleThread, deleteThread } = useThreadActions(); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const newThreadContext = useHandleNewThread(); + const openAddProjectCommandPalette = useOpenAddProjectCommandPalette(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); + const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); + const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // Post-settle navigation validates against the CURRENT route, not the one + // captured when the settle started: if the user navigated elsewhere while + // the command was in flight, completing it must not yank them away. + const routeThreadKeyRef = useRef(routeThreadKey); + routeThreadKeyRef.current = routeThreadKey; + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const serverProviders = useAtomValue(primaryServerProvidersAtom); + const providerEntryByInstanceId = useMemo( + () => + new Map( + deriveProviderInstanceEntries(serverProviders).map( + (entry) => [entry.instanceId as string, entry] as const, + ), + ), + [serverProviders], + ); + const projectCwdByKey = useMemo( + () => + new Map( + projects.map((project) => [ + `${project.environmentId}:${project.id}`, + project.workspaceRoot, + ]), + ), + [projects], + ); + const projectTitleByKey = useMemo( + () => + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); + + // now is quantized to the minute so effectiveSettled memoization doesn't + // churn on every render; auto-settle thresholds are day-granular anyway. + const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16)); + useEffect(() => { + const id = window.setInterval( + () => setNowMinute(new Date().toISOString().slice(0, 16)), + 60_000, + ); + return () => window.clearInterval(id); + }, []); + + // PR states stream in per-row (rows own the VCS subscriptions); a merged or + // closed PR auto-settles its thread on the next partition. + const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< + ReadonlyMap + >(() => new Map()); + const handleChangeRequestState = useCallback( + (threadKey: string, state: "open" | "closed" | "merged" | null) => { + setChangeRequestStateByKey((current) => { + if ((current.get(threadKey) ?? null) === state) return current; + const next = new Map(current); + if (state === null) { + next.delete(threadKey); + } else { + next.set(threadKey, state); + } + return next; + }); + }, + [], + ); + + // Project scope: chips above the list. Scoping filters the list AND + // becomes the new-thread target — one visible control doing both jobs the + // old per-project headers did. + const [projectScopeKey, setProjectScopeKey] = useState(null); + const scopedProject = useMemo( + () => + projectScopeKey === null + ? null + : (projects.find( + (project) => `${project.environmentId}:${project.id}` === projectScopeKey, + ) ?? null), + [projectScopeKey, projects], + ); + useEffect(() => { + if ( + projectScopeKey !== null && + !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) + ) { + setProjectScopeKey(null); + } + }, [projectScopeKey, projects]); + // Scope flips drop the selection: rows selected under the old scope may be + // hidden now, and bulk actions must never count or touch invisible rows. + useEffect(() => { + clearSelection(); + }, [clearSelection, projectScopeKey]); + + // Settled threads stay in the live shell stream (settled ≠ archived), so + // the partition works directly off live shells: no archived-snapshot + // merging, no optimistic holds. Archived threads remain hidden here — + // archive keeps its original "remove from sidebar" meaning. + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const { activeThreads, settledThreads } = useMemo(() => { + const now = `${nowMinute}:00.000Z`; + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProject === null || + (thread.environmentId === scopedProject.environmentId && + thread.projectId === scopedProject.id)), + ); + const active: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + 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. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } + } + return { + activeThreads: sortThreadsForSidebarV2(active), + settledThreads: settled.toSorted( + (left, right) => + firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - + firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), + ), + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProject, + serverConfigs, + threads, + ]); + + // The settled tail renders in pages: history shouldn't dominate the + // sidebar, and the common lookups are recent. Expansion resets when the + // filter context changes so a scope/search flip never inherits a deep + // page state. + const [settledVisibleCount, setSettledVisibleCount] = useState(SETTLED_TAIL_INITIAL_COUNT); + const settledResetKey = `${projectScopeKey ?? "all"}`; + const lastSettledResetKeyRef = useRef(settledResetKey); + if (lastSettledResetKeyRef.current !== settledResetKey) { + lastSettledResetKeyRef.current = settledResetKey; + setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); + } + const hiddenSettledCount = Math.max(0, settledThreads.length - settledVisibleCount); + const visibleSettledThreads = useMemo( + () => (hiddenSettledCount > 0 ? settledThreads.slice(0, settledVisibleCount) : settledThreads), + [hiddenSettledCount, settledThreads, settledVisibleCount], + ); + const showMoreSettled = useCallback( + () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), + [], + ); + + const orderedThreads = useMemo( + () => [...activeThreads, ...visibleSettledThreads], + [activeThreads, visibleSettledThreads], + ); + const orderedThreadKeys = useMemo( + () => + orderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [orderedThreads], + ); + // Rows call back into the click handler without carrying the ordered list as + // a prop — a fresh array identity per shell update would defeat every row's + // memoization. The ref keeps shift-range-select working against the list as + // rendered at click time. + const orderedThreadKeysRef = useRef(orderedThreadKeys); + orderedThreadKeysRef.current = orderedThreadKeys; + const threadByKey = useMemo( + () => + new Map( + orderedThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [orderedThreads], + ); + // Handlers read these through refs: depending on per-update Map/Set + // identities would give every row a fresh callback prop on each shell + // event and defeat row memoization during streaming. + const threadByKeyRef = useRef(threadByKey); + threadByKeyRef.current = threadByKey; + // handleNewThread is inherently unstable (depends on the projects list); + // a ref keeps it out of attemptSettle's dependency array. + const handleNewThreadRef = useRef(newThreadContext.handleNewThread); + handleNewThreadRef.current = newThreadContext.handleNewThread; + const settledThreadKeys = useMemo( + () => + new Set( + settledThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + [settledThreads], + ); + const settledThreadKeysRef = useRef(settledThreadKeys); + settledThreadKeysRef.current = settledThreadKeys; + + const jumpLabelByKey = useMemo(() => { + const mapping = new Map(); + for (const [index, threadKey] of orderedThreadKeys.entries()) { + const jumpCommand = threadJumpCommandForIndex(index); + if (!jumpCommand) break; + const label = shortcutLabelForCommand(keybindings, jumpCommand); + if (label) mapping.set(threadKey, label); + } + return mapping; + }, [keybindings, orderedThreadKeys]); + const [showJumpHints, setShowJumpHints] = useState(false); + + // Settled threads are live shells, so opening one is plain navigation: + // history stays readable without un-settling, and sending a message or + // starting a session un-settles server-side. + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + if (isMobile) { + setOpenMobile(false); + } + void router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], + ); + + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, []); + const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); + const commitThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { + void (async () => { + const trimmed = title.trim(); + setRenamingThreadKey(null); + if (trimmed.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + return; + } + if (trimmed === originalTitle) return; + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, title: trimmed }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [updateThreadMetadata], + ); + + const handleThreadClick = useCallback( + (event: ReactMouseEvent, threadRef: ScopedThreadRef) => { + const isMac = isMacPlatform(navigator.platform); + const isModClick = isMac ? event.metaKey : event.ctrlKey; + const threadKey = scopedThreadKey(threadRef); + if (isModClick) { + event.preventDefault(); + toggleThreadSelection(threadKey); + return; + } + if (event.shiftKey) { + event.preventDefault(); + rangeSelectTo(threadKey, orderedThreadKeysRef.current); + return; + } + if (isTrailingDoubleClick(event.detail)) { + return; + } + navigateToThread(threadRef); + }, + [navigateToThread, rangeSelectTo, toggleThreadSelection], + ); + + // A settle per thread at a time: double clicks and repeated menu picks + // must not dispatch a second settle that fails and toasts a false error. + const settlingThreadKeysRef = useRef(new Set()); + const attemptSettle = useCallback( + (threadRef: ScopedThreadRef, opts: { coSettlingKeys?: ReadonlySet } = {}) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + if (settlingThreadKeysRef.current.has(threadKey)) return; + settlingThreadKeysRef.current.add(threadKey); + try { + // Settling the thread you're looking at moves you forward: the next + // remaining card (never a settled row, never one settling in the + // same batch), or a fresh draft in this project when it was the + // last active one. Snapshot the target before the settle mutates + // the partition. Background settles never navigate. + const shell = threadByKeyRef.current.get(threadKey); + let navigateAfterSettle: (() => void) | null = null; + if (routeThreadKey === threadKey) { + const orderedKeys = orderedThreadKeysRef.current; + const settledKeys = settledThreadKeysRef.current; + const currentIndex = orderedKeys.indexOf(threadKey); + const nextCardKey = + currentIndex === -1 + ? null + : ([ + ...orderedKeys.slice(currentIndex + 1), + ...orderedKeys.slice(0, currentIndex), + ].find((key) => !settledKeys.has(key) && !opts.coSettlingKeys?.has(key)) ?? null); + const nextThread = nextCardKey ? threadByKeyRef.current.get(nextCardKey) : null; + navigateAfterSettle = nextThread + ? () => navigateToThread(scopeThreadRef(nextThread.environmentId, nextThread.id)) + : shell + ? () => + void handleNewThreadRef.current( + scopeProjectRef(shell.environmentId, shell.projectId), + ) + : () => void router.navigate({ to: "/" }); + } + const result = await settleThread(threadRef); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not settle. + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + // Only move forward if the user is still on the settled thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSettle?.(); + } + } finally { + settlingThreadKeysRef.current.delete(threadKey); + } + })(); + }, + [navigateToThread, routeThreadKey, router, settleThread], + ); + const attemptUnsettle = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unsettleThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to un-settle thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unsettleThread], + ); + + const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); + const handleMultiSelectContextMenu = useCallback( + async (position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + // One exact actionable set: keys whose rows are actually rendered + // right now. Selections can outlive their rows (settled-tail paging, + // thread deletion elsewhere) and the menu labels must count only what + // the actions will touch. + const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( + (threadKey) => threadByKeyRef.current.has(threadKey), + ); + if (threadKeys.length === 0) return; + const count = threadKeys.length; + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "settle", label: `Settle (${count})` }, + { id: "mark-unread", label: `Mark unread (${count})` }, + { id: "delete", label: `Delete (${count})`, destructive: true }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + if (clicked.value === "settle") { + // Post-settle navigation must skip threads settling in this same + // batch — they are all leaving the card block together. Rows that + // are already explicitly settled are skipped: nothing to do on a + // valid mixed selection. + const coSettlingKeys = new Set(threadKeys); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread || thread.settledOverride === "settled") continue; + attemptSettle(scopeThreadRef(thread.environmentId, thread.id), { coSettlingKeys }); + } + clearSelection(); + return; + } + if (clicked.value === "mark-unread") { + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + markThreadUnread(threadKey, thread?.latestTurn?.completedAt); + } + clearSelection(); + return; + } + if (clicked.value !== "delete") return; + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete ${count} thread${count === 1 ? "" : "s"}?`, + "This permanently clears conversation history for these threads.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + // Grown as deletions actually land, never seeded with the whole batch: + // orphaned-worktree detection must only discount threads that are + // really gone, or the first delete would treat still-alive batch mates + // as deleted and remove a worktree they still point at. + const deletedThreadKeys = new Set(); + for (const threadKey of threadKeys) { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) continue; + const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + deletedThreadKeys.add(threadKey); + } + removeFromSelection(threadKeys); + }, + [ + attemptSettle, + clearSelection, + confirmThreadDelete, + deleteThread, + markThreadUnread, + removeFromSelection, + ], + ); + + const handleThreadContextMenu = useCallback( + (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + void (async () => { + const api = readLocalApi(); + if (!api) return; + const threadKey = scopedThreadKey(threadRef); + const selectionState = useThreadSelectionStore.getState(); + if (selectionState.hasSelection() && selectionState.selectedThreadKeys.has(threadKey)) { + await handleMultiSelectContextMenu(position); + return; + } + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return; + // 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. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const isSettled = settledThreadKeysRef.current.has(threadKey); + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + ...(supportsSettlement + ? [ + isSettled + ? { id: "unsettle", label: "Un-settle thread" } + : { id: "settle", label: "Settle thread" }, + ] + : []), + { id: "rename", label: "Rename thread" }, + { id: "mark-unread", label: "Mark unread" }, + { id: "delete", label: "Delete", destructive: true, icon: "trash" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "settle": + attemptSettle(threadRef); + return; + case "unsettle": + attemptUnsettle(threadRef); + return; + case "rename": + startThreadRename(threadRef, thread.title); + return; + case "mark-unread": + markThreadUnread(threadKey, thread.latestTurn?.completedAt); + return; + case "delete": { + if (confirmThreadDelete) { + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete thread "${thread.title}"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const result = await deleteThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } + default: + return; + } + })(); + }, + [ + attemptSettle, + attemptUnsettle, + confirmThreadDelete, + deleteThread, + handleMultiSelectContextMenu, + markThreadUnread, + serverConfigs, + startThreadRename, + ], + ); + + // Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as + // v1 — the keybinding layer is shared, only the ordered list differs. + const routeTerminalOpen = useTerminalUiStateStore((state) => + routeThreadRef + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen + : false, + ); + useEffect(() => { + const onWindowKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + platform: navigator.platform, + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + modelPickerOpen: isModelPickerOpen(), + }, + }); + const navigateToThreadKey = (targetThreadKey: string | null) => { + if (!targetThreadKey) return false; + const targetThread = threadByKey.get(targetThreadKey); + if (!targetThread) return false; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); + return true; + }; + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + navigateToThreadKey( + resolveAdjacentThreadId({ + threadIds: orderedThreadKeys, + currentThreadId: routeThreadKey, + direction: traversalDirection, + }), + ); + return; + } + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) return; + navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null); + }; + window.addEventListener("keydown", onWindowKeyDown); + return () => window.removeEventListener("keydown", onWindowKeyDown); + }, [ + keybindings, + navigateToThread, + orderedThreadKeys, + routeTerminalOpen, + routeThreadKey, + threadByKey, + ]); + + // Same predicate as v1: hints show only while the held modifiers exactly + // match a thread-jump binding. Adding Shift (screenshots) or Alt no + // longer matches ⌘1..9, so the overlay hides for chords like ⌘⇧4. + const shortcutModifiers = useShortcutModifierState(); + const shouldShowJumpHintsNow = shouldShowThreadJumpHintsForModifiers( + shortcutModifiers, + keybindings, + { platform: navigator.platform }, + ); + useEffect(() => { + setShowJumpHints(shouldShowJumpHintsNow); + }, [shouldShowJumpHintsNow]); + + const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { + if (!node) return; + autoAnimate(node, { duration: 150, easing: "ease-out" }); + }, []); + + // New thread defaults to the project you're in (active thread's project, + // falling back to the top project) — same resolution the command palette + // uses. The chevron menu is the explicit project picker the flat list no + // longer gets from per-project headers. + const [newThreadPickerOpen, setNewThreadPickerOpen] = useState(false); + // chat.new (mod+shift+o / mod+n) is handled by the _chat route layout; in + // v2 with multiple projects it opens this picker via the event bus. + useEffect(() => onOpenNewThreadPicker(() => setNewThreadPickerOpen(true)), []); + const handleNewThreadClick = useCallback(() => { + // One project: nothing to pick, create immediately. + if (projects.length <= 1) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } + setNewThreadPickerOpen(true); + }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + const createThreadInProject = useCallback( + (environmentId: (typeof projects)[number]["environmentId"], projectId: string) => { + setNewThreadPickerOpen(false); + if (isMobile) setOpenMobile(false); + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (!project) return; + void startNewThreadInProjectFromContext( + { + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }, + scopeProjectRef(project.environmentId, project.id), + ); + }, + [isMobile, newThreadContext, projects, setOpenMobile], + ); + const contextualProjectRef = resolveThreadActionProjectRef({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + const newThreadTargetRef = scopedProject + ? scopeProjectRef(scopedProject.environmentId, scopedProject.id) + : contextualProjectRef; + const newThreadTargetProject = newThreadTargetRef + ? (projects.find( + (project) => + project.environmentId === newThreadTargetRef.environmentId && + project.id === newThreadTargetRef.projectId, + ) ?? null) + : null; + // Picker order: the contextual default first (preselected), everything else + // after — the common case is Enter/click on the top row. + const newThreadPickerProjects = useMemo(() => { + if (!newThreadTargetProject) return projects; + return [ + newThreadTargetProject, + ...projects.filter( + (project) => + project.environmentId !== newThreadTargetProject.environmentId || + project.id !== newThreadTargetProject.id, + ), + ]; + }, [newThreadTargetProject, projects]); + + const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); + // Same resolution as v1: prefer the local-thread binding, fall back to + // chat.new, no platform gating — web users have working shortcuts too. + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal") ?? + shortcutLabelForCommand(keybindings, "chat.new"); + const projectScrollerRef = useRef(null); + const [canScrollProjectsRight, setCanScrollProjectsRight] = useState(false); + const updateProjectScrollFade = useCallback(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + setCanScrollProjectsRight( + scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1, + ); + }, []); + useEffect(() => { + const scroller = projectScrollerRef.current; + if (!scroller) return; + + updateProjectScrollFade(); + const resizeObserver = new ResizeObserver(updateProjectScrollFade); + resizeObserver.observe(scroller); + return () => resizeObserver.disconnect(); + }, [projects, updateProjectScrollFade]); + + return ( + <> + + + + + + + } + > + + Search + {commandPaletteShortcutLabel ? ( + + {commandPaletteShortcutLabel} + + ) : null} + + + + + + + + + + {projects.length > 0 ? ( + +
    +
    + {projects.length > 1 ? ( + + ) : null} + {projects.map((project) => { + const scopeKey = `${project.environmentId}:${project.id}`; + const isScoped = projectScopeKey === scopeKey; + return ( + + ); + })} +
    +
    + + + } + > + + + Add project + +
    +
    +
    + ) : null} + +
      + {orderedThreads.map((thread, threadIndex) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const isSettledRow = settledThreadKeys.has(threadKey); + // Settled is the ONLY thing that collapses a row: every + // not-settled thread is a full card. Density comes from users + // (or the auto rules) actually settling work, not from the + // sidebar second-guessing what still matters. + const isCard = !isSettledRow; + const previousThread = threadIndex > 0 ? orderedThreads[threadIndex - 1] : null; + const previousWasCard = + previousThread != null && + !settledThreadKeys.has( + scopedThreadKey(scopeThreadRef(previousThread.environmentId, previousThread.id)), + ); + const showSettledGap = !isCard && previousWasCard; + return ( + + ); + })} + {hiddenSettledCount > 0 ? ( +
    • + +
    • + ) : null} +
    + {orderedThreads.length === 0 ? ( +
    + {projects.length === 0 ? ( + <> + No projects yet + + + ) : scopedProject ? ( + `No threads in ${scopedProject.title} yet` + ) : ( + "No threads yet" + )} +
    + ) : null} +
    +
    + + + + + + New thread in… + +
    { + if ( + event.key !== "ArrowDown" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + const container = event.currentTarget; + const options = [...container.querySelectorAll("button")]; + if (options.length === 0) return; + const currentIndex = options.findIndex((option) => option === document.activeElement); + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? (currentIndex + 1) % options.length + : (currentIndex - 1 + options.length) % options.length; + event.preventDefault(); + options[nextIndex]?.focus(); + }} + > + {newThreadPickerProjects.map((project, index) => { + const isDefault = index === 0 && newThreadTargetProject !== null; + return ( + + ); + })} + +
    +
    +
    + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f35bff081e1..94e4af3bba6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2143,8 +2143,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ref={composerSurfaceRef} data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"} className={cn( - "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45", - isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border", + "chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-foreground/40", + isDragOverComposer + ? "border-primary/70 bg-accent/45" + : "border-black/12 dark:border-white/12", projectSelectionRequired ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ef3ec863d0b..d39828752fb 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -16,6 +16,7 @@ import ProjectScriptsControl, { } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; +import { ProjectFavicon } from "../ProjectFavicon"; import { cn } from "~/lib/utils"; interface ChatHeaderProps { @@ -24,6 +25,7 @@ interface ChatHeaderProps { draftId?: DraftId; activeThreadTitle: string; activeProjectName: string | undefined; + activeProjectCwd: string | null; openInCwd: string | null; activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; @@ -58,6 +60,7 @@ export const ChatHeader = memo(function ChatHeader({ draftId, activeThreadTitle, activeProjectName, + activeProjectCwd, openInCwd, activeProjectScripts, preferredScriptId, @@ -79,6 +82,26 @@ export const ChatHeader = memo(function ChatHeader({ return (
    + {/* The project always leads the header: knowing which project a + thread lives in is priority zero, and the thread title alone + doesn't answer it. */} + {activeProjectName ? ( + + + + + {activeProjectName} + + + + / + + + ) : null} 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 >= AUTO_SETTLE_MIN_DAYS && + parsed <= AUTO_SETTLE_MAX_DAYS + ) { + onCommit(parsed); + } + }} + onBlur={() => setDraft(String(value))} + aria-label="Days of inactivity before auto-settle" + /> + ); +} + +export function BetaSettingsPanel() { + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const sidebarAutoSettleAfterDays = useClientSettings( + (settings) => settings.sidebarAutoSettleAfterDays, + ); + const updateSettings = useUpdateClientSettings(); + + return ( + + + updateSettings({ sidebarV2Enabled: Boolean(checked) })} + aria-label="Enable the sidebar v2 beta" + /> + } + /> + {sidebarV2Enabled ? ( + <> + + updateSettings({ + sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, + }) + } + aria-label="Auto-settle inactive threads" + /> + } + /> + {sidebarAutoSettleAfterDays !== null ? ( + updateSettings({ sidebarAutoSettleAfterDays: days })} + /> + } + /> + ) : null} + + ) : null} + + + ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f..9d690f2a4e4 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + FlaskConicalIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -28,6 +29,7 @@ export type SettingsSectionPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/beta" | "/settings/archived"; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ @@ -40,6 +42,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, + { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx new file mode 100644 index 00000000000..c665f7741e7 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -0,0 +1,127 @@ +import { useAtomValue } from "@effect/atom-react"; +import { SettingsIcon } from "lucide-react"; +import { memo, useCallback } from "react"; +import { Link, useNavigate } from "@tanstack/react-router"; + +import { APP_STAGE_LABEL } from "../../branding"; +import { cn } from "../../lib/utils"; +import { primaryServerConfigAtom } from "../../state/server"; +import { resolveSidebarStageBadgeLabel } from "../Sidebar.logic"; +import { SidebarStageBackdrop, resolveSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; +import { + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; +import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; +import { SidebarUpdatePill } from "./SidebarUpdatePill"; + +export const SidebarChromeHeader = memo(function SidebarChromeHeader({ + isElectron, +}: { + isElectron: boolean; +}) { + const stageLabel = useSidebarStageLabel(); + const backdropVariant = resolveSidebarStageBackdropVariant(stageLabel); + + return ( + + {backdropVariant ? : null} + + + + ); +}); + +function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + return ( + + + + Code + + + ); +} + +function useSidebarStageLabel() { + const primaryServerVersion = + useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; + + return resolveSidebarStageBadgeLabel({ + primaryServerVersion, + fallbackStageLabel: APP_STAGE_LABEL, + }); +} + +function T3Wordmark() { + return ( + + + + ); +} + +export const SidebarChromeFooter = memo(function SidebarChromeFooter() { + const navigate = useNavigate(); + const { isMobile, setOpenMobile } = useSidebar(); + const handleSettingsClick = useCallback(() => { + if (isMobile) { + setOpenMobile(false); + } + void navigate({ to: "/settings" }); + }, [isMobile, navigate, setOpenMobile]); + + return ( + + + + + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index dbde5acce99..de31ff09aa6 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -4,6 +4,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { canSettle } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -19,7 +20,12 @@ import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; import { readLocalApi } from "../localApi"; -import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; +import { + readEnvironmentSupportsSettlement, + readEnvironmentThreadRefs, + readProject, + readThreadShell, +} from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -39,6 +45,30 @@ export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettlementUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This environment's server does not support settling yet. Update the server to use Settle."; + } +} + +export class ThreadSettleBlockedError extends Schema.TaggedErrorClass()( + "ThreadSettleBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "This thread still needs attention. Resolve or interrupt it first, then try again."; + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -50,6 +80,12 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const settleThreadMutation = useAtomCommand(threadEnvironment.settle, { + reportFailure: false, + }); + const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -345,6 +381,66 @@ export function useThreadActions() { ], ); + const settleThread = useCallback( + async (target: ScopedThreadRef) => { + // Version skew: never send the command to a server that predates it — + // the raw protocol rejection would read as a random failure. + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + const resolved = resolveThreadTarget(target); + // Settle may only target what effectiveSettled could classify as + // settled: not starting/running sessions, not threads waiting on + // approvals or user input. Anything else would hide live work. + if (resolved && !canSettle(resolved.thread, { now: new Date().toISOString() })) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettleBlockedError({ + environmentId: resolved.threadRef.environmentId, + threadId: resolved.threadRef.threadId, + }), + ), + ); + } + // Settle is a high-frequency lifecycle action and stays silent — no + // toast. + return settleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + }, + [resolveThreadTarget, settleThreadMutation], + ); + + const unsettleThread = useCallback( + async (target: ScopedThreadRef) => { + if (!readEnvironmentSupportsSettlement(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadSettlementUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + // reason "user" pins the thread active: auto-settle (PR merged / + // inactivity) stays suppressed until real activity clears the pin. + return unsettleThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, reason: "user" }, + }); + }, + [unsettleThreadMutation], + ); + const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { const localApi = readLocalApi(); @@ -379,7 +475,16 @@ export function useThreadActions() { unarchiveThread, deleteThread, confirmAndDeleteThread, + settleThread, + unsettleThread, }), - [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + [ + archiveThread, + confirmAndDeleteThread, + deleteThread, + settleThread, + unarchiveThread, + unsettleThread, + ], ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 41abcbd3b17..72caf539e8b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -108,11 +108,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; - /* Duty-cycled indicator animations: long opacity holds with short stepped - ramps, so the compositor only produces frames while the value changes - (~20% of the cycle) instead of every vsync. Keep flat holds dominant. */ + /* Duty-cycled indicator animations: long holds with stepped ramps, so the + compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; --animate-status-ping: status-ping 2s infinite; + --animate-sidebar-working-text: sidebar-working-text 3.4s infinite; --font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; @@ -185,6 +185,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil scale: 2; } } + @keyframes sidebar-working-text { + 0%, + 36% { + opacity: 1; + animation-timing-function: steps(10); + } + 50%, + 86% { + opacity: 0.75; + animation-timing-function: steps(10); + } + 100% { + opacity: 1; + } + } } @layer base { @@ -232,8 +247,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. */ + mask lets the surface grain show through at the boundary. Panels whose + background differs from the app chrome (e.g. sidebar v2) override + --sidebar-stage-fade so the art fades into their own surface color. */ .sidebar-stage-backdrop { + --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); } @@ -246,12 +264,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil to bottom, transparent 0%, transparent 28%, - color-mix(in srgb, var(--app-chrome-background) 10%, transparent) 40%, - color-mix(in srgb, var(--app-chrome-background) 30%, transparent) 52%, - color-mix(in srgb, var(--app-chrome-background) 58%, transparent) 64%, - color-mix(in srgb, var(--app-chrome-background) 82%, transparent) 75%, - color-mix(in srgb, var(--app-chrome-background) 96%, transparent) 85%, - var(--app-chrome-background) 93% + color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, + color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, + color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, + color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, + color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, + var(--stage-fade) 93% ); } @@ -450,6 +468,41 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +/* Give the navigation rail its own cool graphite surface instead of borrowing + the workspace card color. The scoped semantic tokens keep sidebar v2's + controls in the same palette without changing v1 or the settings nav. */ +.app-sidebar { + --background: oklch(0.965 0.009 265); + --foreground: oklch(0.27 0.025 265); + --card: oklch(0.945 0.014 265); + --card-foreground: var(--foreground); + --accent: oklch(0.875 0.008 265); + --accent-foreground: oklch(0.24 0.012 265); + --muted: oklch(0.9 0.018 265); + --muted-foreground: oklch(0.49 0.03 265); + --border: oklch(0.72 0.025 265 / 28%); + --input: oklch(0.62 0.03 265 / 28%); + --sidebar-stage-fade: var(--card); + background-color: var(--card); +} + +.dark .app-sidebar { + --background: #000; + --foreground: #f1f3f7; + --card: #000; + --card-foreground: var(--foreground); + --accent: #191a1d; + --accent-foreground: #f7f9ff; + --muted: #0a0a0a; + --muted-foreground: #a3a3a3; + --border: rgb(255 255 255 / 14%); + --input: rgb(255 255 255 / 18%); + /* The stage-channel header art must ramp to THIS panel's surface, not the + global chrome background, or the fade shows a seam (same rule as the + light palette above). */ + --sidebar-stage-fade: var(--card); +} + body { font-family: "DM Sans Variable", diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 2b1d7b09b9f..2189f1e3ca2 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -6,6 +6,7 @@ import { resolveNewDraftStartFromOrigin, startNewLocalThreadFromContext, startNewThreadFromContext, + startNewThreadInProjectFromContext, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -117,6 +118,26 @@ describe("chatThreadActions", () => { }); }); + it("does not carry branch or worktree context into a different project", async () => { + const handleNewThread = vi.fn(async () => {}); + const targetProjectRef = scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID); + + await startNewThreadInProjectFromContext( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/refactor", + worktreePath: "/tmp/worktree", + }, + handleNewThread, + }), + targetProjectRef, + ); + + expect(handleNewThread).toHaveBeenCalledWith(targetProjectRef); + }); + it("delegates the target environment defaults to the new-thread handler", async () => { const handleNewThread = vi.fn(async () => {}); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 4f30885610a..d8b831ac3ad 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -75,6 +75,14 @@ export async function startNewThreadInProjectFromContext( context: ChatThreadActionContext, projectRef: ScopedProjectRef, ): Promise { + const contextualProjectRef = resolveThreadActionProjectRef(context); + const matchesContext = + contextualProjectRef?.environmentId === projectRef.environmentId && + contextualProjectRef.projectId === projectRef.projectId; + if (!matchesContext) { + await context.handleNewThread(projectRef); + return; + } await context.handleNewThread(projectRef, buildContextualThreadOptions(context)); } diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index b9981bc2e3e..ca9a5986c66 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, diff --git a/apps/web/src/newThreadPickerBus.ts b/apps/web/src/newThreadPickerBus.ts new file mode 100644 index 00000000000..a1cc7560e20 --- /dev/null +++ b/apps/web/src/newThreadPickerBus.ts @@ -0,0 +1,14 @@ +// Tiny event bus connecting the global chat.new shortcut (handled in the +// _chat route layout) to SidebarV2's project picker dialog. The route layer +// can't render the picker itself — it lives with the sidebar — and the +// sidebar can't own the window keydown handler without racing the layout's. +const NEW_THREAD_PICKER_EVENT = "t3code:open-new-thread-picker"; + +export function openNewThreadPicker(): void { + window.dispatchEvent(new CustomEvent(NEW_THREAD_PICKER_EVENT)); +} + +export function onOpenNewThreadPicker(listener: () => void): () => void { + window.addEventListener(NEW_THREAD_PICKER_EVENT, listener); + return () => window.removeEventListener(NEW_THREAD_PICKER_EVENT, listener); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b524fe018e9..563d1b43755 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybi import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' @@ -79,6 +80,11 @@ const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ path: '/connections', getParentRoute: () => SettingsRoute, } as any) +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', + getParentRoute: () => SettingsRoute, +} as any) const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ id: '/archived', path: '/archived', @@ -108,6 +114,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -123,6 +130,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -141,6 +149,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRouteWithChildren '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute + '/settings/beta': typeof SettingsBetaRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute @@ -160,6 +169,7 @@ export interface FileRouteTypes { | '/settings' | '/connect/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -175,6 +185,7 @@ export interface FileRouteTypes { | '/settings' | '/connect/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -192,6 +203,7 @@ export interface FileRouteTypes { | '/settings' | '/connect_/callback' | '/settings/archived' + | '/settings/beta' | '/settings/connections' | '/settings/diagnostics' | '/settings/general' @@ -290,6 +302,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport + parentRoute: typeof SettingsRoute + } '/settings/archived': { id: '/settings/archived' path: '/archived' @@ -337,6 +356,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute + SettingsBetaRoute: typeof SettingsBetaRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute @@ -347,6 +367,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, + SettingsBetaRoute: SettingsBetaRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 9fb1eae721e..b590c59ed9d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -3,6 +3,9 @@ import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { isCommandPaletteOpen } from "../commandPaletteContext"; +import { useClientSettings } from "../hooks/useSettings"; +import { openNewThreadPicker } from "../newThreadPickerBus"; +import { useProjects } from "../state/entities"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -25,6 +28,8 @@ function ChatRouteGlobalShortcuts() { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); + const projectCount = useProjects().length; const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -75,6 +80,13 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); + // Sidebar v2 routes creation through its project picker whenever + // there is a real choice to make; v1 (and single-project setups) + // keep the immediate contextual create. + if (sidebarV2Enabled && projectCount > 1) { + openNewThreadPicker(); + return; + } void startNewThreadFromContext({ activeDraftThread, activeThread: activeThread ?? undefined, @@ -140,8 +152,10 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, + projectCount, routeThreadRef, selectedThreadKeysSize, + sidebarV2Enabled, terminalOpen, ]); diff --git a/apps/web/src/routes/settings.beta.tsx b/apps/web/src/routes/settings.beta.tsx new file mode 100644 index 00000000000..a1e78f2dff7 --- /dev/null +++ b/apps/web/src/routes/settings.beta.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BetaSettingsPanel } from "../components/settings/BetaSettingsPanel"; + +function SettingsBetaRoute() { + return ; +} + +export const Route = createFileRoute("/settings/beta")({ + component: SettingsBetaRoute, +}); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index db895e37c4a..47e63ca2cd3 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -185,6 +185,16 @@ export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } +/** Whether the environment's server understands thread.settle/unsettle. + False for pre-settlement servers (capability defaults false on decode), + so clients under version skew fall back instead of erroring. */ +export function readEnvironmentSupportsSettlement(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadSettlement === true + ); +} + export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); } diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 13ff2f0f73e..89734357889 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -26,6 +26,8 @@ function makeThread(overrides: Partial = {}): Thread { createdAt: "2026-02-13T00:00:00.000Z", updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, latestTurn: null, branch: null, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..4fa05f850e5 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -127,6 +127,10 @@ "types": "./src/state/threadSort.ts", "default": "./src/state/threadSort.ts" }, + "./state/thread-settled": { + "types": "./src/state/threadSettled.ts", + "default": "./src/state/threadSettled.ts" + }, "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 5cc3f0c1a86..0cb1650066c 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -21,7 +21,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; +import { + archiveThread, + createProject, + settleThread, + stopThreadSession, + unsettleThread, +} from "./commands.ts"; const TEST_CRYPTO_LAYER = Layer.succeed( Crypto.Crypto, @@ -134,4 +140,35 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("dispatches settle and unsettle commands without timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* settleThread({ + commandId: CommandId.make("settle-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + yield* unsettleThread({ + commandId: CommandId.make("unsettle-command"), + threadId: ThreadId.make("thread-1"), + reason: "user", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.settle", + commandId: "settle-command", + threadId: "thread-1", + }, + { + type: "thread.unsettle", + commandId: "unsettle-command", + threadId: "thread-1", + reason: "user", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..ef767854804 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -35,6 +35,8 @@ export type CreateThreadInput = CommandInput<"thread.create">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type SettleThreadInput = CommandInput<"thread.settle">; +export type UnsettleThreadInput = CommandInput<"thread.unsettle">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -153,6 +155,26 @@ export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = E }); }); +export const settleThread: (input: SettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.settleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.settle", + commandId: yield* commandId(input), + }); +}); + +export const unsettleThread: (input: UnsettleThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unsettleThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unsettle", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index c772d134a67..e08fd9e552f 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -96,6 +96,8 @@ const THREAD_SHELL = { createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, session: null, latestUserMessageAt: null, hasPendingApprovals: false, diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index a069460e63c..fdccc4c47dd 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -36,6 +36,8 @@ const stubThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, latestUserMessageAt: null, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..ea87298e98f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -12,9 +12,11 @@ import { type RevertThreadCheckpointInput, type SetThreadInteractionModeInput, type SetThreadRuntimeModeInput, + type SettleThreadInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, + type UnsettleThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, @@ -25,9 +27,11 @@ import { revertThreadCheckpoint, setThreadInteractionMode, setThreadRuntimeMode, + settleThread, startThreadTurn, stopThreadSession, unarchiveThread, + unsettleThread, updateThreadMetadata, } from "../operations/commands.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -42,9 +46,11 @@ export type { RevertThreadCheckpointInput, SetThreadInteractionModeInput, SetThreadRuntimeModeInput, + SettleThreadInput, StartThreadTurnInput, StopThreadSessionInput, UnarchiveThreadInput, + UnsettleThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -82,6 +88,18 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + settle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:settle", + execute: (input: SettleThreadInput) => settleThread(input), + scheduler, + concurrency, + }), + unsettle: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:unsettle", + execute: (input: UnsettleThreadInput) => unsettleThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 430900bdbb0..770738ad9bb 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -57,6 +57,8 @@ export function mergeEnvironmentThread( createdAt: shell.createdAt, updatedAt: shell.updatedAt, archivedAt: shell.archivedAt, + settledOverride: shell.settledOverride, + settledAt: shell.settledAt, session: shell.session, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 94eb1c65370..211f8748f4e 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -34,6 +34,8 @@ const baseThread: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -164,6 +166,62 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.settled / thread.unsettled", () => { + it("sets the settled override and timestamp", () => { + const settledAt = "2026-04-01T05:00:00.000Z"; + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe("settled"); + expect(result.thread.settledAt).toBe(settledAt); + } + }); + + it.each([ + ["user", "active"], + ["activity", null], + ] as const)("unsettles for %s with override %s", (reason, settledOverride) => { + const settledThread: OrchestrationThread = { + ...baseThread, + settledOverride: "settled", + settledAt: "2026-04-01T05:00:00.000Z", + }; + const updatedAt = "2026-04-01T06:00:00.000Z"; + const result = applyThreadDetailEvent(settledThread, { + ...baseEventFields, + sequence: 6, + occurredAt: updatedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.unsettled", + payload: { + threadId: ThreadId.make("thread-1"), + reason, + updatedAt, + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.settledOverride).toBe(settledOverride); + expect(result.thread.settledAt).toBeNull(); + } + }); + }); + describe("thread.meta-updated", () => { it("patches title and branch", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 670540fee70..7fdee37c0e6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -72,6 +72,8 @@ export function applyThreadDetailEvent( createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], @@ -100,6 +102,28 @@ export function applyThreadDetailEvent( thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; + case "thread.settled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: "settled", + settledAt: event.payload.settledAt, + updatedAt: event.payload.updatedAt, + }, + }; + + case "thread.unsettled": + return { + kind: "updated", + thread: { + ...thread, + settledOverride: event.payload.reason === "user" ? "active" : null, + settledAt: null, + updatedAt: event.payload.updatedAt, + }, + }; + // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts new file mode 100644 index 00000000000..50f3c2d3912 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -0,0 +1,345 @@ +import { + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + canSettle, + 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"; + +function makeShell(input: { + readonly settledOverride?: "settled" | "active" | null; + readonly activityAt: string | null; + readonly sessionStatus?: "starting" | "running"; + readonly pending?: "approval" | "user-input"; +}): OrchestrationThreadShell { + const threadId = ThreadId.make("thread-1"); + return { + id: threadId, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: + input.activityAt === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: input.activityAt, + startedAt: null, + completedAt: null, + assistantMessageId: null, + }, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: NOW, + archivedAt: null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledOverride === "settled" ? NOW : null, + session: + input.sessionStatus === undefined + ? null + : { + threadId, + status: input.sessionStatus, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + latestUserMessageAt: null, + hasPendingApprovals: input.pending === "approval", + hasPendingUserInput: input.pending === "user-input", + hasActionableProposedPlan: false, + }; +} + +describe("threadLastActivityAt", () => { + it("returns the latest real user or turn activity and ignores thread/session updates", () => { + const shell = makeShell({ activityAt: null, sessionStatus: "running" }); + const withActivity: OrchestrationThreadShell = { + ...shell, + latestUserMessageAt: "2026-04-04T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-03T00:00:00.000Z", + startedAt: "2026-04-05T00:00:00.000Z", + completedAt: "2026-04-06T00:00:00.000Z", + assistantMessageId: null, + }, + }; + + expect(threadLastActivityAt(withActivity)).toBe("2026-04-06T00:00:00.000Z"); + expect(threadLastActivityAt(shell)).toBeNull(); + }); +}); + +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. + expected: + pending === undefined && + !running && + (settledOverride === "settled" || + (settledOverride === null && + (changeRequestState === "merged" || 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 + ? {} + : { changeRequestState: 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 }); + expect( + effectiveSettled(shell, { + now: NOW, + autoSettleAfterDays: null, + changeRequestState: "closed", + }), + ).toBe(true); + }); + + 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, + changeRequestState: "merged", + }), + ).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", () => { + const QUEUED_AT = "2026-04-09T12:00:00.000Z"; + // Within the adoption grace window of the queued message. + const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; + + it("flags a user message no turn has picked up, within the grace window", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); + + const staleTurn = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(staleTurn, JUST_AFTER)).toBe(true); + }); + + it("expires after the grace window: an unadopted message is a failed start, not queued work", () => { + const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; + expect(hasQueuedTurnStart(noTurn, { now: "2026-04-09T12:03:00.000Z" })).toBe(false); + // Historical shells (e.g. from servers that never carried latestTurn) + // must never read as queued. + expect(hasQueuedTurnStart(noTurn, { now: NOW })).toBe(false); + }); + + it("clears once a turn adopts the message or the start fails", () => { + const adopted = { + ...makeShell({ activityAt: QUEUED_AT }), + latestUserMessageAt: QUEUED_AT, + }; + expect(hasQueuedTurnStart(adopted, JUST_AFTER)).toBe(false); + + const failed = makeShell({ activityAt: FRESH }); + const failedShell = { + ...failed, + latestUserMessageAt: QUEUED_AT, + session: { + threadId: failed.id, + status: "error" as const, + providerName: "Codex", + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: "boom", + updatedAt: NOW, + }, + }; + expect(hasQueuedTurnStart(failedShell, JUST_AFTER)).toBe(false); + }); + + it("is quiet without user messages", () => { + expect(hasQueuedTurnStart(makeShell({ activityAt: FRESH }), JUST_AFTER)).toBe(false); + }); + + it("bounds the grace window in both directions: a future-stamped message is skew, not queued work", () => { + // Message timestamps originate on other devices; a clock an hour ahead + // must not hold the queued state for the whole skew. + const skewed = { + latestUserMessageAt: "2026-04-09T13:00:00.000Z", + latestTurn: null, + session: null, + }; + expect(hasQueuedTurnStart(skewed, { now: "2026-04-09T12:00:00.000Z" })).toBe(false); + // A small negative age (within the grace window) still reads as queued. + const slightlyAhead = { + latestUserMessageAt: "2026-04-09T12:00:30.000Z", + latestTurn: null, + session: null, + }; + expect(hasQueuedTurnStart(slightlyAhead, { now: "2026-04-09T12:00:00.000Z" })).toBe(true); + }); +}); + +describe("canSettle", () => { + it("blocks every state effectiveSettled refuses to classify as settled", () => { + expect(canSettle(makeShell({ activityAt: FRESH }), { now: NOW })).toBe(true); + expect( + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "starting" }), { now: NOW }), + ).toBe(false); + expect( + canSettle(makeShell({ activityAt: FRESH, sessionStatus: "running" }), { now: NOW }), + ).toBe(false); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "approval" }), { now: NOW })).toBe( + false, + ); + expect(canSettle(makeShell({ activityAt: FRESH, pending: "user-input" }), { now: NOW })).toBe( + false, + ); + }); + + it("blocks settling a queued turn start, only within the grace window", () => { + const queued = { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: "2026-04-09T12:00:00.000Z", + }; + 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, + changeRequestState: "merged", + }), + ).toBe(false); + // Past the window the message is a failed/stale start: settleable again. + expect(canSettle(queued, { now: NOW })).toBe(true); + }); + + it("lets a server-accepted settle overrule the clock-derived queued blocker", () => { + // The settle action ran with wall-clock `now` (past the grace window); + // the list partition re-evaluates with a minute-floored `now` that is + // still INSIDE the window. settledAt >= message time proves the server + // already adjudicated this exact message, so the row must not snap back + // to active until the coarser clock catches up. + const messageAt = "2026-04-09T12:00:00.000Z"; + const flooredNow = "2026-04-09T12:01:00.000Z"; + const base = makeShell({ settledOverride: "settled", activityAt: null }); + const settledAfterMessage = { + ...base, + latestUserMessageAt: messageAt, + settledAt: "2026-04-09T12:02:10.000Z", + }; + expect(hasQueuedTurnStart(settledAfterMessage, { now: flooredNow })).toBe(true); + expect(effectiveSettled(settledAfterMessage, { now: flooredNow, autoSettleAfterDays: 3 })).toBe( + true, + ); + + // A message NEWER than settledAt is genuinely new work: still blocked + // until the server's auto-unsettle lands. + const messageAfterSettle = { + ...base, + 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); + }); + + it("agrees with effectiveSettled's blockers for explicitly settled shells", () => { + // Anything canSettle rejects must render as active even when the user + // settled it earlier. + const blocked = makeShell({ + settledOverride: "settled", + activityAt: FRESH, + pending: "user-input", + }); + expect(canSettle(blocked, { now: NOW })).toBe(false); + expect(effectiveSettled(blocked, { now: NOW, autoSettleAfterDays: 3 })).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts new file mode 100644 index 00000000000..3cb4f7e65f7 --- /dev/null +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -0,0 +1,147 @@ +import type { OrchestrationThreadShell } from "@t3tools/contracts"; + +export type ChangeRequestStateLike = "open" | "closed" | "merged"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export function threadLastActivityAt(shell: OrchestrationThreadShell): string | null { + const candidates = [ + shell.latestUserMessageAt, + shell.latestTurn?.requestedAt, + shell.latestTurn?.startedAt, + shell.latestTurn?.completedAt, + ]; + let latest: string | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + + for (const candidate of candidates) { + if (candidate === null || candidate === undefined) continue; + const timestamp = Date.parse(candidate); + if (timestamp > latestTimestamp) { + latest = candidate; + latestTimestamp = timestamp; + } + } + + return latest; +} + +/** + * A queued turn start lives for at most this long: session adoption takes + * seconds, so a user message still unadopted after the grace window is a + * failed start (or stale data — shells from older servers can carry user + * messages with no latestTurn at all), not pending work. Without this bound + * such threads would be permanently unsettleable. + */ +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +/** + * A user message no turn has picked up yet: the turn.start command was + * dispatched (message-sent + turn-start-requested) but no session has + * adopted it, so `session` is still null and the pending work is invisible + * to the session-status checks. Detectable as a user message strictly newer + * than every timestamp on the latest turn — on adoption the new turn's + * requestedAt equals the message time, clearing the condition — and only + * within the adoption grace window. + */ +export function hasQueuedTurnStart( + shell: Pick, + options: { readonly now: string }, +): boolean { + if (shell.latestUserMessageAt == null) return false; + // A failed session start clears the queued state: the failure is already + // visible (status edge / error). + if (shell.session?.status === "error") return false; + const messageAt = Date.parse(shell.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const nowMs = Date.parse(options.now); + if (Number.isNaN(nowMs)) return false; + // Bounded on both sides: message timestamps originate on whichever device + // sent the message, so a clock ahead of this one yields a negative age + // that would otherwise hold the queued state for the whole skew. Mirrors + // the decider's guard. + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = shell.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); +} + +/** + * A thread may be settled only when none of effectiveSettled's activity + * blockers hold. This is deliberately the same list: anything the partition + * refuses to CLASSIFY as settled must also be refused as a settle TARGET. + * The server enforces its own invariants; this client-side twin exists so + * the UI can disable/reject before a round trip. + */ +export function canSettle( + shell: Pick< + OrchestrationThreadShell, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + >, + options: { readonly now: string }, +): boolean { + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + // Queued work is as blocked-on-progress as a live session: settling it + // (or auto-settling it on a closed PR) would hide a just-requested turn. + if (hasQueuedTurnStart(shell, options)) return false; + return true; +} + +/** + * Settled resolution over the server-backed settled lifecycle. The explicit + * user override (thread.settle / thread.unsettle commands, projected into + * settledOverride + settledAt) wins in both directions; without one, a + * thread auto-settles on a merged/closed PR or inactivity past the window. + * 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 changeRequestState?: ChangeRequestStateLike | null; + }, +): boolean { + // Blocked work must remain visible even when a user explicitly settled it. + if (shell.hasPendingApprovals || shell.hasPendingUserInput) return false; + if (shell.session?.status === "starting" || shell.session?.status === "running") return false; + if (hasQueuedTurnStart(shell, { now: options.now })) { + // The queued-turn blocker alone is forgivable: it is clock-derived, and + // list callers pass a coarser `now` than the settle action used. When + // the server already adjudicated the queued message by accepting a + // settle after it (settledAt stamps server accept time), trust that + // ruling — otherwise a settle near the grace boundary leaves the row + // pinned active until the caller's clock ticks over. A message NEWER + // than settledAt is genuinely new work and keeps the block until the + // server's auto-unsettle lands. + const serverAdjudicated = + shell.settledOverride === "settled" && + shell.settledAt !== null && + shell.latestUserMessageAt !== null && + Date.parse(shell.settledAt) >= Date.parse(shell.latestUserMessageAt); + 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 (options.changeRequestState === "merged" || options.changeRequestState === "closed") { + return true; + } + 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 + ); +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index f3c4c4e6338..8d0e4d0a35c 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -69,6 +69,8 @@ const BASE_THREAD: OrchestrationThread = { createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", archivedAt: null, + settledOverride: null, + settledAt: null, deletedAt: null, messages: [], proposedPlans: [], diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index bc8db25a95a..6fc0c914d8a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -23,6 +23,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T export const ExecutionEnvironmentCapabilities = Schema.Struct({ repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), connectionProbe: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.settle / thread.unsettle commands. Absent on + pre-settlement servers, so clients treat missing as unsupported and + never send the commands under version skew. */ + threadSettlement: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29a732ca69b..1ebc23a483b 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -15,6 +15,8 @@ import { ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, + OrchestrationThread, + OrchestrationThreadShell, ProjectCreateCommand, ThreadMetaUpdatedPayload, ThreadTurnStartCommand, @@ -37,6 +39,8 @@ const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLatestTurn); const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); +const decodeOrchestrationThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeOrchestrationThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); const encodeThreadCreatedPayload = Schema.encodeEffect(ThreadCreatedPayload); function getOptionValue( @@ -344,6 +348,75 @@ it.effect("decodes thread archive and unarchive commands", () => }), ); +it.effect("decodes thread settle and unsettle commands", () => + Effect.gen(function* () { + const settle = yield* decodeOrchestrationCommand({ + type: "thread.settle", + commandId: "cmd-settle-1", + threadId: "thread-1", + }); + const unsettle = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-1", + threadId: "thread-1", + reason: "user", + }); + + assert.strictEqual(settle.type, "thread.settle"); + assert.strictEqual(unsettle.type, "thread.unsettle"); + + // "activity" is server-owned: it exists on the event, never on the + // command, so a client cannot forge the neutral reset. + const forged = yield* decodeOrchestrationCommand({ + type: "thread.unsettle", + commandId: "cmd-unsettle-2", + threadId: "thread-1", + reason: "activity", + }).pipe(Effect.flip); + assert.ok(forged); + }), +); + +it.effect("defaults settled fields when decoding historical thread data", () => + Effect.gen(function* () { + const common = { + id: "thread-1", + projectId: "project-1", + title: "Historical thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + session: null, + }; + const thread = yield* decodeOrchestrationThread({ + ...common, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + }); + const shell = yield* decodeOrchestrationThreadShell({ + ...common, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }); + + assert.strictEqual(thread.settledOverride, null); + assert.strictEqual(thread.settledAt, null); + assert.strictEqual(shell.settledOverride, null); + assert.strictEqual(shell.settledAt, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ @@ -388,6 +461,48 @@ it.effect("decodes thread archived and unarchived events", () => }), ); +it.effect("decodes thread settled and unsettled events", () => + Effect.gen(function* () { + const settled = yield* decodeOrchestrationEvent({ + sequence: 1, + eventId: "event-settle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.settled", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-settle-1", + causationEventId: null, + correlationId: "cmd-settle-1", + metadata: {}, + payload: { + threadId: "thread-1", + settledAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + const unsettled = yield* decodeOrchestrationEvent({ + sequence: 2, + eventId: "event-unsettle-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.unsettled", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-unsettle-1", + causationEventId: null, + correlationId: "cmd-unsettle-1", + metadata: {}, + payload: { + threadId: "thread-1", + reason: "user", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + }); + + assert.strictEqual(settled.type, "thread.settled"); + assert.strictEqual(unsettled.type, "thread.unsettled"); + }), +); + it.effect("accepts provider-scoped model options in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c0c9c62d080..b01df310062 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -356,6 +356,10 @@ export const OrchestrationThread = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), deletedAt: Schema.NullOr(IsoDateTime), messages: Schema.Array(OrchestrationMessage), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( @@ -402,6 +406,10 @@ export const OrchestrationThreadShell = Schema.Struct({ createdAt: IsoDateTime, updatedAt: IsoDateTime, archivedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + settledOverride: Schema.NullOr(Schema.Literals(["settled", "active"])).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), hasPendingApprovals: Schema.Boolean, @@ -558,6 +566,22 @@ const ThreadUnarchiveCommand = Schema.Struct({ threadId: ThreadId, }); +const ThreadSettleCommand = Schema.Struct({ + type: Schema.Literal("thread.settle"), + commandId: CommandId, + threadId: ThreadId, +}); + +const ThreadUnsettleCommand = Schema.Struct({ + type: Schema.Literal("thread.unsettle"), + commandId: CommandId, + threadId: ThreadId, + // Commands only carry "user": activity un-settles are decided server-side + // (the decider emits thread.unsettled(reason: "activity") events directly, + // never through this command), so a client cannot forge the neutral reset. + reason: Schema.Literal("user"), +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -700,6 +724,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -721,6 +747,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, + ThreadSettleCommand, + ThreadUnsettleCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -823,6 +851,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.deleted", "thread.archived", "thread.unarchived", + "thread.settled", + "thread.unsettled", "thread.meta-updated", "thread.runtime-mode-set", "thread.interaction-mode-set", @@ -902,6 +932,18 @@ export const ThreadUnarchivedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadSettledPayload = Schema.Struct({ + threadId: ThreadId, + settledAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + +export const ThreadUnsettledPayload = Schema.Struct({ + threadId: ThreadId, + reason: Schema.Literals(["user", "activity"]), + updatedAt: IsoDateTime, +}); + export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), @@ -1069,6 +1111,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.unarchived"), payload: ThreadUnarchivedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.settled"), + payload: ThreadSettledPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.unsettled"), + payload: ThreadUnsettledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.meta-updated"), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f618729c43..3f2bfc5e954 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -4,12 +4,14 @@ import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, + ClientSettingsPatch, DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); +const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -31,6 +33,25 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings sidebar v2", () => { + it("defaults the beta off with a three-day auto-settle threshold", () => { + const settings = decodeClientSettings({}); + expect(settings.sidebarV2Enabled).toBe(false); + expect(settings.sidebarAutoSettleAfterDays).toBe(3); + }); + + it("allows auto-settle by inactivity to be disabled", () => { + expect( + decodeClientSettings({ sidebarAutoSettleAfterDays: null }).sidebarAutoSettleAfterDays, + ).toBeNull(); + }); + + 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(); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fe593f49199..f683344324b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -38,6 +38,16 @@ 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 ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -72,6 +82,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -88,6 +101,7 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + sidebarV2Enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -567,6 +581,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -574,6 +589,7 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + sidebarV2Enabled: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), wordWrap: Schema.optionalKey(Schema.Boolean), });