Skip to content

Commit 79ea0f6

Browse files
authored
merge: rebuild mobile Threads inbox (#384)
## Summary - move the mobile shell to root-level native tabs with a stacked UIKit search bar and native header actions - keep Settings and the host switcher reachable while disconnected, with pooled host connections and an opt-in keep-connected preference - enrich thread rows and use a direct UIKit blur view for the navigation-bar backdrop ## Why The previous route hierarchy tied navigation to the selected host, hid the escape path when that host was unreachable, and recreated system navigation chrome in app UI. Thread rows also omitted agent, project, and recency context already available in the schema. ## Validation - `pnpm check:ci` — passed on stacked descendant `xuan/code-530` - `pnpm test` — 2,390 passed, 1 skipped on stacked descendant `xuan/code-530` - iPhone 17 Pro / iOS 26.5 Simulator — search activate/type/cancel, tab switching, host switching, thread rows, and navigation-bar backdrop ## Notes - Last-message previews remain deferred because transcripts are provider-local history rather than session-list data. - Follow-up PR #382 is intentionally stacked on this branch. - The branch is currently behind `master`; this stays draft until it is updated and checks are rerun on the rebased branch.
2 parents 6c6f67d + ef3dc0e commit 79ea0f6

38 files changed

Lines changed: 759 additions & 263 deletions

apps/mobile/AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ only by driving the simulator:
6868
view, but mounting it directly red-boxes). Give that host `style={{ position: 'absolute' }}` +
6969
`pointerEvents="box-none"` so it claims no layout, and set `fitToContents` or SwiftUI presents a
7070
short sheet at a near-full-screen detent.
71+
- **Keep navigation chrome in UIKit end to end.** Expo Router's native-stack header is UIKit; when
72+
it needs an unexposed blur/material/mask, register the smallest `UIView`/`UIVisualEffectView`
73+
through a direct RN view manager (Expo may autolink the pod) and pass it to `headerBackground`.
74+
Do not insert an `ExpoSwiftUI.View`/`UIHostingController`: a hosted `.bar` rendered here but did
75+
not blur scrolling rows across that boundary, while `UIVisualEffectView` did. This rule is for
76+
UIKit-owned chrome, not SwiftUI forms.
7177

7278
## What deliberately stays React Native
7379

apps/mobile/e2e/flows/settings.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ appId: com.arcboxlabs.linkcode.mobile
99
- launchApp
1010
# A cold start redirects once the persisted host registry hydrates, which can land after the deep
1111
# link and replace the screen it opened; retrying re-issues the link past that window.
12+
# The guard has to be a row, not the title: a host screen carries a Settings *tab*, so "Settings"
13+
# alone is satisfied by the very redirect this retry exists to outlast.
1214
- retry:
1315
maxRetries: 3
1416
commands:
1517
- openLink: linkcode://settings
1618
- waitForAnimationToEnd
17-
- assertVisible: 'Settings'
19+
- assertVisible: 'Manage hosts'
1820

