From dd8c6493fdb1fea1e35c4cc5a3303ba5bb667253 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 29 Jun 2026 16:01:14 -0700 Subject: [PATCH 01/13] Mobile: enlarge new-task composer text to 17pt The new-task draft composer (route /new/draft) typed its text at the compact 14pt `composer` size, which felt too small for a full-screen, primary task-description input. Bump it to the iOS-standard 17pt body size via the existing `headline` typography token. Scoped to the new-task screen only; the in-thread ThreadComposer pill keeps its intentional 14pt. Co-Authored-By: Claude Opus 4.8 --- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ce24198f5e2..e9043f64472 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -457,7 +457,7 @@ export function NewTaskDraftScreen(props: { onPasteImages={(uris) => void handleNativePasteImages(uris)} placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - textStyle={MOBILE_TYPOGRAPHY.composer} + textStyle={MOBILE_TYPOGRAPHY.headline} /> From e899698f2f665a5ded3e14e3e2f4e6277a5b0f6e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 04:15:42 -0700 Subject: [PATCH 02/13] Pull uncommitted work: fix mobile thread scroll jump (worktree 20990773) Co-Authored-By: Claude Fable 5 --- .../features/threads/ThreadDetailScreen.tsx | 37 +++++++++++++------ .../src/features/threads/ThreadFeed.tsx | 4 ++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 62d1bce1157..60f44a4cc43 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -19,7 +19,7 @@ import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; 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"; @@ -291,16 +291,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); }, [ diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a82e7c49ccd..324fe96134a 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1456,6 +1456,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ref={props.listRef} key={props.threadId} 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 automaticallyAdjustsScrollIndicatorInsets={false} scrollIndicatorInsets={{ top: topContentInset, bottom: 0 }} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} From 6f5dd37c933c3253b466b370a5cfd956d993e15f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 04:15:42 -0700 Subject: [PATCH 03/13] Pull uncommitted work: quick project thread button (worktree 27a5341b) Co-Authored-By: Claude Fable 5 --- apps/mobile/src/app/index.tsx | 10 ++++++++++ apps/mobile/src/features/home/HomeScreen.tsx | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx index 7f9962efc98..d8bc35902de 100644 --- a/apps/mobile/src/app/index.tsx +++ b/apps/mobile/src/app/index.tsx @@ -102,6 +102,16 @@ export default function HomeRouteScreen() { onAddConnection={() => router.push("/connections/new")} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onNewThreadInProject={(project) => { + router.push({ + pathname: "/new/draft", + params: { + environmentId: project.environmentId, + projectId: project.id, + title: project.title, + }, + }); + }} onOpenEnvironments={() => router.push("/settings/environments")} onSelectThread={(thread) => { router.push(buildThreadRoutePath(thread)); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 7ee5660edf1..8a683714731 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -55,6 +55,7 @@ interface HomeScreenProps { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; + readonly onNewThreadInProject: (project: EnvironmentProject) => void; } /* ─── Status indicator colors ────────────────────────────────────────── */ @@ -174,8 +175,10 @@ function ProjectGroupLabel(props: { readonly totalThreadCount: number; readonly isExpanded: boolean; readonly onToggleExpand: () => void; + readonly onNewThread: () => void; }) { const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; + const iconMutedColor = useThemeColor("--color-icon-muted"); return ( @@ -193,6 +196,22 @@ function ProjectGroupLabel(props: { {props.title} + ({ opacity: pressed ? 0.5 : 1 })} + > + + + {hiddenCount > 0 ? ( props.onNewThreadInProject(group.representative)} onToggleExpand={() => toggleExpanded(group.key)} project={group.representative} title={group.title} From fab05c3ebe539beed9d85675a776fe06dc98cdce Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 04:15:42 -0700 Subject: [PATCH 04/13] Pull uncommitted work: relative time formatting tweaks (worktree 2d307dad) Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx | 2 +- apps/mobile/src/features/home/HomeScreen.tsx | 2 +- apps/mobile/src/lib/time.ts | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 3e1934100cd..1ebfddf7ee8 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -272,7 +272,7 @@ function ArchivedThreadRow(props: { {timestamp} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8a683714731..8634f745ec9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -370,7 +370,7 @@ function ThreadRow(props: { {timestamp} 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`; From ad6c6c1a30d717e9257a7417e05d55be97bdd93a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 04:16:25 -0700 Subject: [PATCH 05/13] Pull uncommitted work: thread swipe area / route screens (worktree 42795bc9), resolve import conflict Co-Authored-By: Claude Fable 5 --- .../[environmentId]/[threadId]/_layout.tsx | 2 + .../features/threads/ThreadDetailScreen.tsx | 218 ++++++++---------- .../features/threads/ThreadRouteScreen.tsx | 169 ++++++++------ 3 files changed, 200 insertions(+), 189 deletions(-) diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx index 92e90920e2d..9eff7d765e6 100644 --- a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx +++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx @@ -18,6 +18,8 @@ export default function ThreadLayout() { headerTransparent: true, headerShadowVisible: false, headerTitle: "", + headerBackVisible: true, + headerBackButtonDisplayMode: "minimal", }} /> void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; @@ -204,8 +201,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { }); export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { - const { onOpenDrawer } = props; - const insets = useSafeAreaInsets(); const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; @@ -233,7 +228,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; - const isSplitLayout = layoutVariant === "split"; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const selectedProviderSkills = useMemo( @@ -243,28 +237,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]); @@ -374,104 +346,102 @@ 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/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index f8c916974e5..d698b52c1dc 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -3,6 +3,8 @@ import { useCallback, useMemo, useState } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import * as Haptics from "expo-haptics"; +import { SymbolView } from "expo-symbols"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { useWorkspaceState } from "../../state/workspace"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -145,6 +147,7 @@ export function ThreadRouteScreen() { const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenDrawer = useCallback(() => { + void Haptics.selectionAsync(); setDrawerVisible(true); }, []); @@ -270,48 +273,6 @@ export function ThreadRouteScreen() { ], ); - if (!environmentId || !threadId) { - return ; - } - - if (!selectedThread) { - const stillHydrating = - workspaceState.isLoadingConnections || - routeConnectionState === "connecting" || - routeConnectionState === "reconnecting"; - - if (stillHydrating) { - return ; - } - - return ( - - - - ); - } - - const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const contentPresentation = projectThreadContentPresentation({ - hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), - detailDeleted: selectedThreadDetailState.status === "deleted", - connectionState: routeConnectionState, - }); - const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; - const headerSubtitle = [ selectedThreadProject?.title ?? null, selectedEnvironmentConnection?.environmentLabel ?? null, @@ -319,23 +280,31 @@ export function ThreadRouteScreen() { .filter(Boolean) .join(" · "); - return ( - <> - ( - { - // TODO: trigger rename modal - }} - > + // Rendered in every state (loading / unavailable / ready) so the back button and + // title stay consistent instead of flickering as the thread hydrates. Tapping the + // title opens the thread switcher drawer. + const headerScreen = ( + ( + + - {selectedThread.title} + {selectedThread?.title ?? "Opening thread…"} + {selectedThread ? ( + + ) : null} + + {headerSubtitle ? ( {headerSubtitle} - - ), - }} - /> + ) : null} + + ), + }} + /> + ); + + if (!environmentId || !threadId) { + return ( + <> + {headerScreen} + + + ); + } + + if (!selectedThread) { + const stillHydrating = + workspaceState.isLoadingConnections || + routeConnectionState === "connecting" || + routeConnectionState === "reconnecting"; + + if (stillHydrating) { + return ( + <> + {headerScreen} + + + ); + } + + return ( + <> + {headerScreen} + + + + + ); + } + + const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); + const contentPresentation = projectThreadContentPresentation({ + hasDetail: selectedThreadDetail !== null, + detailError: Option.getOrNull(selectedThreadDetailState.error), + detailDeleted: selectedThreadDetailState.status === "deleted", + connectionState: routeConnectionState, + }); + const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; + + return ( + <> + {headerScreen} Date: Sat, 4 Jul 2026 04:16:31 -0700 Subject: [PATCH 06/13] Pull uncommitted work: new-task draft flow changes (worktree 4673f128) Co-Authored-By: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 26 ++++-- .../threads/new-task-flow-provider.tsx | 88 +++++++++++-------- 2 files changed, 67 insertions(+), 47 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e9043f64472..fd0e37988e9 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -66,28 +66,36 @@ export function NewTaskDraftScreen(props: { const { logicalProjects, selectedProject, setProject } = flow; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); + const appliedInitialRefKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); 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)"; useEffect(() => { - 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) { - if ( - selectedProject?.environmentId === directProject.environmentId && - selectedProject.id === directProject.id - ) { - return; + // Honor the route's project ref only once. Re-applying it on every + // selection change would immediately revert a manual computer switch, + // silently snapping back to the machine the draft was opened from. + const initialKey = `${initialEnvironmentId}:${initialProjectId}`; + if (appliedInitialRefKeyRef.current !== initialKey) { + appliedInitialRefKeyRef.current = initialKey; + if ( + selectedProject?.environmentId !== directProject.environmentId || + selectedProject.id !== directProject.id + ) { + setProject(directProject); + } } - setProject(directProject); return; } } 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 9a8dde3429a..47862f45194 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -171,40 +171,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setExpandedProvider(null); }, []); - 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( @@ -220,6 +186,40 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) ?? 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. + const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + const environments = useMemo(() => { + const seen = new Set(); + const result: Array<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + }> = []; + for (const project of projects) { + if ( + selectedRepositoryKey !== null && + (project.repositoryIdentity?.canonicalKey ?? null) !== selectedRepositoryKey + ) { + 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]); + const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, ); @@ -384,10 +384,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedProjectKey(nextProjectKey); }, []); - const selectEnvironment = useCallback((environmentId: EnvironmentId) => { - setSelectedEnvironmentId(environmentId); - setSelectedProjectKey(null); - }, []); + const selectEnvironment = useCallback( + (environmentId: EnvironmentId) => { + const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + const match = + repositoryKey === null + ? undefined + : projects.find( + (project) => + project.environmentId === environmentId && + (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, + ); + setSelectedEnvironmentId(environmentId); + setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); + }, + [projects, selectedProject?.repositoryIdentity?.canonicalKey], + ); const setWorkspaceMode = useCallback( (mode: WorkspaceMode) => { From efee0c9f264c62b2e27660309cdfa1829acc2723 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 04:16:56 -0700 Subject: [PATCH 07/13] Pull uncommitted work: home thread list changes (worktree c4c62ae9), resolve HomeScreen conflicts Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 16 ++-- .../src/features/home/homeThreadList.test.ts | 94 +++++++++++++++++++ .../src/features/home/homeThreadList.ts | 43 ++++++++- 3 files changed, 144 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8634f745ec9..ffc39090f5a 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -172,12 +172,11 @@ function deriveEmptyState(props: { function ProjectGroupLabel(props: { readonly project: EnvironmentProject; readonly title: string; - readonly totalThreadCount: number; + readonly hiddenCount: number; readonly isExpanded: boolean; readonly onToggleExpand: () => void; readonly onNewThread: () => void; }) { - const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT; const iconMutedColor = useThemeColor("--color-icon-muted"); return ( @@ -212,13 +211,13 @@ function ProjectGroupLabel(props: { /> - {hiddenCount > 0 ? ( + {props.hiddenCount > 0 ? ( - {props.isExpanded ? "Show less" : `${hiddenCount} more`} + {props.isExpanded ? "Show less" : `${props.hiddenCount} more`} ) : null} @@ -568,9 +567,10 @@ export function HomeScreen(props: HomeScreenProps) { ) : ( projectGroups.map((group) => { const isExpanded = expandedProjects.has(group.key); - const visibleThreads = isExpanded - ? group.threads - : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); + // Collapsed shows recent threads only; expanding reveals full history. + const collapsedThreads = group.recentThreads.slice(0, COLLAPSED_THREAD_LIMIT); + const visibleThreads = isExpanded ? group.threads : collapsedThreads; + const hiddenCount = group.threads.length - collapsedThreads.length; return ( toggleExpanded(group.key)} project={group.representative} title={group.title} - totalThreadCount={group.threads.length} + hiddenCount={hiddenCount} /> , 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 9f09e894c20..c848d1dc79c 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -20,12 +20,23 @@ import { scopedProjectKey } from "../../lib/scopedEntities"; 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; + /** 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 { @@ -41,6 +52,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr ); } +/** + * 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; @@ -49,7 +78,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(); @@ -113,12 +145,21 @@ 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, - threads: sortThreads(matchingThreads, input.threadSortOrder), + threads: sortedThreads, + recentThreads, }); } From 9ac1d7f398b10f61c88ba0d910bf4c49a5fd595b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 05:58:46 -0700 Subject: [PATCH 08/13] =?UTF-8?q?Upgrade=20react-native-keyboard-controlle?= =?UTF-8?q?r=201.21.6=20=E2=86=92=201.21.13=20for=20contentInset=20scroll?= =?UTF-8?q?=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The far-edge/blank-area scroll failures trace to the keyboard-controller version: @legendapp/list's KeyboardAwareLegendList requires >= 1.21.7, and 1.21.12 additionally fixes the non-scrollable KeyboardChatScrollView when blankSpace is large (viewport clamp + Android touch-dispatch workaround). Co-Authored-By: Claude Fable 5 --- apps/mobile/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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/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) From 90ea2d376aba53c72098d90c583751cdf0c42eb2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 06:03:59 -0700 Subject: [PATCH 09/13] Fail the header drawer pan at touch-down outside the header strip The drawer gesture's 40px-wide left-edge hitSlop spans the full screen height; its y-position check only ran in onEnd, after the pan had already activated and stolen the touch from the native back-swipe recognizer. Fail the gesture immediately when the touch starts below the header so left-edge back-swipes work everywhere else. Co-Authored-By: Claude Fable 5 --- .../mobile/src/features/threads/ThreadDetailScreen.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 4a507f572d4..b1e3d95593d 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -276,6 +276,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .hitSlop({ left: 0, width: 40 }) .activeOffsetX([10, 999]) .failOffsetY([-24, 24]) + // Fail at touch-down for anything below the header strip. Activating + // there (the y check in onEnd fires too late to matter) swallows the + // left-edge touch and blocks the native back-swipe along the whole + // screen height. + .onTouchesDown((event, stateManager) => { + const touchY = event.allTouches[0]?.y ?? Number.POSITIVE_INFINITY; + if (touchY >= drawerGestureThreshold) { + stateManager.fail(); + } + }) .onEnd((event) => { const translationX = Math.max(event.translationX, 0); if (event.y < drawerGestureThreshold && translationX > 56) { From f726113e148e2a48c295ec13cf9e974ad4a3f7ff Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 06:12:06 -0700 Subject: [PATCH 10/13] Remove the legacy thread navigation drawer and its edge-pan gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer predates the current navigation (it shipped with the old sheet-based thread routing). On phones its only trigger was an invisible 40px left-edge pan that competed with the native back swipe — the cause of the dead far-left-edge back gesture — and on split view it never rendered at all. Thread switching now goes through Home (compact) or the persistent sidebar (split view), so the drawer, its gesture, its modal component, and the now-orphaned thread-navigation-groups helpers are all removed. Co-Authored-By: Claude Fable 5 --- .../features/threads/ThreadDetailScreen.tsx | 245 +++++++---------- .../threads/ThreadNavigationDrawer.tsx | 255 ------------------ .../features/threads/ThreadRouteScreen.tsx | 41 +-- .../threads/thread-navigation-groups.test.ts | 111 -------- .../threads/thread-navigation-groups.ts | 64 ----- 5 files changed, 104 insertions(+), 612 deletions(-) delete mode 100644 apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx delete mode 100644 apps/mobile/src/features/threads/thread-navigation-groups.test.ts delete mode 100644 apps/mobile/src/features/threads/thread-navigation-groups.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b1e3d95593d..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 { 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,38 +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]) - // Fail at touch-down for anything below the header strip. Activating - // there (the y check in onEnd fires too late to matter) swallows the - // left-edge touch and blocks the native back-swipe along the whole - // screen height. - .onTouchesDown((event, stateManager) => { - const touchY = event.allTouches[0]?.y ?? Number.POSITIVE_INFINITY; - if (touchY >= drawerGestureThreshold) { - stateManager.fail(); - } - }) - .onEnd((event) => { - const translationX = Math.max(event.translationX, 0); - if (event.y < drawerGestureThreshold && translationX > 56) { - runOnJS(completeDrawerGesture)(); - } - }), - [completeDrawerGesture, isSplitLayout], - ); - useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; }, [selectedThreadKey]); @@ -404,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/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 eb2d013152d..27b39c4f1b1 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,15 +1,9 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { - StackActions, - useFocusEffect, - useNavigation, - type StaticScreenProps, -} from "@react-navigation/native"; +import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import * as Haptics from "expo-haptics"; import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -50,7 +44,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"; @@ -198,7 +191,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); @@ -327,19 +319,6 @@ function ThreadRouteContent( ); const gitActionProgress = useGitActionProgress(gitActionProgressTarget); - const handleOpenDrawer = useCallback(() => { - if (!layout.usesSplitView) { - void Haptics.selectionAsync(); - setDrawerVisible(true); - } - }, [layout.usesSplitView]); - - useEffect(() => { - if (layout.usesSplitView) { - setDrawerVisible(false); - } - }, [layout.usesSplitView]); - const handleOpenGitInspector = useCallback(() => { setInspectorSelection({ routeThreadIdentity, mode: "git" }); showAuxiliaryPane("inspector"); @@ -706,7 +685,6 @@ function ThreadRouteContent( selectedThreadQueueCount={composer.selectedThreadQueueCount} layoutVariant={layout.variant} usesAutomaticContentInsets={usesNativeHeaderGlass} - onOpenDrawer={handleOpenDrawer} onOpenConnectionEditor={handleOpenConnectionEditor} onChangeDraftMessage={composer.onChangeDraftMessage} onPickDraftImages={composer.onPickDraftImages} @@ -724,23 +702,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" })} - /> - )} ); 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, - }, - ]; - }); -} From 3edb72e0166f9163f41b5f5df82e666f34694c8b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 06:20:14 -0700 Subject: [PATCH 11/13] Address macroscope review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectEnvironment: fall back to workspace basename, then title, when the selected project has no repositoryIdentity, so computer switching still follows the same repo instead of resetting to the target's first project. - ThreadListGroupHeader: lift the new-thread button out of the collapse toggle Pressable — nested touchables are invisible to VoiceOver/TalkBack. - homeListItems: compare canShowLess against the group's recency baseline instead of the global page size, so stale groups can collapse back. - HomeScreen: only offer the quick new-thread button on single-project groups; an aggregated group's representative is arbitrary. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 6 +- .../src/features/home/homeListItems.test.ts | 41 +++++++++++ .../mobile/src/features/home/homeListItems.ts | 5 +- .../threads/new-task-flow-provider.tsx | 30 +++++--- .../features/threads/thread-list-items.tsx | 69 ++++++++++--------- 5 files changed, 110 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index a43cc05513b..4b97b8f759d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -249,7 +249,11 @@ export function HomeScreen(props: HomeScreenProps) { isFirst={item.isFirst} groupKey={item.group.key} onGroupAction={updateGroupDisplay} - onNewThread={props.onNewThreadInProject} + // Aggregated groups (same repo across machines) have no single + // target project, so the quick new-thread button is single-project only. + onNewThread={ + item.group.projects.length === 1 ? props.onNewThreadInProject : undefined + } project={item.group.representative} threadCount={item.group.threads.length + item.group.pendingTasks.length} title={item.group.title} diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 12b0a3248fb..c964453a6d2 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -157,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 ce1f492838f..eb3f2a5de19 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -195,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/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 8760f54de5f..7c3759d2dd2 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -482,19 +482,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { 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 - ? undefined - : projects.find( - (project) => - project.environmentId === environmentId && - (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, - ); + (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?.repositoryIdentity?.canonicalKey], + [projects, selectedProject], ); const setWorkspaceMode = useCallback( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 938eb8a4295..dbb341111a1 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -55,22 +55,27 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: ); 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), so the row is a plain View with two adjacent pressables. return ( - - {props.threadCount} - {onNewThread ? ( - ({ opacity: pressed ? 0.5 : 1, marginRight: compact ? 10 : 8 })} - > - - - ) : null} + + {onNewThread ? ( + ({ opacity: pressed ? 0.5 : 1, marginRight: compact ? 10 : 8 })} + > + + + ) : null} + - - + + ); }); From 3fd7f73c95d302e5a8e5c7fdc6f411cc21e40c12 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 06:29:21 -0700 Subject: [PATCH 12/13] Group header polish + second-round review fixes - Replace the pencil icon with a larger plus, flush right with full-height padded hit area; drop the fold/unfold chevron (title press still toggles). - Give the collapse toggle back its padded hit area (was reduced when padding moved to the outer View). - Hide the quick new-thread button on synthetic pending-project groups. - Environment picker: fall back to workspace basename/title matching when either side lacks repositoryIdentity, so still-indexing hosts stay visible. - Deep-link/cold-start threads (no back stack) get an explicit header button to reach the threads list. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/home/HomeScreen.tsx | 8 +++- .../features/threads/ThreadRouteScreen.tsx | 37 +++++++++++++++--- .../threads/new-task-flow-provider.tsx | 32 +++++++++++++--- .../features/threads/thread-list-items.tsx | 38 +++++++++---------- 4 files changed, 81 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4b97b8f759d..999cec2d669 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -250,9 +250,13 @@ export function HomeScreen(props: HomeScreenProps) { groupKey={item.group.key} onGroupAction={updateGroupDisplay} // Aggregated groups (same repo across machines) have no single - // target project, so the quick new-thread button is single-project only. + // 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 ? props.onNewThreadInProject : undefined + 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} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 27b39c4f1b1..5f5353c6eff 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,5 +1,10 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { + StackActions, + useFocusEffect, + useNavigation, + type StaticScreenProps, +} from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; @@ -636,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 ; @@ -720,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 7c3759d2dd2..1590728b924 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -279,19 +279,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // 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. + // 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; + 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 ( - selectedRepositoryKey !== null && - (project.repositoryIdentity?.canonicalKey ?? null) !== selectedRepositoryKey - ) { + if (!hostsSelectedRepository(project)) { continue; } if (seen.has(project.environmentId)) { @@ -308,7 +322,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }); } return result; - }, [projects, savedConnectionsById, selectedRepositoryKey]); + }, [ + projects, + savedConnectionsById, + selectedRepositoryKey, + selectedWorkspaceBasename, + selectedProjectTitle, + ]); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index dbb341111a1..baea38bb47e 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -45,7 +45,6 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void; readonly onNewThread?: (project: EnvironmentProject) => void; }) { - const iconSubtleColor = useThemeColor("--color-icon-subtle"); const iconMutedColor = useThemeColor("--color-icon-muted"); const { groupKey, onGroupAction, onNewThread, project } = props; const compact = props.variant === "compact"; @@ -57,14 +56,11 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: // 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), so the row is a plain View with two adjacent pressables. + // swallows focus). The row padding lives on the pressables themselves so + // the whole padded strip is tappable, not just the inner content. return ( @@ -108,28 +106,26 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: ({ opacity: pressed ? 0.5 : 1, marginRight: compact ? 10 : 8 })} + style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })} > - ) : null} - - - + ) : ( + + )} ); }); From cf9a56460cbe0b74ac676c116d808e337b5c5d3c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 4 Jul 2026 06:38:03 -0700 Subject: [PATCH 13/13] Treat empty workspaceRoot as null when matching hosts by basename A pending-task placeholder project has workspaceRoot "" when no cwd was snapshotted; an "" basename rejected every real host, emptying the environment picker for that queued task. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/threads/new-task-flow-provider.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 1590728b924..ee2bde9d971 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -283,7 +283,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // 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; - const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) ?? 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(); @@ -510,7 +512,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // 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 workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; const match = (repositoryKey !== null ? projectsOnTarget.find(