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)