Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
da34e8e
feat(mobile): spike v5 stacks and native iPad columns
juliusmarminge Sep 19, 2026
081a88e
fix(mobile): resize markdown during native column layout
juliusmarminge Sep 19, 2026
aa42c89
fix(mobile): retain real v5 screen descriptors through pops
juliusmarminge Sep 19, 2026
90e070d
refactor(mobile): retain v5 screen state as one snapshot
juliusmarminge Sep 19, 2026
ef1b6a7
fix(mobile): retain selection in native primary columns
juliusmarminge Sep 19, 2026
c8a37f7
fix(mobile): stabilize primary column selection context
juliusmarminge Sep 19, 2026
60763c1
fix(mobile): retain native iPhone toolbar search on v5
juliusmarminge Sep 19, 2026
66d0ddc
fix(mobile): narrow the iOS platform check for toolbar search
juliusmarminge Sep 19, 2026
404d10d
fix(mobile): restore iPad sidebar controls and drawer colors
juliusmarminge Sep 20, 2026
f3ac5b7
fix(mobile): match v5 headers to their column backgrounds
juliusmarminge Sep 20, 2026
7280d26
fix(mobile): remove duplicate loading and back controls
juliusmarminge Sep 20, 2026
2f3f4fa
fix(mobile): restore native thread list header blur
juliusmarminge Sep 20, 2026
3b3b790
fix(mobile): keep native scroll registration scoped to iOS
juliusmarminge Sep 20, 2026
41a87ef
fix(mobile): restore full-width native header blur on iOS 27
juliusmarminge Sep 20, 2026
e44622c
fix(mobile): restore automatic scroll edges for v5 custom titles
juliusmarminge Sep 20, 2026
3278b45
fix(mobile): sync v5 integration with upstream Screens proposals
juliusmarminge Sep 30, 2026
287f3e4
fix(mobile): package self-contained split conversion templates
juliusmarminge Sep 30, 2026
88e3412
fix(mobile): finish native snapshot and sync formatting
juliusmarminge Sep 30, 2026
2e01d2a
build(mobile): consume a versioned Screens fork package
juliusmarminge Sep 30, 2026
ec840f1
fix(mobile): keep trackpad scrolling out of horizontal row pans
juliusmarminge Oct 1, 2026
c2acbde
fix(mobile): keep workspace flows beside the iPad sidebar
juliusmarminge Oct 1, 2026
4328f94
fix(mobile): inset rows in the native sidebar list
juliusmarminge Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
3 changes: 3 additions & 0 deletions apps/mobile/fingerprint.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// SDK 58 fingerprints native packages by version by default. Our pinned
// Screens PoC needs native source changes to invalidate the development client.
module.exports = { nativeModuleSourceType: "files" };
17 changes: 9 additions & 8 deletions apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ @implementation T3MarkdownText {
UILongPressGestureRecognizer *_longPressGestureRecognizer;
UITapGestureRecognizer *_pressGestureRecognizer;
NSArray *_contextAccessibilityElements;
BOOL _textLayoutNeedsUpdate;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
Expand Down Expand Up @@ -378,16 +379,18 @@ - (void)prepareForRecycle
_textView.frame = CGRectZero;
_textView.attributedText = nil;
_contextAccessibilityElements = nil;
_textLayoutNeedsUpdate = YES;
}

- (void)layoutSubviews
{
[super layoutSubviews];
// _textView's frame is assigned inside drawRect, which only fires when
// state changes. Trigger a redraw whenever the host frame moves out from
// under it (rotation, parent relayout) so the text view resizes and
// onTextLayout re-fires with the new line wrapping.
// Resize during UIKit's layout pass. Doing this in drawRect can leave the
// text view's drawing surface clipped to its previous width after a pane
// expands, even though TextKit has already rewrapped the lines.
if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
_textView.frame = _view.frame;
_textLayoutNeedsUpdate = YES;
[self setNeedsDisplay];
}
}
Expand Down Expand Up @@ -449,7 +452,7 @@ - (void)drawRect:(CGRect)rect
// entirely when nothing actually changed so a JS-side state update made in
// response to onSelectionChange doesn't deselect what the user is selecting.
const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame);
const BOOL frameChanged = _textLayoutNeedsUpdate;
if (!textChanged && !frameChanged) {
return;
}
Expand All @@ -475,9 +478,7 @@ - (void)drawRect:(CGRect)rect
}
_suppressSelectionChange = NO;
}
if (frameChanged) {
_textView.frame = _view.frame;
}
_textLayoutNeedsUpdate = NO;

// Text attachments have no native link element. Expose their existing runs
// at the measured glyph bounds, without inserting views into text layout.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import ExpoModulesCore
import UIKit

