diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f65c15696d2c..611d4cf44f75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,9 +52,6 @@ jobs: - name: Check run: vp check - - name: Check className overrides on components/ui do not grow - run: vp run lint:restyle-ceiling - - name: Typecheck run: vpr typecheck diff --git a/AGENTS.md b/AGENTS.md index 2f3265d7cafd..00425996ccc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,7 +158,7 @@ Full glossary with file links: `docs/internals/glossary.md` ## Taste - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. -- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits, add a variant to the component, not classes at the call site. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` reports violations and CI caps their count. +- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. - Inferred types over annotations. `any` is the enemy. - Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts index cc7aac6cfd30..0420e755d92b 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts @@ -7,10 +7,6 @@ vi.mock("@napi-rs/keyring", () => { throw new Error("Cannot find native binding"); }); -it("loads browser import code without a keyring native binding", async () => { - await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined(); -}); - it.effect("reports an unavailable keychain when the macOS binding cannot load", () => Effect.gen(function* () { const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index aef812ce354b..52eae6db9777 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.3.0", + version: "1.3.1", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 91845bb9d0ac..524eac860716 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,7 +10,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Platform, Pressable, @@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; +import { + RenderErrorBoundary, + RenderFailureView, + type RenderFailureProps, +} from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; @@ -777,10 +782,13 @@ const RootStackConfig = createNativeStackNavigator({ // The whole new-task flow (choose project → draft → add project) shares // draft state via NewTaskFlowProvider. The expo-router era mounted it in // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. - layout: ({ children }) => ( - - {children} - + // A screen's layout replaces the navigator's screenLayout. + layout: ({ children, route }) => ( + + + {children} + + ), options: { gestureEnabled: true, @@ -794,6 +802,33 @@ const RootStackConfig = createNativeStackNavigator({ }, }); +function GuardedScreenLayout(props: { + readonly children: ReactNode; + readonly route: { readonly name: string; readonly params?: object | undefined }; +}) { + return ( + ( + + )} + > + {props.children} + + ); +} + +function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) { + const navigation = useNavigation(); + const exit = navigation.canGoBack() + ? { label: "Go back", onPress: () => navigation.goBack() } + : props.routeName === "Home" + ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") } + : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) }; + + return ; +} + export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { const { width, height } = useWindowDimensions(); const usesWorkspaceFlowScreens = @@ -801,6 +836,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig return ( { if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { return {}; diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx new file mode 100644 index 000000000000..9b71916d01e7 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,127 @@ +import { Component, type ReactNode } from "react"; +import { ScrollView, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { MaterialButton } from "./MaterialButton"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +interface RenderErrorBoundaryProps { + readonly children: ReactNode; + readonly resetKeys?: ReadonlyArray; + readonly renderFallback?: (props: RenderFailureProps) => ReactNode; +} + +interface RenderErrorBoundaryState { + readonly failed: boolean; + readonly error: unknown; + readonly componentStack?: string; + readonly resetKeys?: ReadonlyArray; +} + +export interface RenderFailureProps { + readonly details: string; + readonly retry: () => void; +} + +function errorDetails(error: unknown, componentStack?: string): string { + let description: string; + try { + description = error instanceof Error ? (error.stack ?? error.message) : String(error); + } catch { + description = "Unknown render error"; + } + return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description; +} + +/** A failed subtree remounts on retry or when its identifying inputs change. */ +export class RenderErrorBoundary extends Component< + RenderErrorBoundaryProps, + RenderErrorBoundaryState +> { + override state: RenderErrorBoundaryState = { + failed: false, + error: null, + resetKeys: this.props.resetKeys, + }; + + static getDerivedStateFromProps( + { resetKeys }: RenderErrorBoundaryProps, + state: RenderErrorBoundaryState, + ): Partial | null { + if ( + resetKeys?.length !== state.resetKeys?.length || + resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index])) + ) { + return { failed: false, error: null, componentStack: undefined, resetKeys }; + } + return null; + } + + static getDerivedStateFromError(error: unknown): Partial { + return { failed: true, error }; + } + + override componentDidCatch(_error: unknown, info: { componentStack?: string }) { + this.setState({ componentStack: info.componentStack }); + } + + private readonly retry = () => { + this.setState({ failed: false, error: null, componentStack: undefined }); + }; + + override render() { + if (!this.state.failed) return this.props.children; + const fallback = + this.props.renderFallback ?? + ((props: RenderFailureProps) => ); + return fallback({ + details: errorDetails(this.state.error, this.state.componentStack), + retry: this.retry, + }); + } +} + +export function RenderFailureView( + props: RenderFailureProps & { + readonly title?: string; + readonly bottomInset?: number; + readonly exit?: { readonly label: string; readonly onPress: () => void }; + }, +) { + const title = props.title ?? "This screen couldn't be displayed"; + return ( + + + {title} + + + Try again. If it keeps happening, copy the details for a bug report. + + + {props.details.split("\n", 1)[0]?.slice(0, 300)} + + + + copyTextWithHaptic(props.details, { target: "error details" })} + fullWidth + /> + {props.exit ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 5c58dfc1eaf9..bfcaf4013ab1 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -264,7 +264,7 @@ export function ConnectionsNewRouteScreen({ {pairingConnectionError ? : null} - + ); - return Platform.OS === "android" ? ( - - {content} - - ) : ( - content - ); + return Platform.OS === "android" ? {content} : content; } export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index c3c906e3d515..dfe0c05743c2 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -147,7 +147,7 @@ export function ThreadFileNavigatorPane(props: { } > {Platform.OS === "android" ? ( diff --git a/apps/mobile/src/features/home/HomeHeader.android.tsx b/apps/mobile/src/features/home/HomeHeader.android.tsx index 78e87431b471..0c15bcc09ce7 100644 --- a/apps/mobile/src/features/home/HomeHeader.android.tsx +++ b/apps/mobile/src/features/home/HomeHeader.android.tsx @@ -1,13 +1,7 @@ import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { MaterialThreadListToolbar } from "./MaterialThreadListToolbar"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -17,13 +11,11 @@ function checkedMenuState(checked: boolean) { } export function HomeHeader(props: HomeHeaderProps) { - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. - const threadListV2Enabled = useThreadListV2Enabled(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + // The list uses a fixed creation order and ignores sort/group options, so + // the filter menu only carries the filters and the "customized" icon state + // keys off those alone. + const hasCustomListOptions = + props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const menuActions = useMemo( () => [ { @@ -62,38 +54,8 @@ export function HomeHeader(props: HomeHeaderProps) { ], }, ] satisfies MenuAction[])), - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectSortOrder === option.value), - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: checkedMenuState(props.threadSortOrder === option.value), - })), - }, - ] satisfies MenuAction[])), - ], - [ - props.environments, - props.projectSortOrder, - props.projects, - props.selectedEnvironmentId, - props.selectedProjectKey, - props.threadSortOrder, - threadListV2Enabled, ], + [props.environments, props.projects, props.selectedEnvironmentId, props.selectedProjectKey], ); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { @@ -126,20 +88,6 @@ export function HomeHeader(props: HomeHeaderProps) { } return; } - - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => id === `project-sort:${option.value}`, - ); - if (projectSort) { - props.onProjectSortOrderChange(projectSort.value); - return; - } - - const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); - if (threadSort) { - props.onThreadSortOrderChange(threadSort.value); - return; - } }, [props], ); diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 2f044862f703..e1cc7448eb59 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -2,7 +2,6 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { useCallback, useRef } from "react"; import type { SearchBarCommands } from "react-native-screens"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { @@ -10,11 +9,6 @@ import { NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, } from "../layout/native-mail-search-toolbar"; import { buildHomeListFilterMenu } from "./home-list-filter-menu"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; import type { HomeHeaderProps } from "./HomeHeader.types"; export type { HomeHeaderEnvironment } from "./HomeHeader.types"; @@ -22,22 +16,17 @@ export type { HomeHeaderEnvironment } from "./HomeHeader.types"; export function HomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); const iconColor = useUniwindTheme()["--color-icon"]; - // Thread List v2 lays the list out in fixed creation order, so the - // sort/group filter controls would be silently ignored — hide them and - // key the "customized" icon state off the environment filter alone. - const threadListV2Enabled = useThreadListV2Enabled(); - const hasCustomListOptions = threadListV2Enabled - ? props.selectedEnvironmentId !== null || props.selectedProjectKey !== null - : hasCustomHomeListOptions(props); + // The list uses a fixed creation order and ignores sort/group options, so + // the filter menu only carries the filters and the "customized" icon state + // keys off those alone. + const hasCustomListOptions = + props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; const focusSearch = useCallback(() => { searchBarRef.current?.focus(); return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu({ - ...props, - listOrganization: !threadListV2Enabled, - }); + const filterMenu = buildHomeListFilterMenu(props); return ( <> @@ -100,7 +89,7 @@ export function HomeHeader(props: HomeHeaderProps) { {NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : ( ) : null} - - {threadListV2Enabled ? null : ( - - Sort projects - {PROJECT_SORT_OPTIONS.map((option) => ( - props.onProjectSortOrderChange(option.value)} - > - {option.label} - - ))} - - )} - - {threadListV2Enabled ? null : ( - - Sort threads - {THREAD_SORT_OPTIONS.map((option) => ( - props.onThreadSortOrderChange(option.value)} - > - {option.label} - - ))} - - )} void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 1ef8ea7b286b..c480d0be48f1 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -76,12 +76,8 @@ export function HomeRouteScreen() { () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { - options: listOptions, - setSelectedEnvironmentId, - setProjectSortOrder, - setThreadSortOrder, - } = useHomeListOptions(availableEnvironmentIds); + const { options: listOptions, setSelectedEnvironmentId } = + useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectFilterOptions = useMemo( @@ -175,8 +171,6 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} - projectSortOrder={listOptions.projectSortOrder} - threadSortOrder={listOptions.threadSortOrder} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => @@ -191,10 +185,8 @@ export function HomeRouteScreen() { params: { screen: "Settings" }, }) } - onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} /> navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} projects={projects} @@ -262,7 +252,6 @@ export function HomeRouteScreen() { selectedEnvironmentId={selectedEnvironmentId} selectedProjectKey={selectedProjectKey} threads={threads} - threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a01f5fbe606e..4fb442b94273 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,10 +1,6 @@ import type { ThreadMoveDestination } from "../threads/threadOrder"; -import { createThreadMovePlanner } from "../threads/threadOrder"; -import { - LegendList, - type LegendListRef, - type LegendListRenderItemProps, -} from "@legendapp/list/react-native"; +import { computeThreadMoveAvailability } from "../threads/threadOrder"; +import { LegendList } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -17,13 +13,11 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, type SidebarProjectGroupingMode, - type SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; +import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Platform, View } from "react-native"; +import { ActivityIndicator, Platform, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -34,20 +28,12 @@ import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspace import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; -import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; -import { - PendingTaskListRow, - ThreadListGroupHeader, - ThreadListRow, - ThreadListShowMoreRow, -} from "../threads/thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, @@ -55,30 +41,20 @@ import { ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, } from "../threads/thread-list-v2-items"; -import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; +import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + threadListV2ListItemsAreEqual, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "../threads/threadListV2"; import { useThreadListV2ShelfPreferences } from "../threads/use-thread-list-v2-shelf-preferences"; import type { HomeListFilterMenuEnvironment } from "./home-list-filter-menu"; -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - EMPTY_HOME_LIST_LAYOUT, - homeListItemsAreEqual, - nextGroupDisplayState, - type HomeGroupDisplayAction, - type HomeGroupDisplayState, - type HomeListItem, -} from "./homeListItems"; import { buildHomeProjectScopes, - buildHomeThreadGroups, sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; @@ -100,13 +76,10 @@ interface HomeScreenProps { readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; readonly onAddConnection: () => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; @@ -137,7 +110,21 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ -const ESTIMATED_THREAD_ROW_HEIGHT = 72; +// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards +// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line +// cards ~94dp. The estimate seeds the recycler's initial container count, +// `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the +// initial draw distance capped at 50, so an estimate at or below the average +// row height starts the pool at or above the item count for the short lists +// that LegendList otherwise keeps pooling to exactly its item count — that is +// what stopped the dev-mode "no unused container available" warning on the +// seeded short-list device passes. It is a mitigation, not an elimination: +// after first layout the full drawDistance applies, and a sudden expansion +// past the pooled headroom (~25+ items appearing at once) still creates a +// container on demand with the dev-only warning one pass ahead of the +// measured-height pool expansion. The old tallest-card estimate (~92) fired +// that warning on every ordinary shelf expand, so the average wins. +const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android @@ -220,15 +207,8 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { - const [groupDisplayStates, setGroupDisplayStates] = useState< - ReadonlyMap - >(() => new Map()); - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const threadListV2Enabled = useThreadListV2Enabled(); const queuedThreadKeys = useQueuedThreadKeys(); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); - const listRef = useRef(null); const insets = useSafeAreaInsets(); const iosBottomToolbarClearance = Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED @@ -263,42 +243,6 @@ export function HomeScreen(props: HomeScreenProps) { () => new Set(threadSearch.matches.map(threadSearchMatchKey)), [threadSearch.matches], ); - const effectiveGroupDisplayStates = useMemo(() => { - const next = new Map(groupDisplayStates); - if (!AsyncResult.isSuccess(preferencesResult)) { - return next; - } - for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { - const existing = next.get(key); - next.set(key, { - ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), - collapsed: true, - }); - } - return next; - }, [groupDisplayStates, preferencesResult]); - const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); - effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; - - const updateGroupDisplay = useCallback( - (key: string, action: HomeGroupDisplayAction) => { - const next = new Map(effectiveGroupDisplayStatesRef.current); - next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); - effectiveGroupDisplayStatesRef.current = next; - setGroupDisplayStates(next); - if (action === "toggle-collapsed") { - const collapsedProjectGroups: string[] = []; - for (const [groupKey, state] of next) { - if (state.collapsed) { - collapsedProjectGroups.push(groupKey); - } - } - savePreferences({ collapsedProjectGroups }); - } - }, - [savePreferences], - ); - const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { openSwipeableRef.current?.close(); @@ -330,112 +274,7 @@ export function HomeScreen(props: HomeScreenProps) { }), [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], ); - const selectedProjectScope = useMemo( - () => - props.selectedProjectKey === null - ? null - : (projectScopes.find( - (scope) => - scope.key === props.selectedProjectKey || - scope.projectRefs.some( - (projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId) === - props.selectedProjectKey, - ), - ) ?? null), - [projectScopes, props.selectedProjectKey], - ); - const selectedProjectRefKeys = useMemo( - () => - selectedProjectScope === null - ? null - : new Set( - selectedProjectScope.projectRefs.map((projectRef) => - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - ), - ), - [selectedProjectScope], - ); - const scopedProjects = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.projects - : props.projects.filter((project) => - selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), - ), - [threadListV2Enabled, props.projects, selectedProjectRefKeys], - ); - const scopedThreads = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.threads - : props.threads.filter((thread) => - selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [threadListV2Enabled, props.threads, selectedProjectRefKeys], - ); - const scopedPendingTasks = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefKeys === null - ? props.pendingTasks - : props.pendingTasks.filter((pendingTask) => - selectedProjectRefKeys.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - ), - ), - [threadListV2Enabled, props.pendingTasks, selectedProjectRefKeys], - ); - - const projectGroups = useMemo( - () => - threadListV2Enabled - ? [] - : buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - queuedThreadKeys, - environmentId: props.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: props.projectSortOrder, - threadSortOrder: props.threadSortOrder, - projectGroupingMode: props.projectGroupingMode, - }), - [ - threadListV2Enabled, - queuedThreadKeys, - props.projectGroupingMode, - props.projectSortOrder, - props.searchQuery, - props.selectedEnvironmentId, - props.threadSortOrder, - matchedThreadKeys, - scopedPendingTasks, - scopedProjects, - scopedThreads, - ], - ); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const listLayout = useMemo( - () => - threadListV2Enabled - ? EMPTY_HOME_LIST_LAYOUT - : buildHomeListLayout({ - groups: projectGroups, - displayStates: effectiveGroupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], - ); - const projectByKey = useMemo(() => { const map = new Map(); for (const project of props.projects) { @@ -581,12 +420,11 @@ export function HomeScreen(props: HomeScreenProps) { const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useFocusEffect( useCallback(() => { - if (!threadListV2Enabled) return; // Refresh immediately on enable or focus because the previous value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); - }, [threadListV2Enabled]), + }, []), ); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). @@ -655,12 +493,19 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); + // Reference-stable provider glyphs: a fresh object per render would break + // the memoized rows' props comparison on every parent render. + const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - const threadMovePlanners = useMemo(() => { - const sectionPlanner = (section: "pinned" | "active") => - createThreadMovePlanner({ + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const threadMoveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ allThreads: props.threads, section, + pendingOrder, reorderableEnvironmentIds: new Set( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" @@ -680,7 +525,7 @@ export function HomeScreen(props: HomeScreenProps) { queuedThreadKeys, }), }); - return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); }, [ serverConfigs, props.threads, @@ -692,16 +537,6 @@ export function HomeScreen(props: HomeScreenProps) { snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ @@ -734,7 +569,6 @@ export function HomeScreen(props: HomeScreenProps) { props.selectedEnvironmentId, props.threads, matchedThreadKeys, - threadListV2Enabled, v2ScopedProjectGroup, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -783,21 +617,28 @@ export function HomeScreen(props: HomeScreenProps) { settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, }), - [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], + [ + nowMinute, + queuedThreadKeys, + threadMoveAvailability, + settledShelfExpanded, + shelfPreferencesLoaded, + snoozedShelfExpanded, + snoozeEnvironmentIds, + threadListV2Layout, + v2PendingTasks, + ], ); - useThreadJumpShortcuts( - threadListV2Enabled ? threadListV2Items : listLayout.items, - props.onSelectThread, - ); + useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); const renderV2Item = useCallback( - ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { - const nextItem = threadListV2Items[index + 1]; - const showTrailingDivider = - nextItem?.type === "v2-thread" || - (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); + ({ item }: { readonly item: ThreadListV2ListItem }) => { if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( item.pendingTask.environmentId, @@ -816,7 +657,7 @@ export function HomeScreen(props: HomeScreenProps) { } environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} showPendingDivider={item.showPendingDivider} - showTrailingDivider={showTrailingDivider} + showTrailingDivider={item.showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} /> @@ -826,7 +667,7 @@ export function HomeScreen(props: HomeScreenProps) { return ( @@ -836,33 +677,32 @@ export function HomeScreen(props: HomeScreenProps) { return ( ); } const thread = item.item.thread; - const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; - const movedId = `${thread.environmentId}:${thread.id}`; return ( 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -891,8 +731,8 @@ export function HomeScreen(props: HomeScreenProps) { ? pinReorderEnvironmentIds.has(thread.environmentId) : activeReorderEnvironmentIds.has(thread.environmentId) } - canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} - canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} @@ -907,9 +747,6 @@ export function HomeScreen(props: HomeScreenProps) { [ handleDeleteThread, activeReorderEnvironmentIds, - threadMovePlanners, - pendingOrder, - queuedThreadKeys, handleMoveThread, handlePinThread, handleRegenerateThreadTitle, @@ -931,25 +768,23 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.onNewThreadOnBranch, props.savedConnectionsById, - serverConfigs, - shelfPreferencesLoaded, + resolveProviderInstance, settlementEnvironmentIds, snoozeEnvironmentIds, - threadListV2Items, threadSearchMatchByKey, titleRegenerationEnvironmentIds, toggleSettledShelf, toggleSnoozedShelf, v2ProjectTitleByProjectKey, props.searchQuery, - nowMinute, ], ); const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); - // FlatList treats a changed extraData identity as "re-render every visible - // row", so an inline object literal would invalidate all rows on every - // HomeScreen render. + // FlatList/LegendList treat a changed extraData identity as "re-render every + // visible row", so an inline object literal would invalidate all rows on + // every HomeScreen render — and the minute clock must stay out of it for + // the same reason: the clock text is precomputed per item instead. const v2ExtraData = useMemo( () => ({ projectByKey, @@ -957,7 +792,6 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, - snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -965,132 +799,18 @@ export function HomeScreen(props: HomeScreenProps) { props.searchQuery, props.savedConnectionsById, serverConfigs, - nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, ], ); - const extraData = useMemo( - () => ({ - savedConnectionsById: props.savedConnectionsById, - searchQuery: props.searchQuery, - threadSearchMatchByKey, - }), - [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], - ); - - const renderItem = useCallback( - ({ item }: LegendListRenderItemProps) => { - switch (item.type) { - case "header": - return ( - - ); - case "pending-task": - return ( - - ); - case "thread": { - const thread = item.thread; - return ( - - ); - } - case "show-more": - return ( - - ); - } - }, - [ - handleSwipeableClose, - handleSwipeableWillOpen, - handleRegenerateThreadTitle, - handleRenameThread, - machineByEnvironmentId, - queuedThreadKeys, - props.onArchiveThread, - props.onDeletePendingTask, - props.onDeleteThread, - props.onNewThreadInProject, - props.onSelectPendingTask, - props.onSelectThread, - props.onNewThreadOnBranch, - props.searchQuery, - props.savedConnectionsById, - threadSearchMatchByKey, - titleRegenerationEnvironmentIds, - updateGroupDisplay, - ], - ); - - 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. + // so the archived-at check already covers the settled shelf. const hasAnyThreads = props.threads.some((thread) => thread.archivedAt === null) || props.pendingTasks.length > 0; - const hasResults = threadListV2Enabled ? threadListV2Items.length > 0 : projectGroups.length > 0; const selectedEnvironmentLabel = props.selectedEnvironmentId === null ? null @@ -1106,7 +826,7 @@ export function HomeScreen(props: HomeScreenProps) { if (!hasAnyThreads) { return ( - + - ) : selectedProjectScope !== null ? ( + ) : v2ScopedProjectGroup !== null ? ( @@ -1178,92 +900,23 @@ export function HomeScreen(props: HomeScreenProps) { detail="Create a task to start a new coding session." variant={Platform.OS === "android" ? "plain" : undefined} /> - ) - ) : null; - // Use the v2 project scope for its empty state. Snoozed threads need no - // special empty state: their shelf header is a list row even while collapsed. - const v2ListEmpty = - hasSearchQuery && threadSearch.isPending ? null : hasSearchQuery ? ( - - ) : v2ScopedProjectGroup !== null ? ( - - ) : ( - listEmpty ); - if ( - Platform.OS === "android" && - (threadListV2Enabled ? threadListV2Items.length === 0 : listLayout.items.length === 0) - ) { + if (Platform.OS === "android" && threadListV2Items.length === 0) { return ( - {threadListV2Enabled ? v2ListEmpty : listEmpty} - - - ); - } - - if (threadListV2Enabled) { - return ( - - - - 0 ? ( - - ) : 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 + iosBottomToolbarClearance - : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), - }} - /> - + {v2ListEmpty} ); } return ( - + - {/* Sticky headers are deliberately not wired up: LegendList's JS sticky - implementation mispositions pinned headers at mount under iOS - automatic content insets (headers render one nav-inset too low until - the first scroll event) and blanks non-pinned headers after - collapse/expand data changes. The flattened layout still exposes - `stickyHeaderIndices` if this gets revisited. */} + {/* Shared with the iPad sidebar: cells are reused across data + rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated + shell update) from re-rendering untouched rows. */} item.type} + itemsAreEqual={threadListV2ListItemsAreEqual} + estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} drawDistance={500} - estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} - extraData={extraData} - ListHeaderComponent={listHeader} - ListEmptyComponent={listEmpty} + recycleItems + extraData={v2ExtraData} + ListHeaderComponent={v2ListHeader} + ListFooterComponent={ + settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0 ? ( + + ) : null + } + ListEmptyComponent={v2ListEmpty} style={{ flex: 1 }} - automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED} - contentInsetAdjustmentBehavior={NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"} + automaticallyAdjustsScrollIndicatorInsets={Platform.OS === "ios"} + contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} showsVerticalScrollIndicator={false} keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" {...scrollGateHandlers} - recycleItems scrollEventThrottle={16} contentContainerStyle={{ - // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a - // standard 44pt bottom toolbar that overlays the list and is not - // reflected in insets while contentInsetAdjustmentBehavior is - // "never". paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance + ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance : Math.max(insets.bottom, 16) + (Platform.OS === "android" ? 148 : 88), }} - scrollIndicatorInsets={ - Platform.OS === "ios" - ? { - bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, - top: 0, - } - : undefined - } /> diff --git a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx index 72671bc046d3..2d852f8f7be2 100644 --- a/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx +++ b/apps/mobile/src/features/home/MaterialThreadListToolbar.tsx @@ -130,7 +130,7 @@ export function MaterialThreadListToolbar(props: { {(open) => ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 99e3cb36c072..0febe8e31031 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -13,12 +13,8 @@ describe("buildHomeListFilterMenu", () => { ], selectedEnvironmentId: null, selectedProjectKey: "environment-1:project-1", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", onEnvironmentChange: vi.fn(), onProjectChange, - onProjectSortOrderChange: vi.fn(), - onThreadSortOrderChange: vi.fn(), }); const projectMenu = menu.items.find( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index edd0176f8627..ac53e427d21f 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,7 +1,4 @@ -import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; - -import type { HomeProjectSortOrder } from "./homeThreadList"; -import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; +import type { EnvironmentId } from "@t3tools/contracts"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -37,16 +34,8 @@ export function buildHomeListFilterMenu(props: { readonly projects: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; - readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; - readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - /** False hides the sort/group submenus. Thread List v2 uses a fixed - creation-order layout, so offering those controls while it silently - ignores them would be a lie; the environment filter still applies. */ - readonly listOrganization?: boolean; }): HomeListFilterMenu { const items: Array = []; @@ -95,31 +84,6 @@ export function buildHomeListFilterMenu(props: { }); } - if (props.listOrganization !== false) { - items.push( - { - type: "submenu", - title: "Sort projects", - items: PROJECT_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.projectSortOrder === option.value ? "on" : "off", - onPress: () => props.onProjectSortOrderChange(option.value), - })), - }, - { - type: "submenu", - title: "Sort threads", - items: THREAD_SORT_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - state: props.threadSortOrder === option.value ? "on" : "off", - onPress: () => props.onThreadSortOrderChange(option.value), - })), - }, - ); - } - return { title: "Thread list options", items, diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts deleted file mode 100644 index ac3893956ca7..000000000000 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-options"; - -const defaults: HomeListOptions = { - selectedEnvironmentId: null, - projectSortOrder: - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -}; - -describe("home list options", () => { - it("recognizes default options", () => { - expect(hasCustomHomeListOptions(defaults)).toBe(false); - }); - - it("marks environment filters as customized", () => { - expect( - hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), - ).toBe(true); - expect( - hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), - ).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 14f842d73e2c..d280c05ee0b1 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,12 +1,5 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; -import { - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - DEFAULT_SIDEBAR_THREAD_SORT_ORDER, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts"; import { createContext, createElement, @@ -24,29 +17,12 @@ import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; } export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } -export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ - readonly value: HomeProjectSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - -export const THREAD_SORT_OPTIONS: ReadonlyArray<{ - readonly value: SidebarThreadSortOrder; - readonly label: string; -}> = [ - { value: "updated_at", label: "Last user message" }, - { value: "created_at", label: "Created at" }, -]; - function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, @@ -54,7 +30,6 @@ function defaultHomeListOptions(): HomeListOptions { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, - threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, }; } @@ -81,23 +56,6 @@ export function HomeListOptionsProvider({ return createElement(HomeListOptionsContext, { value }, children); } -export function hasCustomHomeListOptions( - options: HomeListOptions & { - readonly selectedProjectKey?: string | null; - }, -): boolean { - const defaultProjectSortOrder = - DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" - ? "updated_at" - : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; - return ( - options.selectedEnvironmentId !== null || - (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || - options.projectSortOrder !== defaultProjectSortOrder || - options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER - ); -} - export function useHomeListOptions(availableEnvironmentIds: ReadonlySet) { const shared = useContext(HomeListOptionsContext); const [localOptions, setLocalOptions] = useState(defaultHomeListOptions); @@ -123,13 +81,9 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, projectSortOrder: value })); }, []); - const setThreadSortOrder = useCallback((value: SidebarThreadSortOrder) => { - setOptions((current) => ({ ...current, threadSortOrder: value })); - }, []); return { options: resolvedOptions, setSelectedEnvironmentId, setProjectSortOrder, - setThreadSortOrder, } as const; } diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts deleted file mode 100644 index d1e3315fd59a..000000000000 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - HOME_INITIAL_VISIBLE_THREADS, - HOME_SHOW_MORE_STEP, - nextGroupDisplayState, - type HomeGroupDisplayState, - type HomeListItem, -} from "./homeListItems"; -import type { HomeThreadGroup } from "./homeThreadList"; -import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; - -const environmentId = EnvironmentId.make("environment-1"); - -function makeProject(id: string, title: string): EnvironmentProject { - return { - environmentId, - id: ProjectId.make(id), - title, - workspaceRoot: `/workspaces/${id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - }; -} - -function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { - return { - environmentId, - id: ThreadId.make(id), - projectId, - title: `Thread ${id}`, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - pullRequests: [], - latestTurn: null, - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-01T00:00:00.000Z", - archivedAt: null, - settledOverride: null, - settledAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }; -} - -function makeGroup(key: string, threadCount: number): HomeThreadGroup { - const project = makeProject(key, key); - const threads = Array.from({ length: threadCount }, (_, index) => - makeThread(`${key}-thread-${index}`, project.id), - ); - return { - key, - title: key, - representative: project, - projects: [project], - pendingTasks: [], - threads, - // All threads inside the recency window, so the baseline stays at the - // initial page size and the pagination expectations below hold. - recentThreads: threads, - newThreadTarget: project, - }; -} - -function itemTypes(items: ReadonlyArray): string[] { - return items.map((item) => item.type); -} - -function displayStates( - entries: Record, -): ReadonlyMap { - return new Map(Object.entries(entries)); -} - -describe("threadJumpTarget", () => { - it("numbers only displayed threads across groups, skipping collapsed groups and pagination rows", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("collapsed", 3), makeGroup("alpha", 8), makeGroup("beta", 3)], - displayStates: displayStates({ collapsed: { collapsed: true, visibleCount: 6 } }), - }); - expect(threadJumpTarget(layout.items, "thread.jump.1")?.id).toBe("alpha-thread-0"); - expect(threadJumpTarget(layout.items, "thread.jump.7")?.id).toBe("beta-thread-0"); - expect(threadJumpTarget(layout.items, "thread.jump.9")?.id).toBe("beta-thread-2"); - }); - - it("ignores missing positions and unrelated commands", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 1)], - displayStates: displayStates({}), - }); - expect(threadJumpTarget(layout.items, "thread.jump.2")).toBeNull(); - expect(threadJumpTarget([], "thread.jump.1")).toBeNull(); - expect(threadJumpTarget(layout.items, "commandPalette")).toBeNull(); - }); -}); - -describe("buildHomeListLayout", () => { - it("renders a header plus all threads for a small group without a show-more row", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 3)], - displayStates: displayStates({}), - }); - - expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); - expect(layout.stickyHeaderIndices).toEqual([0]); - expect(layout.items.at(-1)).toMatchObject({ type: "thread", isLast: true }); - }); - - it("limits large groups to the initial visible count with a show-more row", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 133)], - displayStates: displayStates({}), - }); - - const threadItems = layout.items.filter((item) => item.type === "thread"); - expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); - expect(layout.items.at(-1)).toMatchObject({ - type: "show-more", - groupKey: "alpha", - hiddenCount: 133 - HOME_INITIAL_VISIBLE_THREADS, - canShowLess: false, - }); - // The show-more row takes over the last slot, so no thread is marked last. - expect(threadItems.every((item) => item.type === "thread" && !item.isLast)).toBe(true); - }); - - it("reveals more threads per show-more step and offers show-less when exhausted", () => { - const group = makeGroup("alpha", 20); - - const expandedOnce = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - }), - }); - expect(expandedOnce.items.filter((item) => item.type === "thread")).toHaveLength( - HOME_INITIAL_VISIBLE_THREADS + HOME_SHOW_MORE_STEP, - ); - expect(expandedOnce.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 4, - canShowLess: true, - }); - - const fullyExpanded = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - alpha: nextGroupDisplayState( - nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - "show-more", - ), - }), - }); - expect(fullyExpanded.items.filter((item) => item.type === "thread")).toHaveLength(20); - expect(fullyExpanded.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 0, - canShowLess: true, - }); - - const reset = nextGroupDisplayState( - nextGroupDisplayState( - nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - "show-more", - ), - "show-less", - ); - expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS); - }); - - it("offers show-less after expanding a stale group whose baseline is below the page size", () => { - // Stale project: 10 threads total but only 3 within the recency window. - const project = makeProject("stale", "stale"); - const threads = Array.from({ length: 10 }, (_, index) => - makeThread(`stale-thread-${index}`, project.id), - ); - const group: HomeThreadGroup = { - key: "stale", - title: "stale", - representative: project, - projects: [project], - pendingTasks: [], - threads, - recentThreads: threads.slice(0, 3), - newThreadTarget: project, - }; - - const collapsedToRecent = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({}), - }); - expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3); - expect(collapsedToRecent.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 7, - canShowLess: false, - }); - - const expanded = buildHomeListLayout({ - groups: [group], - displayStates: displayStates({ - stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), - }), - }); - expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10); - expect(expanded.items.at(-1)).toMatchObject({ - type: "show-more", - hiddenCount: 0, - canShowLess: true, - }); - }); - - it("hides threads and the show-more row for collapsed groups", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 12), makeGroup("beta", 2)], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), - }), - }); - - expect(itemTypes(layout.items)).toEqual(["header", "header", "thread", "thread"]); - expect(layout.items[0]).toMatchObject({ type: "header", collapsed: true, isFirst: true }); - expect(layout.items[1]).toMatchObject({ type: "header", collapsed: false, isFirst: false }); - expect(layout.stickyHeaderIndices).toEqual([0, 1]); - }); - - it("suspends collapse and pagination while searching", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 12)], - displayStates: displayStates({ - alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "toggle-collapsed"), - }), - showAllThreads: true, - }); - - expect(layout.items.filter((item) => item.type === "thread")).toHaveLength(12); - expect(layout.items.some((item) => item.type === "show-more")).toBe(false); - }); - - it("keeps sticky indices aligned across multiple expanded groups", () => { - const layout = buildHomeListLayout({ - groups: [makeGroup("alpha", 8), makeGroup("beta", 1)], - displayStates: displayStates({}), - }); - - // header + 6 threads + show-more = 8 items, so beta's header is index 8. - expect(layout.stickyHeaderIndices).toEqual([0, 8]); - expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); - }); -}); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts deleted file mode 100644 index 910ddb5896b7..000000000000 --- a/apps/mobile/src/features/home/homeListItems.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import type { HomeThreadGroup } from "./homeThreadList"; - -/** Threads shown per project before the "Show more" affordance appears. */ -export const HOME_INITIAL_VISIBLE_THREADS = 6; -/** Additional threads revealed per "Show more" tap. */ -export const HOME_SHOW_MORE_STEP = 10; - -export interface HomeGroupDisplayState { - readonly collapsed: boolean; - /** How many threads are currently revealed (clamped to the group size). */ - readonly visibleCount: number; -} - -export const DEFAULT_GROUP_DISPLAY_STATE: HomeGroupDisplayState = { - collapsed: false, - visibleCount: HOME_INITIAL_VISIBLE_THREADS, -}; - -export interface HomeHeaderListItem { - readonly type: "header"; - readonly key: string; - readonly group: HomeThreadGroup; - readonly collapsed: boolean; - readonly isFirst: boolean; -} - -export interface HomeThreadListItem { - readonly type: "thread"; - readonly key: string; - readonly thread: EnvironmentThreadShell; - readonly isLast: boolean; -} - -export interface HomePendingTaskListItem { - readonly type: "pending-task"; - readonly key: string; - readonly pendingTask: PendingNewTask; - readonly isLast: boolean; -} - -export interface HomeShowMoreListItem { - readonly type: "show-more"; - readonly key: string; - readonly groupKey: string; - /** Threads still hidden. 0 means the group is fully expanded. */ - readonly hiddenCount: number; - /** Whether more than the initial count is revealed, so "Show less" applies. */ - readonly canShowLess: boolean; -} - -export type HomeListItem = - | HomeHeaderListItem - | HomePendingTaskListItem - | HomeThreadListItem - | HomeShowMoreListItem; - -export interface HomeListLayout { - readonly items: ReadonlyArray; - readonly stickyHeaderIndices: ReadonlyArray; -} - -export const EMPTY_HOME_LIST_LAYOUT: HomeListLayout = { items: [], stickyHeaderIndices: [] }; - -export type HomeGroupDisplayAction = "toggle-collapsed" | "show-more" | "show-less"; - -export function nextGroupDisplayState( - current: HomeGroupDisplayState, - action: HomeGroupDisplayAction, -): HomeGroupDisplayState { - switch (action) { - case "toggle-collapsed": - return { ...current, collapsed: !current.collapsed }; - case "show-more": - return { ...current, visibleCount: current.visibleCount + HOME_SHOW_MORE_STEP }; - case "show-less": - return { ...current, visibleCount: HOME_INITIAL_VISIBLE_THREADS }; - } -} - -/** - * Structural equality for list items. Item objects are rebuilt on every - * collapse/show-more toggle; without this the lists would consider every - * mounted row changed and re-render all of them (each carrying a swipeable + - * a vcs-status subscription). Group/thread references are stable across - * toggles. - */ -export function homeListItemsAreEqual(previous: HomeListItem, item: HomeListItem): boolean { - switch (item.type) { - case "header": - return ( - previous.type === "header" && - previous.group === item.group && - previous.collapsed === item.collapsed && - previous.isFirst === item.isFirst - ); - case "pending-task": - return ( - previous.type === "pending-task" && - previous.pendingTask === item.pendingTask && - previous.isLast === item.isLast - ); - case "thread": - return ( - previous.type === "thread" && - previous.thread === item.thread && - previous.isLast === item.isLast - ); - case "show-more": - return ( - previous.type === "show-more" && - previous.groupKey === item.groupKey && - previous.hiddenCount === item.hiddenCount && - previous.canShowLess === item.canShowLess - ); - } -} - -export function buildHomeListLayout(input: { - readonly groups: ReadonlyArray; - readonly displayStates: ReadonlyMap; - /** - * When searching, pagination is suspended so every match stays visible. - */ - readonly showAllThreads?: boolean; -}): HomeListLayout { - const items: HomeListItem[] = []; - const stickyHeaderIndices: number[] = []; - - for (const [groupIndex, group] of input.groups.entries()) { - const display = input.displayStates.get(group.key) ?? DEFAULT_GROUP_DISPLAY_STATE; - const collapsed = display.collapsed && input.showAllThreads !== true; - - stickyHeaderIndices.push(items.length); - items.push({ - type: "header", - key: `header:${group.key}`, - group, - collapsed, - isFirst: groupIndex === 0, - }); - - if (collapsed) { - continue; - } - - const totalCount = group.threads.length; - // Default to the group's recent-activity window (last few days, or a small - // fallback for stale projects), capped at the initial page size. Until the - // user taps "Show more", older threads stay hidden to save vertical space; - // "Show less" resets visibleCount to the initial constant, which lands back - // here at the recency baseline. - const baselineCount = Math.min( - group.recentThreads.length, - HOME_INITIAL_VISIBLE_THREADS, - totalCount, - ); - const visibleCount = input.showAllThreads - ? totalCount - : Math.min( - display.visibleCount > HOME_INITIAL_VISIBLE_THREADS - ? display.visibleCount - : baselineCount, - totalCount, - ); - const visibleThreads = group.threads.slice(0, visibleCount); - const hiddenCount = totalCount - visibleCount; - const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount; - - // Pending (unsent) tasks lead the group and are never paginated away. - for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) { - items.push({ - type: "pending-task", - key: pendingTask.key, - pendingTask, - isLast: - pendingIndex === group.pendingTasks.length - 1 && - visibleThreads.length === 0 && - !hasShowMoreRow, - }); - } - - for (const [threadIndex, thread] of visibleThreads.entries()) { - items.push({ - type: "thread", - key: `thread:${thread.environmentId}:${thread.id}`, - thread, - isLast: threadIndex === visibleThreads.length - 1 && !hasShowMoreRow, - }); - } - - if (hasShowMoreRow) { - items.push({ - type: "show-more", - key: `show-more:${group.key}`, - groupKey: group.key, - hiddenCount, - // Compare against the group's own baseline, not the global page size: - // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show - // less" must be offered as soon as anything beyond the baseline shows. - canShowLess: visibleCount > baselineCount, - }); - } - } - - return { items, stickyHeaderIndices }; -} diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 0f7b14bf9365..98b32edfd748 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -2,15 +2,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - buildHomeProjectScopes, - buildHomeThreadGroups, - sortHomeProjectScopes, -} from "./homeThreadList"; +import { buildHomeProjectScopes, sortHomeProjectScopes } from "./homeThreadList"; function makeProject( input: Partial & Pick, @@ -52,27 +47,7 @@ function makeThread( }; } -const NOW = Date.parse("2026-06-29T00:00:00.000Z"); - -function buildGroups( - projects: ReadonlyArray, - threads: ReadonlyArray, - overrides: Partial[0]> = {}, -) { - return buildHomeThreadGroups({ - projects, - threads, - environmentId: null, - searchQuery: "", - projectSortOrder: "updated_at", - threadSortOrder: "updated_at", - projectGroupingMode: "repository", - now: NOW, - ...overrides, - }); -} - -describe("buildHomeThreadGroups", () => { +describe("home project scopes", () => { it("builds one v2 scope for the same repository across environments", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -116,70 +91,6 @@ describe("buildHomeThreadGroups", () => { ); }); - it("routes stale duplicate project refs through the canonical repository group", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - const repositoryIdentity = { - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }; - const local = makeProject({ - id: ProjectId.make("project-local"), - environmentId: localEnvironmentId, - title: "t3code", - workspaceRoot: "/workspaces/t3code", - repositoryIdentity, - }); - const stale = makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-stale"), - title: "t3code", - workspaceRoot: "/remote/t3code", - updatedAt: "2026-06-01T00:00:00.000Z", - }); - const canonicalRemote = makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-canonical-remote"), - title: "t3code", - workspaceRoot: "/remote/t3code/", - repositoryIdentity, - updatedAt: "2026-06-02T00:00:00.000Z", - }); - const projects = [local, stale, canonicalRemote]; - const staleThread = makeThread({ - environmentId: remoteEnvironmentId, - id: ThreadId.make("thread-stale-project-ref"), - projectId: stale.id, - title: "Still visible", - updatedAt: "2026-06-03T00:00:00.000Z", - }); - - const scopes = buildHomeProjectScopes({ - projects, - environmentId: null, - projectGroupingMode: "repository", - }); - const groups = buildGroups(projects, [staleThread]); - - expect(scopes).toHaveLength(1); - expect(scopes[0]?.projects.map((project) => project.id)).toEqual([ - local.id, - canonicalRemote.id, - ]); - expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([ - local.id, - stale.id, - canonicalRemote.id, - ]); - expect(groups).toHaveLength(1); - expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]); - expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id); - }); - it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); @@ -375,410 +286,4 @@ describe("buildHomeThreadGroups", () => { }), ).toHaveLength(2); }); - - it("uses the physical project title for a singleton scope", () => { - const project = makeProject({ - environmentId: EnvironmentId.make("environment-1"), - id: ProjectId.make("project-1"), - title: "local-worktree-name", - repositoryIdentity: { - canonicalKey: "github.com/pingdotgg/t3code", - displayName: "codething-mvp", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }, - }); - - const scopes = buildHomeProjectScopes({ - projects: [project], - environmentId: null, - projectGroupingMode: "repository", - }); - const groups = buildGroups( - [project], - [ - makeThread({ - environmentId: project.environmentId, - id: ThreadId.make("thread-1"), - projectId: project.id, - title: "Thread", - }), - ], - ); - - expect(scopes[0]?.title).toBe("local-worktree-name"); - expect(groups[0]?.title).toBe("local-worktree-name"); - }); - - it("sorts the newest thread first regardless of snapshot order", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("thread-old"), - projectId: project.id, - title: "Older thread", - updatedAt: "2026-06-02T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("thread-new"), - projectId: project.id, - title: "Newer thread", - updatedAt: "2026-06-03T00:00:00.000Z", - }), - ]; - - expect(buildGroups([project], threads)[0]?.threads.map((thread) => thread.id)).toEqual([ - "thread-new", - "thread-old", - ]); - }); - - it("supports independent project and thread creation-time sorting", () => { - const environmentId = EnvironmentId.make("environment-1"); - const olderProject = makeProject({ - environmentId, - id: ProjectId.make("project-older"), - title: "Older project", - }); - const newerProject = makeProject({ - environmentId, - id: ProjectId.make("project-newer"), - title: "Newer project", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("old-created"), - projectId: olderProject.id, - title: "Updated recently", - createdAt: "2026-06-01T00:00:00.000Z", - updatedAt: "2026-06-05T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("new-created"), - projectId: olderProject.id, - title: "Created recently", - createdAt: "2026-06-04T00:00:00.000Z", - updatedAt: "2026-06-04T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("newest-project-thread"), - projectId: newerProject.id, - title: "Newest project", - createdAt: "2026-06-06T00:00:00.000Z", - }), - ]; - - const groups = buildGroups([olderProject, newerProject], threads, { - projectSortOrder: "created_at", - threadSortOrder: "created_at", - projectGroupingMode: "separate", - }); - - expect(groups.map((group) => group.representative.id)).toEqual([ - "project-newer", - "project-older", - ]); - expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["new-created", "old-created"]); - }); - - it("filters both projects and threads to one environment", () => { - const localEnvironmentId = EnvironmentId.make("environment-local"); - const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - const projects = [ - makeProject({ - environmentId: localEnvironmentId, - id: ProjectId.make("project-local"), - title: "Local", - }), - makeProject({ - environmentId: remoteEnvironmentId, - id: ProjectId.make("project-remote"), - title: "Remote", - }), - ]; - const threads = projects.map((project) => - makeThread({ - environmentId: project.environmentId, - id: ThreadId.make(`thread-${project.id}`), - projectId: project.id, - title: project.title, - }), - ); - - const groups = buildGroups(projects, threads, { environmentId: remoteEnvironmentId }); - - expect(groups).toHaveLength(1); - expect(groups[0]?.representative.environmentId).toBe(remoteEnvironmentId); - expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); - }); - - it("matches web repository, repository-path, and separate grouping modes", () => { - const environmentId = EnvironmentId.make("environment-1"); - const repositoryIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - provider: "github", - owner: "t3tools", - name: "t3code", - displayName: "T3 Code", - rootPath: "/workspaces/t3code", - }; - const projects = [ - makeProject({ - environmentId, - id: ProjectId.make("project-web"), - title: "Web", - workspaceRoot: "/workspaces/t3code/apps/web", - repositoryIdentity, - }), - makeProject({ - environmentId, - id: ProjectId.make("project-mobile"), - title: "Mobile", - workspaceRoot: "/workspaces/t3code/apps/mobile", - repositoryIdentity, - }), - ]; - const threads = projects.map((project) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${project.id}`), - projectId: project.id, - title: project.title, - }), - ); - - expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - expect( - buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - expect( - buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( - (group) => group.title, - ), - ).toEqual(["Mobile", "Web"]); - }); - - it("default view shows only threads from the last 5 days", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("recent-1"), - projectId: project.id, - title: "Today", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("recent-2"), - projectId: project.id, - title: "Within window", - updatedAt: "2026-06-25T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old"), - projectId: project.id, - title: "Two weeks ago", - updatedAt: "2026-06-14T00:00:00.000Z", - }), - ]; - - const group = buildGroups([project], threads)[0]; - // Default view trims to recent threads... - expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]); - // ...while full history stays available for the expanded view. - expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); - }); - - it("keeps an old thread in the default view while a message waits in its outbox", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = [ - makeThread({ - environmentId, - id: ThreadId.make("recent"), - projectId: project.id, - title: "Today", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old-queued"), - projectId: project.id, - title: "Two weeks ago, follow-up queued offline", - updatedAt: "2026-06-14T00:00:00.000Z", - }), - makeThread({ - environmentId, - id: ThreadId.make("old"), - projectId: project.id, - title: "Two weeks ago", - updatedAt: "2026-06-13T00:00:00.000Z", - }), - ]; - - const group = buildGroups([project], threads, { - queuedThreadKeys: new Set([`${environmentId}:old-queued`]), - })[0]; - expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent", "old-queued"]); - }); - - it("falls back to the most recent 3 threads when none are within 5 days", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( - (day, index) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${index}`), - projectId: project.id, - title: `Thread ${index}`, - updatedAt: `${day}T00:00:00.000Z`, - }), - ); - - const group = buildGroups([project], threads)[0]; - expect(group?.recentThreads.map((thread) => thread.id)).toEqual([ - "thread-4", - "thread-3", - "thread-2", - ]); - expect(group?.threads).toHaveLength(5); - }); - - it("does not apply the recency window while searching", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( - (day, index) => - makeThread({ - environmentId, - id: ThreadId.make(`thread-${index}`), - projectId: project.id, - title: `Thread ${index}`, - updatedAt: `${day}T00:00:00.000Z`, - }), - ); - - const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0]; - // Search reaches the full history rather than the 3-thread fallback. - expect(group?.recentThreads).toHaveLength(5); - expect(group?.recentThreads.map((thread) => thread.id)).toEqual( - group?.threads.map((thread) => thread.id), - ); - }); - - it("includes a thread matched by message content", () => { - const environmentId = EnvironmentId.make("environment-1"); - const project = makeProject({ - environmentId, - id: ProjectId.make("project-1"), - title: "T3 Code", - }); - const thread = makeThread({ - environmentId, - id: ThreadId.make("thread-content"), - projectId: project.id, - title: "Unrelated title", - }); - - const groups = buildGroups([project], [thread], { - searchQuery: "relay reconnect", - matchedThreadKeys: new Set([ - threadSearchMatchKey({ - environmentId, - threadId: thread.id, - }), - ]), - }); - - expect(groups[0]?.threads.map((candidate) => candidate.id)).toEqual(["thread-content"]); - }); - - it("targets quick new threads at the group member with the newest thread", () => { - const laptopEnv = EnvironmentId.make("environment-laptop"); - const desktopEnv = EnvironmentId.make("environment-desktop"); - const repositoryIdentity = { - canonicalKey: "github.com/pingdotgg/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:pingdotgg/t3code.git", - }, - }; - const laptopProject = makeProject({ - environmentId: laptopEnv, - id: ProjectId.make("project-laptop"), - title: "t3code", - repositoryIdentity, - }); - const desktopProject = makeProject({ - environmentId: desktopEnv, - id: ProjectId.make("project-desktop"), - title: "t3code", - repositoryIdentity, - }); - const threads = [ - makeThread({ - environmentId: laptopEnv, - id: ThreadId.make("thread-laptop"), - projectId: laptopProject.id, - title: "Older laptop thread", - updatedAt: "2026-06-27T00:00:00.000Z", - }), - makeThread({ - environmentId: desktopEnv, - id: ThreadId.make("thread-desktop"), - projectId: desktopProject.id, - title: "Newest desktop thread", - updatedAt: "2026-06-28T00:00:00.000Z", - }), - ]; - - // Aggregated into one group by repository; the quick new-thread target - // must follow the newest thread (desktop), not the arbitrary first member. - const groups = buildGroups([laptopProject, desktopProject], threads); - expect(groups).toHaveLength(1); - expect(groups[0]?.projects).toHaveLength(2); - expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv); - expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id); - }); }); diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 895c73bbe266..0c85972e43e5 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,31 +1,22 @@ -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; -import { - buildProjectGroups, - derivePhysicalProjectKey, - deriveProjectGroupLabel, -} from "@t3tools/client-runtime/state/project-grouping"; +import { buildProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { getThreadSortTimestamp, - sortThreads, toSortableTimestamp, } from "@t3tools/client-runtime/state/thread-sort"; -import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentId, ScopedProjectRef, SidebarProjectGroupingMode, SidebarProjectSortOrder, - SidebarThreadSortOrder, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; -import * as Option from "effect/Option"; import * as Order from "effect/Order"; -import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; @@ -133,260 +124,3 @@ export function sortHomeProjectScopes(input: { ), ); } - -/** - * Default home view only surfaces threads active within this window, to keep the - * screen compact while keeping recent work visible. - */ -const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000; -/** Fallback when a project has no threads inside the recency window. */ -const RECENT_THREAD_FALLBACK_COUNT = 3; - -export interface HomeThreadGroup { - readonly key: string; - readonly title: string; - readonly representative: EnvironmentProject; - readonly projects: ReadonlyArray; - readonly pendingTasks: ReadonlyArray; - /** Full sorted thread history for the group (revealed when expanded / searching). */ - readonly threads: ReadonlyArray; - /** Subset shown by default: threads from the last few days, or the most recent few. */ - readonly recentThreads: ReadonlyArray; - /** - * Where a quick "new thread in this project" should land. For aggregated - * groups (same repo on several machines) this is the member that owns the - * group's most recent thread — the machine the user last worked on — rather - * than the arbitrary first member; the draft's computer picker covers - * switching from there. Null only for synthetic pending-project groups, - * whose single "project" is a placeholder built from queued-task metadata. - */ - readonly newThreadTarget: EnvironmentProject | null; -} - -interface MutableHomeThreadGroup { - readonly key: string; - readonly projects: EnvironmentProject[]; - readonly pendingTasks: PendingNewTask[]; - readonly threads: EnvironmentThreadShell[]; -} - -function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOrder): number { - const latestThread = group.threads.reduce( - (latest, thread) => Math.max(latest, getThreadSortTimestamp(thread, sortOrder)), - Number.NEGATIVE_INFINITY, - ); - return group.pendingTasks.reduce((latest, pendingTask) => { - const timestamp = Date.parse(pendingTask.createdAt); - return Number.isNaN(timestamp) ? latest : Math.max(latest, timestamp); - }, latestThread); -} - -/** - * Trims a group's threads to recent activity for the default home view. - * `sortedThreads` must already be ordered newest-first for `threadSortOrder`. - * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps - * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes. - */ -function selectRecentThreads( - sortedThreads: ReadonlyArray, - threadSortOrder: SidebarThreadSortOrder, - now: number, - queuedThreadKeys: ReadonlySet | undefined, -): ReadonlyArray { - const cutoff = now - RECENT_THREAD_WINDOW_MS; - // A thread with a message waiting in the outbox has work the user is - // waiting on, however old its last activity; it never trims away. - const recent = sortedThreads.filter( - (thread) => - getThreadSortTimestamp(thread, threadSortOrder) >= cutoff || - queuedThreadKeys?.has(scopedThreadKey(thread.environmentId, thread.id)) === true, - ); - return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT); -} - -export function buildHomeThreadGroups(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly pendingTasks?: ReadonlyArray; - /** Thread keys with a message waiting in the outbox; kept in the default view. */ - readonly queuedThreadKeys?: ReadonlySet; - readonly environmentId: EnvironmentId | null; - readonly searchQuery: string; - readonly matchedThreadKeys?: ReadonlySet; - readonly projectSortOrder: HomeProjectSortOrder; - readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; - /** Current time used for the recency window; defaults to now. Injectable for tests. */ - readonly now?: number; -}): ReadonlyArray { - const now = input.now ?? Date.now(); - const groups = new Map(); - const groupTitleByKey = new Map(); - const groupKeyByProjectKey = new Map(); - - for (const scope of buildHomeProjectScopes(input)) { - groupTitleByKey.set(scope.key, scope.title); - groups.set(scope.key, { - key: scope.key, - projects: [...scope.projects], - pendingTasks: [], - threads: [], - }); - for (const projectRef of scope.projectRefs) { - groupKeyByProjectKey.set( - scopedProjectKey(projectRef.environmentId, projectRef.projectId), - scope.key, - ); - } - } - - for (const pendingTask of input.pendingTasks ?? []) { - if (input.environmentId !== null && pendingTask.environmentId !== input.environmentId) { - continue; - } - - const physicalKey = scopedProjectKey(pendingTask.environmentId, pendingTask.projectId); - let groupKey = groupKeyByProjectKey.get(physicalKey); - if (!groupKey) { - // The project shell is not loaded (environment offline / project gone). - // A queued task must stay visible and deletable regardless, so build a - // standalone group from the metadata snapshotted at enqueue time. - groupKey = `pending-project:${physicalKey}`; - groupKeyByProjectKey.set(physicalKey, groupKey); - groups.set(groupKey, { - key: groupKey, - projects: [ - { - environmentId: pendingTask.environmentId, - id: pendingTask.projectId, - title: pendingTask.projectTitle ?? "Unknown project", - workspaceRoot: pendingTask.projectCwd ?? String(pendingTask.projectId), - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: pendingTask.createdAt, - updatedAt: pendingTask.createdAt, - }, - ], - pendingTasks: [], - threads: [], - }); - } - groups.get(groupKey)?.pendingTasks.push(pendingTask); - } - - for (const thread of input.threads) { - if (thread.archivedAt !== null) { - continue; - } - if (input.environmentId !== null && thread.environmentId !== input.environmentId) { - continue; - } - - const physicalKey = scopedProjectKey(thread.environmentId, thread.projectId); - const groupKey = groupKeyByProjectKey.get(physicalKey); - if (!groupKey) { - continue; - } - groups.get(groupKey)?.threads.push(thread); - } - - const query = input.searchQuery.trim().toLocaleLowerCase(); - const result: HomeThreadGroup[] = []; - - for (const group of groups.values()) { - const representative = group.projects[0]; - if (!representative || (group.threads.length === 0 && group.pendingTasks.length === 0)) { - continue; - } - - const title = - groupTitleByKey.get(group.key) ?? - deriveProjectGroupLabel({ representative, members: group.projects }); - const groupMatches = - query.length === 0 || - title.toLocaleLowerCase().includes(query) || - group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); - const matchingThreads = groupMatches - ? group.threads - : group.threads.filter( - (thread) => - thread.title.toLocaleLowerCase().includes(query) || - threadPullRequestSearchTerms(thread).some((term) => - term.toLocaleLowerCase().includes(query), - ) || - input.matchedThreadKeys?.has( - threadSearchMatchKey({ - environmentId: thread.environmentId, - threadId: thread.id, - }), - ) === true, - ); - const matchingPendingTasks = groupMatches - ? group.pendingTasks - : group.pendingTasks.filter((pendingTask) => - pendingTask.title.toLocaleLowerCase().includes(query), - ); - - if (matchingThreads.length === 0 && matchingPendingTasks.length === 0) { - continue; - } - - const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder); - // An active search should reach the full history, so the recency window - // only trims the default (no-query) view. - const recentThreads = - query.length === 0 - ? selectRecentThreads(sortedThreads, input.threadSortOrder, now, input.queuedThreadKeys) - : sortedThreads; - - // A stale project id still resolves to the canonical member with the same - // environment/path, so quick creation follows the machine with the newest activity. - const lastActiveProject = Arr.head(sortedThreads).pipe( - Option.flatMap((thread) => - Arr.findFirst( - input.projects, - (project) => - project.environmentId === thread.environmentId && project.id === thread.projectId, - ), - ), - Option.flatMap((threadProject) => - Arr.findFirst( - group.projects, - (project) => - derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), - ), - ), - Option.getOrNull, - ); - - result.push({ - key: group.key, - title, - representative, - projects: group.projects, - pendingTasks: matchingPendingTasks, - threads: sortedThreads, - recentThreads, - newThreadTarget: group.key.startsWith("pending-project:") - ? null - : (lastActiveProject ?? representative), - }); - } - - return Arr.sort( - result, - Order.mapInput( - Order.Struct({ - timestamp: Order.flip(Order.Number), - title: Order.String, - key: Order.String, - }), - (group: HomeThreadGroup) => ({ - timestamp: groupSortTimestamp(group, input.projectSortOrder), - title: group.title, - key: group.key, - }), - ), - ); -} diff --git a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts index b76d2f8a90bf..45b6ccb13d17 100644 --- a/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts +++ b/apps/mobile/src/features/keyboard/threadKeyboardShortcuts.ts @@ -2,23 +2,19 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { useCallback } from "react"; -import type { HomeListItem } from "../home/homeListItems"; import type { ThreadListV2ListItem } from "../threads/threadListV2"; import { useHardwareKeyboardCommand, type HardwareKeyboardCommand, } from "./hardwareKeyboardCommands"; -type ThreadShortcutListItem = - | HomeListItem - | ThreadListV2ListItem - | { readonly type: "v2-show-more" }; +type ThreadShortcutListItem = ThreadListV2ListItem | { readonly type: "v2-show-more" }; export function threadJumpIndex(command: HardwareKeyboardCommand) { return THREAD_JUMP_KEYBINDING_COMMANDS.findIndex((candidate) => candidate === command); } -/** Uses the rendered list so filters, collapsed groups and shelves keep their order. */ +/** Uses the rendered list so filters and shelves keep their order. */ export function threadJumpTarget( items: ReadonlyArray, command: HardwareKeyboardCommand, @@ -26,8 +22,7 @@ export function threadJumpTarget( let index = threadJumpIndex(command); if (index < 0) return null; for (const item of items) { - const thread = - item.type === "thread" ? item.thread : item.type === "v2-thread" ? item.item.thread : null; + const thread = item.type === "v2-thread" ? item.item.thread : null; if (thread !== null && index-- === 0) return thread; } return null; diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 6c6133c7ef13..60df38b30357 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -57,6 +57,7 @@ import { import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; @@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - - - + ( + + )} + > + + + + ) : null} @@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: { style={inspectorStyle} > - {props.renderInspector?.()} + ( + + )} + > + + ) : null} ); } + +// The render callback must run inside the boundary's child, not while its +// parent constructs the boundary element. +function InspectorRenderer(props: { readonly render?: () => ReactNode }) { + return <>{props.render?.()}; +} diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 2fac5376e5b1..08888dd469d2 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -735,7 +735,7 @@ export function ReviewSheet(props: ReviewSheetProps) { /> - + {showConnectionNotice ? ( {afterDays !== null ? ( - - - - Inactive days - + + + Inactive days - savePreferences({ legacyThreadListEnabled: value })} - /> ( ( ) : null} + {selectedProviderStatus?.compatibilityAdvisory?.message && + (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || + selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( + + {selectedProviderStatus.compatibilityAdvisory.message} + + ) : null} {modelUnavailable ? ( Model unavailable. Open model settings. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad0872ea9116..845d67483c83 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; @@ -892,39 +893,51 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : "absolute inset-0 bg-screen" } /> - + resetKeys={[props.threadCwd]} + renderFallback={(fallback) => ( + + )} + > + + ) : ( diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index bf957ad54195..160392fdd5d9 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,5 +1,5 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import { createThreadMovePlanner } from "./threadOrder"; +import { computeThreadMoveAvailability } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -29,7 +29,6 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; -import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -39,24 +38,9 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; -import { - hasCustomHomeListOptions, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, - useHomeListOptions, -} from "../home/home-list-options"; +import { useHomeListOptions } from "../home/home-list-options"; import { buildHomeListFilterMenu } from "../home/home-list-filter-menu"; -import { - buildHomeListLayout, - DEFAULT_GROUP_DISPLAY_STATE, - EMPTY_HOME_LIST_LAYOUT, - homeListItemsAreEqual, - nextGroupDisplayState, - type HomeGroupDisplayAction, - type HomeGroupDisplayState, - type HomeListItem, -} from "../home/homeListItems"; -import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; +import { buildHomeProjectScopes } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -71,12 +55,6 @@ import { useMaterialFabScroll } from "../home/MaterialFabScrollContext"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; import { SidebarNavigationShell } from "./sidebar-navigation-shell"; -import { - PendingTaskListRow, - ThreadListGroupHeader, - ThreadListRow, - ThreadListShowMoreRow, -} from "./thread-list-items"; import { ThreadListV2PendingRow, ThreadListV2Row, @@ -84,21 +62,21 @@ import { ThreadListV2ShowMoreRow, ThreadListV2SnoozedShelfHeader, } from "./thread-list-v2-items"; -import { resolveThreadProviderInstance } from "./thread-provider-instance"; +import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; import { buildThreadListV2Items, getThreadListV2OrderedSection, buildThreadListV2ListItems, + isThreadListV2ListItem, + threadListV2ListItemsAreEqual, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, type ThreadListV2ListItem, } from "./threadListV2"; -/** The sidebar list serves both lists: v1 grouped items or, when the Thread - List v2 beta is on, flat v2 rows with queued tasks spliced in, and a settled - "Show more" pager. */ +/** The sidebar list: flat v2 rows with queued tasks spliced in, plus a + settled "Show more" pager row. */ type SidebarListItem = - | HomeListItem | ThreadListV2ListItem | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; @@ -176,7 +154,6 @@ function ThreadNavigationSidebarPane( renameThread, regenerateThreadTitle, } = useThreadListActions(); - const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); const queuedThreadKeys = useQueuedThreadKeys(); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); @@ -194,8 +171,7 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = - useHomeListOptions(availableEnvironmentIds); + const { options, setSelectedEnvironmentId } = useHomeListOptions(availableEnvironmentIds); const searchEnvironmentIds = useMemo( () => options.selectedEnvironmentId === null @@ -284,93 +260,6 @@ function ThreadNavigationSidebarPane( ), [selectedProjectScope], ); - const scopedProjects = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? projects - : projects.filter((project) => - selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), - ), - [threadListV2Enabled, projects, selectedProjectRefs], - ); - const scopedThreads = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [threadListV2Enabled, selectedProjectRefs, threads], - ); - const scopedPendingTasks = useMemo( - () => - threadListV2Enabled - ? [] - : selectedProjectRefs === null - ? pendingTasks - : pendingTasks.filter((pendingTask) => - selectedProjectRefs.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - ), - ), - [threadListV2Enabled, pendingTasks, selectedProjectRefs], - ); - const groups = useMemo( - () => - threadListV2Enabled - ? [] - : buildHomeThreadGroups({ - projects: scopedProjects, - threads: scopedThreads, - pendingTasks: scopedPendingTasks, - queuedThreadKeys, - environmentId: options.selectedEnvironmentId, - searchQuery: props.searchQuery, - matchedThreadKeys, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, - }), - [ - threadListV2Enabled, - queuedThreadKeys, - matchedThreadKeys, - options, - props.searchQuery, - scopedPendingTasks, - scopedProjects, - scopedThreads, - ], - ); - const [groupDisplayStates, setGroupDisplayStates] = useState< - ReadonlyMap - >(() => new Map()); - const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { - setGroupDisplayStates((previous) => { - const next = new Map(previous); - next.set( - key, - nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), - ); - return next; - }); - }, []); - const hasSearchQuery = props.searchQuery.trim().length > 0; - const listLayout = useMemo( - () => - threadListV2Enabled - ? EMPTY_HOME_LIST_LAYOUT - : buildHomeListLayout({ - groups, - displayStates: groupDisplayStates, - showAllThreads: hasSearchQuery, - }), - [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], - ); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -410,12 +299,11 @@ function ThreadNavigationSidebarPane( // thread reappears immediately instead of on the next minute tick. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { - if (!threadListV2Enabled) return; // Refresh immediately because the mount-time value can be hours old. setNowMinute(new Date().toISOString().slice(0, 16)); const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000); return () => clearInterval(id); - }, [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); @@ -483,12 +371,19 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); + // Reference-stable provider glyphs: a fresh object per render would break + // the memoized rows' props comparison on every parent render. + const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); - const threadMovePlanners = useMemo(() => { - const sectionPlanner = (section: "pinned" | "active") => - createThreadMovePlanner({ + // Up/down menu availability for every card, computed once per section per + // rebuild (see computeThreadMoveAvailability): per-thread planner calls made + // list construction quadratic, and this list rebuilds on every minute tick. + const threadMoveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ allThreads: threads, section, + pendingOrder, reorderableEnvironmentIds: new Set( [...serverConfigs].flatMap(([id, config]) => (section === "pinned" @@ -508,7 +403,7 @@ function ThreadNavigationSidebarPane( queuedThreadKeys, }), }); - return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); }, [ serverConfigs, threads, @@ -520,16 +415,6 @@ function ThreadNavigationSidebarPane( snoozeWakeTick, ]); const threadListV2Layout = useMemo(() => { - if (!threadListV2Enabled) - return { - items: [], - hiddenSettledCount: 0, - snoozedCount: 0, - snoozedShelfHeaderIndex: null, - settledCount: 0, - settledShelfHeaderIndex: null, - nextSnoozeWakeAt: null, - }; return buildThreadListV2Items({ pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), @@ -560,7 +445,6 @@ function ThreadNavigationSidebarPane( settledVisibleCount, settlementEnvironmentIds, snoozeEnvironmentIds, - threadListV2Enabled, threads, selectedProjectScope, ]); @@ -579,7 +463,6 @@ function ThreadNavigationSidebarPane( // range) the boundary string is identical and the chain would die. }, [nextSnoozeWakeAt, snoozeWakeTick]); const listItems = useMemo(() => { - if (!threadListV2Enabled) return listLayout.items; // Queued offline tasks are not thread shells, so the v2 item builder // never sees them; the shared splice puts them below the active block // (mirrors the compact Home v2 list) where they stay visible and @@ -607,6 +490,10 @@ function ThreadNavigationSidebarPane( settledShelfExpanded, settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, snoozeLabelNow: `${nowMinute}:00.000Z`, + snoozeEnvironmentIds, + queuedThreadKeys, + moveAvailability: threadMoveAvailability, + shelfPreferencesLoading: !shelfPreferencesLoaded, }); if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { items.push({ @@ -617,15 +504,17 @@ function ThreadNavigationSidebarPane( } return items; }, [ - listLayout.items, nowMinute, options.selectedEnvironmentId, pendingTasks, props.searchQuery, + queuedThreadKeys, + threadMoveAvailability, selectedProjectRefs, settledShelfExpanded, + shelfPreferencesLoaded, snoozedShelfExpanded, - threadListV2Enabled, + snoozeEnvironmentIds, threadListV2Layout, ]); const listMenuActions = useMemo( @@ -671,33 +560,8 @@ function ThreadNavigationSidebarPane( ], }, ] satisfies MenuAction[])), - // v2 lays the list out in fixed creation order — offering sort/group - // controls it silently ignores would be a lie. Environment still - // scopes the v2 partition, so it stays. - ...(threadListV2Enabled - ? [] - : ([ - { - id: "project-sort", - title: "Sort projects", - subactions: PROJECT_SORT_OPTIONS.map((option) => ({ - id: `project-sort:${option.value}`, - title: option.label, - state: options.projectSortOrder === option.value ? "on" : "off", - })), - }, - { - id: "thread-sort", - title: "Sort threads", - subactions: THREAD_SORT_OPTIONS.map((option) => ({ - id: `thread-sort:${option.value}`, - title: option.label, - state: options.threadSortOrder === option.value ? "on" : "off", - })), - }, - ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [environments, options, projectFilterOptions, selectedProjectKey], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -724,28 +588,8 @@ function ThreadNavigationSidebarPane( } return; } - const projectSort = PROJECT_SORT_OPTIONS.find( - (option) => `project-sort:${option.value}` === event, - ); - if (projectSort) { - setProjectSortOrder(projectSort.value); - return; - } - const threadSort = THREAD_SORT_OPTIONS.find( - (option) => `thread-sort:${option.value}` === event, - ); - if (threadSort) { - setThreadSortOrder(threadSort.value); - return; - } }, - [ - environments, - projectFilterOptions, - setProjectSortOrder, - setSelectedEnvironmentId, - setThreadSortOrder, - ], + [environments, projectFilterOptions, setSelectedEnvironmentId], ); const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); @@ -789,9 +633,11 @@ function ThreadNavigationSidebarPane( onScroll: onMaterialFabScroll, onScrollBeginDrag: handleScrollBeginDrag, }); - // Project shells load after the first rows draw, so the maps they feed have - // to bust the recycler's memoization — otherwise a row keeps the blank - // favicon and fallback title it was first rendered with. + // The sticky header's project shells and search maps feed row props, so + // they have to bust the recycler's memoization — otherwise a row keeps the + // blank favicon and fallback title it was first rendered with. The minute + // clock deliberately stays out: its per-row text lives on the items, so a + // tick only re-renders rows whose displayed text actually moved. const listExtraData = useMemo( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", @@ -799,7 +645,6 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, - snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), [ @@ -808,53 +653,19 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, savedConnectionsById, serverConfigs, - nowMinute, threadSearchMatchByKey, ], ); useThreadJumpShortcuts(listItems, handleSelectThread); const sidebarItemsAreEqual = useCallback( (previous: SidebarListItem, item: SidebarListItem): boolean => { - if (previous.type === "v2-thread" && item.type === "v2-thread") { - return ( - previous.key === item.key && - previous.item.thread === item.item.thread && - previous.item.variant === item.item.variant && - previous.item.snoozed === item.item.snoozed && - previous.item.pinned === item.item.pinned && - previous.snoozeWakeLabelText === item.snoozeWakeLabelText - ); + if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) { + return threadListV2ListItemsAreEqual(previous, item); } if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } - if (previous.type === "v2-pending" && item.type === "v2-pending") { - return ( - previous.pendingTask === item.pendingTask && - previous.showPendingDivider === item.showPendingDivider - ); - } - if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { - return previous.count === item.count && previous.expanded === item.expanded; - } - if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { - return previous.count === item.count && previous.expanded === item.expanded; - } - if ( - previous.type === "v2-thread" || - previous.type === "v2-show-more" || - previous.type === "v2-pending" || - previous.type === "v2-snoozed-shelf" || - previous.type === "v2-settled-shelf" || - item.type === "v2-thread" || - item.type === "v2-show-more" || - item.type === "v2-pending" || - item.type === "v2-snoozed-shelf" || - item.type === "v2-settled-shelf" - ) { - return false; - } - return homeListItemsAreEqual(previous, item); + return false; }, [], ); @@ -904,24 +715,29 @@ function ThreadNavigationSidebarPane( } case "v2-thread": { const thread = item.item.thread; - const movePlanner = item.item.pinned - ? threadMovePlanners.pinned - : threadMovePlanners.active; - const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); + // Intentional difference from Home: the sidebar never passes + // `showTrailingDivider` because its rows render no Home-style row + // hairline at all — card rows carry tonal containers in this pane + // (the hairline branch is !sidebarPane-only) and slim rows have no + // hairline branch. The stamp still rides the shared list items + // because Home's boundary suppression consumes it; the sidebar's + // only cost is the occasional divider-only equality invalidation, + // which re-renders identically. return ( 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -955,8 +771,8 @@ function ThreadNavigationSidebarPane( ? pinReorderEnvironmentIds.has(thread.environmentId) : activeReorderEnvironmentIds.has(thread.environmentId) } - canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} - canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} + canMoveUp={item.canMoveUp} + canMoveDown={item.canMoveDown} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} @@ -973,7 +789,7 @@ function ThreadNavigationSidebarPane( return ( ); - case "header": - return ( - - ); - case "pending-task": - return ( - - ); - case "thread": { - const thread = item.thread; - return ( - - ); - } - case "show-more": - return ( - - ); } }, [ archiveThread, activeReorderEnvironmentIds, - threadMovePlanners, - pendingOrder, - queuedThreadKeys, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, @@ -1098,15 +833,13 @@ function ThreadNavigationSidebarPane( projectTitleByProjectKey, regenerateThreadTitle, renameThread, + threadSearchMatchByKey, props.onNewThreadInProject, props.onNewThreadOnBranch, props.searchQuery, props.selectedThreadKey, props.width, savedConnectionsById, - serverConfigs, - shelfPreferencesLoaded, - threadSearchMatchByKey, titleRegenerationEnvironmentIds, settleThread, settlementEnvironmentIds, @@ -1114,20 +847,17 @@ function ThreadNavigationSidebarPane( sidebarScrollGesture, snoozeEnvironmentIds, snoozeThread, - nowMinute, + resolveProviderInstance, toggleSettledShelf, toggleSnoozedShelf, unpinThread, unsettleThread, unsnoozeThread, - updateGroupDisplay, ], ); - // v2 ignores the sort/group options, so only the environment filter can - // light the "customized" state while the beta is on. - const filterCustomized = threadListV2Enabled - ? options.selectedEnvironmentId !== null || selectedProjectKey !== null - : hasCustomHomeListOptions({ ...options, selectedProjectKey }); + // The list ignores sort/group options, so only the environment and project + // filters can light the "customized" state. + const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null; const filterIcon = filterCustomized ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"; @@ -1138,24 +868,10 @@ function ThreadNavigationSidebarPane( projects: projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, selectedProjectKey, - projectSortOrder: options.projectSortOrder, - threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, - onProjectSortOrderChange: setProjectSortOrder, - onThreadSortOrderChange: setThreadSortOrder, - listOrganization: !threadListV2Enabled, }), - [ - environments, - options, - projectFilterOptions, - selectedProjectKey, - setProjectSortOrder, - setSelectedEnvironmentId, - setThreadSortOrder, - threadListV2Enabled, - ], + [environments, options, projectFilterOptions, selectedProjectKey, setSelectedEnvironmentId], ); const nativeHeaderItems = useMemo( () => @@ -1365,10 +1081,7 @@ function ThreadNavigationSidebarPane( /> - + diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index e5bfc1590cf0..4fb22d152496 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -606,10 +606,19 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} + resetKeys={[routeThreadIdentity, selectedThreadCwd]} Route={props.renderInspector ? RouteInspector : undefined} /> ), - [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + [ + FilesInspector, + GitInspector, + RouteInspector, + inspectorMode, + props.renderInspector, + routeThreadIdentity, + selectedThreadCwd, + ], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, @@ -947,18 +956,7 @@ function ThreadRouteContent( <> - + {content} @@ -185,9 +184,8 @@ function DisclosureRow(props: { diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 7ca5085965f5..d16b72d13d81 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -63,7 +63,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { return ( {Platform.OS === "android" ? ( @@ -83,31 +83,19 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - - + + New branch - - + + New worktree {Platform.OS === "android" ? ( @@ -157,7 +133,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBaseBranch} placeholder="main" accessibilityLabel="Worktree base branch" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> {Platform.OS === "android" ? ( New branch @@ -167,7 +143,7 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { onChangeText={setWorktreeBranchName} placeholder="feature/mobile-thread" accessibilityLabel="Worktree branch name" - className={Platform.OS === "android" ? "rounded-xl bg-sheet-solid" : "rounded-[18px]"} + className="android:rounded-xl android:bg-sheet-solid ios:rounded-[18px]" /> - + Existing branches {branchesLoading ? ( - + Loading branches... ) : null} {!branchesLoading && availableBranches.length === 0 ? ( - + No local branches found. ) : null} @@ -236,16 +196,10 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { ) : null} - + {branch.name} {subtitle} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 7f70a39d9372..19d77a327def 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -72,7 +72,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { return ( {Platform.OS === "android" ? ( @@ -92,35 +92,21 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - - + + Branch - + {gitStatus.data?.refName ?? "(detached HEAD)"} @@ -131,20 +117,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - + - + Files @@ -154,39 +130,19 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {!allSelected && isEditingFiles ? ( setExcludedFiles(new Set())} > - + Reset ) : null} setIsEditingFiles((current) => !current)} > - + {isEditingFiles ? "Done" : "Edit"} @@ -226,15 +182,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { - - + + Commit message - - + + void runCommitAction(true)} /> - + {Platform.OS === "android" ? ( @@ -130,49 +130,31 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {Platform.OS !== "android" ? ( Confirm ) : null} - + {copy?.title ?? "Run action on default branch?"} - + {copy?.description ?? "Choose how to continue."} {sheetMenuItems.map(({ item, disabledReason }, index) => ( @@ -320,11 +316,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { {linkedPrChains.map((chain) => ( {chain.layers.length > 1 ? ( @@ -483,13 +475,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} {isInspector ? ( - + {Platform.OS === "android" ? ( {refreshMenu} ) : ( diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index daf494cb79cc..c358466e22d2 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -71,13 +71,7 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -97,21 +91,11 @@ export function SheetListRow(props: { }) { return ( - + - + {props.title} {props.subtitle ? ( diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index b80fffda057d..1f9297f47258 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -45,7 +45,7 @@ export function createSidebarHeaderItems(input: { withNativeGlassHeaderItem({ type: "menu", label: "", - accessibilityLabel: "Filter and sort threads", + accessibilityLabel: "Filter threads", icon: sfSymbolIcon(input.filterIcon), menu: { title: input.filterMenu.title, diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 9b41ed4e362a..5a1601b0e5ae 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { View } from "react-native"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; + export type ThreadInspectorMode = "route" | "git" | "files"; const INSPECTOR_PREWARM_DELAY_MS = 350; @@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; + readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { if (!props.mounted) { @@ -27,7 +30,14 @@ function InspectorContentPane(props: { zIndex: props.visible ? 1 : 0, }} > - {props.children} + ( + + )} + > + {props.children} + ); } @@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; readonly mode: ThreadInspectorMode; + readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; }) { const [mountedModes, setMountedModes] = useState>( @@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: { @@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx deleted file mode 100644 index 04d30a26a901..000000000000 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ /dev/null @@ -1,849 +0,0 @@ -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind } from "@t3tools/contracts"; -import type { MenuAction } from "@react-native-menu/menu"; -import { SymbolView } from "../../components/AppSymbol"; -import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Platform, Pressable, useWindowDimensions, View } from "react-native"; -import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import Svg, { Circle, Path } from "react-native-svg"; - -import { RowPressable } from "../../components/RowPressable"; -import { AppText as Text } from "../../components/AppText"; -import { ControlPillMenu } from "../../components/ControlPill"; -import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; -import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { cn } from "../../lib/cn"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { relativeTime } from "../../lib/time"; -import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import type { PendingNewTask } from "../../state/use-pending-new-tasks"; -import { useThreadPr, type ThreadPrPresentation } from "../../state/use-thread-pr"; -import type { HomeGroupDisplayAction } from "../home/homeListItems"; -import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; -import { QueuedMessageIcon } from "./queued-message-icon"; -import { resolveThreadStatus } from "./threadPresentation"; -import { ThreadSearchMatchExcerpt } from "./thread-search-match"; - -/** - * Shared presentation for the thread lists: the compact (phone) Home list and - * the iPad sidebar render the SAME items — group headers with collapse, - * thread rows with status/PR/subtitle, and show-more rows — differing only in - * metrics and chrome via `variant`. - */ -export type ThreadListVariant = "compact" | "sidebar"; - -/** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; -const SIDEBAR_ROW_RADIUS = 12; - -function pullRequestTintColor( - pr: Pick, - colorScheme: "light" | "dark", -) { - const dark = colorScheme === "dark"; - if (pr.state === "open" && pr.isDraft === true) { - return dark ? "#a1a1aa" : "#71717a"; - } - switch (pr.state) { - case "open": - return dark ? "#34d399" : "#059669"; - case "merged": - return dark ? "#a78bfa" : "#7c3aed"; - case "closed": - if (pr.kind === "stack" || pr.others > 0) { - return dark ? "#fb7185" : "#e11d48"; - } - return dark ? "#a1a1aa" : "#71717a"; - case null: - return dark ? "#a1a1aa" : "#71717a"; - } -} - -function PullRequestIcon(props: { readonly size: number; readonly color: string }) { - return ( - - - - - - - ); -} - -/* ─── Project group header ───────────────────────────────────────────── */ - -export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { - readonly variant: ThreadListVariant; - readonly project: EnvironmentProject; - readonly title: string; - readonly threadCount: number; - readonly collapsed: boolean; - readonly isFirst: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; - /** Project a quick new thread should target; null hides the button. */ - readonly newThreadTarget?: EnvironmentProject | null; - readonly onNewThread?: (project: EnvironmentProject) => void; -}) { - const { groupKey, onGroupAction, onNewThread } = props; - const newThreadTarget = props.newThreadTarget ?? null; - const compact = props.variant === "compact"; - const handleToggle = useCallback( - () => onGroupAction(groupKey, "toggle-collapsed"), - [groupKey, onGroupAction], - ); - const handleNewThread = useCallback(() => { - if (newThreadTarget) { - onNewThread?.(newThreadTarget); - } - }, [newThreadTarget, onNewThread]); - const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null; - - // The new-thread button is a SIBLING of the collapse toggle, not a child: - // nested touchables are unreachable to VoiceOver/TalkBack (the parent - // swallows focus). Row padding lives on the container (explicit styles — - // dynamic padding classes on Pressable did not apply reliably) so both - // children share one centerline; hitSlop restores the padded tap area. - const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 }; - return ( - - - - - {props.title} - - - {props.threadCount} - - - {showNewThreadButton ? ( - ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })} - > - - - ) : null} - - ); -}); - -/* ─── Show more / show less row ──────────────────────────────────────── */ - -export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props: { - readonly variant: ThreadListVariant; - readonly hiddenCount: number; - readonly canShowLess: boolean; - readonly groupKey: string; - readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; -}) { - const showsMore = props.hiddenCount > 0; - const compact = props.variant === "compact"; - const { groupKey, onGroupAction } = props; - const handleShowMore = useCallback( - () => onGroupAction(groupKey, "show-more"), - [groupKey, onGroupAction], - ); - const handleShowLess = useCallback( - () => onGroupAction(groupKey, "show-less"), - [groupKey, onGroupAction], - ); - - const button = (label: string, icon: "chevron.down" | "chevron.up", onPress: () => void) => ( - ({ - opacity: pressed ? 0.6 : 1, - paddingHorizontal: compact ? 14 : 12, - paddingVertical: compact ? 7 : 6, - borderCurve: "continuous", - })} - > - - - - {label} - - - - ); - - return ( - - {showsMore ? button("Show more", "chevron.down", handleShowMore) : null} - {props.canShowLess ? button("Show less", "chevron.up", handleShowLess) : null} - - ); -}); - -/* ─── Pending task row ───────────────────────────────────────────────── */ - -const PENDING_TASK_MENU_ACTIONS: MenuAction[] = [ - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - -const DRAFT_TASK_MENU_ACTIONS: MenuAction[] = [ - { id: "delete", title: "Discard", image: "trash", attributes: { destructive: true } }, -]; - -/** - * Unsent work: a task queued in the outbox for its environment to reconnect, - * or a draft still sitting in the project's new-task composer. Tapping - * reopens the composer with everything prefilled; the row disappears once - * the work is sent and the real thread arrives. The two kinds differ in what - * happens next, so the pill and icon say which one this is: a queued task - * sends itself, a draft waits for the user. - */ -export const PendingTaskListRow = memo(function PendingTaskListRow(props: { - readonly variant: ThreadListVariant; - readonly pendingTask: PendingNewTask; - readonly environmentLabel: string | null; - readonly environmentMachine?: EnvironmentMachineKind; - readonly isLast: boolean; - readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; - readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; -}) { - const compact = props.variant === "compact"; - - const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props; - const isDraft = pendingTask.kind === "draft"; - const timestamp = isDraft ? null : relativeTime(pendingTask.createdAt); - // The pill only has room for one word, so what happens next goes in the - // subtitle: a queued task sends itself, a draft waits for the user. - const subtitleParts = [ - isDraft ? null : "Sends on reconnect", - props.environmentLabel, - pendingTask.branch, - ].filter((part): part is string => Boolean(part)); - - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "delete") onDeletePendingTask(pendingTask); - }, - [onDeletePendingTask, pendingTask], - ); - - const statusPill = isDraft ? ( - - Draft - - ) : ( - - - Pending - - - ); - - const subtitleRow = - subtitleParts.length > 0 ? ( - - - {props.environmentLabel && props.environmentMachine ? ( - - ) : null} - - {subtitleParts.join(" · ")} - - - ) : null; - - const accessibilityHint = isDraft - ? "Opens the draft in the new task composer" - : "Sends when the environment reconnects. Opens the task for editing"; - - const rowContent = compact ? ( - onSelectPendingTask(pendingTask)} - > - - - - - {pendingTask.title} - - - {statusPill} - {timestamp !== null ? ( - - {timestamp} - - ) : null} - - - - {subtitleRow} - - - - ) : ( - onSelectPendingTask(pendingTask)} - style={{ - borderRadius: SIDEBAR_ROW_RADIUS, - minHeight: 64, - justifyContent: "center", - paddingHorizontal: 12, - paddingVertical: 10, - }} - > - - - - {pendingTask.title} - - - {statusPill} - {timestamp !== null ? ( - - {timestamp} - - ) : null} - - - {subtitleRow} - - - ); - - return ( - - {rowContent} - - ); -}); - -/* ─── Thread row ─────────────────────────────────────────────────────── */ - -const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ - { id: "archive", title: "Archive", image: "archivebox" }, - { id: "rename", title: "Rename", image: "square.and.pencil" }, - { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, -]; - -export const ThreadListRow = memo(function ThreadListRow(props: { - readonly variant: ThreadListVariant; - readonly thread: EnvironmentThreadShell; - readonly environmentLabel: string | null; - readonly environmentMachine?: EnvironmentMachineKind; - /** A message for this thread is waiting in the outbox. */ - readonly hasQueuedMessages?: boolean; - readonly searchMatch?: EnvironmentThreadSearchMatch; - readonly searchQuery?: string; - readonly isLast: boolean; - /** Sidebar only: the thread currently open in the detail pane. */ - readonly selected?: boolean; - /** Defaults to window width minus compact margins. */ - readonly fullSwipeWidth?: number; - readonly onSelectThread: (thread: EnvironmentThreadShell) => void; - readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; - readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; - readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; - readonly onRenameThread: (thread: EnvironmentThreadShell) => void; - readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; - readonly titleRegenerationSupported: boolean; - readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; - readonly onSwipeableClose: (methods: SwipeableMethods) => void; - readonly simultaneousSwipeGesture?: ComponentProps< - typeof ThreadSwipeable - >["simultaneousWithExternalGesture"]; -}) { - const { width: windowWidth } = useWindowDimensions(); - const { themeAppearance: colorScheme } = useAppearancePreferences(); - const compact = props.variant === "compact"; - const selected = props.selected === true; - const visuallySelected = selected && (!compact || Platform.OS === "android"); - const theme = useUniwindTheme(); - const screenColor = theme["--color-screen"]; - const drawerColor = theme["--color-drawer"]; - const selectedBackgroundColor = theme["--color-thread-selected"]; - - const { - thread, - onSelectThread, - onArchiveThread, - onDeleteThread, - onRenameThread, - onRegenerateThreadTitle, - onNewThreadOnBranch, - } = props; - const status = resolveThreadStatus(thread); - const pr = useThreadPr(thread); - const timestamp = relativeTime( - thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, - ); - const threadAccessibilityLabel = [ - thread.title, - pr?.accessibilityLabel, - props.hasQueuedMessages ? "messages queued to send" : null, - ] - .filter(Boolean) - .join(", "); - const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => - Boolean(part), - ); - - const backgroundColor = compact ? screenColor : drawerColor; - - const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); - const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); - const handleRegenerateTitle = useCallback( - () => onRegenerateThreadTitle(thread), - [onRegenerateThreadTitle, thread], - ); - const menuActions = useMemo( - () => [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - title: - Platform.OS === "ios" ? "New thread on branch" : `New thread on ${thread.branch}`, - image: "square.and.pencil", - }, - ] - : []), - THREAD_ROW_MENU_ACTIONS[0]!, - THREAD_ROW_MENU_ACTIONS[1]!, - { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, - ...buildThreadTitleRegenerationMenuItems({ - supported: props.titleRegenerationSupported, - isRegenerating: thread.titleRegeneration != null, - }), - THREAD_ROW_MENU_ACTIONS[2]!, - ], - [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], - ); - const primaryAction = useMemo( - () => ({ - accessibilityLabel: `Archive ${thread.title}`, - icon: "archivebox" as const, - label: "Archive", - onPress: handleArchive, - }), - [handleArchive, thread.title], - ); - const handleMenuAction = useCallback( - ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { - if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); - if (nativeEvent.event === "archive") handleArchive(); - if (nativeEvent.event === "rename") handleRename(); - if (nativeEvent.event === "copy-thread-id") { - copyTextWithHaptic(thread.id, { target: "thread-id" }); - } - if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); - if (nativeEvent.event === "delete") handleDelete(); - }, - [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], - ); - - const statusPill = status ? ( - - {status.label} - - ) : null; - - const subtitleRow = - subtitleParts.length > 0 || pr !== null ? ( - - {subtitleParts.length > 0 ? ( - <> - {props.environmentLabel && props.environmentMachine ? ( - - ) : null} - - {subtitleParts.join(" · ")} - - - ) : null} - {pr !== null ? ( - - {pr.kind === "stack" ? ( - - ) : ( - - )} - - {pr.label} - - - ) : null} - - ) : null; - - const rowContent = (close: () => void) => - compact ? ( - { - close(); - onSelectThread(thread); - }} - > - - - - - {thread.title} - - - {props.hasQueuedMessages ? ( - - ) : null} - {statusPill} - - {timestamp} - - - - - {props.searchMatch ? ( - - ) : null} - {subtitleRow} - - - - ) : ( - { - close(); - onSelectThread(thread); - }} - style={{ - backgroundColor: visuallySelected ? selectedBackgroundColor : backgroundColor, - borderRadius: Platform.OS === "android" ? 20 : SIDEBAR_ROW_RADIUS, - minHeight: 64, - justifyContent: "center", - paddingHorizontal: 12, - paddingVertical: 10, - }} - > - - - - {thread.title} - - - {props.hasQueuedMessages ? ( - - ) : null} - {statusPill} - - {timestamp} - - - - {props.searchMatch ? ( - - ) : null} - {subtitleRow} - - - ); - - return ( - - {(close) => ( - // Messages-style row actions on long-press. iOS: a real - // UIContextMenuInteraction with the row as the zoom preview (needs the - // patched @react-native-menu, see - // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the - // interaction is hosted by the component view and the underlying - // UIButton passes touches through, so row taps keep working). Android: - // ControlPillMenu injects onLongPress into the row and anchors the - // token-styled dropdown to it; taps and swipes are untouched. - - {rowContent(close)} - - )} - - ); -}); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 797781d16a4c..43c5de09c726 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -18,7 +18,6 @@ import type { import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Alert, Pressable, useWindowDimensions, View } from "react-native"; @@ -33,7 +32,6 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; -import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; @@ -69,10 +67,6 @@ const STATUS_LABEL_BY_STATUS: Partial< failed: { label: "Failed", className: "text-danger-foreground" }, }; -function threadTimeLabel(thread: EnvironmentThreadShell): string { - return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); -} - // Menus keep lifecycle and title regeneration together. Archive keeps its // own surface (thread screen / settings) rather than crowding v2 rows. const CARD_MENU_ACTIONS: MenuAction[] = [ @@ -453,8 +447,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; - /** Parent minute tick passed as a prop so this memoized row refreshes its - native snooze menu while mounted. */ + /** Preformatted against the parent clock (row order timestamp: settle stamp + on settled rows, latest activity otherwise). Blank while a status label + or the wake countdown owns that slot. Precomputed per row — not via the + list's extraData — so the minute tick re-renders only rows whose + displayed text moved. */ + readonly timeLabel: string; + /** Parent minute tick carried on the row's list item, present only when the + row's menu offers snooze presets, so those menus refresh while mounted + without invalidating every other row. */ readonly snoozePresetMinute: string; readonly project: EnvironmentProject | null; readonly projectTitle?: string; @@ -549,13 +550,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; - // Settled rows label by the same stamp they sort by, so order and label - // can't disagree. updatedAt is always present, so the resolver never - // returns null here. - const settledTimestamp = - variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; - const timeLabel = - settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); + // The timestamp is precomputed on the list item (same stamps the settled + // tail sorts by) so a minute tick only re-renders rows that draw it. + const timeLabel = props.timeLabel; const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); @@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); + // A recycled cell reassigns this mounted row to a different thread without + // remounting it, and the render closure stops running while list equality + // says the item is unchanged — so any row-local UI state must be dismissed + // when the identity under it changes. Without this, a custom snooze sheet + // opened for one thread survives the thread's removal/reorder and its + // submit snoozes whichever thread the cell was reassigned to. (ThreadSwipeable + // enforces the same contract on the swipe layer with its resetKey.) + const rowIdentity = `${thread.environmentId}:${thread.id}`; + const [boundIdentity, setBoundIdentity] = useState(rowIdentity); + if (boundIdentity !== rowIdentity) { + setBoundIdentity(rowIdentity); + setCustomSnoozeOpen(false); + } const handleSnooze = useCallback( (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), [onSnoozeThread, thread], @@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnsettle, handleUnsnooze, snoozePresets, + setCustomSnoozeOpen, ], ); const primaryAction = useMemo(() => { diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts index 2afef9759070..4912bf2b61ba 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts @@ -8,7 +8,10 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadProviderInstance } from "./thread-provider-instance"; +import { + createThreadRowProviderInstanceResolver, + resolveThreadProviderInstance, +} from "./thread-provider-instance"; function makeConfig( providers: ReadonlyArray<{ @@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => { expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); }); }); + +describe("createThreadRowProviderInstanceResolver", () => { + const environmentId = EnvironmentId.make("environment-a"); + const serverConfigs = new Map([ + [ + environmentId, + makeConfig([ + { instanceId: "codex", driver: "codex", displayName: "Codex" }, + { instanceId: "codex_work", driver: "codex", displayName: "Codex" }, + ]), + ], + ]); + + it("hands out the same reference for repeated lookups of one instance", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + const first = resolve(makeThread(environmentId, "codex")); + const second = resolve(makeThread(environmentId, "codex")); + // Memoized rows compare props by reference: a fresh object per call would + // re-render every row on every parent render (minute tick included). + expect(first).not.toBeNull(); + expect(second).toBe(first); + }); + + it("distinguishes instances of the same driver", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + const personal = resolve(makeThread(environmentId, "codex")); + const work = resolve(makeThread(environmentId, "codex_work")); + expect(personal).not.toBeNull(); + expect(work).not.toBeNull(); + expect(work).not.toBe(personal); + expect(work?.displayName).toBe("Codex Work"); + }); + + it("hands out a new identity when the server-config generation changes", () => { + const before = createThreadRowProviderInstanceResolver(serverConfigs); + const nextConfigs = new Map([ + [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], + ]); + const after = createThreadRowProviderInstanceResolver(nextConfigs); + expect(after(makeThread(environmentId, "codex"))).not.toBe( + before(makeThread(environmentId, "codex")), + ); + }); + + it("resolves unknown instances to null without throwing", () => { + const resolve = createThreadRowProviderInstanceResolver(serverConfigs); + expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); + expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index 29dbe8828b66..cd07f636820c 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -1,3 +1,5 @@ +import { useMemo } from "react"; + import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { normalizeProviderAccentColor, @@ -40,3 +42,33 @@ export function resolveThreadProviderInstance( ), }; } + +/** + * Builds a resolver handing out reference-stable `ThreadRowProviderInstance` + * objects. `resolveThreadProviderInstance` builds a fresh object per call, + * which breaks the memoized row's props comparison on every parent render — + * the result only depends on (environment, instance id), so one cache per + * server-config generation keeps each row's `providerInstance` prop stable + * until the instance behind the row actually changes. + */ +export function createThreadRowProviderInstanceResolver( + serverConfigs: ReadonlyMap, +): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { + const cache = new Map(); + return (thread) => { + const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const cacheKey = `${thread.environmentId}|${instanceId ?? ""}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) return cached; + const resolved = resolveThreadProviderInstance(serverConfigs, thread); + cache.set(cacheKey, resolved); + return resolved; + }; +} + +/** List-scoped wrapper: one cache per server-config generation. */ +export function useThreadRowProviderInstanceResolver( + serverConfigs: ReadonlyMap, +): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { + return useMemo(() => createThreadRowProviderInstanceResolver(serverConfigs), [serverConfigs]); +} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index afc00ef9ab40..4bbcf2233a42 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -6,6 +6,7 @@ import { threadDropLifecycle, reconcilePendingThreadOrder, type PendingThreadOrder, + type ThreadMoveAvailability, } from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; @@ -19,7 +20,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; @@ -27,12 +28,14 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, - resolveThreadListV2Enabled, + isThreadListV2ListItem, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2Status, resolveThreadListV2SwipeActions, sortThreadsForListV2, + threadListV2ListItemsAreEqual, + type ThreadListV2ListItem, } from "./threadListV2"; const environmentId = EnvironmentId.make("environment-1"); @@ -120,29 +123,6 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { }); }); -describe("resolveThreadListV2Enabled", () => { - it("defaults on when the device has never chosen", () => { - expect( - resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: true }), - ).toBe(true); - }); - - it("honors an explicit legacy opt-in", () => { - expect(resolveThreadListV2Enabled({ legacyPreference: true, preferencesLoaded: true })).toBe( - false, - ); - expect(resolveThreadListV2Enabled({ legacyPreference: false, preferencesLoaded: true })).toBe( - true, - ); - }); - - it("holds the default while preferences are still loading so the list does not remount", () => { - expect( - resolveThreadListV2Enabled({ legacyPreference: undefined, preferencesLoaded: false }), - ).toBe(true); - }); -}); - describe("resolveThreadListV2Status", () => { it("prioritizes approval over a running session", () => { const thread = makeThread({ @@ -1482,3 +1462,551 @@ describe("cross-section thread drops", () => { ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); }); }); + +/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */ + +const BASE_MS = Date.parse(NOW); +const isoAt = (ms: number) => new Date(ms).toISOString(); +const MINUTE_MS = 60_000; + +function runningSession(threadId: string) { + return { + threadId: ThreadId.make(threadId), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; +} + +function buildTickThreads() { + return { + ready: makeThread({ + id: ThreadId.make("tick-ready"), + title: "tick ready", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }), + approval: makeThread({ + id: ThreadId.make("tick-approval"), + title: "tick approval", + hasPendingApprovals: true, + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }), + settled: makeThread({ + id: ThreadId.make("tick-settled"), + title: "tick settled", + settledOverride: "settled", + settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), + }), + snoozed: makeThread({ + id: ThreadId.make("tick-snoozed"), + title: "tick snoozed", + snoozedAt: isoAt(BASE_MS - MINUTE_MS), + snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS), + }), + }; +} + +function buildTickList( + threads: ReadonlyArray, + clockMs: number, + pendingTasks: ReadonlyArray, + options?: { + readonly snoozeEnvironmentIds?: ReadonlySet; + readonly queuedThreadKeys?: ReadonlySet; + readonly moveAvailability?: ReadonlyMap; + readonly shelfPreferencesLoading?: boolean; + }, +): ThreadListV2ListItem[] { + const now = isoAt(clockMs); + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now, + snoozedShelfExpanded: true, + }); + return buildThreadListV2ListItems({ + items: layout.items, + pendingTasks, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: true, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + snoozeLabelNow: now, + ...(options?.snoozeEnvironmentIds + ? { snoozeEnvironmentIds: options.snoozeEnvironmentIds } + : {}), + ...(options?.queuedThreadKeys ? { queuedThreadKeys: options.queuedThreadKeys } : {}), + ...(options?.moveAvailability ? { moveAvailability: options.moveAvailability } : {}), + ...(options?.shelfPreferencesLoading !== undefined + ? { shelfPreferencesLoading: options.shelfPreferencesLoading } + : {}), + }); +} + +function itemsByThreadKey(items: ReadonlyArray) { + const byKey = new Map(); + for (const item of items) byKey.set(item.key, item); + return byKey; +} + +describe("threadListV2ListItemsAreEqual", () => { + const thread = makeThread({ + id: ThreadId.make("eq"), + title: "eq", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }); + const layout = buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + now: NOW, + }); + // One queued task object shared across builds: identity, not content, is + // what the row equality compares (mirrors the store's stable references). + const queued = makePendingTask("eq-queued"); + const build = () => + buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [queued], + snoozeLabelNow: NOW, + }); + + it("treats rebuilt wrappers over identical rows as equal", () => { + const first = build(); + const second = build(); + expect(first.length).toBe(second.length); + for (let index = 0; index < first.length; index += 1) { + expect(first[index]).not.toBe(second[index]); + expect(threadListV2ListItemsAreEqual(first[index]!, second[index]!)).toBe(true); + } + }); + + it("notices a replaced thread shell", () => { + const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" }); + const rebuilt = buildThreadListV2Items({ + threads: [replacement], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const next = buildThreadListV2ListItems({ + items: rebuilt.items, + pendingTasks: [], + snoozeLabelNow: NOW, + }); + const previousThread = build().find((item) => item.type === "v2-thread")!; + const nextThread = next.find((item) => item.type === "v2-thread")!; + expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false); + }); + + it("notices a changed wake countdown label", () => { + const snoozedLayout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("wake"), + title: "wake", + snoozedAt: NOW, + snoozedUntil: isoAt(BASE_MS + 61 * MINUTE_MS), + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + snoozedShelfExpanded: true, + }); + const earlier = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [], + snoozedShelfExpanded: true, + snoozeLabelNow: NOW, + }); + const later = buildThreadListV2ListItems({ + items: snoozedLayout.items, + pendingTasks: [], + snoozedShelfExpanded: true, + snoozeLabelNow: isoAt(BASE_MS + MINUTE_MS), + }); + const rows = (items: ThreadListV2ListItem[]) => { + const row = items.find((item) => item.key === `v2-thread:${environmentId}:wake`); + expect(row?.type).toBe("v2-thread"); + if (row?.type !== "v2-thread") throw new Error("missing snoozed row"); + return row; + }; + expect(rows(earlier).snoozeWakeLabelText).toBe("2h"); + expect(rows(later).snoozeWakeLabelText).toBe("1h"); + expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false); + }); + + it("notices shelf count, expansion, and loading-disabled changes", () => { + const shelf = { + type: "v2-settled-shelf", + key: "v2-settled-shelf", + count: 2, + expanded: true, + disabled: false, + } as const; + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true); + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false); + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false); + // A recycled cell ignores the render closure, so the shelf header's + // preference-loading disabled state has to ride on the item too. + expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false); + }); + + it("treats different item kinds as unequal", () => { + const built = build(); + const threadItem = built.find((item) => item.type === "v2-thread")!; + const pendingItem = built.find((item) => item.type === "v2-pending")!; + expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false); + }); + + it("notices a trailing-divider flip caused by a neighbour change", () => { + const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" }); + const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" }); + const bare = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [threadA, threadB], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + snoozeLabelNow: NOW, + }); + // The same shells with B settled: A now sits above the Settled section + // rule instead of another row, so A's hairline must flip through the + // recycled equality — its own shell reference never changed. + const settledB = makeThread({ + id: ThreadId.make("flip-b"), + title: "flip b", + settledOverride: "settled", + settledAt: NOW, + }); + const withSettled = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [threadA, settledB], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + settledCount: 1, + settledShelfHeaderIndex: 1, + snoozeLabelNow: NOW, + }); + const firstA = bare[0]!; + const secondA = withSettled[0]!; + expect(firstA.type === "v2-thread" && firstA.showTrailingDivider).toBe(true); + expect(secondA.type === "v2-thread" && secondA.showTrailingDivider).toBe(false); + expect(threadListV2ListItemsAreEqual(firstA, secondA)).toBe(false); + }); +}); + +describe("isThreadListV2ListItem", () => { + it("narrows the v2 kinds and rejects the legacy discriminators", () => { + expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true); + expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true); + expect(isThreadListV2ListItem({ type: "thread" })).toBe(false); + expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false); + }); +}); + +describe("buildThreadListV2ListItems clock scoping", () => { + const allEnvironments = new Set([environmentId]); + const tickQueued = () => [makePendingTask("tick-queued")]; + + it("carries the snooze menu clock on every row whose swipe menu offers presets", () => { + const threads = buildTickThreads(); + const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), { + snoozeEnvironmentIds: allEnvironments, + }); + const byKey = itemsByThreadKey(items); + const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!; + const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!; + const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!; + const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; + expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW); + // The swipe-revealed snooze action exists on slim rows too (the variant + // only swaps the primary action), so settled rows need the fresh clock. + expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW); + // Approval rows are never snoozable; snoozed rows only offer Wake. + expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined(); + expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined(); + }); + + it("keeps the snooze menu clock off rows on servers without the capability", () => { + const threads = buildTickThreads(); + const items = buildTickList([threads.ready], BASE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + const ready = items.find((item) => item.type === "v2-thread")!; + expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined(); + }); + + it("blanks the precomputed time for rows that render a label instead", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const working = makeThread({ + id: ThreadId.make("tick-working"), + title: "tick working", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + session: runningSession("tick-working"), + }); + const threads = buildTickThreads(); + const items = buildTickList( + [threads.ready, working, threads.settled, threads.snoozed], + BASE_MS, + tickQueued(), + ); + const byKey = itemsByThreadKey(items); + const label = (key: string) => { + const item = byKey.get(key)!; + return item.type === "v2-thread" ? item.timeLabel : ""; + }; + // Ready cards and settled/snoozed slim rows draw a time; the wake + // countdown outranks the time on snoozed rows; cards with a status + // label never draw one. + expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m"); + expect(label(`v2-thread:${environmentId}:tick-working`)).toBe(""); + expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d"); + expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe(""); + const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; + expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("thread list v2 minute tick invalidation", () => { + it("only invalidates rows whose clock-driven content moved", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const threads = buildTickThreads(); + const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed]; + // One queued-task reference shared by both builds: the store hands the + // list the same pending-task objects between rebuilds. + const pendingTasks = [makePendingTask("tick-queued")]; + const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks); + + expect(atStart.length).toBe(atNextMinute.length); + const invalidated: string[] = []; + for (let index = 0; index < atStart.length; index += 1) { + if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) { + invalidated.push(atStart[index]!.key); + } + } + // The ready row draws a minute-granular time and carries the snooze + // menu, and the settled slim row's swipe-revealed snooze menu shows + // preset times too, so both rows' menu content moved. Every other row + // — the approval card (status label, never snoozable), the snoozed + // shelf row ("2h" unchanged, Wake only), the shelf headers, and the + // queued row — survives the tick untouched. + expect(invalidated).toEqual([ + `v2-thread:${environmentId}:tick-ready`, + `v2-thread:${environmentId}:tick-settled`, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const staleReady = makeThread({ + id: ThreadId.make("tick-stale"), + title: "tick stale", + latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS), + }); + const atStart = buildTickList([staleReady], BASE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], { + snoozeEnvironmentIds: new Set(), + }); + expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("re-renders the snoozed countdown row when the wake label advances", () => { + vi.useFakeTimers(); + try { + const wakeAt = BASE_MS + 120 * MINUTE_MS; + const snoozed = makeThread({ + id: ThreadId.make("tick-wake"), + title: "tick wake", + snoozedAt: isoAt(BASE_MS - MINUTE_MS), + snoozedUntil: isoAt(wakeAt), + }); + const wakeKey = `v2-thread:${environmentId}:tick-wake`; + const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!; + vi.setSystemTime(BASE_MS); + const atStart = buildTickList([snoozed], BASE_MS, []); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []); + expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true); + // Minutes round up, so the countdown holds "2h" until the remaining + // time drops to the hour boundary — and only that row flips when it + // finally moves to minute granularity. + vi.setSystemTime(BASE_MS + 61 * MINUTE_MS); + const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []); + const oneHourRow = wakeRow(oneHour); + expect(oneHourRow.type).toBe("v2-thread"); + expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m"); + expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("buildThreadListV2ListItems trailing dividers", () => { + it("follows the final neighbour order, not the pre-splice blocks", () => { + const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" }); + const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" }); + const layout = buildThreadListV2Items({ + threads: [activeA, activeB], + environmentId: null, + searchQuery: "", + now: NOW, + }); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")], + snoozeLabelNow: NOW, + }); + const dividers = items.map((item) => + item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a", + ); + // thread A | thread B | queued 1 | queued 2: consecutive threads keep + // their hairlines, the row before the Unsent section rule loses its own, + // queued rows divide each other, and the last row has nothing under it. + expect(dividers).toEqual([true, false, true, false]); + }); +}); + +describe("buildThreadListV2ListItems row-state stamps", () => { + const readyThread = makeThread({ + id: ThreadId.make("stamp-ready"), + title: "stamp ready", + latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), + }); + const settledThread = makeThread({ + id: ThreadId.make("stamp-settled"), + title: "stamp settled", + settledOverride: "settled", + settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), + }); + const allEnvironments = new Set([environmentId]); + + it("stamps queued outbox messages onto the matching row and notices removal", () => { + // An outbox write never touches the thread shell, so the queued icon has + // to ride on the item for the recycled cell to ever update it. + const queued = buildTickList([readyThread, settledThread], BASE_MS, [], { + queuedThreadKeys: new Set([`${environmentId}:stamp-ready`]), + snoozeEnvironmentIds: allEnvironments, + }); + const plain = buildTickList([readyThread, settledThread], BASE_MS, [], { + queuedThreadKeys: new Set(), + snoozeEnvironmentIds: allEnvironments, + }); + const byKey = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items); + const readyQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-ready`)!; + const readyPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-ready`)!; + const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!; + const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!; + expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true); + expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false); + expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false); + // The neighbour row is untouched by the outbox change. + expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true); + }); + + it("notices move-availability changes on card rows without a shell update", () => { + const permissive = new Map([ + [`${environmentId}:stamp-ready`, { canMoveUp: true, canMoveDown: true }], + [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], + ]); + const blocked = new Map([ + [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], + ]); + const open = buildTickList([readyThread, settledThread], BASE_MS, [], { + moveAvailability: permissive, + snoozeEnvironmentIds: allEnvironments, + }); + const closed = buildTickList([readyThread, settledThread], BASE_MS, [], { + moveAvailability: blocked, + snoozeEnvironmentIds: allEnvironments, + }); + const readyOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-ready`)!; + const readyClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-ready`)!; + expect(readyOpen.type === "v2-thread" && readyOpen.canMoveUp).toBe(true); + expect(readyClosed.type === "v2-thread" && readyClosed.canMoveUp).toBe(false); + expect(threadListV2ListItemsAreEqual(readyOpen, readyClosed)).toBe(false); + // Slim rows never carry the move actions, so availability is inert there. + const settledOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-settled`)!; + const settledClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-settled`)!; + expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false); + expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true); + }); + + it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(BASE_MS); + const atStart = buildTickList([settledThread], BASE_MS, [], { + snoozeEnvironmentIds: allEnvironments, + }); + vi.setSystemTime(BASE_MS + MINUTE_MS); + const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], { + snoozeEnvironmentIds: allEnvironments, + }); + const rowAtStart = atStart.find((item) => item.type === "v2-thread")!; + const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!; + // The swipe-revealed secondary action carries the snooze preset menu on + // slim rows; its minute clock must move, or the displayed wake times + // drift while the row is recycled-stable. + expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim"); + expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS)); + expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe( + isoAt(BASE_MS + MINUTE_MS), + ); + expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("stamps the shelf loading-disabled state so recycled headers refresh", () => { + const loading = buildTickList([settledThread], BASE_MS, [], { + shelfPreferencesLoading: true, + snoozeEnvironmentIds: allEnvironments, + }); + const loaded = buildTickList([settledThread], BASE_MS, [], { + shelfPreferencesLoading: false, + snoozeEnvironmentIds: allEnvironments, + }); + const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!; + const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!; + expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true); + expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false); + expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index e1cb8e9ece7f..9a876c899ac4 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,5 +1,6 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { + canSnooze, effectiveSnoozed, hasQueuedTurnStart, QUEUED_TURN_START_GRACE_MS, @@ -16,6 +17,9 @@ import { } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { ThreadMoveAvailability } from "./threadOrder"; + +import { relativeTime } from "../../lib/time"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -112,26 +116,6 @@ export function resolveThreadListV2SnoozeGateExpiryMs( export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; -/** - * The flat Thread List v2 is the default on every app variant; the Settings → - * Legacy toggle opts a device back into the grouped legacy list. Preferences - * persist as sparse patches, so `undefined` genuinely means "never chosen". - * - * `preferencesLoaded` guards the startup window: preferences load - * asynchronously, and rendering one list before the stored choice arrives would - * remount the whole thing a tick later. While loading, hold the default — that - * is where every device without an explicit legacy opt-in lands anyway. - */ -export function resolveThreadListV2Enabled(input: { - readonly legacyPreference: boolean | undefined; - readonly preferencesLoaded: boolean; -}): boolean { - if (!input.preferencesLoaded) { - return true; - } - return input.legacyPreference !== true; -} - export function resolveThreadListV2Status( thread: Pick, ): ThreadListV2Status { @@ -244,6 +228,28 @@ export interface ThreadListV2ThreadListItem { readonly item: ThreadListV2Item; /** Precomputed so recycled-list equality can see a minute-tick change. */ readonly snoozeWakeLabelText: string | undefined; + /** Row timestamp precomputed against the parent clock so the recycler's + equality only sees a change on rows that actually draw a time. Blank + while the row renders a status label or the wake countdown instead. */ + readonly timeLabel: string; + /** Minute clock feeding the row's native snooze menu, carried only on rows + whose menu holds snooze presets (capability-gated cards the user may + snooze). Keeping it out of the list's `extraData` means the minute tick + re-renders just these rows instead of every visible one. */ + readonly snoozePresetMinute: string | undefined; + /** Inset hairline drawn under the row. Precomputed from the final order so + a neighbour change (e.g. the queued block appearing) updates the row + through recycled-list equality instead of leaving a stale divider. */ + readonly showTrailingDivider: boolean; + /** A message for this thread is waiting in the outbox. Carried on the item + so an outbox write (which never touches the thread shell) reaches the + row through recycled-list equality instead of leaving a stale icon. */ + readonly hasQueuedMessages: boolean; + /** Move up/down availability in the row's card section. Carried on the item + for the same reason: a reorder in flight (or its commit) changes menu + availability without changing any shell. */ + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; } export interface ThreadListV2PendingListItem { @@ -252,6 +258,9 @@ export interface ThreadListV2PendingListItem { readonly pendingTask: PendingNewTask; /** First queued row after the active block draws the PENDING divider. */ readonly showPendingDivider: boolean; + /** Same rule as the thread rows: a hairline unless the next row carries its + own section rule or none follows. */ + readonly showTrailingDivider: boolean; } export interface ThreadListV2SnoozedShelfListItem { @@ -259,6 +268,11 @@ export interface ThreadListV2SnoozedShelfListItem { readonly key: "v2-snoozed-shelf"; readonly count: number; readonly expanded: boolean; + /** Shelf preferences still loading: the toggle is disabled until they + arrive. Carried on the item because a recycled cell ignores the render + closure — without it the header would stay visibly disabled (or enabled + too early) after the preference load lands. */ + readonly disabled: boolean; } export interface ThreadListV2SettledShelfListItem { @@ -266,6 +280,8 @@ export interface ThreadListV2SettledShelfListItem { readonly key: "v2-settled-shelf"; readonly count: number; readonly expanded: boolean; + /** See the snoozed shelf header's field. */ + readonly disabled: boolean; } export type ThreadListV2ListItem = @@ -274,6 +290,90 @@ export type ThreadListV2ListItem = | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; +/** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to + the v2 item kinds the shared equality understands. */ +export function isThreadListV2ListItem(value: { + readonly type: string; +}): value is ThreadListV2ListItem { + return ( + value.type === "v2-thread" || + value.type === "v2-pending" || + value.type === "v2-snoozed-shelf" || + value.type === "v2-settled-shelf" + ); +} + +/** Recycled-list equality for the flat v2 list (Home + iPad sidebar). + Item objects are rebuilt on every minute tick and every partition run; + without this the lists would consider every mounted row changed and + re-render all of them (each carrying a swipeable + a PR subscription). + The clock-derived fields are per-row precomputed text, so a minute tick + only flips equality on rows whose visible text (or snooze menu) actually + moved. Thread references are stable across rebuilds. */ +export function threadListV2ListItemsAreEqual( + previous: ThreadListV2ListItem, + item: ThreadListV2ListItem, +): boolean { + switch (item.type) { + case "v2-thread": + return ( + previous.type === "v2-thread" && + previous.key === item.key && + previous.item.thread === item.item.thread && + previous.item.variant === item.item.variant && + previous.item.snoozed === item.item.snoozed && + previous.item.pinned === item.item.pinned && + previous.snoozeWakeLabelText === item.snoozeWakeLabelText && + previous.timeLabel === item.timeLabel && + previous.snoozePresetMinute === item.snoozePresetMinute && + previous.showTrailingDivider === item.showTrailingDivider && + previous.hasQueuedMessages === item.hasQueuedMessages && + previous.canMoveUp === item.canMoveUp && + previous.canMoveDown === item.canMoveDown + ); + case "v2-pending": + return ( + previous.type === "v2-pending" && + previous.key === item.key && + previous.pendingTask === item.pendingTask && + previous.showPendingDivider === item.showPendingDivider && + previous.showTrailingDivider === item.showTrailingDivider + ); + case "v2-snoozed-shelf": + return ( + previous.type === "v2-snoozed-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); + case "v2-settled-shelf": + return ( + previous.type === "v2-settled-shelf" && + previous.count === item.count && + previous.expanded === item.expanded && + previous.disabled === item.disabled + ); + } +} + +/** The timestamp a row renders when it shows no status label: the settle + stamp on settled slim rows, otherwise the latest activity. Blank for + status-labelled cards and snoozed rows with a wake countdown — those + never draw a time, so their minute tick must not invalidate the cell. */ +function resolveThreadListV2ItemTimeLabel( + item: ThreadListV2Item, + showSnoozeWakeLabel: boolean, +): string { + const { thread, variant, snoozed } = item; + if (showSnoozeWakeLabel) return ""; + if (variant === "card" && resolveThreadListV2Status(thread) !== "ready") return ""; + const settledTimestamp = + variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null; + return relativeTime( + settledTimestamp ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, + ); +} + /** * Builds the shared mobile order: active → pending → snoozed shelf → settled. * Pending tasks are waiting rather than asking, and parked work remains @@ -289,21 +389,64 @@ export function buildThreadListV2ListItems(input: { readonly settledShelfExpanded?: boolean; readonly settledShelfHeaderIndex?: number | null; readonly snoozeLabelNow?: string; + /** Environments whose server supports thread.snooze. Rows on other + environments never carry the minute clock that feeds the snooze menu. + Absent = no gating (tests). */ + readonly snoozeEnvironmentIds?: ReadonlySet; + /** Thread keys (`environmentId:threadId`) with a message waiting in the + outbox; stamped onto the matching rows as `hasQueuedMessages`. */ + readonly queuedThreadKeys?: ReadonlySet; + /** Menu availability for Move up/down, keyed by `environmentId:threadId` + (only consulted for card rows — slim menus omit the moves). Produced by + `computeThreadMoveAvailability` in one pass per section; a recycled cell + ignores the render closure, so the stamps ride on the item. Absent = + never available (tests). */ + readonly moveAvailability?: ReadonlyMap; + /** True while the shelf expansion preferences are still loading; stamped + onto both shelf headers so the disabled state reaches recycled cells. */ + readonly shelfPreferencesLoading?: boolean; }): ThreadListV2ListItem[] { - const threadItems = input.items.map((item): ThreadListV2ListItem => ({ - type: "v2-thread", - key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, - item, - snoozeWakeLabelText: + const threadItems = input.items.map((item): ThreadListV2ListItem => { + const snoozeWakeLabelText = item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) - : undefined, - })); + : undefined; + // The minute clock belongs on the item, not the list's extraData, so the + // recycler's equality can confine the per-minute re-render to rows whose + // snooze menu actually shows preset times. The swipe-revealed snooze menu + // exists on slim rows too (the variant only swaps the primary action), + // so the gate follows actual snooze availability, not the variant. + const snoozePresetMinute = + !item.snoozed && + input.snoozeLabelNow !== undefined && + (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) && + canSnooze(item.thread, { now: input.snoozeLabelNow }) + ? input.snoozeLabelNow + : undefined; + const move = + item.variant === "card" + ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) + : undefined; + return { + type: "v2-thread", + key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, + item, + snoozeWakeLabelText, + timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined), + snoozePresetMinute, + showTrailingDivider: false, + hasQueuedMessages: + input.queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true, + canMoveUp: move?.canMoveUp === true, + canMoveDown: move?.canMoveDown === true, + }; + }); const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ type: "v2-pending", key: `v2-${pendingTask.key}`, pendingTask, showPendingDivider: index === 0, + showTrailingDivider: false, })); const snoozedCount = input.snoozedCount ?? 0; const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; @@ -312,12 +455,14 @@ export function buildThreadListV2ListItems(input: { const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; + const shelfDisabled = input.shelfPreferencesLoading === true; if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { result.push({ type: "v2-snoozed-shelf", key: "v2-snoozed-shelf", count: snoozedCount, expanded: input.snoozedShelfExpanded === true, + disabled: shelfDisabled, }); result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); } @@ -327,10 +472,21 @@ export function buildThreadListV2ListItems(input: { key: "v2-settled-shelf", count: settledCount, expanded: input.settledShelfExpanded !== false, + disabled: shelfDisabled, }); result.push(...threadItems.slice(settledShelfHeaderIndex)); } - return result; + // Hairlines depend on the final neighbour, so they are stamped after the + // splice: a recycled cell only re-renders when its divider actually flips. + return result.map((entry, index) => { + if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry; + const next = result[index + 1]; + const showTrailingDivider = + next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider); + return showTrailingDivider === entry.showTrailingDivider + ? entry + : { ...entry, showTrailingDivider }; + }); } /** diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts index c71c1040bf40..570251f4a985 100644 --- a/apps/mobile/src/features/threads/threadOrder.ts +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -1,5 +1,9 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, + planPinnedReorder, +} from "@t3tools/client-runtime/state/thread-sort"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -42,7 +46,7 @@ export function threadOrderAfterMove( return result; } -type OrderRow = Pick< +export type OrderRow = Pick< EnvironmentThreadShell, | "id" | "environmentId" @@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: { }; } +export interface ThreadMoveAvailability { + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; +} + +/** + * Batch form of "call `createThreadMovePlanner` once per card": one pass over + * the section answers up/down availability for every ordered row, so list + * construction stays linear instead of one full planner probe (array copies, + * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of + * the planner's plan rules lives in the body below; the reference-parity test + * pins them row-by-row, including adversarial keys and hidden reservations. + */ +export function computeThreadMoveAvailability(input: { + readonly ordered: readonly OrderRow[]; + readonly allThreads?: readonly OrderRow[]; + readonly section: PendingThreadOrder["section"]; + readonly reorderableEnvironmentIds: ReadonlySet; + readonly pendingOrder?: PendingThreadOrder | null; +}): Map { + const result = new Map(); + // A reorder in flight locks the whole list until its receipt lands. + if (input.pendingOrder != null) return result; + const rows = input.ordered; + const orderedIds = rows.map(rowId); + const indexById = new Map(orderedIds.map((id, index) => [id, index] as const)); + const keysById = new Map( + (input.allThreads ?? input.ordered).map( + (row) => [rowId(row), rowOrder(row, input.section).key] as const, + ), + ); + const writableIds = new Set( + (input.allThreads ?? input.ordered) + .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) + .map(rowId), + ); + const visibleIds = new Set(orderedIds); + const reservedKeys = new Set( + [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), + ); + // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is + // answered in O(1) amortized instead of one planner probe per row: + // + // Fast path (both neighbors keyed): the fresh key between the landing + // neighbors, walking forward while hidden reserved keys block it. The walk + // depends only on the neighbor key pair - each adjacency is probed by at + // most two rows (down of the left member, up of the right member) - so the + // memo keeps even a fully adversarial reserved-key layout linear per build. + // + // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh + // spread keys for every row; only positions whose current key differs get + // written. A swap permutes two rows without changing the multiset, so the + // assignment set differs from the unswapped baseline at at most those two + // positions, and mismatch/writability tallies computed once per section + // answer each row with a constant-size delta. + const midpoints = new Map(); + const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => { + const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`; + const cached = midpoints.get(memoKey); + if (cached !== undefined) return cached; + let key = pinOrderKeyBetween(beforeKey, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); + midpoints.set(memoKey, key); + return key; + }; + const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) + .filter((key) => !reservedKeys.has(key)) + .slice(0, orderedIds.length); + const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null); + const writableRow = orderedIds.map((id) => writableIds.has(id)); + let baselineWrites = 0; + let baselineUnwritableWrites = 0; + for (let position = 0; position < orderedIds.length; position += 1) { + if (currentKeys[position] === spreadKeys[position]) continue; + baselineWrites += 1; + if (!writableRow[position]) baselineUnwritableWrites += 1; + } + // `movedId` swaps with its neighbor; ids at the two swapped positions change, + // so only their tally contributions are recomputed. + const rewriteViable = (index: number): boolean => { + let writes = baselineWrites; + let unwritableWrites = baselineUnwritableWrites; + for (const position of [index, index + 1]) { + const other = position === index ? index + 1 : index; + if (currentKeys[position] !== spreadKeys[position]) { + writes -= 1; + if (!writableRow[position]) unwritableWrites -= 1; + } + // After the swap this position holds the row that was at `other`. + if (currentKeys[other] !== spreadKeys[position]) { + writes += 1; + if (!writableRow[other]) unwritableWrites += 1; + } + } + return writes > 0 && unwritableWrites === 0; + }; + for (const row of rows) { + const movedId = rowId(row); + const denied = { canMoveUp: false, canMoveDown: false }; + if (!writableIds.has(movedId)) { + result.set(movedId, denied); + continue; + } + const index = indexById.get(movedId); + if (index === undefined) { + result.set(movedId, denied); + continue; + } + const adjacentAvailable = (towardUp: boolean): boolean => { + const shifted = index + (towardUp ? -1 : 1); + if (shifted < 0 || shifted >= orderedIds.length) return false; + // The swap exchanges the row with its neighbor; afterwards the moved row + // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD + // order: moving up it lands between old(index-2) and old(index-1), + // moving down between old(index+1) and old(index+2). + const beforeIndex = towardUp ? index - 2 : index + 1; + const afterIndex = towardUp ? index - 1 : index + 2; + const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null); + const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null); + const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null); + const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null); + if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) { + const key = fastPathKey(beforeKey, afterKey); + // A fresh key is a single-write plan for the (writable) moved row. + if (key !== null) return true; + } + // Keyless neighbor or exhausted key space: the section rewrite runs. + return rewriteViable(Math.min(index, shifted)); + }; + result.set(movedId, { + canMoveUp: adjacentAvailable(true), + canMoveDown: adjacentAvailable(false), + }); + } + return result; +} + export function createPendingThreadOrder(input: { readonly section: PendingThreadOrder["section"]; readonly ordered: readonly OrderRow[]; diff --git a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts new file mode 100644 index 000000000000..765eff4cf61e --- /dev/null +++ b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, +} from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import { + computeThreadMoveAvailability, + createThreadMovePlanner, + type OrderRow, +} from "./threadOrder"; + +// The batch availability computation must answer exactly what the reference +// per-move planner answers; these tests randomize sections that stress every +// branch (keyless rows, hidden keys, non-writable rows, adversarial keys). + +function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow { + return { + id: id as OrderRow["id"], + environmentId: environmentId as OrderRow["environmentId"], + pinOrderKey: pinned ? key : null, + activeOrderKey: pinned ? null : key, + createdAt: "2026-01-01T00:00:00.000Z", + unsettledAt: "2026-01-01T00:00:00.000Z", + pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null, + }; +} + +function makeRng(seed: number) { + let state = seed >>> 0; + return () => { + state = (state * 1_664_525 + 1_013_904_223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +const KEY_POOL = [ + "a", + "b", + "ba", + "bb", + "c", + "d", + "ca", + "cb", + "da", + "db", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + null, +]; + +function referenceAvailability( + ordered: readonly OrderRow[], + allThreads: readonly OrderRow[], + writable: ReadonlySet, +) { + const planner = createThreadMovePlanner({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: writable, + }); + const answers = new Map(); + for (const row of ordered) { + const movedId = `${row.environmentId}:${row.id}`; + answers.set(movedId, { + canMoveUp: planner(movedId, "up") !== null, + canMoveDown: planner(movedId, "down") !== null, + }); + } + return answers; +} + +function randomCase(rng: () => number) { + const rowCount = 1 + Math.floor(rng() * 9); + // Two environments; "writable" env vs. one lacking the reorder capability. + const rows: OrderRow[] = []; + for (let index = 0; index < rowCount; index += 1) { + const environment = rng() < 0.75 ? "env-w" : "env-x"; + const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; + // Ids with colons: a `${environmentId}:${id}` string is not splittable + // back into its parts, so batch and planner must agree even here. + const id = rng() < 0.4 ? `t:${index}` : `t${index}`; + rows.push(makeRow(id, environment, key, true)); + } + // Hidden rows (in allThreads, not in the visible ordered section) may hold + // keys that collide with fast-path midpoints. + const hidden: OrderRow[] = []; + const hiddenCount = Math.floor(rng() * 4); + for (let index = 0; index < hiddenCount; index += 1) { + const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; + const id = rng() < 0.4 ? `h:${index}` : `h${index}`; + hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true)); + } + return { ordered: rows, allThreads: [...rows, ...hidden] }; +} + +const WRITABLE = new Set(["env-w" as EnvironmentId]); + +describe("computeThreadMoveAvailability matches the reference planner", () => { + it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => { + for (let seed = 1; seed <= 4_000; seed += 1) { + const rng = makeRng(seed); + const { ordered, allThreads } = randomCase(rng); + const batch = computeThreadMoveAvailability({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(ordered, allThreads, WRITABLE); + for (const [id, answer] of reference) { + expect( + batch.get(id) ?? { canMoveUp: false, canMoveDown: false }, + `seed ${seed} row ${id} ordered=${ordered + .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`) + .join(",")}`, + ).toEqual(answer); + } + } + }); + + it("locks every row while a pending reorder is in flight", () => { + const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)]; + const pending = { + section: "pinned" as const, + orderedIds: ["env-w:t1", "env-w:t0"], + before: new Map(), + assignments: new Map(), + confirmed: new Set(), + commandsComplete: false, + }; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + pendingOrder: pending, + }); + expect(batch.size).toBe(0); + }); + + it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => { + // Reported parity case: keys ["f","gn",null], middle row NOT writable. + // The rewrite assigns the middle row the key it already holds, so the + // diff never writes it and the first row's down-move stays available. + const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)]; + const withThird = [...rows, makeRow("t2", "env-w", null, true)]; + const batch = computeThreadMoveAvailability({ + ordered: withThird, + allThreads: withThird, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(withThird, withThird, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true }); + }); + + it("keeps moves available for ids containing colons (composite-id parsing)", () => { + // The reported case: environment `env`, ids `thread:1`/`thread:2`. Splitting + // the composite id at the last colon yields `env:thread` and falsely locks + // both rows; writability must come from the row's own environmentId. + const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: new Set(["env" as EnvironmentId]), + }); + const reference = referenceAvailability( + rows, + rows, + new Set(["env" as EnvironmentId]), + ); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true }); + expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false }); + }); + + it("denies single-row sections on both directions", () => { + const rows = [makeRow("t0", "env-w", "a", true)]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false }); + }); + + it("denies rows whose section has non-writable neighbours when the fast path fails", () => { + // Keyless neighbors force the section-rewrite fallback; a non-writable + // neighbor makes the rewrite illegal for every row in the section. + const rows = [ + makeRow("t0", "env-w", null, true), + makeRow("t1", "env-x", "c", true), + makeRow("t2", "env-w", "d", true), + ]; + const batch = computeThreadMoveAvailability({ + ordered: rows, + allThreads: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(rows, rows, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + // Sanity: the middle (non-writable) row is denied on both sides. + expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false }); + }); + + it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => { + // The worst case for reserved-key collisions: one hidden row holds the + // exact midpoint key of every adjacent visible pair, so every probe walks. + const visibleKeys = generateSpreadPinOrderKeys(24); + const hiddenKeys = visibleKeys + .slice(0, -1) + .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!)) + .filter((key): key is string => key != null); + const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true)); + const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true)); + const allThreads = [...ordered, ...hidden]; + const batch = computeThreadMoveAvailability({ + ordered, + allThreads, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(ordered, allThreads, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + }); + + it("agrees on an adversarial section of consecutive single-char keys", () => { + // Every midpoint between consecutive one-char keys is unrepresentable, so + // no row may claim a fast-path plan. + const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true)); + const batch = computeThreadMoveAvailability({ + ordered: rows, + allThreads: rows, + section: "pinned", + reorderableEnvironmentIds: WRITABLE, + }); + const reference = referenceAvailability(rows, rows, WRITABLE); + expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts deleted file mode 100644 index b5f286937e0c..000000000000 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { StatusTone } from "../../components/StatusPill"; -import type { OrchestrationLatestTurn, OrchestrationSession } from "@t3tools/contracts"; -import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -export type ThreadStatusKind = - | "pending-approval" - | "awaiting-input" - | "working" - | "connecting" - | "error" - | "plan-ready"; - -export interface ThreadStatusPresentation extends StatusTone { - readonly kind: ThreadStatusKind; - /** Whether the indicator represents in-flight activity. */ - readonly pulse: boolean; -} - -function isLatestTurnSettled( - latestTurn: OrchestrationLatestTurn | null, - session: OrchestrationSession | null, -): boolean { - if (!latestTurn?.startedAt) return false; - if (!latestTurn.completedAt) return false; - if (!session) return true; - return session.status !== "running"; -} - -/** - * Resolves the user-facing status of a thread, in priority order. Returns - * `null` for quiescent threads so rows stay free of "Idle"-style noise. - * Mirrors `resolveThreadStatusPill` in apps/web/src/components/Sidebar.logic.ts. - */ -export function resolveThreadStatus( - thread: EnvironmentThreadShell, -): ThreadStatusPresentation | null { - if (thread.hasPendingApprovals) { - return { - kind: "pending-approval", - label: "Needs Approval", - pillClassName: "bg-warning", - textClassName: "text-warning-foreground", - pulse: false, - }; - } - - if (thread.hasPendingUserInput) { - return { - kind: "awaiting-input", - label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-600-300", - pulse: false, - }; - } - - if (thread.session?.status === "running") { - return { - kind: "working", - label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-600-400", - pulse: true, - }; - } - - if (thread.session?.status === "starting") { - return { - kind: "connecting", - label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-600-400", - pulse: true, - }; - } - - if (thread.session?.status === "error" || thread.latestTurn?.state === "error") { - return { - kind: "error", - label: "Error", - pillClassName: "bg-danger", - textClassName: "text-danger-foreground", - pulse: false, - }; - } - - const hasPlanReadyPrompt = - thread.interactionMode === "plan" && - isLatestTurnSettled(thread.latestTurn, thread.session) && - thread.hasActionableProposedPlan; - if (hasPlanReadyPrompt) { - return { - kind: "plan-ready", - label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-600-400", - pulse: false, - }; - } - - return null; -} diff --git a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts b/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts deleted file mode 100644 index 2672942c2d36..000000000000 --- a/apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { mobilePreferencesAtom } from "../../state/preferences"; -import { resolveThreadListV2Enabled } from "./threadListV2"; - -/** - * Resolved Thread List v2 state: on unless the device opted into the legacy - * grouped list (Settings → Legacy). Every consumer must read through this - * rather than the raw preference, which is undefined until explicitly chosen. - */ -export function useThreadListV2Enabled(): boolean { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const loaded = AsyncResult.isSuccess(preferencesResult); - return resolveThreadListV2Enabled({ - legacyPreference: loaded ? preferencesResult.value.legacyThreadListEnabled : undefined, - preferencesLoaded: loaded, - }); -} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index d1bec8cbc140..7494ac9b34bd 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -267,20 +267,20 @@ export function UsageRouteScreen() { <> {/* Period and metric together: neither applies to Limits, and both change every number below, so they share one bar. */} - + {merged.duplicateSources.length > 0 ? ( diff --git a/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs new file mode 100644 index 000000000000..8d8017f28f83 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.fixture.cjs @@ -0,0 +1,183 @@ +"use strict"; + +// Fixture for uniwind-platform-variants.test.ts. Run as a plain Node process +// (`node uniwind-platform-variants.fixture.cjs `) so it executes the +// real compiler with no test-runner transforms in the way. Prints one JSON +// object to stdout describing what each platform bundle compiled. +// +// It reproduces what the Metro transformer does for `global.css`: compile the +// Tailwind entry, then run uniwind's CSS processor and stylesheet serializer +// once per platform. Loading `dist/common` needs a small custom loader because +// uniwind declares `"type": "module"` over that CommonJS output and its +// internal `@/...` import alias only exists at uniwind's own build time; the +// loader hands unmodified file bytes to V8 and resolves those aliases the same +// way, so this runs the exact compiled code the Metro transformer bundles. +const fs = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +function createUniwindCompilerLoader(uniwindPackageJson) { + const uniwindRoot = path.dirname(fs.realpathSync(uniwindPackageJson)); + const uniwindRequire = createRequire(path.join(uniwindRoot, "package.json")); + const cache = new Map(); + + const load = (absoluteFileWithoutExtension) => { + const resolved = [ + absoluteFileWithoutExtension, + `${absoluteFileWithoutExtension}.js`, + path.join(absoluteFileWithoutExtension, "index.js"), + ].find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }); + if (resolved === undefined) { + throw new Error(`Cannot resolve uniwind module ${absoluteFileWithoutExtension}`); + } + const cached = cache.get(resolved); + if (cached !== undefined) return cached; + + const cjsModule = { exports: {} }; + + const localRequire = (request) => { + if (request.startsWith("@/")) { + return load(path.join(uniwindRoot, "dist/common", request.slice(2))); + } + if (request.startsWith(".")) { + return load(path.resolve(path.dirname(resolved), request)); + } + // uniwind's own dependencies (lightningcss, culori, ...) resolve through + // the package's dependency tree, like they do inside the Metro bundle. + return uniwindRequire(request); + }; + + const code = fs.readFileSync(resolved, "utf8"); + new Function("require", "module", "exports", code)(localRequire, cjsModule, cjsModule.exports); + cache.set(resolved, cjsModule.exports); + return cjsModule.exports; + }; + + const loadBundler = (request) => load(path.join(uniwindRoot, "dist/common", request)); + return { loadBundler, uniwindRequire, uniwindRoot }; +} + +async function main() { + const fixtureDir = fs.realpathSync(path.resolve(process.argv[2])); + const uniwindPackageJson = require.resolve("uniwind/package.json", { + paths: [fixtureDir], + }); + const { loadBundler, uniwindRequire, uniwindRoot } = + createUniwindCompilerLoader(uniwindPackageJson); + const { ProcessorBuilder } = loadBundler("bundler/css-processor/processor.js"); + const { addMetaToStylesTemplate } = loadBundler( + "bundler/css-processor/addMetaToStylesTemplate.js", + ); + const { compileNativeCSS } = loadBundler("bundler/css-compiler/compileNativeCSS.js"); + + const { compile } = uniwindRequire("@tailwindcss/node"); + const { Scanner } = uniwindRequire("@tailwindcss/oxide"); + const compiler = await compile(fs.readFileSync(path.join(fixtureDir, "global.css"), "utf8"), { + base: fixtureDir, + onDependency: () => {}, + }); + const scanner = new Scanner({ + sources: [...compiler.sources, { negated: false, pattern: "**/*.tsx", base: fixtureDir }], + }); + const tailwindCSS = await compiler.build(scanner.scan()); + + const platforms = {}; + // Every ios:/android: utility class Tailwind generated for the fixture. + const compiledGuardedClasses = {}; + for (const match of tailwindCSS.matchAll(/\.(ios|android)\\:([^{ ,]+) \{/g)) { + compiledGuardedClasses[`${match[1]}:${match[2]}`.replace(/\\(.)/g, "$1")] = true; + } + const platformBlock = (name) => + new RegExp(`@media ${name} \\{([\\s\\S]*?)\\n \\}`).exec(tailwindCSS)?.[1] ?? ""; + const utilityCountIn = (block) => (block.match(/^\s+\.[^ ]+ \{/gm) ?? []).length; + const tailwindChecks = { + iosBlocks: (tailwindCSS.match(/@media ios \{/g) ?? []).length, + androidBlocks: (tailwindCSS.match(/@media android \{/g) ?? []).length, + iosUtilities: utilityCountIn(platformBlock("ios")), + androidUtilities: utilityCountIn(platformBlock("android")), + }; + for (const platform of ["ios", "android"]) { + // Same config the app's withUniwindConfig produces (polyfills.rem: 14). + const bundlerConfig = { platform, themes: ["light", "dark"], polyfills: { rem: 14 } }; + const processor = new ProcessorBuilder(bundlerConfig); + processor.transform(tailwindCSS); + const compiled = addMetaToStylesTemplate(processor, platform); + const payload = compileNativeCSS(bundlerConfig, tailwindCSS); + const styles = {}; + for (const [className, entries] of Object.entries(compiled)) { + styles[className] = entries.map( + ({ native, minWidth, maxWidth, active, focus, disabled, dataAttributes }) => ({ + native, + minWidth, + maxWidth, + active: active ?? null, + focus: focus ?? null, + disabled: disabled ?? null, + dataAttributes: dataAttributes ?? null, + }), + ); + } + platforms[platform] = { + styles, + payloadIncludesAllCompiled: Object.keys(compiled).every((className) => + payload.includes(`"${className}"`), + ), + // A utility dropped from this platform's stylesheet must not appear + // anywhere in the payload the bundle embeds for this platform. + payloadLeaks: Object.keys(compiledGuardedClasses) + .filter((className) => compiled[className] === undefined) + .filter((className) => payload.includes(`"${className}"`)), + }; + } + + // Exercise the real Metro transformer entry point (the shipped + // `dist/metro/transformer.cjs`, not a copy) with a stubbed downstream worker, + // to check what the global.css virtual module actually becomes — including + // the native styles fingerprint that lets dev reloads skip reinitializing. + const transformerPath = path.join(uniwindRoot, "dist/metro/transformer.cjs"); + let reinitCode = ""; + const Module = require("node:module"); + const origLoad = Module._load; + Module._load = function (request, ...rest) { + if (request === "metro-transform-worker") { + return { + transform: async (_config, _root, _file, data) => { + reinitCode = data.toString(); + return { output: [{ data: {} }] }; + }, + }; + } + return origLoad.call(this, request, ...rest); + }; + process.chdir(fixtureDir); + try { + const { transform } = require(transformerPath); + await transform( + { uniwind: { cssEntryFile: "./global.css", isExpoProject: false } }, + fixtureDir, + "global.css", + Buffer.from(""), + { platform: "ios", type: "module" }, + ); + } finally { + Module._load = origLoad; + } + const transformerCheck = { + reinitPayload: reinitCode.includes("Uniwind.__reinit(rt =>"), + fingerprintArg: /, '[0-9a-f]{64}'\);\s*$/.test(reinitCode), + themesArg: reinitCode.includes("['light', 'dark']"), + }; + + process.stdout.write(JSON.stringify({ tailwindChecks, transformerCheck, platforms })); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/mobile/src/lib/uniwind-platform-variants.test.ts b/apps/mobile/src/lib/uniwind-platform-variants.test.ts new file mode 100644 index 000000000000..12fa86b33c08 --- /dev/null +++ b/apps/mobile/src/lib/uniwind-platform-variants.test.ts @@ -0,0 +1,253 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +// Regression guard for the uniwind platform-variant leak (audit #15 / #13161): +// Tailwind groups every `ios:` (or `android:`) utility into one shared +// `@media ios { ... }` block, and uniwind's CSS processor used to drop the +// block's media queries after its first nested rule. Everything past the first +// utility compiled unguarded and shipped to both platforms. These tests run +// the installed (patched) uniwind compiler over real Tailwind output, per +// platform, and assert what each bundle receives. The compiler itself runs in +// a plain Node child process (uniwind-platform-variants.fixture.cjs) so no +// test-runner module transforms sit between the test and the shipped code. + +interface CompiledStyle { + native: boolean; + minWidth: number; + maxWidth: number; + active: boolean | null; + focus: boolean | null; + disabled: boolean | null; + dataAttributes: Record | null; +} + +interface FixtureOutput { + tailwindChecks: { + iosBlocks: number; + androidBlocks: number; + iosUtilities: number; + androidUtilities: number; + }; + transformerCheck: { + reinitPayload: boolean; + fingerprintArg: boolean; + themesArg: boolean; + }; + platforms: Record< + string, + { + styles: Record; + payloadIncludesAllCompiled: boolean; + payloadLeaks: string[]; + } + >; +} + +// Mirrors the classNames the audits flagged: multiple platform utilities per +// block (only the first one used to keep its guard), a base utility overridden +// by an `ios:` variant (NewTaskDraftScreen), and opposing `ios:`/`android:` +// font families (worktree-setup-card). +const FIXTURE_SOURCE = `export const Probe = () => ( +
+); +`; + +const IOS_CLASSES = ["ios:flex-1", "ios:pt-[72px]", "ios:font-[family-name:Menlo]"]; +const ANDROID_CLASSES = ["android:shrink", "android:grow-0", "android:font-mono"]; +const SHARED_CLASSES = ["flex-1", "pt-12"]; +const RESPONSIVE_CLASSES = ["sm:p-6", "sm:text-lg"]; + +const runFixture = (globalCss: string, probeSource: string): FixtureOutput => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "uniwind-platform-")); + try { + // So `@import "tailwindcss"` and `@import "uniwind"` resolve like in the app. + NodeFS.symlinkSync( + NodeFS.realpathSync(new URL("../../node_modules", import.meta.url)), + NodePath.join(tempDir, "node_modules"), + "dir", + ); + NodeFS.writeFileSync(NodePath.join(tempDir, "global.css"), globalCss); + NodeFS.writeFileSync(NodePath.join(tempDir, "Probe.tsx"), probeSource); + + const fixture = NodeURL.fileURLToPath( + new URL("./uniwind-platform-variants.fixture.cjs", import.meta.url), + ); + const stdout = NodeChildProcess.execFileSync(process.execPath, [fixture, tempDir], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: 60_000, + }); + // The fixture's compiler may log warnings to stdout before the JSON payload. + const start = stdout.indexOf('{"tailwindChecks"'); + if (start === -1) { + throw new Error(`Fixture produced no JSON:\n${stdout}`); + } + return JSON.parse(stdout.slice(start)) as FixtureOutput; + } finally { + NodeFS.rmSync(tempDir, { recursive: true, force: true }); + } +}; + +describe("uniwind platform variants compile per platform", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture('@import "tailwindcss";\n@import "uniwind";\n', FIXTURE_SOURCE); + }, 60_000); + + it("compiles the fixture utilities into shared platform blocks", () => { + // Without this grouping the leak could not happen and the assertions below + // would prove nothing. + expect(output.tailwindChecks).toEqual({ + iosBlocks: 1, + androidBlocks: 1, + iosUtilities: 3, + androidUtilities: 3, + }); + }); + + it("keeps ios: utilities out of the android bundle and guards them on ios", () => { + for (const className of IOS_CLASSES) { + expect( + output.platforms.android?.styles[className], + `android has ${className}`, + ).toBeUndefined(); + expect(output.platforms.ios?.styles[className], `ios lacks ${className}`).toBeDefined(); + expect(output.platforms.ios?.styles[className]?.every((style) => style.native)).toBe(true); + } + }); + + it("keeps android: utilities out of the ios bundle and guards them on android", () => { + for (const className of ANDROID_CLASSES) { + expect(output.platforms.ios?.styles[className], `ios has ${className}`).toBeUndefined(); + expect( + output.platforms.android?.styles[className], + `android lacks ${className}`, + ).toBeDefined(); + expect(output.platforms.android?.styles[className]?.every((style) => style.native)).toBe( + true, + ); + } + }); + + it("keeps shared and responsive utilities usable on both platforms", () => { + for (const className of [...SHARED_CLASSES, ...RESPONSIVE_CLASSES]) { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles[className]; + expect(styles, `${platform} lacks ${className}`).toBeDefined(); + expect(styles?.every((style) => !style.native)).toBe(true); + } + } + // The leak hid a second regression: only the first utility of a width + // block kept its breakpoint, so the second applied at every screen size. + for (const platform of ["ios", "android"]) { + for (const className of RESPONSIVE_CLASSES) { + const style = output.platforms[platform]?.styles[className]?.at(-1); + expect(style?.minWidth, `${className} lost its breakpoint`).toBeGreaterThan(0); + } + } + }); + + it("serializes every compiled style into the platform payload and leaks nothing", () => { + for (const platform of ["ios", "android"]) { + expect(output.platforms[platform]?.payloadIncludesAllCompiled).toBe(true); + // A platform utility dropped from this platform's stylesheet must not + // survive anywhere in the payload this platform bundles. + expect(output.platforms[platform]?.payloadLeaks, `${platform} payload leaks`).toEqual([]); + } + }); +}); + +// The media-query fix must not lose the surrounding rule context: Tailwind +// also emits media rules *nested inside a class rule* (`@utility` bodies with +// `@variant`, nested breakpoints), and those declarations belong to the +// enclosing class. These expectations match the pre-fix compiler output for +// the same inputs, captured against the unpatched package. +describe("uniwind keeps media rules nested inside class rules attached to the class", () => { + let nested: FixtureOutput; + + beforeAll(() => { + nested = runFixture( + [ + '@import "tailwindcss";', + '@import "uniwind";', + "", + ".container-x {", + " width: 100%;", + " @media (width >= 40rem) {", + " max-width: 40rem;", + " }", + "}", + "", + "@utility foo-x {", + " padding: 2px;", + " @variant ios {", + " padding: 3px;", + " }", + "}", + "", + ].join("\n"), + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps the nested breakpoint entry of a nested media rule", () => { + for (const platform of ["ios", "android"]) { + const entries = nested.platforms[platform]?.styles["container-x"]; + expect(entries, `${platform} lacks container-x`).toBeDefined(); + expect(entries?.some((style) => !style.native && style.minWidth === 0)).toBe(true); + expect(entries?.some((style) => !style.native && style.minWidth > 0)).toBe(true); + } + }); + + it("keeps @variant declarations inside @utility on their platform", () => { + const iosEntries = nested.platforms.ios?.styles["foo-x"]; + expect(iosEntries?.some((style) => !style.native)).toBe(true); + expect(iosEntries?.some((style) => style.native)).toBe(true); + + const androidEntries = nested.platforms.android?.styles["foo-x"]; + expect(androidEntries?.some((style) => !style.native)).toBe(true); + expect(androidEntries?.some((style) => style.native)).toBe(false); + }); +}); + +// The patch file carries three independent uniwind fixes (state/data selector +// variants, the Metro native-styles fingerprint, and this media-query scoping +// fix). Regenerating it for one fix must not silently drop the others — this +// suite exercises the shipped transformer and compiler for the other two. +describe("uniwind patch keeps pre-existing selector and transformer behavior", () => { + let output: FixtureOutput; + + beforeAll(() => { + output = runFixture( + '@import "tailwindcss";\n@import "uniwind";\n', + 'export const Probe = () =>
;\n', + ); + }, 60_000); + + it("keeps state and data variants conditioned, and rejects unsupported compounds", () => { + for (const platform of ["ios", "android"]) { + const styles = output.platforms[platform]?.styles; + expect(styles?.["active:opacity-50"]?.every((style) => style.active === true)).toBe(true); + expect(styles?.["disabled:opacity-50"]?.every((style) => style.disabled === true)).toBe(true); + expect(styles?.["data-x:underline"]?.every((style) => style.dataAttributes?.["data-x"])).toBe( + true, + ); + // `[aria-disabled="true"]` is not expressible at runtime; emitting it + // unconditionally (the pre-#9355 behavior) styles the element always. + expect(styles).not.toHaveProperty("aria-disabled:text-red-500"); + } + }); + + it("emits the global.css virtual module with a native styles fingerprint", () => { + expect(output.transformerCheck).toEqual({ + reinitPayload: true, + fingerprintArg: true, + themesArg: true, + }); + }); +}); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 508630b1e581..9514efdff424 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -33,14 +33,6 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; - /** - * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has - * no client-settings sync, so the legacy grouped thread list is opted into - * per device. Deliberately a fresh key (was `threadListV2Enabled`, an - * opt-out): sanitizing drops the old key, so every device resets to the - * default flat list — see `resolveThreadListV2Enabled`. - */ - readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; /** Model favorites belong to this device, like the web client setting. */ @@ -107,7 +99,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { composerEnterBehavior?: ComposerEnterBehavior; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; - legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; @@ -176,9 +167,6 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } - if (typeof parsed.legacyThreadListEnabled === "boolean") { - preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled; - } if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 96d91ec170e9..6a1a8c1510fa 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -15,6 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import type { @@ -1308,7 +1309,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/deleted-fork-branch": JSON.stringify([ + "feature/deleted-fork-branch": JSON.stringify([ { number: 218, title: "Deleted fork branch PR", @@ -1338,8 +1339,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-05T15:00:00.000Z", }); expect( - ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ghCalls.some((call) => call.includes("--head feature/deleted-fork-branch --state all")), ).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); }), ); @@ -1490,6 +1492,61 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const pullRequest = (number: number, headRefName: string, state: string) => ({ + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state, + updatedAt: "2026-04-07T15:00:00Z", + }); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), + "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), + }, + }, + }); + const lookupAll = Effect.forEach( + ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], + (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), + ); + const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list ")); + + yield* lookupAll; + expect(prListCalls()).toHaveLength(3); + + yield* TestClock.adjust("61 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(4); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just inside the 5-minute window only the open PR is asked again. + yield* TestClock.adjust("238 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(5); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just past it the settled answers expire too. + yield* TestClock.adjust("2 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(7); + expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr"); + }), + ); + it.effect("branch PR lookup propagates provider failures", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1720,8 +1777,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - "alice:feature": output, - "fork:feature": output, feature: output, }, }, @@ -1888,10 +1943,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -1927,7 +1978,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1959,7 +2010,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:main": JSON.stringify([ + main: JSON.stringify([ { number: 777, title: "Fork PR from main", @@ -1993,7 +2044,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2058,34 +2109,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), }, }, }); @@ -2296,7 +2319,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-plain": JSON.stringify([ + "feature/fork-plain": JSON.stringify([ { number: 89, title: "Fork PR pushed without -u", @@ -2316,9 +2339,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(89); - expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( - true, - ); + expect(ghCalls.some((call) => call.includes("--head feature/fork-plain"))).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); @@ -2353,7 +2375,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-settle": JSON.stringify([ + "feature/fork-settle": JSON.stringify([ { number: 91, title: "Fork PR to settle", @@ -3514,7 +3536,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", + "returns existing cross-repo PR metadata found under the bare branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3533,8 +3555,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -3566,7 +3586,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); expect( ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), + call.includes("pr list --head statemachine --state open --limit 100"), ), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); @@ -3632,30 +3652,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), }, }, }); @@ -3675,7 +3671,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + "picks the fork PR among same-named branches from other repositories", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3707,9 +3703,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3726,8 +3719,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), }, }, }); @@ -3739,11 +3730,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect( + ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")), + ).toBe(false); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, @@ -3772,7 +3761,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ + statemachine: JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3790,11 +3779,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([]), }, }, }); @@ -3807,10 +3792,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100")); expect(openLookupCalls).toHaveLength(1); expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", + "pr list --head statemachine --state open --limit 100", ); }), 12_000, @@ -3835,12 +3820,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, ], - "fork-seed:statemachine": ["[]"], - statemachine: ["[]"], }, }, }); @@ -4239,7 +4222,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -4261,10 +4244,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), ], - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": [JSON.stringify([])], - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: [JSON.stringify([])], }, }, }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 820c6a781f8b..43d674662d61 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -32,6 +32,7 @@ import { ModelSelection, type ProjectId, SourceControlProviderError, + type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; @@ -144,6 +145,12 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); +// Answers without an open PR ("no PR yet", merged, closed) only change when +// someone opens a PR, and the paths that do that in-app (turn end, push, +// create PR, user refresh) bypass this cache. Re-asking every minute for each +// idle branch was the bulk of background GitHub quota use, so these wait out +// a longer TTL and a PR opened outside the app shows up within minutes. +const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; @@ -588,6 +595,26 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } +// Without the owner selector, a bare branch name also lists same-named +// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full +// page and let matchesBranchHeadContext pick the right head. gh fetches up to +// 100 in one request, and GitHub prices a first:100 connection like first:1. +const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; + +// `gh pr list --head` filters on the head ref name alone and accepts anything, so an +// `owner:branch` or `remote:branch` selector silently lists zero pull requests +// while spending a GraphQL call. Git branch names cannot contain ":", and the +// bare head branch is always among the selectors, so GitHub probes skip them and +// leave the owner check to matchesBranchHeadContext. +function probeableHeadSelectors( + providerKind: SourceControlProviderKind, + headSelectors: ReadonlyArray, +): ReadonlyArray { + return providerKind === "github" + ? headSelectors.filter((selector) => !selector.includes(":")) + : headSelectors; +} + function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -1105,7 +1132,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); - return PR_LOOKUP_CACHE_TTL; + return exit.value.latest?.state === "open" + ? PR_LOOKUP_CACHE_TTL + : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, @@ -1596,12 +1625,14 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); + for (const headSelector of headSelectors) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", - limit: 1, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -1626,12 +1657,13 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", - limit: 20, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, }); for (const pr of pullRequests.map(toPullRequestInfo)) { diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 35819d9e7430..319ea46ad64b 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -303,7 +303,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(logSkipped("pull request sync skipped", { key })), ) : Effect.void, - { concurrency: 8, discard: true }, + // As wide as one batched summary read, so the sweep's reads on a host arrive together and + // GitHub answers them in one request rather than one `gh pr view` apiece. + { concurrency: 25, discard: true }, ); }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 314c0f87488f..07afbaf05e6a 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -475,6 +475,49 @@ describe("ThreadSettlementReactor", () => { }), ), ); + it.effect("skips the branch recheck when a terminal link would settle nothing", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const previous = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("resumed-manual", { + branch: "main", + linkedPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ], + [makeProject()], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("open")), + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: "2026-08-27T00:00:00.000Z", + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 0dec28a7df64..ff6f995df46f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -209,9 +209,30 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); + const wouldSettle = Effect.fn("ThreadSettlementReactor.wouldSettle")(function* ( + group: ReadonlyArray<(typeof candidates)[number]>, + pullRequest: SettlementPullRequest, + ) { + const currentSettings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + return group.some((thread) => { + const { settings } = resolveProjectSettings(currentSettings, thread.projectId); + return ( + resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) !== null + ); + }); + }); + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( - thread: (typeof candidates)[number], + group: ReadonlyArray<(typeof candidates)[number]>, ) { + const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; if (reference != null) { const matchesMerge = @@ -236,10 +257,21 @@ export const make = Effect.gen(function* () { }, { recoverTransientFailure: false }, ); + const terminal = { + state: summary.state, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + } satisfies SettlementPullRequest; const cwd = lookupCwdByThreadId.get(thread.id); if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { // A reused branch can already have a new open PR while discovery // is replacing its old link. Do not let settlement win that race. + // Only pay for the uncached lookup when this sweep would otherwise + // settle: a terminal link that settles nothing (resumed thread, + // settle-on-merge off) would re-query the host every minute. A + // group that becomes eligible after this check waits for the next + // sweep rather than settling on the unverified link. + if (!(yield* wouldSettle(group, terminal))) return undefined; const current = yield* git.branchPullRequest( { cwd, branch: thread.branch }, { refresh: true }, @@ -253,11 +285,7 @@ export const make = Effect.gen(function* () { return current; } } - return { - state: summary.state, - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - } satisfies SettlementPullRequest; + return terminal; } if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); @@ -271,7 +299,8 @@ export const make = Effect.gen(function* () { groups.values(), (group) => Effect.gen(function* () { - const pullRequest = yield* pullRequestFor(group[0]!); + const pullRequest = yield* pullRequestFor(group); + if (pullRequest === undefined) return; yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { discard: true, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..354484e6d395 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +// Provider metadata checks use a bundled manifest and stubbed HTTP. const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), -); +).pipe(Layer.provideMerge(ModelManifest.layerTest)); const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..a65bc66edf32 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ModelManifest from "../ModelManifest.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry; + const manifestService = yield* ModelManifest.ModelManifest; + const serviceScope = yield* Effect.scope; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( ), ), ); - const providersRef = yield* Ref.make>(cachedProviders); + const initialManifest = yield* manifestService.current; + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => + applyProviderCompatibility( + provider, + manifest.compatibility, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + const providersRef = yield* Ref.make>( + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( readonly replace?: boolean; }, ) { + const manifest = yield* manifestService.current; const nextProvidersWithUpdateState = yield* Effect.forEach( nextProviders, applyProviderUpdateState, @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( ); } - const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providers = orderProviderSnapshots( + [...mergedProviders.values()].map((provider) => + classifyCompatibility(provider, manifest), + ), + ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), ); @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); + const compatibilityRefreshRunning = yield* Ref.make(false); const syncProvider = Effect.fn("syncProvider")(function* ( provider: ServerProvider, options?: { readonly publish?: boolean; }, ) { - return yield* upsertProviders([provider], options); + const providers = yield* upsertProviders([provider], options); + // Reclassify the current read model after fetching. Never republish the + // probe captured before the fetch: a newer health result may have landed. + if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { + yield* manifestService.refresh.pipe( + Effect.andThen(upsertProviders([], { persist: false })), + Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), + Effect.forkIn(serviceScope), + ); + } + return providers; }); const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 1f00cb56040a..77e06530f2e5 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -549,3 +549,43 @@ describe("ModelManifest service", () => { ), ); }); + +it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { + const remote: ModelManifestData = { + ...REMOTE_MANIFEST, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [{ range: "=2.0.0", status: "supported" }], + }, + ], + }; + let invalid = false; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + invalid = true; + yield* TestClock.adjust("1 hour"); + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + const rebooted = yield* make; + assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-compatibility-test", + response: () => + Response.json( + invalid + ? { + ...remote, + compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], + } + : remote, + ), + }), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 7f2305097b78..b698ed6c0c82 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; import type { ServerProviderDraft } from "./providerSnapshot.ts"; const MODEL_MANIFEST_URL = @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ * files still decode; they count as older than any dated bundle. */ updatedAt: Schema.optional(Schema.String), + compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index a5924f9cadf2..32a103be9d16 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,14 @@ { "version": 1, - "updatedAt": "2026-09-22T19:42:55Z", + "updatedAt": "2026-09-22T21:40:00Z", + "compatibility": [ + { + "driver": "opencode", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.14.19", + "ranges": [{ "range": "<1.14.19", "status": "broken" }] + } + ], "currentModels": { "codex": [ "gpt-6-astra", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts new file mode 100644 index 000000000000..e6b4e1a608b9 --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -0,0 +1,258 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "../config.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import * as Schema from "effect/Schema"; +import { + applyProviderCompatibility, + ProviderCompatibilityPolicy, + resolveProviderCompatibility, +} from "./providerCompatibility.ts"; + +const driver = ProviderDriverKind.make("codex"); +const policy: ProviderCompatibilityPolicy = { + driver, + t3CodeRange: ">=0.0.42 <0.1.0", + recommendedVersion: "2.0.0", + recommendedRange: ">=2.0.0 <3.0.0", + ranges: [ + { range: "<1.0.0", status: "broken" }, + { range: ">=1.0.0 <1.5.0", status: "unsupported" }, + { range: ">=1.5.0 <2.0.0", status: "graceful" }, + { range: ">=2.0.0 <3.0.0", status: "supported" }, + ], +}; +const provider: ServerProvider = { + driver, + instanceId: ProviderInstanceId.make("codex-work"), + enabled: true, + installed: true, + version: "0.9.0", + status: "error", + message: "Authentication failed", + checkedAt: "2026-09-22T00:00:00Z", + auth: { status: "unauthenticated" }, + models: [], + skills: [], + slashCommands: [], +}; + +describe("provider compatibility", () => { + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { + for (const [version, expected] of [ + ["0.9.9", "broken"], + ["1.0.0", "unsupported"], + ["1.5.0", "graceful"], + ["2.0.0", "supported"], + ["v2.0.0", "supported"], + ["3.0.0", "unknown"], + ["2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + [null, "unknown"], + ] as const) { + assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); + } + assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); + }); + + it("supports every driver without inventing policies for uncovered adapters", () => { + for (const kind of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", + "customDriver", + ]) { + const adapter = ProviderDriverKind.make(kind); + assert.strictEqual( + resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, + "supported", + ); + assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); + } + }); + + it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { + const broken = applyProviderCompatibility(provider, [], [policy]); + assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); + const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; + const supported = applyProviderCompatibility(broken, [relaxed], [policy]); + assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(supported.status, "error"); + assert.strictEqual(supported.message, "Authentication failed"); + assert.strictEqual( + applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) + .compatibilityAdvisory?.status, + "broken", + ); + const removed = applyProviderCompatibility(supported, [], []); + assert.isUndefined(removed.compatibilityAdvisory); + assert.strictEqual(removed.status, "error"); + assert.strictEqual(removed.message, "Authentication failed"); + assert.isUndefined( + applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, + ); + assert.isUndefined( + applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) + .compatibilityAdvisory, + ); + }); + + it("rejects invalid ranges and recommendations outside the first supported match", () => { + const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); + assert.doesNotThrow(() => decode(policy)); + const prefixed = decode({ + ...policy, + t3CodeRange: ">=v0.0.42 =v2.0 =2.0.0 garbage", status: "supported" }] }, + { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, + { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, + ]) + assert.throws(() => decode(invalid)); + }); +}); + +it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const health = yield* Ref.make({ + ...provider, + status: "ready" as ServerProvider["status"], + message: "Healthy", + }); + const manifest = yield* Ref.make({ + version: 1, + currentModels: {}, + compatibility: [policy], + }); + const instance: ProviderInstance = { + instanceId: provider.instanceId, + driverKind: driver, + enabled: true, + displayName: undefined, + continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, + snapshot: { + getSnapshot: Ref.get(health), + refresh: Ref.get(health), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), + ), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const refresh = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + Ref.updateAndGet(manifest, (current) => ({ + ...current, + compatibility: [ + { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, + ], + })), + ), + ); + const dependencies = Layer.mergeAll( + Layer.succeed(ModelManifest.ModelManifest, { + current: Ref.get(manifest), + refresh, + forceRefresh: refresh, + refreshInBackground: Effect.void, + }), + Layer.succeed(ProviderInstanceRegistry, { + getInstance: (id) => Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + yield* Deferred.await(started); + assert.strictEqual( + (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, + "broken", + ); + yield* Ref.set(health, provider); + const latestHealth = yield* registry.refreshInstance(provider.instanceId); + assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); + const supported = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), + ), + ); + const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + const updated = (yield* Fiber.join(subscribed))[0]?.[0]; + assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(updated?.status, "error"); + assert.strictEqual(updated?.message, "Authentication failed"); + }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.scoped), +); + +it("recomputes latest-version compatibility without losing the underlying update advisory", () => { + const snapshot: ServerProvider = { + ...provider, + version: "2.0.0", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.0", + latestVersion: "4.0.0", + canUpdate: true, + updateCommand: "npm install -g fixture@latest", + checkedAt: provider.checkedAt, + message: null, + }, + }; + const blockedPolicy: ProviderCompatibilityPolicy = { + ...policy, + ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], + }; + const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); + assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); + const relaxed = applyProviderCompatibility( + blocked, + [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], + [], + ); + assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); + assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); +}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts new file mode 100644 index 000000000000..11aedf8dca0d --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.ts @@ -0,0 +1,119 @@ +import { + TrimmedNonEmptyString, + ServerProviderCompatibilityStatus, + type ProviderDriverKind, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; +import packageJson from "../../package.json" with { type: "json" }; + +// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. +// Release tags and prereleases remain unknown instead of matching stable ranges. +const StableVersion = TrimmedNonEmptyString.pipe( + Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), +); +const VersionRange = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((value) => + value.split("||").every((group) => { + const tokens = group.trim().split(/\s+/); + return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); + }), + ), + ), +); +const Policy = Schema.Struct({ + driver: TrimmedNonEmptyString, + t3CodeRange: VersionRange, + recommendedRange: Schema.optionalKey(VersionRange), + recommendedVersion: Schema.optionalKey(StableVersion), + ranges: Schema.Array( + Schema.Struct({ + range: VersionRange, + status: ServerProviderCompatibilityStatus, + }), + ), +}); + +export const ProviderCompatibilityPolicy = Policy.pipe( + Schema.check( + Schema.makeFilter( + (policy) => { + const version = policy.recommendedVersion; + if (version === undefined) return true; + return ( + (policy.recommendedRange === undefined || + satisfiesSemverRange(version, policy.recommendedRange)) && + policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === + "supported" + ); + }, + { expected: "a recommended version in a supported range" }, + ), + ), +); +export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; + +export function resolveProviderCompatibility( + policies: ReadonlyArray | undefined, + driver: ProviderDriverKind, + version: string | null, + t3CodeVersion = packageJson.version, +): ServerProviderCompatibilityAdvisory | undefined { + const policy = policies?.find( + (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), + ); + if (!policy) return undefined; + const stable = version?.replace(/^v/, ""); + const status = + stable && /^\d+\.\d+\.\d+$/.test(stable) + ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? + "unknown") + : "unknown"; + const message = + status === "broken" + ? "This provider version is known to be incompatible with this T3 Code release." + : status === "unsupported" + ? "This provider version is outside the supported range for this T3 Code release." + : status === "graceful" + ? "This provider version has limited compatibility with this T3 Code release." + : null; + const recommendedVersion = policy.recommendedVersion ?? null; + const recommendedRange = policy.recommendedRange ?? null; + const recommendation = recommendedVersion ?? recommendedRange; + return { + status, + message: message && recommendation ? `${message} Use ${recommendation}.` : message, + recommendedVersion, + recommendedRange, + }; +} + +/** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ +export function applyProviderCompatibility( + snapshot: ServerProvider, + policies: ReadonlyArray | undefined, + fallback: ReadonlyArray | undefined, +): ServerProvider { + const { compatibilityAdvisory: _previous, ...base } = snapshot; + if (!snapshot.enabled || !snapshot.installed) return base; + const advisory = + resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? + resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); + const latestVersion = snapshot.versionAdvisory?.latestVersion; + const latestAdvisory = latestVersion + ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? + resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) + : undefined; + return advisory + ? { + ...base, + compatibilityAdvisory: { + ...advisory, + ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), + }, + } + : base; +} diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 3e0810f51b7a..50aee2b08181 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createProviderVersionAdvisory, + makeTargetedProviderUpdateAction, enrichProviderSnapshotWithVersionAdvisory, homebrewOwnershipFromCommandPath, makeCachedProviderMaintenanceResolution, @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); }); + +it("pins only owned package-manager installs and preserves their execution context", () => { + const capabilities = makeProviderMaintenanceCapabilities({ + provider: driver("codex"), + packageName: "@openai/codex", + updateExecutable: "npm", + updateLockKey: "npm-global:/opt/node", + updateArgs: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + env: { PATH: "/opt/node/bin" }, + }); + const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); + expect(pinned).toMatchObject({ + executable: "npm", + lockKey: capabilities.update?.lockKey, + env: capabilities.update?.env, + args: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@2.0.0", + ], + }); + expect(pinned?.command).toContain("@openai/codex@2.0.0"); + for (const lockKey of ["codex-native", "homebrew", "manual"]) { + expect( + makeTargetedProviderUpdateAction( + { ...capabilities, update: { ...capabilities.update!, lockKey } }, + "2.0.0", + ), + ).toBeNull(); + } + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 6a11b22462ee..ace445b03060 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { }; } +/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ +export function makeTargetedProviderUpdateAction( + capabilities: ProviderMaintenanceCapabilities, + version: string, +): ProviderMaintenanceCommandAction | null { + if (!/^\d+\.\d+\.\d+$/.test(version)) return null; + const update = capabilities.update; + const packageName = capabilities.packageName; + if (!update || !packageName) return null; + if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) + return null; + const packageIndex = update.args.findIndex( + (arg) => arg === `${packageName}@latest` || arg === packageName, + ); + if (packageIndex < 0) return null; + const args = update.args.map((arg, index) => + index === packageIndex ? `${packageName}@${version}` : arg, + ); + const previous = update.args[packageIndex]!; + const commandIndex = update.command.lastIndexOf(previous); + const command = + commandIndex < 0 + ? update.command + : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; + return { ...update, args, command }; +} + export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { latestVersion, updateCommand: capabilities.update?.command ?? null, canUpdate: capabilities.update !== null, + canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, message: advisory.message, }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae..2da7c9183365 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -208,13 +209,22 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = ( + registry: ProviderRegistryShape, + manifest = ModelManifest.BUNDLED_MODEL_MANIFEST, +) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.succeed(manifest), + forceRefresh: Effect.succeed(manifest), + refreshInBackground: Effect.void, + }), // Fresh per runner so a version cached by one test cannot leak into another. Layer.sync(ProviderVersionCache, () => new Map()), ), @@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( NonWindowsPlatform, - latestVersionHttpClient("0.0.0"), + latestVersionHttpClient("2.0.0"), mockSpawnerLayer((_command, args) => { calls.push(args.join(" ")); if (calls.length === 1) { @@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => { ); }); }); + +it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { + const calls: string[] = []; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [ + { range: "=2.0.0", status: "supported" }, + { range: ">2.0.0", status: "broken" }, + ], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(); + const pinnedCapabilities = makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global:/fixture", + }); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + }, + manifest, + ); + for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + ...(targetVersion !== undefined ? { targetVersion } : {}), + }); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + } + assert.deepStrictEqual(calls, []); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "2.0.0" })), + ); + const installed = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); + assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "1.0.0" })), + ); + const unchanged = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); + const nativeUpdater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + ...pinnedCapabilities, + update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, + }), + }, + manifest, + ); + const refused = yield* nativeUpdater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); + assert.strictEqual(calls.length, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("3.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 121e5aeab7d1..17be8eb087f6 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ModelManifest from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeTargetedProviderUpdateAction, + resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, ProviderVersionCache, } from "./providerMaintenance.ts"; @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly targetVersion?: string | undefined; }, ) => Effect.Effect; } @@ -214,6 +219,7 @@ function makeUpdateState(input: { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; + const manifestService = yield* ModelManifest.ModelManifest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - const result = yield* runMaintenanceCommand(fresh.update); + const manifest = yield* manifestService.current; + const candidateVersion = + targetVersion ?? + (yield* resolveLatestProviderVersion(fresh).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + )); + const advisory = + resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + provider, + candidateVersion, + ); + const command = + targetVersion !== undefined + ? makeTargetedProviderUpdateAction(fresh, targetVersion) + : fresh.update; + const rejected = + targetVersion !== undefined + ? !command || + advisory?.recommendedVersion !== targetVersion || + advisory.status !== "supported" + : advisory?.status === "broken" || advisory?.status === "unsupported"; + if (rejected || !command) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: + targetVersion !== undefined + ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." + : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", + }), + ); + } + const result = yield* runMaintenanceCommand(command); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // Cursor's `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); - const stillOutdated = verifiedProviders.some((verifiedProvider) => - isOutdatedProvider(verifiedProvider), - ); + verifiedProviders.some( + (verifiedProvider) => + !isStillInstalled(verifiedProvider) || + (targetVersion !== undefined && + verifiedProvider.version?.replace(/^v/, "") !== targetVersion), + ); + const stillOutdated = + targetVersion === undefined && + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index fd5ab04e8a81..291105e25cd9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -452,77 +452,137 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); - it.effect("reads linked pull request status with the overview fields in one request", () => + it.effect("reads linked pull requests on one host together, filed back by position", () => Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); mockedExecute.mockReturnValueOnce( Effect.succeed( output( // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, - ], - body: "", + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const summary = yield* cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); assert.deepStrictEqual( { - number: summary.number, - state: summary.state, - headBranch: summary.headBranch, - isDraft: summary.isDraft, - author: summary.author?.login, - additions: summary.additions, - deletions: summary.deletions, - changedFiles: summary.changedFiles, - reviewDecision: summary.reviewDecision, - checksState: summary.checksState, - mergeability: summary.mergeability, + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, }, { number: 7, state: "open", - headBranch: "feat/summary", - isDraft: false, + headBranch: "feat/7", author: "octocat", - additions: 12, - deletions: 3, changedFiles: 2, reviewDecision: "approved", checksState: "passing", mergeability: "mergeable", }, ); + assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ "pr", "view", "7", @@ -531,7 +591,6 @@ layer("GitHubPullRequestCli.layer", (it) => { "--json", expect.stringContaining("statusCheckRollup"), ]); - expect(mockedGetPullRequest).not.toHaveBeenCalled(); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 723fcfc4d9b9..3ed2a200711c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,10 +1,15 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -48,6 +53,7 @@ import { decodePullRequestCoreJson, PULL_REQUEST_CORE_GRAPHQL_QUERY, type GitHubPullRequestCore, + type GitHubPullRequestSummary, decodePullRequestPreviewJson, PULL_REQUEST_PREVIEW_GRAPHQL_QUERY, decodePullRequestFilesJson, @@ -58,6 +64,7 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodePullRequestStatsJson, + decodePullRequestSummariesJson, decodeReactionSubjectScopeJson, decodeReviewerCandidatesJson, decodeLabelCandidatesJson, @@ -67,6 +74,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, @@ -419,6 +427,23 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestCliError +> {} export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ @@ -1727,6 +1752,133 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ).pipe(Effect.map(([, runs]) => runs)); + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. + const viewPullRequestSummary = (input: PullRequestSummaryRead) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, + }), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), + ); + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + return GitHubPullRequestCli.of({ withVerifiedCredential, getRoutingIdentity, @@ -1933,56 +2085,7 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - getPullRequestSummary: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ), + getPullRequestSummary, getPullRequestDetail, getPullRequestPreview: (input) => { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 81033e2e0d94..7008ca739150 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildPullRequestSummariesGraphQlQuery, + decodePullRequestSummariesJson, buildReviewSubmissionJson, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, @@ -1883,3 +1885,54 @@ describe("pull request stack membership batches", () => { ); }); }); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index eaaa4e34da84..9be1b2a5844b 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1847,6 +1847,129 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** + * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written + * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". + */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, + ); + } + return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + }); + } + return Result.succeed(summaries); +} + export interface GitHubPullRequestCore extends GitHubPullRequestDetail { readonly viewerAccess: GitHubViewerAccess & GitHubRepositoryAccess; readonly comparison: GitHubBaseComparison | null; diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index cf78e5095a38..a8c9027f8a16 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -189,10 +189,7 @@ export function BrowserDeviceToolbar({ = 440 ? "w-36" : "w-24", - )} + className={cn("shrink-0 justify-between", width >= 440 ? "w-36" : "w-24")} aria-label="Browser device preset" > @@ -227,7 +224,8 @@ export function BrowserDeviceToolbar({ nativeInput type="number" inputMode="numeric" - size="sm" + size="compact" + font="mono" min={PREVIEW_VIEWPORT_MIN_DIMENSION} max={PREVIEW_VIEWPORT_MAX_DIMENSION} value={presentedSize.width} @@ -244,17 +242,15 @@ export function BrowserDeviceToolbar({ onChange={(event) => updateCustomDimension("width", event.target.value)} aria-label="Viewport width" aria-invalid={!customValid} - className={cn( - "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", - width >= 360 ? "w-14" : "w-11", - )} + className={width >= 360 ? "w-14" : "w-13"} /> × updateCustomDimension("height", event.target.value)} aria-label="Viewport height" aria-invalid={!customValid} - className={cn( - "h-6 rounded-md text-center tabular-nums [&_[data-slot=input]]:h-full [&_[data-slot=input]]:px-1 [&_[data-slot=input]]:text-xs [&_[data-slot=input]]:leading-none [&_[data-slot=input]::-webkit-inner-spin-button]:appearance-none [&_[data-slot=input]]:[appearance:textfield]", - width >= 360 ? "w-14" : "w-11", - )} + className={width >= 360 ? "w-14" : "w-13"} /> @@ -282,14 +275,13 @@ export function BrowserDeviceToolbar({ event.preventDefault()} onClick={toggleAspectRatio} @@ -316,19 +308,21 @@ export function BrowserDeviceToolbar({ > - + {/* Sticky backing so scrolled controls do not show through the close action. */} + + +
); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 14d871c41804..eccf3ef21355 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; @@ -16,6 +16,13 @@ import { resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; @@ -125,11 +132,12 @@ function SidebarControl() { resolveThreadRouteRef(params), + }); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + previewFocus: isPreviewFocused(), + previewOpen: routeThreadRef + ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview" + : false, + editableFocus: isEditableFocused(event.target), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "navigation.back" && command !== "navigation.forward") return; + + event.preventDefault(); + event.stopPropagation(); + if (command === "navigation.back") window.history.back(); + else window.history.forward(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, routeThreadRef]); + + return null; +} + // Settings swaps the thread sidebar out of the tree. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. @@ -244,7 +302,6 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - className="border-r border-sidebar-border" resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, @@ -269,6 +326,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + ); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 607236f2d63b..8e84ec5d1ed9 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -41,7 +41,7 @@ import { } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; -import { Button } from "./ui/button"; +import { ComposerControl } from "./chat/ComposerControl"; import { Menu, MenuGroup, @@ -206,8 +206,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ return ( } - className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" + render={} + className="min-w-0 max-w-[48%] flex-initial justify-start" data-composer-context-control data-composer-shortcut={[ showEnvironmentPicker && !envLocked ? "composer.host" : "", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 3bab1315ee8b..a548d414bcc8 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -59,6 +59,7 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; +import { ComposerControl } from "./chat/ComposerControl"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { @@ -703,7 +704,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className="pe-2" onClick={() => selectPickerItem(itemValue)} >
@@ -725,7 +725,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className="pe-1.5" onClick={() => selectPickerItem(itemValue)} > Create new ref "{newRefName}" @@ -753,7 +752,6 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - className="pe-1.5" onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} > @@ -790,7 +788,7 @@ export function BranchToolbarBranchSelector({ data-composer-context-control > } badge={prBadge} number={prNumber} url={prUrl} @@ -808,10 +806,10 @@ export function BranchToolbarBranchSelector({ onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)} > } + render={} // No press-scale: the popup aligns live to this trigger, so a // momentary 0.97 shrink would drag the open popup ~3px sideways. - className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80 active:scale-100" + className="min-w-0 max-w-full active:scale-100" disabled={isInitialBranchesLoadPending || isBranchActionPending} > @@ -819,11 +817,12 @@ export function BranchToolbarBranchSelector({ data-composer-label className="min-w-0 max-w-[240px] group-data-[compact]/composer-context:max-w-0" > - + > + + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 0ec794b2970c..1b8b81cfcd74 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -110,7 +110,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) { render={ + + ) } /> @@ -2242,8 +2229,7 @@ function areMarkdownFileLinkPropsEqual( previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel && - previous.className === next.className + previous.revealLabel === next.revealLabel ); } @@ -2571,12 +2557,7 @@ function useChatMarkdownState({ [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); const fileLinkChip = useCallback( - ( - fileLinkMeta: MarkdownFileLinkMeta, - copyMarkdown: string, - className?: string, - mediaSource?: string, - ) => { + (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), ); @@ -2633,7 +2614,6 @@ function useChatMarkdownState({ ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } - className={className} /> ); }, @@ -3068,7 +3048,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, - props.className, normalizedHref, ); }, @@ -3085,7 +3064,6 @@ const CHAT_MARKDOWN_COMPONENTS = { return fileLinkChip( fileLinkMeta, `\`${codeText}\``, - undefined, inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3ce6566c041d..9af410f5722e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9991,7 +9991,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.restoreAfterTimelineReachedEnd(); scrollToEnd(true); }} - className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground" + className="pointer-events-auto" size="xs" variant="glass" > @@ -10378,7 +10378,6 @@ export default function ChatView(props: ChatViewProps) { { ]); }); + it("orders title matches by recent activity before older prefix matches", () => { + const threads = [ + makeThread({ + id: ThreadId.make("old-prefix"), + title: "Convex InvalidCursor in Convex threads query", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("recent-title"), + title: "Disable Convex schema validation", + createdAt: "2025-12-01T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("recent-content"), + title: "Fix schema validation", + createdAt: "2026-03-25T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + }), + ]; + const items = buildThreadActionItems({ + threads, + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "created_at", + icon: null, + getContentMatch: (thread) => + thread.id === ThreadId.make("recent-content") + ? { source: "user", snippet: "Please check Convex", query: "convex" } + : undefined, + runThread: async () => undefined, + }); + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "convex", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:recent-title", + "thread:old-prefix", + "thread:recent-content", + ]); + }); + it("preserves thread project-name matches when there is no stronger title match", () => { const group: CommandPaletteGroup = { value: "threads-search", diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e2114079e729..b75ce84d1469 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -11,7 +11,7 @@ import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; -import { sortThreads } from "../lib/threadSort"; +import { getThreadSortTimestamp, sortThreads } from "../lib/threadSort"; import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; @@ -135,6 +135,7 @@ export interface CommandPaletteItem { readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; + readonly searchRecency?: number; readonly icon: ReactNode; readonly disabled?: boolean; /** Optional content rendered inline before the title text. */ @@ -315,6 +316,7 @@ export function buildThreadActionItems Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false) || right.rank - left.rank || + (right.item.searchRecency ?? 0) - (left.item.searchRecency ?? 0) || left.index - right.index, ) .map((entry) => entry.item); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3498acc33046..7ee1d12036db 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -650,7 +650,7 @@ function CommandPaletteDialog(props: { ? "Search project contents" : "Command palette" } - className={cn("overflow-hidden p-0", props.mode === "content" && "h-105")} + className={cn("overflow-hidden", props.mode === "content" && "h-105")} data-command-palette="true" data-palette-mode={props.mode} data-testid="command-palette" @@ -1528,9 +1528,8 @@ function OpenCommandPaletteDialog(props: { { openSourceControlSettings(); }} @@ -2861,7 +2860,7 @@ function OpenCommandPaletteDialog(props: { variant="outline" size="xs" tabIndex={-1} - className="absolute inset-e-2.5 top-1/2 gap-1.5 pe-1 ps-2 -translate-y-1/2" + className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" aria-label={`${remoteProjectButtonLabel ?? "Continue"} (Enter)`} disabled={!canSubmitRemoteProjectFlow} onMouseDown={(event) => { @@ -2888,10 +2887,7 @@ function OpenCommandPaletteDialog(props: { variant="outline" size="xs" tabIndex={-1} - className={cn( - "absolute inset-e-2.5 top-1/2 pe-1 ps-2 -translate-y-1/2", - hasHighlightedBrowseItem ? "gap-1" : "gap-1.5", - )} + className="absolute inset-e-2.5 top-1/2 -translate-y-1/2" aria-label={`${submitActionLabel} (${addShortcutLabel})`} disabled={ !canCreateProjectInEnvironment(browseEnvironment?.connection.phase) || @@ -2966,9 +2962,6 @@ function OpenCommandPaletteDialog(props: { }) : undefined, placeholder: inputPlaceholder, - wrapperClassName: isSubmenu - ? "[&_[data-slot=autocomplete-start-addon]]:pointer-events-auto" - : undefined, ...(isSubmenu ? { startAddon: ( diff --git a/apps/web/src/components/CommandPaletteContent.tsx b/apps/web/src/components/CommandPaletteContent.tsx index a21de9a3c650..e9e6149b6cd7 100644 --- a/apps/web/src/components/CommandPaletteContent.tsx +++ b/apps/web/src/components/CommandPaletteContent.tsx @@ -50,7 +50,7 @@ export function CommandPaletteContent({ {inputAccessory}
{children} - +
diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index b9cb0c6269c8..b94707f5eca6 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -41,7 +41,7 @@ export function CommandPaletteResults(props: CommandPaletteResultsProps) { {props.groups.map((group) => ( - {group.label} + {group.label} {(item) => item.disabled ? ( @@ -109,7 +109,6 @@ function CommandPaletteResultRow(props: { { event.preventDefault(); }} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index ec9b9e07e2fb..8af2d18b310e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -58,18 +58,13 @@ import { import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; -import { - COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, - COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, - COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, - SKILL_CHIP_ICON_SVG, -} from "./composerInlineChip"; -import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; +import { FileTagChipContent } from "./chat/FileTagChip"; +import { SkillChipIcon } from "./chat/SkillInlineText"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { ContextChipPopover } from "./contextChipParts"; import { Button } from "./ui/button"; +import { ContextChip } from "./ContextChip"; import { ComposerContextActionsContext, ComposerContextReferenceChip, @@ -190,6 +185,13 @@ function resolvedThemeFromDocument(): "light" | "dark" { // ── Inline atom nodes (chips) ───────────────────────────────────────────── +/** + * Wraps an inline chip node view: keeps the caret and text selection out of the chip and + * paints the editor's node selection over it. + */ +const CHIP_NODE_SELECTION_CLASS_NAME = + "relative inline-flex select-none items-center align-middle leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; + const ComposerMentionExtension = Node.create({ name: "composer-mention", group: "inline", @@ -217,11 +219,11 @@ function ComposerMentionNodeView({ node }: NodeViewProps) { const actions = use(ComposerContextActionsContext); const path = (node.attrs.path as string) ?? ""; const chip = ( - + ); return ( - + {path} @@ -275,20 +277,12 @@ function ComposerSkillNodeView({ node }: NodeViewProps) { const skillDescription = (node.attrs.skillDescription as string | null) ?? null; const skill = skills.find((candidate) => candidate.name === skillName); return ( - + } + label={skillLabel} accessibleLabel={`Skill ${skillLabel}`} - triggerClassName={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME} - chip={ - <> -
); }); diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 4e9547842353..2d1159bdb40c 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -11,7 +11,6 @@ import { type ProviderUpdateSidebarPillView, } from "../ProviderUpdateLaunchNotification.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { Button } from "../ui/button"; const PROVIDER_UPDATE_PILL_STYLES = { loading: @@ -192,15 +191,14 @@ export function SidebarProviderUpdatePill() { startExit(displayedView.key, null, displayedView.key)} > - + } /> Dismiss until provider status changes diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 424c50e7f2a2..839c2ba37f01 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -108,8 +108,8 @@ export function SidebarThreadHeader({
); } @@ -2234,16 +2234,13 @@ function CompactFilterMenu({ aria-label={triggerLabel || iconOnly ? `${label}: ${current.label}` : label} title={iconOnly ? `${label}: ${current.label}` : undefined} render={ - outlined ?