diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index acc20af1a05..7455f65b348 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -96,7 +96,7 @@
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-image-viewing": "^0.2.2",
- "react-native-keyboard-controller": "1.21.6",
+ "react-native-keyboard-controller": "1.21.13",
"react-native-nitro-markdown": "^0.5.0",
"react-native-nitro-modules": "0.35.9",
"react-native-reanimated": "4.3.1",
diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
index 7f6f22e7499..6ef0819abd7 100644
--- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
+++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
@@ -333,7 +333,7 @@ function ArchivedThreadRow(props: {
{timestamp}
diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx
index 9c26bd7f5f7..eaea597211f 100644
--- a/apps/mobile/src/features/home/HomeRouteScreen.tsx
+++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx
@@ -122,6 +122,16 @@ export function HomeRouteScreen() {
}}
onSelectPendingTask={openPendingTask}
onDeletePendingTask={confirmDeletePendingTask}
+ onNewThreadInProject={(project) => {
+ navigation.navigate("NewTaskSheet", {
+ screen: "NewTaskDraft",
+ params: {
+ environmentId: String(project.environmentId),
+ projectId: String(project.id),
+ title: project.title,
+ },
+ });
+ }}
onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
onThreadSortOrderChange={setThreadSortOrder}
pendingTasks={pendingTasks}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index a671a5c95c0..999cec2d669 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -72,6 +72,7 @@ interface HomeScreenProps {
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
+ readonly onNewThreadInProject: (project: EnvironmentProject) => void;
}
/* ─── Layout constants ───────────────────────────────────────────────── */
@@ -248,6 +249,15 @@ export function HomeScreen(props: HomeScreenProps) {
isFirst={item.isFirst}
groupKey={item.group.key}
onGroupAction={updateGroupDisplay}
+ // Aggregated groups (same repo across machines) have no single
+ // target project, and `pending-project:` groups hold a placeholder
+ // built from queued-task metadata rather than a real project shell,
+ // so the quick new-thread button is single-real-project only.
+ onNewThread={
+ item.group.projects.length === 1 && !item.group.key.startsWith("pending-project:")
+ ? props.onNewThreadInProject
+ : undefined
+ }
project={item.group.representative}
threadCount={item.group.threads.length + item.group.pendingTasks.length}
title={item.group.title}
@@ -308,6 +318,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onArchiveThread,
props.onDeletePendingTask,
props.onDeleteThread,
+ props.onNewThreadInProject,
props.onSelectPendingTask,
props.onSelectThread,
props.savedConnectionsById,
diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts
index 5a10fcb9d55..c964453a6d2 100644
--- a/apps/mobile/src/features/home/homeListItems.test.ts
+++ b/apps/mobile/src/features/home/homeListItems.test.ts
@@ -57,15 +57,19 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
function makeGroup(key: string, threadCount: number): HomeThreadGroup {
const project = makeProject(key, key);
+ const threads = Array.from({ length: threadCount }, (_, index) =>
+ makeThread(`${key}-thread-${index}`, project.id),
+ );
return {
key,
title: key,
representative: project,
projects: [project],
pendingTasks: [],
- threads: Array.from({ length: threadCount }, (_, index) =>
- makeThread(`${key}-thread-${index}`, project.id),
- ),
+ threads,
+ // All threads inside the recency window, so the baseline stays at the
+ // initial page size and the pagination expectations below hold.
+ recentThreads: threads,
};
}
@@ -153,6 +157,47 @@ describe("buildHomeListLayout", () => {
expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);
});
+ it("offers show-less after expanding a stale group whose baseline is below the page size", () => {
+ // Stale project: 10 threads total but only 3 within the recency window.
+ const project = makeProject("stale", "stale");
+ const threads = Array.from({ length: 10 }, (_, index) =>
+ makeThread(`stale-thread-${index}`, project.id),
+ );
+ const group: HomeThreadGroup = {
+ key: "stale",
+ title: "stale",
+ representative: project,
+ projects: [project],
+ pendingTasks: [],
+ threads,
+ recentThreads: threads.slice(0, 3),
+ };
+
+ const collapsedToRecent = buildHomeListLayout({
+ groups: [group],
+ displayStates: displayStates({}),
+ });
+ expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3);
+ expect(collapsedToRecent.items.at(-1)).toMatchObject({
+ type: "show-more",
+ hiddenCount: 7,
+ canShowLess: false,
+ });
+
+ const expanded = buildHomeListLayout({
+ groups: [group],
+ displayStates: displayStates({
+ stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
+ }),
+ });
+ expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10);
+ expect(expanded.items.at(-1)).toMatchObject({
+ type: "show-more",
+ hiddenCount: 0,
+ canShowLess: true,
+ });
+ });
+
it("hides threads and the show-more row for collapsed groups", () => {
const layout = buildHomeListLayout({
groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],
diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts
index f15b1252b8f..eb3f2a5de19 100644
--- a/apps/mobile/src/features/home/homeListItems.ts
+++ b/apps/mobile/src/features/home/homeListItems.ts
@@ -145,12 +145,27 @@ export function buildHomeListLayout(input: {
}
const totalCount = group.threads.length;
+ // Default to the group's recent-activity window (last few days, or a small
+ // fallback for stale projects), capped at the initial page size. Until the
+ // user taps "Show more", older threads stay hidden to save vertical space;
+ // "Show less" resets visibleCount to the initial constant, which lands back
+ // here at the recency baseline.
+ const baselineCount = Math.min(
+ group.recentThreads.length,
+ HOME_INITIAL_VISIBLE_THREADS,
+ totalCount,
+ );
const visibleCount = input.showAllThreads
? totalCount
- : Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount);
+ : Math.min(
+ display.visibleCount > HOME_INITIAL_VISIBLE_THREADS
+ ? display.visibleCount
+ : baselineCount,
+ totalCount,
+ );
const visibleThreads = group.threads.slice(0, visibleCount);
const hiddenCount = totalCount - visibleCount;
- const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS;
+ const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount;
// Pending (unsent) tasks lead the group and are never paginated away.
for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
@@ -180,7 +195,10 @@ export function buildHomeListLayout(input: {
key: `show-more:${group.key}`,
groupKey: group.key,
hiddenCount,
- canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS,
+ // Compare against the group's own baseline, not the global page size:
+ // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show
+ // less" must be offered as soon as anything beyond the baseline shows.
+ canShowLess: visibleCount > baselineCount,
});
}
}
diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts
index cf9b0824aa4..864591f7540 100644
--- a/apps/mobile/src/features/home/homeThreadList.test.ts
+++ b/apps/mobile/src/features/home/homeThreadList.test.ts
@@ -44,6 +44,8 @@ function makeThread(
};
}
+const NOW = Date.parse("2026-06-29T00:00:00.000Z");
+
function buildGroups(
projects: ReadonlyArray,
threads: ReadonlyArray,
@@ -57,6 +59,7 @@ function buildGroups(
projectSortOrder: "updated_at",
threadSortOrder: "updated_at",
projectGroupingMode: "repository",
+ now: NOW,
...overrides,
});
}
@@ -220,4 +223,95 @@ describe("buildHomeThreadGroups", () => {
);
expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2);
});
+
+ it("default view shows only threads from the last 5 days", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const project = makeProject({
+ environmentId,
+ id: ProjectId.make("project-1"),
+ title: "T3 Code",
+ });
+ const threads = [
+ makeThread({
+ environmentId,
+ id: ThreadId.make("recent-1"),
+ projectId: project.id,
+ title: "Today",
+ updatedAt: "2026-06-28T00:00:00.000Z",
+ }),
+ makeThread({
+ environmentId,
+ id: ThreadId.make("recent-2"),
+ projectId: project.id,
+ title: "Within window",
+ updatedAt: "2026-06-25T00:00:00.000Z",
+ }),
+ makeThread({
+ environmentId,
+ id: ThreadId.make("old"),
+ projectId: project.id,
+ title: "Two weeks ago",
+ updatedAt: "2026-06-14T00:00:00.000Z",
+ }),
+ ];
+
+ const group = buildGroups([project], threads)[0];
+ // Default view trims to recent threads...
+ expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]);
+ // ...while full history stays available for the expanded view.
+ expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]);
+ });
+
+ it("falls back to the most recent 3 threads when none are within 5 days", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const project = makeProject({
+ environmentId,
+ id: ProjectId.make("project-1"),
+ title: "T3 Code",
+ });
+ const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map(
+ (day, index) =>
+ makeThread({
+ environmentId,
+ id: ThreadId.make(`thread-${index}`),
+ projectId: project.id,
+ title: `Thread ${index}`,
+ updatedAt: `${day}T00:00:00.000Z`,
+ }),
+ );
+
+ const group = buildGroups([project], threads)[0];
+ expect(group?.recentThreads.map((thread) => thread.id)).toEqual([
+ "thread-4",
+ "thread-3",
+ "thread-2",
+ ]);
+ expect(group?.threads).toHaveLength(5);
+ });
+
+ it("does not apply the recency window while searching", () => {
+ const environmentId = EnvironmentId.make("environment-1");
+ const project = makeProject({
+ environmentId,
+ id: ProjectId.make("project-1"),
+ title: "T3 Code",
+ });
+ const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map(
+ (day, index) =>
+ makeThread({
+ environmentId,
+ id: ThreadId.make(`thread-${index}`),
+ projectId: project.id,
+ title: `Thread ${index}`,
+ updatedAt: `${day}T00:00:00.000Z`,
+ }),
+ );
+
+ const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0];
+ // Search reaches the full history rather than the 3-thread fallback.
+ expect(group?.recentThreads).toHaveLength(5);
+ expect(group?.recentThreads.map((thread) => thread.id)).toEqual(
+ group?.threads.map((thread) => thread.id),
+ );
+ });
});
diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts
index 01605adb849..4e3516331b6 100644
--- a/apps/mobile/src/features/home/homeThreadList.ts
+++ b/apps/mobile/src/features/home/homeThreadList.ts
@@ -21,13 +21,24 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
export type HomeProjectSortOrder = Exclude;
+/**
+ * Default home view only surfaces threads active within this window, to keep the
+ * screen compact while keeping recent work visible.
+ */
+const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;
+/** Fallback when a project has no threads inside the recency window. */
+const RECENT_THREAD_FALLBACK_COUNT = 3;
+
export interface HomeThreadGroup {
readonly key: string;
readonly title: string;
readonly representative: EnvironmentProject;
readonly projects: ReadonlyArray;
readonly pendingTasks: ReadonlyArray;
+ /** Full sorted thread history for the group (revealed when expanded / searching). */
readonly threads: ReadonlyArray;
+ /** Subset shown by default: threads from the last few days, or the most recent few. */
+ readonly recentThreads: ReadonlyArray;
}
interface MutableHomeThreadGroup {
@@ -48,6 +59,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr
}, latestThread);
}
+/**
+ * Trims a group's threads to recent activity for the default home view.
+ * `sortedThreads` must already be ordered newest-first for `threadSortOrder`.
+ * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps
+ * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes.
+ */
+function selectRecentThreads(
+ sortedThreads: ReadonlyArray,
+ threadSortOrder: SidebarThreadSortOrder,
+ now: number,
+): ReadonlyArray {
+ const cutoff = now - RECENT_THREAD_WINDOW_MS;
+ const recent = sortedThreads.filter(
+ (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff,
+ );
+ return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT);
+}
+
export function buildHomeThreadGroups(input: {
readonly projects: ReadonlyArray;
readonly threads: ReadonlyArray;
@@ -57,7 +86,10 @@ export function buildHomeThreadGroups(input: {
readonly projectSortOrder: HomeProjectSortOrder;
readonly threadSortOrder: SidebarThreadSortOrder;
readonly projectGroupingMode: SidebarProjectGroupingMode;
+ /** Current time used for the recency window; defaults to now. Injectable for tests. */
+ readonly now?: number;
}): ReadonlyArray {
+ const now = input.now ?? Date.now();
const groups = new Map();
const groupKeyByProjectKey = new Map();
@@ -165,13 +197,22 @@ export function buildHomeThreadGroups(input: {
continue;
}
+ const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder);
+ // An active search should reach the full history, so the recency window
+ // only trims the default (no-query) view.
+ const recentThreads =
+ query.length === 0
+ ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
+ : sortedThreads;
+
result.push({
key: group.key,
title,
representative,
projects: group.projects,
pendingTasks: matchingPendingTasks,
- threads: sortThreads(matchingThreads, input.threadSortOrder),
+ threads: sortedThreads,
+ recentThreads,
});
}
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index 3ffbad1997b..9dfec046b53 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -111,7 +111,7 @@ export function NewTaskDraftScreen(props: {
}, [props.pendingTaskId, cancelEditingPendingTask]);
const borderColor = useThemeColor("--color-border");
- const bodyText = useScaledTextRole("body");
+ const headlineText = useScaledTextRole("headline");
const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
@@ -129,12 +129,13 @@ export function NewTaskDraftScreen(props: {
lastInitialProjectRefRef.current = props.initialProjectRef;
appliedInitialProjectKeyRef.current = null;
}
- if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {
+ const initialEnvironmentId = props.initialProjectRef?.environmentId;
+ const initialProjectId = props.initialProjectRef?.projectId;
+ if (initialEnvironmentId && initialProjectId) {
const directProject =
projects.find(
(project) =>
- project.environmentId === props.initialProjectRef?.environmentId &&
- project.id === props.initialProjectRef?.projectId,
+ project.environmentId === initialEnvironmentId && project.id === initialProjectId,
) ?? null;
if (directProject) {
@@ -602,7 +603,7 @@ export function NewTaskDraftScreen(props: {
onPasteImages={(uris) => void handleNativePasteImages(uris)}
placeholder={`Describe a coding task in ${selectedProject.title}`}
style={{ flex: 1, minHeight: 0 }}
- textStyle={bodyText}
+ textStyle={headlineText}
/>
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index 81c3b9d0076..e0634102cb1 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -18,10 +18,8 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { View, type GestureResponderEvent } from "react-native";
-import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import { KeyboardStickyView } from "react-native-keyboard-controller";
+import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { runOnJS } from "react-native-reanimated";
import { AppText as Text } from "../../components/AppText";
import type { ComposerEditorHandle } from "../../components/ComposerEditor";
@@ -73,7 +71,6 @@ export interface ThreadDetailScreenProps {
readonly layoutVariant?: LayoutVariant;
readonly usesAutomaticContentInsets?: boolean;
readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
- readonly onOpenDrawer: () => void;
readonly onOpenConnectionEditor: () => void;
readonly onChangeDraftMessage: (value: string) => void;
readonly onPickDraftImages: () => Promise;
@@ -208,8 +205,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
});
export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
- const { onOpenDrawer } = props;
-
const insets = useSafeAreaInsets();
const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
@@ -263,28 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
[props.serverConfig, selectedInstanceId],
);
- const completeDrawerGesture = useCallback(() => {
- void Haptics.selectionAsync();
- onOpenDrawer();
- }, [onOpenDrawer]);
-
- const drawerGestureThreshold = 80;
- const headerDrawerGesture = useMemo(
- () =>
- Gesture.Pan()
- .enabled(!isSplitLayout)
- .hitSlop({ left: 0, width: 40 })
- .activeOffsetX([10, 999])
- .failOffsetY([-24, 24])
- .onEnd((event) => {
- const translationX = Math.max(event.translationX, 0);
- if (event.y < drawerGestureThreshold && translationX > 56) {
- runOnJS(completeDrawerGesture)();
- }
- }),
- [completeDrawerGesture, isSplitLayout],
- );
-
useLayoutEffect(() => {
selectedThreadKeyRef.current = selectedThreadKey;
}, [selectedThreadKey]);
@@ -311,16 +284,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
return;
}
lastScrolledAnchorMessageIdRef.current = anchorMessageId;
- void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
- if (
- selectedThreadKeyRef.current !== targetThreadKey ||
- lastScrolledAnchorMessageIdRef.current !== anchorMessageId
- ) {
- return;
- }
- lastScrolledAnchorMessageIdRef.current = null;
- freeze.set(false);
- });
+ // Wait for the keyboard dismissal (started by blur() on send) to finish
+ // before scrolling: scrollMessageToEnd freezes keyboard-driven inset
+ // updates while it runs, and a close event swallowed by that freeze
+ // leaves the keyboard padding permanently applied — overshooting the
+ // anchor and leaving a phantom bottom inset once the reply streams in.
+ void KeyboardController.dismiss()
+ .then(() => {
+ if (
+ selectedThreadKeyRef.current !== targetThreadKey ||
+ lastScrolledAnchorMessageIdRef.current !== anchorMessageId
+ ) {
+ return;
+ }
+ return scrollMessageToEnd({ animated: true, closeKeyboard: false });
+ })
+ .catch(() => {
+ if (
+ selectedThreadKeyRef.current !== targetThreadKey ||
+ lastScrolledAnchorMessageIdRef.current !== anchorMessageId
+ ) {
+ return;
+ }
+ lastScrolledAnchorMessageIdRef.current = null;
+ freeze.set(false);
+ });
});
return () => cancelAnimationFrame(frame);
}, [
@@ -379,115 +367,113 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
}, []);
return (
-
-
- {showContent ? (
+
+ {showContent ? (
+
+
+
+ ) : (
+
+ )}
+
+ {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
+ {showContent ? (
+
-
+ {props.activeWorkStartedAt ? (
+
+ ) : null}
+
+ {props.activePendingApproval || props.activePendingUserInput ? (
+
+ {props.activePendingApproval ? (
+
+ ) : null}
+ {props.activePendingUserInput ? (
+
+ ) : null}
+
+ ) : null}
+
+
+
- ) : (
-
- )}
-
- {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
- {showContent ? (
-
-
-
- {props.activeWorkStartedAt ? (
-
- ) : null}
-
- {props.activePendingApproval || props.activePendingUserInput ? (
-
- {props.activePendingApproval ? (
-
- ) : null}
- {props.activePendingUserInput ? (
-
- ) : null}
-
- ) : null}
-
-
-
-
-
- ) : null}
-
-
+
+ ) : null}
+
);
});
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 443a924bd2d..fc9cebe93c8 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1508,6 +1508,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// mount positions during attach, where UIKit applies the inset.
key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
style={{ flex: 1 }}
+ // RN 0.81+ drops touches inside the contentInset area
+ // (facebook/react-native#54123); the anchored end space after a send
+ // is pure inset, so without this the blank region can't be scrolled.
+ applyWorkaroundForContentInsetHitTestBug
contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
{...(usesNativeAutomaticInsets
diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx
deleted file mode 100644
index a143c2f1834..00000000000
--- a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-import { SymbolView } from "expo-symbols";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import {
- type ColorValue,
- Modal,
- Pressable,
- ScrollView,
- useWindowDimensions,
- View,
-} from "react-native";
-import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import Animated, {
- runOnJS,
- useAnimatedStyle,
- useSharedValue,
- withTiming,
-} from "react-native-reanimated";
-import { useThemeColor } from "../../lib/useThemeColor";
-
-import { AppText as Text } from "../../components/AppText";
-import { StatusPill } from "../../components/StatusPill";
-import { useProjects, useThreadShells } from "../../state/entities";
-import { scopedThreadKey } from "../../lib/scopedEntities";
-import { relativeTime } from "../../lib/time";
-import { resolveThreadStatus } from "./threadPresentation";
-import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
-import { buildThreadNavigationGroups } from "./thread-navigation-groups";
-
-export function ThreadNavigationDrawer(props: {
- readonly visible: boolean;
- readonly selectedThreadKey: string | null;
- readonly onClose: () => void;
- readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
- readonly onStartNewTask: () => void;
-}) {
- const insets = useSafeAreaInsets();
- const { width } = useWindowDimensions();
- const drawerWidth = Math.min(width * 0.84, 360);
- const [mounted, setMounted] = useState(props.visible);
- const translateX = useSharedValue(-drawerWidth);
- const overlayOpacity = useSharedValue(0);
-
- const backdropColor = useThemeColor("--color-backdrop");
- const drawerBg = useThemeColor("--color-drawer");
- const drawerShadow = useThemeColor("--color-drawer-shadow");
- const primaryForeground = useThemeColor("--color-primary-foreground");
- const borderSubtleColor = useThemeColor("--color-border-subtle");
-
- useEffect(() => {
- if (props.visible) {
- setMounted(true);
- translateX.value = withTiming(0, { duration: 240 });
- overlayOpacity.value = withTiming(1, { duration: 220 });
- return;
- }
-
- overlayOpacity.value = withTiming(0, { duration: 180 });
- translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => {
- if (finished) {
- runOnJS(setMounted)(false);
- }
- });
- }, [drawerWidth, overlayOpacity, props.visible, translateX]);
-
- const closeDrawer = useCallback(() => {
- props.onClose();
- }, [props]);
-
- const panGesture = useMemo(
- () =>
- Gesture.Pan()
- .activeOffsetX([-12, 12])
- .failOffsetY([-24, 24])
- .onUpdate((event) => {
- translateX.value = Math.min(0, event.translationX);
- })
- .onEnd((event) => {
- const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500;
- if (shouldClose) {
- runOnJS(closeDrawer)();
- return;
- }
-
- translateX.value = withTiming(0, { duration: 180 });
- }),
- [closeDrawer, drawerWidth, translateX],
- );
-
- const drawerStyle = useAnimatedStyle(() => ({
- transform: [{ translateX: translateX.value }],
- }));
-
- const backdropStyle = useAnimatedStyle(() => ({
- opacity: overlayOpacity.value,
- }));
-
- if (!mounted) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
- Threads
- {
- props.onClose();
- props.onStartNewTask();
- }}
- className="h-11 w-11 items-center justify-center rounded-full bg-primary"
- >
-
-
-
-
-
-
-
-
-
- );
-}
-
-function ThreadNavigationDrawerContent(props: {
- readonly bottomInset: number;
- readonly borderSubtleColor: ColorValue;
- readonly selectedThreadKey: string | null;
- readonly onClose: () => void;
- readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
-}) {
- const projects = useProjects();
- const threads = useThreadShells();
- const groupedThreads = useMemo(
- () => buildThreadNavigationGroups({ projects, threads }),
- [projects, threads],
- );
-
- return (
-
- {groupedThreads.map((group) => (
-
-
- {group.title}
-
-
-
- {group.threads.length === 0 ? (
-
- No threads yet
-
- ) : (
- group.threads.map((thread, index) => {
- const threadKey = scopedThreadKey(thread.environmentId, thread.id);
- const selected = props.selectedThreadKey === threadKey;
- const status = resolveThreadStatus(thread);
-
- return (
- {
- props.onSelectThread(thread);
- props.onClose();
- }}
- style={{
- paddingHorizontal: 16,
- paddingVertical: 15,
- borderTopWidth: index === 0 ? 0 : 1,
- borderTopColor: props.borderSubtleColor,
- backgroundColor: selected ? undefined : "transparent",
- }}
- className={selected ? "bg-subtle" : undefined}
- >
-
-
-
- {thread.title}
-
-
- {relativeTime(thread.updatedAt ?? thread.createdAt)}
-
-
- {status ? : null}
-
-
- );
- })
- )}
-
-
- ))}
-
- );
-}
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index bf165715d01..5f5353c6eff 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -49,7 +49,6 @@ import {
useThreadGitRightHeaderItems,
} from "./ThreadGitControls";
import { GitOverviewSheet } from "./git/GitOverviewSheet";
-import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
import { useAtomCommand } from "../../state/use-atom-command";
import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
@@ -197,7 +196,6 @@ function ThreadRouteContent(
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const navigation = useNavigation();
const params = props.route.params;
- const [drawerVisible, setDrawerVisible] = useState(false);
const environmentIdRaw = firstRouteParam(params.environmentId);
const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
const threadId = firstRouteParam(params.threadId);
@@ -326,18 +324,6 @@ function ThreadRouteContent(
);
const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
- const handleOpenDrawer = useCallback(() => {
- if (!layout.usesSplitView) {
- setDrawerVisible(true);
- }
- }, [layout.usesSplitView]);
-
- useEffect(() => {
- if (layout.usesSplitView) {
- setDrawerVisible(false);
- }
- }, [layout.usesSplitView]);
-
const handleOpenGitInspector = useCallback(() => {
setInspectorSelection({ routeThreadIdentity, mode: "git" });
showAuxiliaryPane("inspector");
@@ -655,6 +641,22 @@ function ThreadRouteContent(
],
[panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
);
+ // Deep links / cold starts land with Thread as the ONLY route, where the
+ // native back button does not render. Provide an explicit Home escape for
+ // that case; when history exists the native back button is used instead.
+ const canGoBack = navigation.canGoBack();
+ const compactHomeHeaderItems = useMemo(
+ () => [
+ withNativeGlassHeaderItem({
+ accessibilityLabel: "Go to threads list",
+ icon: { name: "list.bullet", type: "sfSymbol" as const },
+ identifier: "thread-left-home",
+ onPress: () => navigation.dispatch(StackActions.replace("Home")),
+ type: "button" as const,
+ }),
+ ],
+ [navigation],
+ );
if (!environmentId || !threadId) {
return ;
@@ -704,7 +706,6 @@ function ThreadRouteContent(
selectedThreadQueueCount={composer.selectedThreadQueueCount}
layoutVariant={layout.variant}
usesAutomaticContentInsets={usesNativeHeaderGlass}
- onOpenDrawer={handleOpenDrawer}
onOpenConnectionEditor={handleOpenConnectionEditor}
onChangeDraftMessage={composer.onChangeDraftMessage}
onPickDraftImages={composer.onPickDraftImages}
@@ -722,23 +723,6 @@ function ThreadRouteContent(
onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
onSubmitUserInput={requests.onSubmitUserInput}
/>
-
- {layout.usesSplitView ? null : (
- setDrawerVisible(false)}
- onSelectThread={(thread) => {
- navigation.dispatch(
- StackActions.replace("Thread", {
- environmentId: String(thread.environmentId),
- threadId: String(thread.id),
- }),
- );
- }}
- onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
- />
- )}
>
);
@@ -757,11 +741,17 @@ function ThreadRouteContent(
: undefined,
title: selectedThread.title,
headerBackVisible: !layout.usesSplitView,
- // Compact uses the NATIVE back button (Thread lives flat in the root
- // stack now, so a real previous route exists); only split view needs
- // custom left items.
+ // Compact uses the NATIVE back button when a previous route exists;
+ // deep links / cold starts get an explicit Home button instead.
+ // Split view always uses its custom left items.
unstable_headerLeftItems:
- Platform.OS === "ios" && layout.usesSplitView ? () => splitLeftHeaderItems : undefined,
+ Platform.OS === "ios"
+ ? layout.usesSplitView
+ ? () => splitLeftHeaderItems
+ : canGoBack
+ ? undefined
+ : () => compactHomeHeaderItems
+ : undefined,
// Search lives in the persistent sidebar, so the split header keeps
// the git controls on the RIGHT (no center items — center space is
// reserved for future breadcrumbs/status).
diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
index 58af4b4fc80..ee2bde9d971 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -231,40 +231,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
}
}, []);
- const environments = useMemo(
- () =>
- pipe(
- [
- ...new Set(
- pipe(
- projects,
- Arr.map((project) => project.environmentId),
- ),
- ),
- ],
- Arr.map((environmentId) => {
- const environment = savedConnectionsById[environmentId];
- if (!environment) {
- return null;
- }
-
- return {
- environmentId,
- environmentLabel: environment.environmentLabel,
- };
- }),
- Arr.filter(
- (
- entry,
- ): entry is {
- readonly environmentId: EnvironmentId;
- readonly environmentLabel: string;
- } => entry !== null,
- ),
- ),
- [projects, savedConnectionsById],
- );
-
const projectsForEnvironment = useMemo(
() =>
pipe(
@@ -310,6 +276,62 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)
? editingPendingProject
: (projectsForEnvironment[0] ?? null));
+
+ // Only offer machines that actually host the currently selected repository, so
+ // switching computers moves the same repo across machines instead of jumping to
+ // whatever unrelated project happens to be first on the other machine. Repository
+ // identity is the primary signal; projects that haven't reported one yet (still
+ // indexing) fall back to workspace basename / title so a valid host isn't hidden.
+ const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
+ // `|| null` (not `??`): a pending-task placeholder project can have an empty
+ // workspaceRoot, and an "" basename would reject every real host below.
+ const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
+ const selectedProjectTitle = selectedProject?.title ?? null;
+ const environments = useMemo(() => {
+ const seen = new Set();
+ const result: Array<{
+ readonly environmentId: EnvironmentId;
+ readonly environmentLabel: string;
+ }> = [];
+ const hostsSelectedRepository = (project: EnvironmentProject) => {
+ if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
+ return true;
+ }
+ const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
+ if (selectedRepositoryKey !== null && projectKey !== null) {
+ return projectKey === selectedRepositoryKey;
+ }
+ return (
+ project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename ||
+ (selectedProjectTitle !== null && project.title === selectedProjectTitle)
+ );
+ };
+ for (const project of projects) {
+ if (!hostsSelectedRepository(project)) {
+ continue;
+ }
+ if (seen.has(project.environmentId)) {
+ continue;
+ }
+ const environment = savedConnectionsById[project.environmentId];
+ if (!environment) {
+ continue;
+ }
+ seen.add(project.environmentId);
+ result.push({
+ environmentId: project.environmentId,
+ environmentLabel: environment.environmentLabel,
+ });
+ }
+ return result;
+ }, [
+ projects,
+ savedConnectionsById,
+ selectedRepositoryKey,
+ selectedWorkspaceBasename,
+ selectedProjectTitle,
+ ]);
+
const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
selectedProject?.environmentId ?? null,
);
@@ -480,10 +502,36 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
setSelectedProjectKey(nextProjectKey);
}, []);
- const selectEnvironment = useCallback((environmentId: EnvironmentId) => {
- setSelectedEnvironmentId(environmentId);
- setSelectedProjectKey(null);
- }, []);
+ const selectEnvironment = useCallback(
+ (environmentId: EnvironmentId) => {
+ const projectsOnTarget = projects.filter(
+ (project) => project.environmentId === environmentId,
+ );
+ const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
+ // Prefer the repository identity; projects without one (e.g. not yet
+ // indexed) fall back to workspace basename, then title, so switching
+ // computers still follows the same repo instead of resetting to
+ // whatever project is first on the target machine.
+ const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
+ const match =
+ (repositoryKey !== null
+ ? projectsOnTarget.find(
+ (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,
+ )
+ : undefined) ??
+ (workspaceBasename !== null
+ ? projectsOnTarget.find(
+ (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename,
+ )
+ : undefined) ??
+ (selectedProject !== null
+ ? projectsOnTarget.find((project) => project.title === selectedProject.title)
+ : undefined);
+ setSelectedEnvironmentId(environmentId);
+ setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null);
+ },
+ [projects, selectedProject],
+ );
const setWorkspaceMode = useCallback(
(mode: WorkspaceMode) => {
diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx
index e8ba7571da8..baea38bb47e 100644
--- a/apps/mobile/src/features/threads/thread-list-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-items.tsx
@@ -43,31 +43,37 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
readonly isFirst: boolean;
readonly groupKey: string;
readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
+ readonly onNewThread?: (project: EnvironmentProject) => void;
}) {
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
- const { groupKey, onGroupAction } = props;
+ const iconMutedColor = useThemeColor("--color-icon-muted");
+ const { groupKey, onGroupAction, onNewThread, project } = props;
const compact = props.variant === "compact";
const handleToggle = useCallback(
() => onGroupAction(groupKey, "toggle-collapsed"),
[groupKey, onGroupAction],
);
+ const handleNewThread = useCallback(() => onNewThread?.(project), [onNewThread, project]);
+ // The new-thread button is a SIBLING of the collapse toggle, not a child:
+ // nested touchables are unreachable to VoiceOver/TalkBack (the parent
+ // swallows focus). The row padding lives on the pressables themselves so
+ // the whole padded strip is tappable, not just the inner content.
return (
-
-
{props.threadCount}
-
-
-
+
+ {onNewThread ? (
+ ({ opacity: pressed ? 0.5 : 1 })}
+ >
+
+
+ ) : (
+
+ )}
+
);
});
diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.test.ts b/apps/mobile/src/features/threads/thread-navigation-groups.test.ts
deleted file mode 100644
index 2cfda2f4865..00000000000
--- a/apps/mobile/src/features/threads/thread-navigation-groups.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import type {
- EnvironmentProject,
- EnvironmentThreadShell,
-} from "@t3tools/client-runtime/state/shell";
-import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
-import { describe, expect, it } from "vite-plus/test";
-
-import { buildThreadNavigationGroups } from "./thread-navigation-groups";
-
-const environmentId = EnvironmentId.make("environment-1");
-
-function makeProject(input: Pick): EnvironmentProject {
- return {
- environmentId,
- workspaceRoot: `/workspaces/${input.id}`,
- repositoryIdentity: null,
- defaultModelSelection: null,
- scripts: [],
- createdAt: "2026-06-01T00:00:00.000Z",
- updatedAt: "2026-06-01T00:00:00.000Z",
- ...input,
- };
-}
-
-function makeThread(
- input: Pick &
- Partial,
-): EnvironmentThreadShell {
- return {
- environmentId,
- modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: null,
- latestTurn: null,
- createdAt: "2026-06-01T00:00:00.000Z",
- updatedAt: "2026-06-01T00:00:00.000Z",
- archivedAt: null,
- session: null,
- latestUserMessageAt: null,
- hasPendingApprovals: false,
- hasPendingUserInput: false,
- hasActionableProposedPlan: false,
- ...input,
- };
-}
-
-describe("buildThreadNavigationGroups", () => {
- const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
- const threads = [
- makeThread({
- id: ThreadId.make("older"),
- projectId: project.id,
- title: "Fix reconnect flow",
- updatedAt: "2026-06-02T00:00:00.000Z",
- }),
- makeThread({
- id: ThreadId.make("newer"),
- projectId: project.id,
- title: "Build adaptive sidebar",
- updatedAt: "2026-06-03T00:00:00.000Z",
- }),
- ];
-
- it("sorts each group by recent activity", () => {
- expect(
- buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map(
- (thread) => thread.id,
- ),
- ).toEqual(["newer", "older"]);
- });
-
- it("matches thread titles without dropping their group", () => {
- const groups = buildThreadNavigationGroups({
- projects: [project],
- threads,
- searchQuery: "reconnect",
- });
-
- expect(groups).toHaveLength(1);
- expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]);
- });
-
- it("keeps every thread when the project title matches", () => {
- expect(
- buildThreadNavigationGroups({
- projects: [project],
- threads,
- searchQuery: "t3 code",
- })[0]?.threads.map((thread) => thread.id),
- ).toEqual(["newer", "older"]);
- });
-
- it("excludes archived threads from the navigation sidebar", () => {
- const archived = makeThread({
- id: ThreadId.make("archived"),
- projectId: project.id,
- title: "Archived work",
- archivedAt: "2026-06-04T00:00:00.000Z",
- updatedAt: "2026-06-04T00:00:00.000Z",
- });
-
- expect(
- buildThreadNavigationGroups({
- projects: [project],
- threads: [...threads, archived],
- })[0]?.threads.map((thread) => thread.id),
- ).toEqual(["newer", "older"]);
- });
-});
diff --git a/apps/mobile/src/features/threads/thread-navigation-groups.ts b/apps/mobile/src/features/threads/thread-navigation-groups.ts
deleted file mode 100644
index 1531f6deb67..00000000000
--- a/apps/mobile/src/features/threads/thread-navigation-groups.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type {
- EnvironmentProject,
- EnvironmentThreadShell,
-} from "@t3tools/client-runtime/state/shell";
-import * as Arr from "effect/Array";
-import * as Order from "effect/Order";
-
-import { groupProjectsByRepository } from "../../lib/repositoryGroups";
-
-export interface ThreadNavigationGroup {
- readonly key: string;
- readonly title: string;
- readonly threads: ReadonlyArray;
-}
-
-const threadActivityOrder = Order.mapInput(
- Order.Struct({
- activityAt: Order.flip(Order.Number),
- title: Order.String,
- }),
- (thread: EnvironmentThreadShell) => ({
- activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
- title: thread.title,
- }),
-);
-
-export function buildThreadNavigationGroups(input: {
- readonly projects: ReadonlyArray;
- readonly threads: ReadonlyArray;
- readonly searchQuery?: string;
-}): ReadonlyArray {
- const query = input.searchQuery?.trim().toLocaleLowerCase() ?? "";
- const activeThreads = input.threads.filter((thread) => thread.archivedAt === null);
-
- return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => {
- const threads = Arr.sort(
- group.projects.flatMap((projectGroup) => projectGroup.threads),
- threadActivityOrder,
- );
- const title = group.projects[0]?.project.title ?? group.title;
- const groupMatches =
- query.length === 0 ||
- title.toLocaleLowerCase().includes(query) ||
- group.title.toLocaleLowerCase().includes(query) ||
- group.projects.some((projectGroup) =>
- projectGroup.project.title.toLocaleLowerCase().includes(query),
- );
- const matchingThreads = groupMatches
- ? threads
- : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
-
- if (query.length > 0 && matchingThreads.length === 0) {
- return [];
- }
-
- return [
- {
- key: group.key,
- title,
- threads: matchingThreads,
- },
- ];
- });
-}
diff --git a/apps/mobile/src/lib/time.ts b/apps/mobile/src/lib/time.ts
index 9cbdada68fb..1fbbc6b3e78 100644
--- a/apps/mobile/src/lib/time.ts
+++ b/apps/mobile/src/lib/time.ts
@@ -1,12 +1,13 @@
export function relativeTime(input: string): string {
const timestamp = Date.parse(input);
if (Number.isNaN(timestamp)) {
- return "now";
+ return "<1m";
}
+ // Anything under a minute renders as "<1m" rather than a live seconds count.
+ // The seconds ticker changed width every second and reflowed the surrounding row.
const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
- if (deltaSeconds < 10) return "now";
- if (deltaSeconds < 60) return `${deltaSeconds}s`;
+ if (deltaSeconds < 60) return "<1m";
const deltaMinutes = Math.floor(deltaSeconds / 60);
if (deltaMinutes < 60) return `${deltaMinutes}m`;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 884804f2bb7..ef7146e194a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -378,8 +378,8 @@ importers:
specifier: ^0.2.2
version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-keyboard-controller:
- specifier: 1.21.6
- version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
+ specifier: 1.21.13
+ version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-nitro-markdown:
specifier: ^0.5.0
version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
@@ -8814,8 +8814,8 @@ packages:
react: '*'
react-native: '*'
- react-native-keyboard-controller@1.21.6:
- resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==}
+ react-native-keyboard-controller@1.21.13:
+ resolution: {integrity: sha512-FLr0MucraPyCGykRAcPM8Bv0JT5TcG1juQGMI+GLDuuaoOUKUY3SMUnRhHn7IgSM8KlxpcNQmMPNDmGpOw1OcA==}
peerDependencies:
react: '*'
react-native: '*'
@@ -18950,7 +18950,7 @@ snapshots:
react: 19.2.3
react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
- react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
+ react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
dependencies:
react: 19.2.3
react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)