/// Reports the body geometry of a native navigation column.
final class T3LayoutMetricsView: ExpoView {
let onMetricsChange = EventDispatcher()
private var lastMetrics: NSDictionary?

required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
registerForTraitChanges([UITraitHorizontalSizeClass.self, UITraitVerticalSizeClass.self]) { (view: T3LayoutMetricsView, _: UITraitCollection) in
view.publishMetrics()
}
}

override func didMoveToWindow() {
super.didMoveToWindow()
lastMetrics = nil
publishMetrics()
}

override func layoutSubviews() {
super.layoutSubviews()
publishMetrics()
}

override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
publishMetrics()
}

private func publishMetrics() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
let metrics: [String: Any] = [
"width": bounds.width, "height": bounds.height,
"horizontalSizeClass": traitCollection.horizontalSizeClass == .regular ? "regular" : "compact",
"safeArea": [
"top": safeAreaInsets.top, "bottom": safeAreaInsets.bottom,
"left": safeAreaInsets.left, "right": safeAreaInsets.right,
],
]
let next = metrics as NSDictionary
guard lastMetrics?.isEqual(next) != true else { return }
lastMetrics = next
onMetricsChange(metrics)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ public final class T3NativeControlsModule: Module {
private var filePresentation: T3NativeFilePresentation?

public func definition() -> ModuleDefinition {
Constants {
["supportsWorkspaceColumns": NSClassFromString("RNSSplitHostComponentView") != nil]
}
View(T3LayoutMetricsView.self) {
ViewName("LayoutMetrics")
Events("onMetricsChange")
}

AsyncFunction("presentVideo") { (url: URL, title: String, sourceIdentifier: String, identifier: String, promise: Promise) in
try self.presentVideo(
url: url,
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
"react-native-nitro-modules": "0.35.9",
"react-native-reanimated": "4.7.0",
"react-native-safe-area-context": "~5.9.1",
"react-native-screens": "~4.28.0",
"react-native-screens": "file:deps/react-native-screens-5.0.0-t3.2.tgz",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.5",
"react-native-webview": "14.0.1",
Expand All @@ -140,7 +140,8 @@
"expo": {
"install": {
"exclude": [
"react-native-keyboard-controller"
"react-native-keyboard-controller",
"react-native-screens"
]
},
"autolinking": {
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createV5StackNavigator as createNativeStackNavigator } from "./native/createV5StackNavigator";
import { createWorkspaceStackNavigator } from "./features/layout/createWorkspaceStackNavigator";
import {
createPathConfigForStaticNavigation,
getPathFromState,
Expand All @@ -6,7 +8,6 @@ import {
useNavigation,
} from "@react-navigation/native";
import {
createNativeStackNavigator,
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
Expand Down Expand Up @@ -602,7 +603,7 @@ function NotFoundScreen() {
);
}

const RootStackConfig = createNativeStackNavigator({
const RootStackConfig = createWorkspaceStackNavigator({
initialRouteName: "Home",
layout: RootStackLayout,
screenOptions: {
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/components/CompactBrandTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NATIVE_WORKSPACE_COLUMNS_SUPPORTED } from "../native/NativeWorkspaceColumns";
import Constants from "expo-constants";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Platform, View } from "react-native";
Expand All @@ -14,7 +15,7 @@ import { useAndroidControlSizing } from "./useAndroidControlSizing";
*/
export function brandTitleOffset(): number {
if (Platform.OS !== "ios") return 0;
return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0;
return Platform.isPad && !NATIVE_WORKSPACE_COLUMNS_SUPPORTED ? IPAD_HOME_TITLE_OFFSET : 0;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/components/ScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export function ScreenHeader(props: ScreenHeaderProps) {
]}
options={{
headerShown: true,
headerBackVisible:
!layout.usesSplitView ||
!props.backInSplitView ||
!(props.backInSplitView.onPress || props.onBack),
title: props.title,
unstable_headerSubtitle: props.subtitle || undefined,
...(props.matchSearchSurface
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
deviceToolUpdatePolicy,
} from "@t3tools/client-runtime/state/device";
import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { createV5StackNavigator as createNativeStackNavigator } from "../../native/createV5StackNavigator";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/files/FileTreeBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,12 @@ export function FileTreeBrowser(props: {
updateCellsBatchingPeriod={16}
windowSize={5}
contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }}
refreshControl={<RefreshControl refreshing={props.isPending} onRefresh={props.onRefresh} />}
refreshControl={
<RefreshControl
refreshing={props.isPending && visibleNodes.length > 0}
onRefresh={props.onRefresh}
/>
}
renderItem={renderItem}
ListHeaderComponent={
<>
Expand Down
113 changes: 74 additions & 39 deletions apps/mobile/src/features/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useCallback, useRef } from "react";
import { use, useCallback, useRef } from "react";
import { Platform } from "react-native";
import type { SearchBarCommands } from "react-native-screens";
import { NativePrimaryColumnContext } from "../../native/v5-workspace-context";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
Expand All @@ -9,11 +11,14 @@ import {
NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED,
} from "../layout/native-mail-search-toolbar";
import { buildHomeListFilterMenu } from "./home-list-filter-menu";
import { createSidebarHeaderItems } from "../threads/sidebar-native-header-items";
import type { HomeHeaderProps } from "./HomeHeader.types";

export type { HomeHeaderEnvironment } from "./HomeHeader.types";

export function HomeHeader(props: HomeHeaderProps) {
const primaryColumn = use(NativePrimaryColumnContext);
const iPadSidebar = Platform.OS === "ios" && Platform.isPad && primaryColumn !== null;
const searchBarRef = useRef<SearchBarCommands>(null);
const iconColor = useUniwindTheme()["--color-icon"];
// The list uses a fixed creation order and ignores sort/group options, so
Expand All @@ -36,57 +41,87 @@ export function HomeHeader(props: HomeHeaderProps) {
// Static header config (glass, title, fonts) lives in Stack.tsx
// (GLASS_HEADER_OPTIONS). Only dynamic values are set here.
headerTintColor: iconColor,
unstable_headerRightItems: () => [
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
identifier: "home-settings",
label: "",
onPress: props.onOpenSettings,
type: "button",
}),
],
unstable_headerRightItems: () =>
iPadSidebar
? createSidebarHeaderItems({
filterIcon: hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle",
filterMenu,
onOpenSettings: props.onOpenSettings,
})
: [
withNativeGlassHeaderItem({
accessibilityLabel: "Open settings",
icon: { name: "ellipsis", type: "sfSymbol" } as const,
identifier: "home-settings",
label: "",
onPress: props.onOpenSettings,
type: "button",
}),
],
// The keys below are set per-branch (not `undefined`) so a later
// reapply cannot clobber options owned by NativeHeaderToolbar.
...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
...(iPadSidebar
? {
unstable_headerToolbarItems: () => [
createNativeMailSearchToolbarItem({
composeButtonId: "home-new-task",
composeSystemImageName: "square.and.pencil",
filterMenu,
filterButtonId: "home-filter",
filterSystemImageName: hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease",
onComposePress: props.onStartNewTask,
onSearchTextChange: props.onSearchQueryChange,
placeholder: "Search",
searchTextChangeId: "home-search-text",
showsSearchDismissButton: true,
}),
],
}
: {
// Pre-Liquid-Glass iOS: standard pull-down search in the nav
// bar; create + sort live in the plain bottom toolbar below.
headerSearchBarOptions: {
ref: searchBarRef,
autoCapitalize: "none" as const,
hideNavigationBar: false,
hideWhenScrolling: false,
obscureBackground: false,
placement: "stacked" as const,
allowToolbarIntegration: false,
placeholder: "Search",
onCancelButtonPress: () => {
props.onSearchQueryChange("");
onCancelButtonPress: () => props.onSearchQueryChange(""),
onChangeText: (event) => props.onSearchQueryChange(event.nativeEvent.text),
},
unstable_headerToolbarItems: () => [],
}
: NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED
? {
headerSearchBarOptions: {
ref: searchBarRef,
autoCapitalize: "none" as const,
onCancelButtonPress: () => props.onSearchQueryChange(""),
},
onChangeText: (event) => {
props.onSearchQueryChange(event.nativeEvent.text);
unstable_headerToolbarItems: () => [
createNativeMailSearchToolbarItem({
composeButtonId: "home-new-task",
composeSystemImageName: "square.and.pencil",
filterMenu,
filterButtonId: "home-filter",
filterSystemImageName: hasCustomListOptions
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease",
onComposePress: props.onStartNewTask,
onSearchTextChange: props.onSearchQueryChange,
placeholder: "Search",
searchTextChangeId: "home-search-text",
showsSearchDismissButton: true,
}),
],
}
: {
// Pre-Liquid-Glass iOS: standard pull-down search in the nav
// bar; create + sort live in the plain bottom toolbar below.
headerSearchBarOptions: {
ref: searchBarRef,
autoCapitalize: "none" as const,
hideNavigationBar: false,
placeholder: "Search",
onCancelButtonPress: () => {
props.onSearchQueryChange("");
},
onChangeText: (event) => {
props.onSearchQueryChange(event.nativeEvent.text);
},
},
},
}),
}),
}}
/>

{NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
{iPadSidebar || NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED ? null : (
<NativeHeaderToolbar placement="bottom">
<NativeHeaderToolbar.Menu
accessibilityLabel="Filter threads"
Expand Down
Loading
Loading