Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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