diff --git a/apps/mobile/src/components/NewThreadButton.android.tsx b/apps/mobile/src/components/NewThreadButton.android.tsx index e874e7816fc1c..b8cca500f0f6c 100644 --- a/apps/mobile/src/components/NewThreadButton.android.tsx +++ b/apps/mobile/src/components/NewThreadButton.android.tsx @@ -10,8 +10,8 @@ export function NewThreadButton(props: ComponentProps diff --git a/apps/mobile/src/components/NewThreadButton.ios.tsx b/apps/mobile/src/components/NewThreadButton.ios.tsx new file mode 100644 index 0000000000000..ce77112a8ef67 --- /dev/null +++ b/apps/mobile/src/components/NewThreadButton.ios.tsx @@ -0,0 +1,53 @@ +import type { ComponentProps } from "react"; +import { Pressable } from "react-native"; + +import { cn } from "../lib/cn"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; +import { AppText } from "./ui/AppText"; +import { SymbolView } from "./ui/AppSymbol"; +import { GlassSurface } from "./ui/GlassSurface"; +import { NewThreadButton as SolidNewThreadButton } from "./NewThreadButton.shared"; + +export function NewThreadButton(props: ComponentProps) { + if (!NATIVE_LIQUID_GLASS_SUPPORTED) { + return ; + } + + const label = props.label ?? "New thread"; + const icon = props.icon ?? "square.and.pencil"; + const showLabel = props.extended === true && props.expanded !== false; + const height = props.extended ? 48 : 56; + + return ( + + + + {showLabel ? ( + {label} + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/NewThreadButton.shared.tsx b/apps/mobile/src/components/NewThreadButton.shared.tsx index fac535e09e974..f234a7a23b09d 100644 --- a/apps/mobile/src/components/NewThreadButton.shared.tsx +++ b/apps/mobile/src/components/NewThreadButton.shared.tsx @@ -2,18 +2,22 @@ import { Pressable, type StyleProp, type ViewStyle } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./ui/AppText"; -import { SymbolView } from "./ui/AppSymbol"; +import { SymbolView, type AppSymbolName } from "./ui/AppSymbol"; export function NewThreadButton(props: { readonly onPress: () => void; + readonly label?: string; + readonly icon?: AppSymbolName; readonly extended?: boolean; readonly expanded?: boolean; readonly className?: string; readonly style?: StyleProp; }) { + const label = props.label ?? "New thread"; + const icon = props.icon ?? "square.and.pencil"; return ( {props.extended && props.expanded !== false ? ( - New thread + {label} ) : null} ); diff --git a/apps/mobile/src/components/ui/EmptyState.tsx b/apps/mobile/src/components/ui/EmptyState.tsx index 74e7b31575904..cb4a40fc8748a 100644 --- a/apps/mobile/src/components/ui/EmptyState.tsx +++ b/apps/mobile/src/components/ui/EmptyState.tsx @@ -2,19 +2,31 @@ import { View } from "react-native"; import type { ReactNode } from "react"; import { AppText as Text } from "./AppText"; +import { SymbolView, type AppSymbolName } from "./AppSymbol"; import { Button } from "./Button"; export function EmptyState(props: { readonly title: string; readonly detail: string; + readonly icon?: AppSymbolName; readonly actionLabel?: string; readonly onAction?: () => void; readonly action?: ReactNode; readonly variant?: "card" | "plain"; }) { + const icon = props.icon ? ( + + ) : null; + if (props.variant === "plain") { return ( + {icon ? {icon} : null} {props.title} @@ -37,6 +49,7 @@ export function EmptyState(props: { return ( + {icon ? {icon} : null} {props.title} diff --git a/apps/mobile/src/components/ui/GlassSurface.tsx b/apps/mobile/src/components/ui/GlassSurface.tsx index 8af080fc19cc7..a06581e074d86 100644 --- a/apps/mobile/src/components/ui/GlassSurface.tsx +++ b/apps/mobile/src/components/ui/GlassSurface.tsx @@ -28,6 +28,7 @@ interface GlassSurfaceProps extends ViewProps { // null leaves UIKit's material untinted instead of applying the theme tint. readonly tintColor?: ColorValue | null; readonly tintColorClassName?: string; + readonly isInteractive?: boolean; readonly chrome?: "default" | "none"; readonly fallbackColor?: ColorValue; readonly fallbackClassName?: string; @@ -40,6 +41,7 @@ export function GlassSurface({ chrome = "default", tintColor, tintColorClassName, + isInteractive = false, fallbackColor, fallbackClassName, className, @@ -72,6 +74,7 @@ export function GlassSurface({ className, )} glassEffectStyle={glassEffectStyle} + isInteractive={isInteractive} tintColor={tintColor == null ? undefined : String(tintColor)} tintColorClassName={ tintColorClassName ?? (tintColor === undefined ? "accent-glass-tint" : undefined) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4ed447eb69f23..b36cfaa02a3fd 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -37,6 +37,7 @@ import { ANDROID_READING_MAX_WIDTH, deriveCenteredContentHorizontalPadding, } from "../../lib/layout"; +import { NewThreadButton } from "../../components/NewThreadButton"; import { EmptyState } from "../../components/ui/EmptyState"; import { FloatingActionButton } from "../../components/ui/FloatingActionButton"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; @@ -62,6 +63,7 @@ import { threadListEnvironmentsAtom } from "../../state/presentation"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { + ThreadListV2CaughtUpPlaceholder, ThreadListV2PendingRow, ThreadListV2Row, ThreadListV2SettledShelfHeader, @@ -139,7 +141,7 @@ interface HomeScreenProps { readonly onNewThreadInProject: (project: EnvironmentProject) => void; } -const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; +const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 112; // Rows away from the viewport skip their swipe actions (see // swipe-row-activation), so render further ahead: a fast fling then reaches // rows that are already built instead of rows still being rebuilt. @@ -199,15 +201,15 @@ function deriveEmptyState(props: { if (props.projectCount === 0 && catalogState.hasLoadedShellSnapshot) { return { - title: "No projects found", - detail: "The connected environment did not report any projects.", + title: "No Projects", + detail: "This environment has not reported any projects yet.", loading: false, }; } return { - title: "No threads yet", - detail: "Create a task to start a new coding runtime in one of your connected projects.", + title: "No Threads", + detail: "Start a new thread to begin working in one of your projects.", loading: false, }; } @@ -222,12 +224,17 @@ function HomeScrollView(props: ComponentProps) { if (Platform.OS !== "ios") return ; // Register the scroll view explicitly; layout and header siblings hide it from UIKit. + // Soft paints a hard-edged white band that bisects the glass filter chips on the phone + // list. Keep soft only for the iOS 26 sidebar fade; otherwise let the glass header alone + // handle the top edge so filters and rows share one continuous surface. + const topEdge = + sidebarSurface && Number.parseInt(String(Platform.Version), 10) < 27 ? "soft" : "hidden"; return ( = 27 ? "automatic" : "soft", + top: topEdge, }} > ) => { const state = listRef.current?.getState(); if (state === undefined || !(state.end >= 0)) return; @@ -357,17 +364,18 @@ export function HomeScreen(props: HomeScreenProps) { 200, ); }; + const { swipeEnabled, releaseGate, scrollGateHandlers } = useSwipeableScrollGate({ + onScroll: handleListScroll, + onScrollBeginDrag: handleScrollBeginDrag, + }); const trackListTouches = (event: GestureResponderEvent, started: boolean) => { const { changedTouches, touches } = event.nativeEvent; swipeRowActivation.trackTouches( started ? changedTouches.map((touch) => String(touch.identifier)) : [], touches.map((touch) => String(touch.identifier)), ); + if (!started && touches.length === 0) releaseGate(); }; - const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: handleListScroll, - onScrollBeginDrag: handleScrollBeginDrag, - }); const { projects, selectedEnvironmentId, projectGroupingMode } = props; const projectScopes = useMemo( @@ -543,6 +551,9 @@ export function HomeScreen(props: HomeScreenProps) { /> ); } + if (item.type === "v2-caught-up") { + return ; + } if (item.type === "v2-working-shelf") { return ( @@ -692,23 +705,27 @@ export function HomeScreen(props: HomeScreenProps) { > + showStartAction ? ( + Platform.OS === "android" ? ( + + ) : ( + + ) ) : undefined } variant="plain" diff --git a/apps/mobile/src/features/home/ThreadListFilters.tsx b/apps/mobile/src/features/home/ThreadListFilters.tsx index 196e6a86480b5..985c23583431d 100644 --- a/apps/mobile/src/features/home/ThreadListFilters.tsx +++ b/apps/mobile/src/features/home/ThreadListFilters.tsx @@ -23,6 +23,11 @@ export function ThreadListFilters(props: { const selectedProject = props.projects.find( (project) => project.key === props.selectedProjectKey, ); + const showReset = props.selectedEnvironmentId !== null || props.selectedProjectKey !== null; + + // Solid chips (not Liquid Glass): glass blooms clip at the pill bounds inside a + // list header and read as a hard color cut. Opaque pills sit on the same continuous + // list surface as the rows below — matching Mail-style secondary controls. return ( - {props.selectedEnvironmentId !== null || props.selectedProjectKey !== null ? ( + {showReset ? ( { + it("leaves rows live before the first activate so swipe works on first paint", () => { + const activation = createSwipeRowActivation(); + expect(activation.isPrimed()).toBe(false); + expect(activation.isActive("a")).toBe(false); + }); + it("defers changes while a finger is on the list so a press is never remounted", () => { const activation = createSwipeRowActivation(); activation.activate(["a"]); + expect(activation.isPrimed()).toBe(true); activation.trackTouches(["1"], ["1"]); activation.activate(["b"]); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts index 40da6157ec339..82cec2258c433 100644 --- a/apps/mobile/src/features/home/swipe-row-activation.ts +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -3,16 +3,27 @@ import { createContext, use, useSyncExternalStore } from "react"; /** * Only rows around the viewport enable swipe gestures and render actions. * Dormant rows keep the same child views so waking them preserves touches. + * Before the first activate(), no row is dormant so swipe works on first paint + * even if LegendList getState() is not ready yet. */ export function createSwipeRowActivation() { let activeKeys = new Set(); + let primed = false; // Turning a row's swipe gesture off ends a swipe in progress, so changes // wait until every finger that started on the list has lifted. const listTouches = new Set(); let pendingKeys: ReadonlyArray | null = null; const listeners = new Set<() => void>(); const apply = (keys: ReadonlyArray) => { - if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + const nextPrimed = true; + if ( + primed === nextPrimed && + keys.length === activeKeys.size && + keys.every((key) => activeKeys.has(key)) + ) { + return; + } + primed = nextPrimed; activeKeys = new Set(keys); for (const listener of listeners) listener(); }; @@ -22,6 +33,7 @@ export function createSwipeRowActivation() { return () => void listeners.delete(listener); }, isActive: (key: string) => activeKeys.has(key), + isPrimed: () => primed, activate(keys: ReadonlyArray) { if (listTouches.size > 0) pendingKeys = keys; else apply(keys); @@ -47,6 +59,10 @@ export function useSwipeRowDormant(key: string | undefined): boolean { const activation = use(SwipeRowActivationContext); return useSyncExternalStore( activation?.subscribe ?? subscribeNever, - () => activation !== null && key !== undefined && !activation.isActive(key), + () => + activation !== null && + key !== undefined && + activation.isPrimed() && + !activation.isActive(key), ); } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 6f2e8a7a9020f..540d092c9510d 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -41,16 +41,18 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; +import { GlassSurface } from "../../components/ui/GlassSurface"; +import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; +// iOS 26 Messages/Mail: round Liquid Glass action pills, not full-height tinted lanes. const ACTION_ITEM_WIDTH = 58; -const ACTION_CIRCLE_SIZE = 36; -const ACTION_ICON_SIZE = 15; -const COMPACT_ACTION_CIRCLE_SIZE = 28; -const COMPACT_ACTION_ICON_SIZE = 13; +const ACTION_CIRCLE_SIZE = 40; +const ACTION_ICON_SIZE = 17; +const COMPACT_ACTION_CIRCLE_SIZE = 32; +const COMPACT_ACTION_ICON_SIZE = 14; -const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; const THREAD_SWIPE_SPRING = { damping: 26, mass: 0.7, @@ -76,7 +78,7 @@ interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { } function swipeActionsWidth(hasSecondaryAction: boolean) { - return hasSecondaryAction ? THREAD_SWIPE_ACTIONS_WIDTH : ACTION_ITEM_WIDTH; + return hasSecondaryAction ? ACTION_ITEM_WIDTH * 2 : ACTION_ITEM_WIDTH; } function resolveSecondaryAction(input: { @@ -200,9 +202,16 @@ export function useSwipeableScrollGate(options?: { const onMomentumScrollEnd = useCallback(() => { update(false); }, [update]); + // Interrupted pans (automation, gesture handoff) can skip end-drag/momentum; lift clears the gate. + const releaseGate = useCallback(() => { + draggingRef.current = false; + clearSettle(); + update(false); + }, [clearSettle, update]); return { swipeEnabled: !gateActive, + releaseGate, scrollGateHandlers: { onScroll, onScrollBeginDrag, @@ -238,6 +247,7 @@ interface ThreadSwipeableProps { export function ThreadSwipeable(props: ThreadSwipeableProps) { const swipeableRef = useRef(null); + const compactActions = props.compactActions === true; const hasSecondaryAction = props.secondaryAction !== null; const actionsWidth = swipeActionsWidth(hasSecondaryAction); const fullSwipeThreshold = Math.max(actionsWidth + 44, props.fullSwipeWidth * 0.58); @@ -419,7 +429,6 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { const handleSwipeableClose = useCallback(() => { if (swipeableRef.current) onSwipeableClose?.(swipeableRef.current); }, [onSwipeableClose]); - const compactActions = props.compactActions === true; const { secondaryAction, threadTitle } = props; // Swipeable calls this on every render; returning the previous element while nothing it shows // changed keeps a selection change from re-rendering the hidden actions. @@ -522,6 +531,32 @@ export function ThreadSwipeable(props: ThreadSwipeableProps) { ); } +// Untinted Liquid Glass at rest (accent tint reads as a solid disc). Overdrag uses this soft +// fill instead — GlassView width animation crashes UIKit / paints a thin line, not a capsule. +function swipeCapsuleFillColor(tone: "primary" | "secondary" | "danger"): string { + if (tone === "danger") return "rgba(255, 59, 48, 0.34)"; + if (tone === "primary") return "rgba(0, 122, 255, 0.3)"; + return "rgba(120, 120, 128, 0.3)"; +} + +function swipeIconTintClassName(tone: "primary" | "secondary" | "danger") { + if (tone === "danger") return "accent-danger"; + if (tone === "secondary") return "accent-icon-muted"; + return "accent-primary"; +} + +function swipeSolidFillClassName(tone: "primary" | "secondary" | "danger") { + if (tone === "danger") return "bg-danger"; + if (tone === "secondary") return "bg-secondary"; + return "bg-primary"; +} + +function swipeSolidIconTintClassName(tone: "primary" | "secondary" | "danger") { + if (tone === "danger") return "accent-danger-foreground"; + if (tone === "secondary") return "accent-secondary-foreground"; + return "accent-primary-foreground"; +} + function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; @@ -545,6 +580,8 @@ function SwipeActionButton(props: { } = props; const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; + const useGlass = NATIVE_LIQUID_GLASS_SUPPORTED; + const capsuleFill = swipeCapsuleFillColor(props.tone); const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( @@ -554,15 +591,16 @@ function SwipeActionButton(props: { Extrapolation.CLAMP, ); const stretch = Math.max(reveal - actionsWidth, 0); - const fullSwipeProgress = interpolate( - reveal, - [actionsWidth, fullSwipeThreshold + 20], + // Secondary yields as soon as the committing pill starts claiming overdrag. + const stretchFade = interpolate( + stretch, + [0, ACTION_ITEM_WIDTH * 0.7], [0, 1], Extrapolation.CLAMP, ); return { - opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress), + opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - stretchFade), transform: [ { translateX: @@ -572,7 +610,8 @@ function SwipeActionButton(props: { ], }; }); - const circleStyle = useAnimatedStyle(() => { + // Messages morph: RN View width only. Glass stays fixed-size and fades once stretch begins. + const pillStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; @@ -581,6 +620,22 @@ function SwipeActionButton(props: { width: circleSize + stretch, }; }); + const glassRestStyle = useAnimatedStyle(() => { + if (!stretchesOnFullSwipe) return { opacity: 1 }; + const reveal = Math.max(-translation.value, 0); + const stretch = Math.max(reveal - actionsWidth, 0); + return { + opacity: interpolate(stretch, [0, 14], [1, 0], Extrapolation.CLAMP), + }; + }); + const capsuleFillStyle = useAnimatedStyle(() => { + if (!stretchesOnFullSwipe) return { opacity: useGlass ? 0 : 1 }; + const reveal = Math.max(-translation.value, 0); + const stretch = Math.max(reveal - actionsWidth, 0); + return { + opacity: useGlass ? interpolate(stretch, [0, 14], [0, 1], Extrapolation.CLAMP) : 1, + }; + }); const iconStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0; @@ -592,7 +647,7 @@ function SwipeActionButton(props: { ); return { - transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.5) }], + transform: [{ translateX: -stretch * (0.5 + armedProgress * 0.2) }], }; }); const labelStyle = useAnimatedStyle(() => { @@ -609,7 +664,7 @@ function SwipeActionButton(props: { [1, 0], Extrapolation.CLAMP, ), - transform: [{ translateX: -stretch * 0.5 }], + transform: [{ translateX: -stretch * 0.45 }], }; }); @@ -622,31 +677,73 @@ function SwipeActionButton(props: { alignItems: "center", height: "100%", justifyContent: "center", - opacity: pressed ? 0.72 : 1, + opacity: pressed ? 0.78 : 1, width: "100%", })} > - + + > + {useGlass ? ( + <> + + + + {null} + + + + ) : ( + + )} + @@ -694,6 +789,7 @@ function SwipeActionButton(props: { alignItems: "center", height: "100%", justifyContent: "center", + overflow: "visible", width: ACTION_ITEM_WIDTH, zIndex: props.stretchesOnFullSwipe ? 2 : 1, }, @@ -739,47 +835,73 @@ function ThreadSwipeActions(props: { [fullSwipeThreshold, onFullSwipeArmedChange, translation], ); + // Messages keeps the committing action trailing so overdrag stretches it left into a capsule. + const primaryTone = + props.primaryAction.destructive === true ? ("danger" as const) : ("primary" as const); + const primaryButton = ( + + ); + const secondaryButton = + secondaryAction === null ? null : ( + + ); + return ( - - {secondaryAction === null ? null : ( - + {fullSwipeIsPrimary ? ( + <> + {secondaryButton} + {primaryButton} + + ) : ( + <> + {primaryButton} + {secondaryButton} + )} ); diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx index e04b72cb9685d..21a52e3beda48 100644 --- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx +++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx @@ -1,5 +1,5 @@ import { SymbolView } from "../../components/ui/AppSymbol"; -import { Platform, Pressable, View } from "react-native"; +import { Platform, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { NewThreadButton } from "../../components/NewThreadButton"; @@ -67,17 +67,7 @@ export function WorkspaceEmptyDetail(props: { : "Choose a thread from the sidebar or start a new task."} {props.onStartNewTask && !pullRequests && !settings ? ( - Platform.OS === "android" ? ( - - ) : ( - - New thread - - ) + ) : null} )} diff --git a/apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx b/apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx index 6905dee8bdd6d..870e7bc0bc06e 100644 --- a/apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx +++ b/apps/mobile/src/features/layout/workspace/ListFilterMenu.tsx @@ -9,6 +9,8 @@ export function ListFilterMenu(props: { readonly accessibilityLabel: string; readonly active?: boolean; readonly icon?: boolean; + /** Liquid Glass blooms clip inside list headers; keep solid there. */ + readonly glass?: boolean; readonly actions: MenuProps["actions"]; readonly onSelect: (id: string) => void; }) { @@ -30,7 +32,7 @@ export function ListFilterMenu(props: { selected={props.active} icon={props.icon ? "line.3.horizontal.decrease" : undefined} trailingIcon={props.icon ? undefined : "chevron.down"} - glass + glass={props.glass !== false} colorScheme={themeAppearance} /> diff --git a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx index e8e7c0d325983..173745687cee0 100644 --- a/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/layout/workspace/WorkspaceRootScreen.tsx @@ -5,13 +5,16 @@ import { WorkspaceSettingsList } from "../../settings/SettingsRouteScreen"; import { WorkspaceSectionToolbar } from "./WorkspaceSectionToolbar"; import { useNavigation } from "@react-navigation/native"; import { use } from "react"; -import { View, useWindowDimensions } from "react-native"; +import { Platform, View, useWindowDimensions } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; import { CompactBrandTitle } from "../../../components/CompactBrandTitle"; import { HeaderItemsWidthContext } from "../../../native/header-items-width"; -import { NativeStackScreenOptions } from "../../../native/StackHeader"; +import { + NativeStackScreenOptions, + nativeHeaderScrollEdgeEffects, +} from "../../../native/StackHeader"; import { NativePrimaryColumnContext } from "../../../native/v5-workspace-context"; import { isDuoDevice } from "../../../lib/device-type"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../../native/native-glass"; @@ -146,6 +149,17 @@ function WorkspaceRootContent() { sidebarNavigation || (!settingsOpen && state.selectedTab !== "usage") ? renderHeaderTitle : undefined, + // Soft/automatic scroll-edge paints a hard band through the glass filter chips. + scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED + ? { + ...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version), + top: + !settingsOpen && + (state.selectedTab === "threads" || state.selectedTab === "pullRequests") + ? "hidden" + : "automatic", + } + : undefined, }} /> diff --git a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx index fbcb62406b7a7..be616463b9237 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx @@ -368,6 +368,7 @@ export function PullRequestListFilters(props: { > {Platform.OS === "android" ? null : ( 0} @@ -378,6 +379,7 @@ export function PullRequestListFilters(props: { )} {props.filters.environmentLabel !== null ? ( ) : null} 0} accessibilityLabel="Filter by label" @@ -406,6 +411,7 @@ export function PullRequestListFilters(props: { onSelect={select} /> ); } + case "v2-caught-up": + return ; case "v2-working-shelf": return ( item.type} itemsAreEqual={sidebarItemsAreEqual} @@ -641,7 +644,7 @@ function ThreadNavigationSidebarPane(props: ThreadNavigationSidebarProps) { itemLayoutAnimation={THREAD_SIDEBAR_ROW_LAYOUT} data={listItems} drawDistance={500} - estimatedItemSize={64} + estimatedItemSize={104} extraData={listExtraData} getItemType={(item) => item.type} itemsAreEqual={sidebarItemsAreEqual} 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 d724d8056e95e..801089b8d226d 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -3,6 +3,7 @@ import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { THREAD_LIST_V2_MONO_FONT as MONO_FONT, THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME, + THREAD_LIST_V2_ROW_DIVIDER_INSET, THREAD_LIST_V2_ROW_DIVIDERS, selectedThreadRowColors, getThreadListV2NewBranchMenuTitle, @@ -70,12 +71,13 @@ import { canTurnThreadIntoProject, turnThreadIntoProject } from "./turnThreadInt import { threadCanArchive } from "../home/threadArchive"; import { THREAD_LIST_V2_SETTLED_PAGE_COUNT, + resolveThreadListMessagePreview, + resolveThreadListV2ProviderDrivers, resolveThreadListV2SnoozeGateExpiryMs, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozePresets, threadHasUnseenCompletion, resolveThreadListV2Status, - resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, layoutThreadListV2RowMenuActions, type ThreadListV2Status, @@ -88,8 +90,7 @@ import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; interface StatusLabel { - // Null when the icon and timer carry the status on their own. - readonly label: string | null; + readonly label: string; readonly icon: AppSymbolName; readonly className: string; readonly iconTintClassName: string; @@ -114,13 +115,13 @@ const STATUS_LABEL_BY_STATUS: Partial> = iconTintClassName: "accent-adaptive-fuchsia-600-300", }, working: { - label: null, + label: "Working", icon: "circle.dashed", className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, waiting: { - label: null, + label: "Waiting", icon: "circle.dashed.inset.filled", className: "text-foreground-muted", iconTintClassName: "accent-foreground-muted", @@ -191,8 +192,8 @@ const LEGACY_MENU_ACTIONS: MenuAction[] = [ function ThreadListV2Section(props: { readonly label: string; + readonly count?: number; readonly pane?: "screen" | "sidebar"; - readonly tone?: "default" | "snoozed"; readonly disclosure?: { readonly expanded: boolean; readonly disabled?: boolean; @@ -201,43 +202,31 @@ function ThreadListV2Section(props: { readonly accessibilityHint: string; }; }) { - const snoozed = props.tone === "snoozed"; const sidebarPane = props.pane === "sidebar"; + const showCount = props.count !== undefined; + const labelClassName = cn( + "text-[15px] font-t3-semibold", + sidebarPane ? "text-drawer-foreground" : "text-foreground", + ); + const mutedClassName = sidebarPane ? "text-drawer-foreground-muted" : "text-foreground-tertiary"; + const chevronTint = sidebarPane ? "accent-drawer-foreground-muted" : "accent-foreground-muted"; const className = cn( - "mb-1.5 mt-4 flex-row items-center gap-2.5", + "mb-1 mt-5 flex-row items-center gap-2", props.pane === "sidebar" ? "px-3" : "px-5", ); const content = ( <> - + {props.label} - + {showCount ? ( + {props.count} + ) : null} {props.disclosure ? ( @@ -289,9 +278,9 @@ function ThreadListV2ShelfHeader( const label = SHELF_LABEL[props.kind]; return ( ; }); +export const ThreadListV2CaughtUpPlaceholder = memo( + function ThreadListV2CaughtUpPlaceholder(props: { readonly pane?: "screen" | "sidebar" }) { + const sidebarPane = props.pane === "sidebar"; + return ( + + + + All Caught Up + + + Nothing needs a response right now. + + + ); + }, +); + export const ThreadListV2ShowMoreRow = memo(function ThreadListV2ShowMoreRow(props: { readonly pane?: "screen" | "sidebar"; readonly hiddenCount: number; @@ -701,9 +725,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }) { const { width: windowWidth, fontScale } = useWindowDimensions(); const titleTypography = useScaledTextRole("body"); - const metadataTypography = useScaledTextRole("label"); - const projectIconSize = titleTypography.fontSize * 1.25 * fontScale; - const machineIconSize = metadataTypography.fontSize * fontScale; + const projectIconSize = Math.round(36 * fontScale); // Android's nonlinear system text scaling can differ from the fontScale estimate. const [androidTitleLineHeight, setTitleLineHeight] = useState( titleTypography.lineHeight * fontScale, @@ -712,7 +734,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { Platform.OS === "android" ? androidTitleLineHeight : titleTypography.lineHeight * fontScale; const { thread, - variant, onSelectThread, onOpenThreadPullRequests, onDeleteThread, @@ -745,7 +766,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { providerIconUrl: provider?.iconUrl, }; }, [thread, props.providers]); - const providerInstance = props.providerInstance; const providerNameLabel = providerInstance ? providerInstanceNameLabel({ @@ -776,8 +796,33 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); const recede = shouldRecedeThreadRow({ status, selected }); const isRegeneratingTitle = thread.titleRegeneration != null; - const rowAccessibilityLabel = `${thread.title}${providerNameLabel ? `, ${providerNameLabel}` : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; - const timeLabel = props.timeLabel; + const messagePreview = resolveThreadListMessagePreview(thread); + const contextLabel = props.project?.title ?? props.environmentLabel ?? null; + const wakeLabel = + snoozedRow && props.snoozeWakeLabelText !== undefined ? props.snoozeWakeLabelText : null; + const timeLabel = wakeLabel ?? props.timeLabel; + const rowAccessibilityLabel = [ + thread.title, + contextLabel, + statusLabel?.label ?? null, + showsWoke ? "woke" : null, + isUnread ? "unread" : null, + snoozedRow ? "snoozed" : null, + wakeLabel != null ? `wakes in ${wakeLabel}` : null, + wakeLabel == null && !showsWoke && statusLabel === undefined && props.timeLabel + ? props.timeLabel + : null, + messagePreview + ? messagePreview.speaker === "you" + ? `You: ${messagePreview.text}` + : messagePreview.text + : null, + providerInstance?.displayName ?? null, + props.hasQueuedMessages ? "messages queued to send" : null, + isRegeneratingTitle ? "regenerating title" : null, + ] + .filter(Boolean) + .join(", "); const visitThread = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const handleAcknowledgeWoke = useCallback(() => { @@ -828,7 +873,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); - const canUnsettle = variant === "slim"; + const canUnsettle = thread.settledOverride === "settled"; // The gate's only time-dependent edge is the queued-turn grace window, so its // expiry is known without a clock: evaluate at the message's own timestamp. // A gate held by runtime status has no expiry, and no clock tick opens it. @@ -839,7 +884,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozeGateNowMs = useNowAfter(snoozeGateExpiryMs === null ? null : snoozeGateExpiryMs + 1); const snoozeGateNow = new Date(snoozeGateNowMs).toISOString(); const swipeActions = resolveThreadListV2SwipeActions({ - variant, + settled: canUnsettle, settlementSupported: props.settlementSupported, snoozeSupported: props.snoozeSupported, snoozable: canSnooze(thread, { now: snoozeGateNow }), @@ -904,12 +949,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { subactions: [ { id: "auto-settle:enabled", - title: "Follow settings", + title: "Enabled", state: thread.autoSettleDisabledAt == null ? "on" : "off", }, { id: "auto-settle:disabled", - title: "Never", + title: "Disabled", state: thread.autoSettleDisabledAt == null ? "off" : "on", }, ], @@ -1154,193 +1199,121 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; - const titleTextInset = props.project ? projectIconSize + 8 : 0; - const cardContent = ( - <> - - {props.project ? ( - - - + const runtimeError = + (status === "failed" || status === "limited") && thread.runtime?.lastError + ? thread.runtime.lastError + : null; + const trailingMeta = ( + + + {props.hasQueuedMessages ? : null} + {pinnedRow ? ( + ) : null} - { - const firstLine = nativeEvent.lines[0]; - if (firstLine && Math.abs(firstLine.height - titleLineHeight) > 0.5) { - setTitleLineHeight(firstLine.height); - } - } - : undefined - } - > - {thread.title} - - - {props.hasQueuedMessages ? : null} - {pinnedRow ? ( + {showsWoke ? ( + undefined} + style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + > - ) : null} - {showsWoke ? ( - undefined} - style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} + - - - Woke - - - ) : statusLabel ? ( - - - - {statusLabel.label} - {status === "working" ? ( - - ) : status === "waiting" ? ( - - ) : null} - - - ) : ( + Woke + + + ) : wakeLabel != null ? ( + + - {timeLabel} + {wakeLabel} - )} - - - {props.searchMatch ? ( - - - - ) : null} - - {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( - - {thread.runtime.lastError} - - ) : props.environmentLabel ? ( - - {props.environmentMachine ? ( - - ) : null} + + ) : statusLabel ? ( + + - {props.environmentLabel} + {statusLabel.label} + {status === "working" ? ( + + ) : status === "waiting" ? ( + + ) : null} ) : ( - + + {timeLabel} + )} {pr ? ( undefined} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })} > @@ -1363,186 +1336,223 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : "accent-adaptive-violet-600-400" } /> - - {pr.label} - ) : null} - {providerInstance ? ( - - {providerDrivers.slice(0, -1).map( - (driver, index) => ( - /* oxlint-disable react/no-array-index-key -- Provider history can repeat a driver; these decorative icons have no local state. */ - - - ) /* oxlint-enable react/no-array-index-key */, - )} - - {providerNameLabel ? ( - - {providerNameLabel} - - ) : null} - - ) : null} + - - ); - - const fadedCardContent = recede ? ( - {cardContent} - ) : ( - cardContent + {providerInstance ? ( + + {providerDrivers.slice(0, -1).map( + (driver, index) => ( + /* oxlint-disable react/no-array-index-key -- Provider history can repeat a driver; these decorative icons have no local state. */ + + + ) /* oxlint-enable react/no-array-index-key */, + )} + + {providerNameLabel ? ( + + {providerNameLabel} + + ) : null} + + ) : null} + ); - - const rowContent = (close: () => void) => - variant === "card" ? ( - { - if (event.nativeEvent.actionName === "openPullRequests") handleOpenPullRequests(); - if (event.nativeEvent.actionName === "dismissWoke") handleAcknowledgeWoke(); - }} - onPress={() => { - close(); - selectThread(); - }} - style={rowAppearance.cardStyle} - > - {sidebarPane ? ( - fadedCardContent + const cardContent = ( + + + {isUnread ? : null} + + + {props.project ? ( + ) : ( - - {fadedCardContent} - {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( - - ) : null} + + )} - - ) : ( - { - close(); - selectThread(); - }} - style={rowAppearance.style} - > - - {props.project ? ( - - - - ) : null} - + + + + { + const firstLine = nativeEvent.lines[0]; + if (firstLine && Math.abs(firstLine.height - titleLineHeight) > 0.5) { + setTitleLineHeight(firstLine.height); + } + } + : undefined + } + > + {thread.title} + + {contextLabel != null ? ( - {thread.title} + {contextLabel} - {providerNameLabel ? ( + ) : null} + {props.searchMatch ? ( + + ) : runtimeError ? ( + + {runtimeError} + + ) : messagePreview ? ( + + {messagePreview.speaker === "you" ? ( + + ) : null} - {providerNameLabel} + {messagePreview.text} - ) : null} - {props.searchMatch ? ( - - ) : null} - - {props.hasQueuedMessages ? : null} - - {snoozedRow && props.snoozeWakeLabelText !== undefined - ? props.snoozeWakeLabelText - : timeLabel} - + + ) : null} - - ); + {trailingMeta} + + + ); + + const fadedCardContent = recede ? ( + {cardContent} + ) : ( + cardContent + ); + + const rowContent = (close: () => void) => ( + { + if (event.nativeEvent.actionName === "openPullRequests") handleOpenPullRequests(); + if (event.nativeEvent.actionName === "dismissWoke") handleAcknowledgeWoke(); + }} + onPress={() => { + close(); + selectThread(); + }} + style={rowAppearance.cardStyle} + > + {sidebarPane ? ( + fadedCardContent + ) : ( + + {fadedCardContent} + {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( + + ) : null} + + )} + + ); if (!canOperateThread) return {rowContent(() => {})}; @@ -1556,7 +1566,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { dormant={dormant} threadKey={`${thread.environmentId}:${thread.id}`} backgroundColor={rowAppearance.swipeBackgroundColor} - compactActions={variant === "slim"} + compactActions={false} containerStyle={rowAppearance.swipeContainerStyle} enableTrackpadSwipe fullSwipeAction="primary" @@ -1566,7 +1576,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onSwipeableWillOpen={props.onSwipeableWillOpen} primaryAction={primaryAction} secondaryAction={secondaryAction} - resetKey={`${thread.environmentId}:${thread.id}:${variant}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`} + resetKey={`${thread.environmentId}:${thread.id}:${snoozedRow}:${thread.settledAt}:${thread.unsettledAt}:${thread.snoozedUntil}:${thread.pinnedAt}`} simultaneousWith={props.simultaneousSwipeGesture} threadTitle={thread.title} > diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts index c63d327764cb6..f47df5be02f96 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts +++ b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.android.ts @@ -2,8 +2,9 @@ import type { ViewStyle } from "react-native"; import type { MobileThemeVariables } from "../../lib/mobileTheme"; export const THREAD_LIST_V2_MONO_FONT = "monospace"; -export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-3 py-2.5"; +export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-3 py-3"; export const THREAD_LIST_V2_ROW_DIVIDERS = false; +export const THREAD_LIST_V2_ROW_DIVIDER_INSET = 40; export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { borderRadius: 20, paddingHorizontal: 12, diff --git a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts index 96bf3d43682b4..1ff93ceef0517 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts +++ b/apps/mobile/src/features/threads/thread-list-v2-row-appearance.ts @@ -2,8 +2,10 @@ import type { ViewStyle } from "react-native"; import type { MobileThemeVariables } from "../../lib/mobileTheme"; export const THREAD_LIST_V2_MONO_FONT = "Menlo"; -export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-5 py-2.5"; +export const THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME = "px-4 py-3"; export const THREAD_LIST_V2_ROW_DIVIDERS = true; +/** Row padding + unread-dot column + gaps; the divider adds the avatar size to start at the text. */ +export const THREAD_LIST_V2_ROW_DIVIDER_INSET = 44; export const THREAD_LIST_V2_SIDEBAR_ROW_STYLE = { borderRadius: 12, paddingHorizontal: 12, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 786f1ce229113..1428d3e20fcb7 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -40,6 +40,7 @@ import { resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SwipeActions, resolveThreadListV2SnoozePresets, + resolveThreadListMessagePreview, resolveThreadListV2Status, resolveThreadListV2WokeAt, sortThreadsForListV2, @@ -224,12 +225,20 @@ describe("resolveThreadListV2SwipeActions", () => { expect(thread.pendingUserInputAt).toBe(NOW); expect( resolveThreadListV2SwipeActions({ - variant: "card", + settled: false, settlementSupported: true, snoozeSupported: true, snoozable: canSnooze(thread, { now: NOW }), - }).secondary, - ).toBe("snooze"); + }), + ).toEqual({ primary: "settle", secondary: "snooze" }); + expect( + resolveThreadListV2SwipeActions({ + settled: true, + settlementSupported: true, + snoozeSupported: true, + snoozable: canSnooze(thread, { now: NOW }), + }), + ).toEqual({ primary: "unsettle", secondary: "snooze" }); const layout = buildThreadListV2Items({ threads: [{ ...thread, snoozedAt: NOW, snoozedUntil: "2026-06-03T00:00:00.000Z" }], @@ -243,6 +252,107 @@ describe("resolveThreadListV2SwipeActions", () => { }); }); +describe("resolveThreadListMessagePreview", () => { + it("collapses whitespace and maps roles for the Mail-style preview line", () => { + const assistant = presentThreadShell( + environmentId, + makeRawThreadShell({ + latestVisibleMessage: { + id: MessageId.make("msg-assistant"), + role: "assistant", + text: " Shipped\nthe fix ", + updatedAt: DateTime.makeUnsafe(NOW), + }, + }), + ); + const user = presentThreadShell( + environmentId, + makeRawThreadShell({ + latestVisibleMessage: { + id: MessageId.make("msg-user"), + role: "user", + text: "Please fix it", + updatedAt: DateTime.makeUnsafe(NOW), + }, + }), + ); + + expect(resolveThreadListMessagePreview(assistant)).toEqual({ + speaker: "agent", + text: "Shipped the fix", + }); + expect(resolveThreadListMessagePreview(user)).toEqual({ + speaker: "you", + text: "Please fix it", + }); + expect( + resolveThreadListMessagePreview( + presentThreadShell(environmentId, makeRawThreadShell({ latestVisibleMessage: null })), + ), + ).toBeNull(); + }); +}); + +describe("unread ready rows keep a date stamp", () => { + it("stamps a relative time instead of clearing the trailing slot for unseen completions", () => { + const completedAt = "2026-06-01T23:55:00.000Z"; + const thread = presentThreadShell( + environmentId, + makeRawThreadShell({ + id: ThreadId.make("unread-ready"), + title: "Unread ready", + status: "completed", + latestRunId: RunId.make("run-unread"), + latestRunCompletedAt: DateTime.makeUnsafe(completedAt), + latestUserMessageAt: DateTime.makeUnsafe("2026-06-01T23:50:00.000Z"), + lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:40:00.000Z"), + }), + ); + expect(threadHasUnseenCompletion(thread)).toBe(true); + expect(resolveThreadListV2Status(thread)).toBe("ready"); + + const [row] = buildThreadListV2ListItems({ + items: buildThreadListV2Items({ + threads: [thread], + environmentId: null, + searchQuery: "", + now: NOW, + }).items, + pendingTasks: [], + }); + expect(row?.type === "v2-thread" && row.timeLabel.length > 0).toBe(true); + }); +}); + +describe("queued messages keep a settled thread active", () => { + const threads = [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ + id: ThreadId.make("settled-queued"), + title: "Settled with outbox", + settledOverride: "settled", + }), + ]; + const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); + + it("lists the thread in the active block instead of the settled shelf", () => { + const layout = buildThreadListV2Items({ + threads, + environmentId: null, + searchQuery: "", + now: NOW, + queuedThreadKeys, + }); + expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ + ["active", "card"], + ["settled-queued", "card"], + ["settled", "card"], + ]); + expect(layout.settledCount).toBe(1); + }); +}); + describe("search lists ranked matches", () => { const matchKey = (id: string) => threadSearchMatchKey({ environmentId, threadId: ThreadId.make(id) }); @@ -262,7 +372,7 @@ describe("search lists ranked matches", () => { }); // The exact title leads even from a collapsed Settled shelf, and keeps its look. expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ - ["exact", "slim"], + ["exact", "card"], ["notes", "card"], ["mentioned", "card"], ]); @@ -492,7 +602,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(0); }); - it("partitions settled threads into a slim shelf", () => { + it("partitions settled threads into a settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -516,8 +626,8 @@ describe("buildThreadListV2Items", () => { expect(layout.items.map((item) => [item.thread.id, item.variant])).toEqual([ ["active", "card"], - ["settled", "slim"], - ["settled-2", "slim"], + ["settled", "card"], + ["settled-2", "card"], ]); expect(layout.items.map((item) => item.isLast)).toEqual([false, false, true]); expect(layout.settledCount).toBe(2); @@ -543,7 +653,7 @@ describe("buildThreadListV2Items", () => { expect(items.map((item) => [item.thread.id, item.variant])).toEqual([ ["match", "card"], - ["settled", "slim"], + ["settled", "card"], ]); }); @@ -619,7 +729,9 @@ describe("buildThreadListV2Items settled paging", () => { }); expect(layout.hiddenSettledCount).toBe(2); - expect(layout.items.filter((item) => item.variant === "slim")).toHaveLength(2); + expect(layout.items.filter((item) => item.thread.settledOverride === "settled")).toHaveLength( + 2, + ); expect(layout.items.map((item) => item.thread.id)).toEqual([ "active", "settled-3", @@ -696,7 +808,7 @@ describe("buildThreadListV2Items settled sort reuse", () => { searchQuery: "", now: NOW, }) - .items.filter((item) => item.variant === "slim") + .items.filter((item) => item.thread.settledOverride === "settled") .map((item) => `${item.thread.environmentId}:${item.thread.id}`); } @@ -842,6 +954,80 @@ describe("buildThreadListV2ListItems", () => { ).toEqual([true, true, true, false, null, false]); }); + it("shows an all-caught-up placeholder when only shelves remain", () => { + const shelvesOnly = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("snoozed"), + title: "snoozed", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T12:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + expect(shelvesOnly.activeCount).toBe(0); + const items = buildThreadListV2ListItems({ + items: shelvesOnly.items, + pendingTasks: [], + activeCount: shelvesOnly.activeCount, + snoozedCount: shelvesOnly.snoozedCount, + snoozedShelfExpanded: false, + snoozedShelfHeaderIndex: shelvesOnly.snoozedShelfHeaderIndex, + settledCount: shelvesOnly.settledCount, + settledShelfExpanded: true, + settledShelfHeaderIndex: shelvesOnly.settledShelfHeaderIndex, + }); + expect(items.map((item) => item.type)).toEqual([ + "v2-caught-up", + "v2-snoozed-shelf", + "v2-settled-shelf", + "v2-thread", + ]); + }); + + it("hides all-caught-up when a pinned thread still needs a response", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ + id: ThreadId.make("pinned-approval"), + title: "pinned-approval", + pinnedAt: NOW, + hasPendingApprovals: true, + }), + makeThread({ + id: ThreadId.make("settled"), + title: "settled", + settledOverride: "settled", + settledAt: NOW, + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + expect(layout.activeCount).toBe(0); + const items = buildThreadListV2ListItems({ + items: layout.items, + pendingTasks: [], + activeCount: layout.activeCount, + settledCount: layout.settledCount, + settledShelfExpanded: true, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + }); + expect( + items.map((item) => (item.type === "v2-thread" ? item.item.thread.id : item.type)), + ).toEqual(["pinned-approval", "v2-settled-shelf", "settled"]); + }); + it("places queued tasks above active rows and a collapsed snoozed shelf", () => { const snoozedLayout = buildThreadListV2Items({ threads: [ @@ -1797,32 +1983,3 @@ describe("Working section beta", () => { ]); }); }); - -describe("queued messages keep a settled thread active", () => { - const threads = [ - makeThread({ id: ThreadId.make("active"), title: "Active" }), - makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), - makeThread({ - id: ThreadId.make("settled-queued"), - title: "Settled with outbox", - settledOverride: "settled", - }), - ]; - const queuedThreadKeys = new Set([`${environmentId}:settled-queued`]); - - it("lists the thread in the active block instead of the settled shelf", () => { - const layout = buildThreadListV2Items({ - threads, - environmentId: null, - searchQuery: "", - now: NOW, - queuedThreadKeys, - }); - expect(layout.items.map((item) => [item.thread.id, item.variant] as const)).toEqual([ - ["active", "card"], - ["settled-queued", "card"], - ["settled", "slim"], - ]); - expect(layout.settledCount).toBe(1); - }); -}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e00..627bc1ca07666 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -113,7 +113,7 @@ export function resolveThreadListV2SnoozeMenuSelection(input: { } export function resolveThreadListV2SwipeActions(input: { - readonly variant: "card" | "slim"; + readonly settled: boolean; readonly settlementSupported: boolean; readonly snoozeSupported: boolean; readonly snoozable: boolean; @@ -125,11 +125,7 @@ export function resolveThreadListV2SwipeActions(input: { if (input.snoozed === true) { return { primary: "unsnooze", secondary: null }; } - const primary = input.settlementSupported - ? input.variant === "slim" - ? "unsettle" - : "settle" - : "archive"; + const primary = input.settlementSupported ? (input.settled ? "unsettle" : "settle") : "archive"; return { primary, secondary: input.snoozeSupported && input.snoozable ? "snooze" : null, @@ -243,6 +239,19 @@ export function threadHasUnseenCompletion( return completedAtMs > lastVisitedAtMs; } +export function resolveThreadListMessagePreview( + thread: Pick, +): { readonly speaker: "you" | "agent"; readonly text: string } | null { + const message = thread.source.latestVisibleMessage; + if (message == null) return null; + const text = message.text.replace(/\s+/g, " ").trim(); + if (text.length === 0) return null; + return { + speaker: message.role === "user" ? "you" : "agent", + text, + }; +} + export function resolveThreadListV2WokeAt( thread: EnvironmentThreadShell, options: { readonly now: string }, @@ -365,6 +374,8 @@ export interface ThreadListV2Layout { readonly hiddenSettledCount: number; /** The settled rows the shelf may list, before its visible limit. */ readonly listedSettledThreads: ReadonlyArray; + /** Attention inbox rows above Working/Snoozed/Settled (excludes pinned). */ + readonly activeCount: number; readonly workingCount: number; readonly workingShelfHeaderIndex: number | null; readonly snoozedCount: number; @@ -399,6 +410,11 @@ export interface ThreadListV2PendingListItem { readonly showTrailingDivider: boolean; } +export interface ThreadListV2CaughtUpListItem { + readonly type: "v2-caught-up"; + readonly key: "v2-caught-up"; +} + export interface ThreadListV2WorkingShelfListItem { readonly type: "v2-working-shelf"; readonly key: "v2-working-shelf"; @@ -427,6 +443,7 @@ export interface ThreadListV2SettledShelfListItem { export type ThreadListV2ListItem = | ThreadListV2ThreadListItem | ThreadListV2PendingListItem + | ThreadListV2CaughtUpListItem | ThreadListV2WorkingShelfListItem | ThreadListV2SnoozedShelfListItem | ThreadListV2SettledShelfListItem; @@ -437,6 +454,7 @@ export function isThreadListV2ListItem(value: { return ( value.type === "v2-thread" || value.type === "v2-pending" || + value.type === "v2-caught-up" || value.type === "v2-working-shelf" || value.type === "v2-snoozed-shelf" || value.type === "v2-settled-shelf" @@ -474,6 +492,8 @@ export function threadListV2ListItemsAreEqual( previous.showPendingDivider === item.showPendingDivider && previous.showTrailingDivider === item.showTrailingDivider ); + case "v2-caught-up": + return previous.type === "v2-caught-up"; case "v2-working-shelf": return ( previous.type === "v2-working-shelf" && @@ -502,17 +522,12 @@ function resolveThreadListV2ItemTimeLabel( item: ThreadListV2Item, showSnoozeWakeLabel: boolean, ): string { - const { thread, variant, snoozed } = item; + const { thread, snoozed } = item; if (showSnoozeWakeLabel) return ""; - if ( - variant === "card" && - (resolveThreadListV2Status(thread) !== "ready" || - item.wokeAt !== null || - threadHasUnseenCompletion(thread)) - ) - return ""; + // Status badges and Woke own the trailing slot; unread-ready rows still keep a date. + if (resolveThreadListV2Status(thread) !== "ready" || item.wokeAt !== null) return ""; const settledTimestamp = - variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null; + !snoozed && thread.settledOverride === "settled" ? resolveSettledThreadTimestamp(thread) : null; return relativeTime( settledTimestamp ?? resolveThreadActivityTimestamp(thread) ?? thread.createdAt, ); @@ -521,6 +536,7 @@ function resolveThreadListV2ItemTimeLabel( export function buildThreadListV2ListItems(input: { readonly items: ReadonlyArray; readonly pendingTasks: ReadonlyArray; + readonly activeCount?: number; readonly workingCount?: number; readonly workingShelfExpanded?: boolean; readonly workingShelfHeaderIndex?: number | null; @@ -551,7 +567,7 @@ export function buildThreadListV2ListItems(input: { ? input.snoozeLabelNow : undefined; const move = - item.variant === "card" + !item.snoozed && item.thread.settledOverride !== "settled" ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) : undefined; return { @@ -575,6 +591,7 @@ export function buildThreadListV2ListItems(input: { showPendingDivider: index === 0, showTrailingDivider: false, })); + const activeCount = input.activeCount ?? 0; const workingCount = input.workingCount ?? 0; const workingShelfHeaderIndex = input.workingShelfHeaderIndex ?? null; const snoozedCount = input.snoozedCount ?? 0; @@ -584,8 +601,24 @@ export function buildThreadListV2ListItems(input: { const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; const workingEnd = snoozedShelfHeaderIndex ?? snoozedEnd; const activeEnd = workingShelfHeaderIndex ?? workingEnd; - const result: ThreadListV2ListItem[] = [...pendingItems, ...threadItems.slice(0, activeEnd)]; + const attentionItems = threadItems.slice(0, activeEnd); + const result: ThreadListV2ListItem[] = [...pendingItems, ...attentionItems]; const shelfDisabled = input.shelfPreferencesLoading === true; + // Pinned rows are outside activeCount; an approval/input/request pin still needs a response. + const actionablePinned = input.items.slice(0, activeEnd).some((item) => { + if (!item.pinned) return false; + const status = resolveThreadListV2Status(item.thread); + return status === "approval" || status === "input" || status === "request"; + }); + // Inbox zero: attention queue is clear, but Working/Snoozed/Settled still exist. + if ( + activeCount === 0 && + pendingItems.length === 0 && + !actionablePinned && + workingCount + snoozedCount + settledCount > 0 + ) { + result.push({ type: "v2-caught-up", key: "v2-caught-up" }); + } if (workingShelfHeaderIndex !== null && workingCount > 0) { result.push({ type: "v2-working-shelf", @@ -763,7 +796,7 @@ export function buildThreadListV2Items(input: { // Searching lists every match by rank across shelves, as the palette and // the web sidebar do, then threads listed for their messages in the // server's order. Each row keeps its shelf's look. - const slim = new Set([...snoozed, ...settled]); + const shelfThreads = new Set([...snoozed, ...settled]); const snoozedThreads = new Set(snoozed); const pinnedThreads = new Set(pinned); const threadByKey = new Map( @@ -780,14 +813,15 @@ export function buildThreadListV2Items(input: { return { items: results.map((thread, index) => ({ thread, - variant: slim.has(thread) ? "slim" : "card", + variant: "card" as const, snoozed: snoozedThreads.has(thread), pinned: pinnedThreads.has(thread), - wokeAt: slim.has(thread) ? null : resolveThreadListV2WokeAt(thread, { now }), + wokeAt: shelfThreads.has(thread) ? null : resolveThreadListV2WokeAt(thread, { now }), isLast: index === results.length - 1, })), hiddenSettledCount: 0, listedSettledThreads: [], + activeCount: 0, workingCount: 0, workingShelfHeaderIndex: null, snoozedCount: 0, @@ -885,7 +919,7 @@ export function buildThreadListV2Items(input: { for (const thread of visibleSnoozed) { items.push({ thread, - variant: "slim", + variant: "card", snoozed: true, pinned: false, wokeAt: null, @@ -896,7 +930,7 @@ export function buildThreadListV2Items(input: { for (const thread of visibleSettled) { items.push({ thread, - variant: "slim", + variant: "card", snoozed: false, pinned: false, wokeAt: null, @@ -911,6 +945,7 @@ export function buildThreadListV2Items(input: { items, hiddenSettledCount: Math.max(0, settledTotal - pagedSettled.length), listedSettledThreads: listedSettled, + activeCount: orderedActive.length, workingCount: orderedWorking.length, workingShelfHeaderIndex, snoozedCount: orderedSnoozed.length, diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index fcf1048d2b6af..13e9b1f8dbdb0 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -214,6 +214,7 @@ export function useThreadListV2(input: { buildThreadListV2ListItems({ items: layout.items, pendingTasks, + activeCount: layout.activeCount, workingCount: layout.workingCount, workingShelfExpanded: input.workingShelfExpanded, workingShelfHeaderIndex: layout.workingShelfHeaderIndex, diff --git a/apps/mobile/src/native/scrollEdgeEffects.ts b/apps/mobile/src/native/scrollEdgeEffects.ts index 163fb328c0cf3..07c4a0af6ce2b 100644 --- a/apps/mobile/src/native/scrollEdgeEffects.ts +++ b/apps/mobile/src/native/scrollEdgeEffects.ts @@ -1,6 +1,6 @@ // Avoid RN/navigation imports so node checks do not load their untranspiled Flow. -export type NativeTopScrollEdgeEffect = "automatic" | "soft"; +export type NativeTopScrollEdgeEffect = "automatic" | "soft" | "hidden"; export type NativeHeaderScrollEdgeEffects = { readonly top: NativeTopScrollEdgeEffect; readonly bottom: "hidden";