Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 38 additions & 17 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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 @@ -70,6 +71,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 @@ -152,7 +154,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 All @@ -168,12 +170,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 @@ -278,7 +285,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 @@ -303,17 +310,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 @@ -489,6 +497,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 @@ -623,6 +634,8 @@ export function HomeScreen(props: HomeScreenProps) {
const emptyState = deriveHomeEmptyState(props.catalogState, props.projects.length);

if (!hasAnyThreads) {
const needsEnvironment = emptyState.showsAddEnvironment;
const showStartAction = !emptyState.loading;
return (
<View className={containerClassName}>
<ScreenContent surface={sidebarSurface ? "drawer" : "screen"}>
Expand All @@ -641,19 +654,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={emptyState.showsAddEnvironment ? "Add environment" : undefined}
onAction={emptyState.showsAddEnvironment ? props.onAddConnection : undefined}
action={
Platform.OS === "android" && emptyState.showsAddEnvironment ? (
<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