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";