1921
- assertVisible: 'Settings'
2022
- assertVisible: 'Manage hosts'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"platforms": ["ios"],
3+
"apple": {
4+
"podspecPath": "ios/LinkCodeNavigationBarBackdrop.podspec"
5+
}
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { ViewProps } from 'react-native';
2+
import { Platform, requireNativeComponent, StyleSheet, View } from 'react-native';
3+
4+
const NativeBackdrop =
5+
Platform.OS === 'ios' ? requireNativeComponent<ViewProps>('LinkCodeNavigationBarBackdrop') : View;
6+
7+
export function NavigationBarBackdrop(): React.ReactNode {
8+
return <NativeBackdrop pointerEvents="none" style={StyleSheet.absoluteFill} />;
9+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
Pod::Spec.new do |s|
2+
s.name = 'LinkCodeNavigationBarBackdrop'
3+
s.version = '1.0.0'
4+
s.summary = 'LinkCode navigation bar backdrop'
5+
s.description = s.summary
6+
s.author = 'ArcBox'
7+
s.homepage = 'https://github.com/arcboxlabs/linkcode'
8+
s.platforms = {
9+
:ios => '16.4'
10+
}
11+
s.source = { git: 'https://github.com/arcboxlabs/linkcode.git' }
12+
s.static_framework = true
13+
14+
s.dependency 'React-Core'
15+
16+
s.pod_target_xcconfig = {
17+
'DEFINES_MODULE' => 'YES'
18+
}
19+
20+
s.source_files = '**/*.{h,m,mm}'
21+
end
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#import <QuartzCore/QuartzCore.h>
2+
#import <React/RCTViewManager.h>
3+
#import <UIKit/UIKit.h>
4+
5+
@interface LinkCodeGradientMaskView : UIView
6+
@end
7+
8+
@implementation LinkCodeGradientMaskView
9+
10+
+ (Class)layerClass
11+
{
12+
return CAGradientLayer.class;
13+
}
14+
15+
- (instancetype)initWithFrame:(CGRect)frame
16+
{
17+
self = [super initWithFrame:frame];
18+
if (self) {
19+
CAGradientLayer *gradient = (CAGradientLayer *)self.layer;
20+
gradient.colors = @[
21+
(id)UIColor.whiteColor.CGColor,
22+
(id)UIColor.whiteColor.CGColor,
23+
(id)UIColor.clearColor.CGColor
24+
];
25+
gradient.locations = @[ @0, @0.82, @1 ];
26+
gradient.startPoint = CGPointMake(0.5, 0);
27+
gradient.endPoint = CGPointMake(0.5, 1);
28+
self.userInteractionEnabled = NO;
29+
}
30+
return self;
31+
}
32+
33+
@end
34+
35+
@interface LinkCodeNavigationBarBackdropView : UIView
36+
@property(nonatomic, strong) UIVisualEffectView *blurView;
37+
@property(nonatomic, strong) LinkCodeGradientMaskView *gradientMask;
38+
@property(nonatomic, assign) CGSize maskSize;
39+
@end
40+
41+
@implementation LinkCodeNavigationBarBackdropView
42+
43+
- (instancetype)initWithFrame:(CGRect)frame
44+
{
45+
self = [super initWithFrame:frame];
46+
if (self) {
47+
UIBlurEffect *effect =
48+
[UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemChromeMaterial];
49+
_blurView = [[UIVisualEffectView alloc] initWithEffect:effect];
50+
_gradientMask = [[LinkCodeGradientMaskView alloc] initWithFrame:CGRectZero];
51+
_maskSize = CGSizeZero;
52+
53+
self.userInteractionEnabled = NO;
54+
_blurView.userInteractionEnabled = NO;
55+
_blurView.maskView = _gradientMask;
56+
[self addSubview:_blurView];
57+
}
58+
return self;
59+
}
60+
61+
- (void)layoutSubviews
62+
{
63+
[super layoutSubviews];
64+
65+
self.blurView.frame = self.bounds;
66+
if (CGSizeEqualToSize(self.maskSize, self.bounds.size)) {
67+
return;
68+
}
69+
70+
self.maskSize = self.bounds.size;
71+
self.gradientMask.frame = self.blurView.bounds;
72+
self.blurView.maskView = nil;
73+
self.blurView.maskView = self.gradientMask;
74+
}
75+
76+
@end
77+
78+
@interface LinkCodeNavigationBarBackdropViewManager : RCTViewManager
79+
@end
80+
81+
@implementation LinkCodeNavigationBarBackdropViewManager
82+
83+
RCT_EXPORT_MODULE(LinkCodeNavigationBarBackdrop)
84+
85+
+ (BOOL)requiresMainQueueSetup
86+
{
87+
return YES;
88+
}
89+
90+
- (UIView *)view
91+
{
92+
return [[LinkCodeNavigationBarBackdropView alloc] initWithFrame:CGRectZero];
93+
}
94+
95+
@end

apps/mobile/scripts/smoke-native-entry-export.cjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ const platforms = ['android', 'ios'];
1111
const requiredRouteModules = [
1212
'/apps/mobile/src/app/_layout.tsx',
1313
'/apps/mobile/src/app/index.tsx',
14-
'/apps/mobile/src/app/host/[hostId]/index.tsx',
15-
'/apps/mobile/src/app/host/[hostId]/terminal/index.tsx',
14+
'/apps/mobile/src/app/(tabs)/threads/index.tsx',
15+
'/apps/mobile/src/app/(tabs)/terminals/index.tsx',
1616
];
1717

1818
async function runExpoExport(platform, outputDirectory) {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { NativeTabs } from 'expo-router/unstable-native-tabs';
2+
import { useTranslations } from 'use-intl';
3+
4+
/** The app's three top-level surfaces. `NativeTabs` is a real `UITabBarController`, so the iOS 26
5+
* floating tab bar and its scroll-minimize behaviour come from UIKit rather than being drawn here.
6+
*
7+
* The tabs sit at the root and the host is a selection, not a parent route — switching hosts is a
8+
* store write that leaves the tab you are standing in alone. Detail screens (a thread, a terminal)
9+
* live outside this layout: react-native-screens exposes `tabBarHidden` only on the host, not per
10+
* pushed screen, so pushing them from the root stack is the only way to keep the bar off a
11+
* composer or a terminal canvas. */
12+
export default function TabsLayout(): React.ReactNode {
13+
const tThreads = useTranslations('mobile.sessions');
14+
const tTerminals = useTranslations('mobile.terminals');
15+
const tSettings = useTranslations('mobile.settings');
16+
17+
return (
18+
<NativeTabs>
19+
<NativeTabs.Trigger name="threads">
20+
<NativeTabs.Trigger.Icon sf="bubble.left.and.text.bubble.right" />
21+
<NativeTabs.Trigger.Label>{tThreads('title')}</NativeTabs.Trigger.Label>
22+
</NativeTabs.Trigger>
23+
<NativeTabs.Trigger name="terminals">
24+
<NativeTabs.Trigger.Icon sf="apple.terminal" />
25+
<NativeTabs.Trigger.Label>{tTerminals('title')}</NativeTabs.Trigger.Label>
26+
</NativeTabs.Trigger>
27+
<NativeTabs.Trigger name="settings">
28+
<NativeTabs.Trigger.Icon sf="gearshape" />
29+
<NativeTabs.Trigger.Label>{tSettings('title')}</NativeTabs.Trigger.Label>
30+
</NativeTabs.Trigger>
31+
</NativeTabs>
32+
);
33+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useStackScreenOptions } from '@mobile/components/shell/use-stack-screen-options';
2+
import { Stack } from 'expo-router';
3+
4+
/** Deliberately ungated: this tab owns "Manage hosts", so it has to survive the host it is
5+
* hosted under being unreachable — otherwise a bad host address is unrecoverable from the app. */
6+
export default function SettingsTabLayout(): React.ReactNode {
7+
const screenOptions = useStackScreenOptions({ softHeaderEdge: true });
8+
9+
return <Stack screenOptions={screenOptions} />;
10+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { SettingsScreen } from '@mobile/components/settings/settings-screen';
2+
3+
/** Tab mount: ungated, so a host that cannot be reached still leaves "Manage hosts" in reach. */
4+
export default function SettingsTabRoute(): React.ReactNode {
5+
return <SettingsScreen />;
6+
}

0 commit comments

Comments
 (0)