Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
10055de
feat(mobile): Mail-style thread list rows on iOS and Android
wSedlacek Nov 5, 2026
bd58197
fix(mobile): restore provider icons on Mail-style thread rows
wSedlacek Nov 5, 2026
12b16ff
fix(mobile): put provider icons under the thread row chevron
wSedlacek Nov 5, 2026
d04a407
fix(mobile): start thread row dividers at the text column
maria-rcks Nov 5, 2026
ac8b5bb
fix(mobile): make thread shelves look like Mail date groups
wSedlacek Nov 7, 2026
4ef5baf
fix(mobile): Mail-like empty state when there are no threads
wSedlacek Nov 7, 2026
2204b3c
fix(mobile): celebrate inbox zero when nothing needs a response
wSedlacek Nov 7, 2026
f5d30cd
fix(mobile): give New thread Liquid Glass on iOS
wSedlacek Nov 7, 2026
c98759d
fix(mobile): Mail-style full-height thread swipe lanes
wSedlacek Nov 7, 2026
d23f151
fix(mobile): stop filter chips from hard-cutting the inbox
wSedlacek Nov 7, 2026
8ffff8f
fix(mobile): Liquid Glass round thread swipe actions
wSedlacek Nov 7, 2026
cf311cf
fix(mobile): Messages-like glass swipe morph and shelf counts
wSedlacek Nov 7, 2026
a192acf
fix(mobile): align Mail list tests after rebase onto main
wSedlacek Nov 7, 2026
723dad1
fix(mobile): hide All Caught Up under actionable pinned rows
wSedlacek Nov 8, 2026
b4b8a9e
fix(mobile): cover swipe primary and announce Mail previews
wSedlacek Nov 9, 2026
76e7d3b
Merge remote-tracking branch 'github/main' into t3/ios-inbox-mail-she…
maria-rcks Nov 10, 2026
3be32b9
fix(mobile): restore main thread-row behaviors after shelf merge
wSedlacek Nov 11, 2026
1a62543
merge: sync main into mobile mail shelves
maria-rcks Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/src/components/NewThreadButton.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export function NewThreadButton(props: ComponentProps<typeof SharedNewThreadButt
return (
<FloatingActionButton
{...props}
icon="square.and.pencil"
label="New thread"
icon={props.icon ?? "square.and.pencil"}
label={props.label ?? "New thread"}
tone="primary"
variant={props.extended ? "extended" : "large"}
/>
Expand Down
53 changes: 53 additions & 0 deletions apps/mobile/src/components/NewThreadButton.ios.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SolidNewThreadButton>) {
if (!NATIVE_LIQUID_GLASS_SUPPORTED) {
return <SolidNewThreadButton {...props} />;
}

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 (
<Pressable
accessibilityLabel={label}
accessibilityRole="button"
onPress={props.onPress}
className={cn("active:opacity-85", props.className)}
style={props.style}
>
<GlassSurface
isInteractive
style={{
height,
borderRadius: height / 2,
alignSelf: "center",
}}
className={cn(
"items-center justify-center",
props.extended ? "flex-row gap-2 px-6" : "w-14",
)}
>
<SymbolView
name={icon}
size={props.extended ? 20 : 23}
tintColorClassName="accent-primary"
type="monochrome"
/>
{showLabel ? (
<AppText className="text-[16px] font-t3-semibold text-primary">{label}</AppText>
) : null}
</GlassSurface>
</Pressable>
);
}
12 changes: 8 additions & 4 deletions apps/mobile/src/components/NewThreadButton.shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewStyle>;
}) {
const label = props.label ?? "New thread";
const icon = props.icon ?? "square.and.pencil";
return (
<Pressable
accessibilityLabel="New thread"
accessibilityLabel={label}
accessibilityRole="button"
onPress={props.onPress}
className={cn(
Expand All @@ -26,13 +30,13 @@ export function NewThreadButton(props: {
style={props.style}
>
<SymbolView
name="square.and.pencil"
name={icon}
size={props.extended ? 24 : 28}
tintColorClassName="accent-primary-foreground"
type="monochrome"
/>
{props.extended && props.expanded !== false ? (
<AppText className="text-[16px] font-t3-medium text-primary-foreground">New thread</AppText>
<AppText className="text-[16px] font-t3-medium text-primary-foreground">{label}</AppText>
) : null}
</Pressable>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<SymbolView
name={props.icon}
size={props.variant === "plain" ? 44 : 28}
tintColorClassName="accent-icon-subtle"
type="hierarchical"
/>
) : null;

if (props.variant === "plain") {
return (
<View className="items-center px-8 py-8">
{icon ? <View className="mb-4">{icon}</View> : null}
<Text textRole="title" className="text-center font-t3-bold text-foreground">
{props.title}
</Text>
Expand All @@ -37,6 +49,7 @@ export function EmptyState(props: {

return (
<View className="rounded-[22px] border border-border bg-card p-5">
{icon ? <View className="mb-3">{icon}</View> : null}
<Text textRole="headline" className="font-t3-bold text-foreground">
{props.title}
</Text>
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/components/ui/GlassSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +41,7 @@ export function GlassSurface({
chrome = "default",
tintColor,
tintColorClassName,
isInteractive = false,
fallbackColor,
fallbackClassName,
className,
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 42 additions & 25 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -222,12 +224,17 @@ function HomeScrollView(props: ComponentProps<typeof ScrollView>) {
if (Platform.OS !== "ios") return <ScrollView {...props} />;

// 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 (
<ScrollViewMarker
style={{ flex: 1 }}
scrollEdgeEffects={{
...nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version),
top: Number.parseInt(String(Platform.Version), 10) >= 27 ? "automatic" : "soft",
top: topEdge,
}}
>
<ScrollView
Expand Down Expand Up @@ -332,7 +339,7 @@ export function HomeScreen(props: HomeScreenProps) {
animated: false,
});
});
const swipeRowActivation = createSwipeRowActivation();
const [swipeRowActivation] = useState(createSwipeRowActivation);
const activateVisibleRows = (rows: ReadonlyArray<ThreadListV2ListItem>) => {
const state = listRef.current?.getState();
if (state === undefined || !(state.end >= 0)) return;
Expand All @@ -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(
Expand Down Expand Up @@ -543,6 +551,9 @@ export function HomeScreen(props: HomeScreenProps) {
/>
);
}
if (item.type === "v2-caught-up") {
return <ThreadListV2CaughtUpPlaceholder pane={sidebarSurface ? "sidebar" : "screen"} />;
}
if (item.type === "v2-working-shelf") {
return (
<ThreadListV2WorkingShelfHeader
Expand Down Expand Up @@ -674,6 +685,8 @@ export function HomeScreen(props: HomeScreenProps) {
});

if (!hasAnyThreads) {
const needsEnvironment = !props.catalogState.hasReadyEnvironment;
const showStartAction = !emptyState.loading;
return (
<View className={containerClassName}>
<ScreenContent surface={sidebarSurface ? "drawer" : "screen"}>
Expand All @@ -692,23 +705,27 @@ export function HomeScreen(props: HomeScreenProps) {
>
<View className="w-full max-w-[430px]">
<EmptyState
icon={needsEnvironment ? "desktopcomputer" : "bubble.left.and.bubble.right"}
title={emptyState.title}
detail={emptyState.detail}
actionLabel={
!props.catalogState.hasReadyEnvironment ? "Add environment" : undefined
}
onAction={
!props.catalogState.hasReadyEnvironment ? props.onAddConnection : undefined
}
action={
Platform.OS === "android" && !props.catalogState.hasReadyEnvironment ? (
<FloatingActionButton
label="Add environment"
icon="plus"
variant="extended"
tone="primary"
onPress={props.onAddConnection}
/>
showStartAction ? (
Platform.OS === "android" ? (
<FloatingActionButton
label={needsEnvironment ? "Add environment" : "New thread"}
icon={needsEnvironment ? "plus" : "square.and.pencil"}
variant="extended"
tone="primary"
onPress={needsEnvironment ? props.onAddConnection : props.onStartNewTask}
/>
) : (
<NewThreadButton
extended
label={needsEnvironment ? "Add environment" : "New thread"}
icon={needsEnvironment ? "plus" : "square.and.pencil"}
onPress={needsEnvironment ? props.onAddConnection : props.onStartNewTask}
/>
)
) : undefined
}
variant="plain"
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/features/home/ThreadListFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ScrollView
horizontal
Expand All @@ -31,6 +36,7 @@ export function ThreadListFilters(props: {
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 2, paddingBottom: 10, gap: 8 }}
>
<ListFilterMenu
glass={false}
label={selectedEnvironment?.label ?? "All environments"}
accessibilityLabel="Filter threads by environment"
active={selectedEnvironment !== undefined}
Expand Down Expand Up @@ -60,6 +66,7 @@ export function ThreadListFilters(props: {
}}
/>
<ListFilterMenu
glass={false}
label={selectedProject?.label ?? "All projects"}
accessibilityLabel="Filter threads by project"
active={selectedProject !== undefined}
Expand All @@ -85,7 +92,7 @@ export function ThreadListFilters(props: {
}
}}
/>
{props.selectedEnvironmentId !== null || props.selectedProjectKey !== null ? (
{showReset ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Reset thread filters"
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/home/swipe-row-activation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@ import { describe, expect, it } from "vite-plus/test";
import { createSwipeRowActivation } from "./swipe-row-activation";

describe("createSwipeRowActivation", () => {
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"]);
Expand Down
Loading
Loading