Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
171 changes: 43 additions & 128 deletions apps/mobile/app/(app)/[workspace]/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,149 +1,64 @@
/**
* Bottom tab bar — JS `<Tabs>` from expo-router (react-navigation under the
* hood). We tried NativeTabs first but its `canPreventDefault: false`
* constraint makes "tap More → open something" impossible. JS Tabs
* supports `listeners.tabPress + e.preventDefault()`, the canonical RN
* pattern for tab-as-action.
* Bottom tab bar — the native iOS UITabBar via expo-router NativeTabs (over
* react-native-screens). This is the real platform tab bar: iOS 26 liquid
* glass, native blur, the system selection spring + haptic, and SF Symbol
* icons that fill on selection. The previous JS `<Tabs>` (react-navigation)
* couldn't render any of that.
*
* The "More" tab is **not a navigation target** — its press opens a
* DropdownMenu popover anchored above the tab. The popover is rendered
* by `<MoreTabDropdownAnchor />` as a sibling of `<Tabs>`, NOT as a
* `tabBarButton` replacement: keeping the real tab button intact means
* the icon + "More" label render identically to the other three tabs.
* We just open the dropdown imperatively from `listeners.tabPress` via
* the exposed `TriggerRef.open()`.
*
* The stub (tabs)/more.tsx file still exists only because expo-router
* requires every Tabs.Screen to have a backing route file — the press
* is preventDefault'd so we never actually navigate to it.
*
* Active / inactive tint colors are derived from the current colour
* scheme via THEME so dark mode picks contrasting values automatically.
* "More" is now a real tab → a pushed More screen (more.tsx), replacing the
* old JS dropdown-popover hack. That hack only existed because NativeTabs
* can't `preventDefault` a tab press; with the workspace switcher moved into
* the header, the dropdown's contents are a natural fit for a More list.
*/
import { useRef } from "react";
import { Tabs } from "expo-router";
import { Image } from "expo-image";
import { View } from "react-native";
import type { TriggerRef } from "@rn-primitives/dropdown-menu";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import {
useInboxUnreadCount,
useChatUnreadSessionCount,
} from "@/lib/unread-counts";
import { MoreTabDropdownAnchor } from "@/components/nav/more-tab-dropdown";

// Only override backgroundColor — @react-navigation/elements Badge internally
// sets borderRadius = size/2, height = size, minWidth = size, so a single
// character renders as a perfect circle. Overriding minWidth/fontSize here
// breaks that geometry. Text color is auto-derived from backgroundColor
// luminance by Badge itself (white on brand blue).
const BADGE_STYLE = {
backgroundColor: THEME.light.brand,
};

export default function TabsLayout() {
const { colorScheme } = useColorScheme();
const t = THEME[colorScheme];

const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const inboxUnread = useInboxUnreadCount(wsId);
const chatUnread = useChatUnreadSessionCount(wsId);

// Truncation aligned with web: inbox 99+, chat 9+ (matches sidebar +
// ChatFab respectively). `undefined` makes React Navigation hide the
// badge, so zero-count is a free no-op.
const inboxBadge =
inboxUnread > 0 ? (inboxUnread > 99 ? "99+" : String(inboxUnread)) : undefined;
const chatBadge =
chatUnread > 0 ? (chatUnread > 9 ? "9+" : String(chatUnread)) : undefined;

// Imperative handle into the More tab's dropdown — listeners.tabPress
// calls .open(); the @rn-primitives Trigger measures itself inside
// open() so the popover anchors to MoreTabDropdownAnchor's rect.
const moreTriggerRef = useRef<TriggerRef>(null);

return (
<View style={{ flex: 1 }}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: t.foreground,
tabBarInactiveTintColor: t.mutedForeground,
tabBarStyle: { backgroundColor: t.background },
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="inbox"
options={{
title: "Inbox",
tabBarBadge: inboxBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:tray.fill" : "sf:tray"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="my-issues"
options={{
title: "My Issues",
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:checklist" : "sf:checklist.unchecked"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
<NativeTabs>
<NativeTabs.Trigger name="inbox">
<NativeTabs.Trigger.Icon
sf={{ default: "tray", selected: "tray.fill" }}
/>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarBadge: chatBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:bubble.left.fill" : "sf:bubble.left"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
<NativeTabs.Trigger.Label>Inbox</NativeTabs.Trigger.Label>
{inboxUnread > 0 ? (
<NativeTabs.Trigger.Badge>
{inboxUnread > 99 ? "99+" : String(inboxUnread)}
</NativeTabs.Trigger.Badge>
) : null}
</NativeTabs.Trigger>

<NativeTabs.Trigger name="my-issues">
<NativeTabs.Trigger.Icon
sf={{ default: "checklist.unchecked", selected: "checklist" }}
/>
<Tabs.Screen
name="more"
options={{
title: "More",
tabBarIcon: ({ color, size }) => (
<Image
source="sf:ellipsis"
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
listeners={() => ({
tabPress: (e) => {
// Don't navigate to the (stub) /more screen — open the
// dropdown popover instead. The trigger is invisible and
// mounted in MoreTabDropdownAnchor below; ref.open() also
// measures its rect so the popover anchors correctly.
e.preventDefault();
moreTriggerRef.current?.open();
},
})}
<NativeTabs.Trigger.Label>My Issues</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>

<NativeTabs.Trigger name="chat">
<NativeTabs.Trigger.Icon
sf={{ default: "bubble.left", selected: "bubble.left.fill" }}
/>
</Tabs>
<NativeTabs.Trigger.Label>Chat</NativeTabs.Trigger.Label>
{chatUnread > 0 ? (
<NativeTabs.Trigger.Badge>
{chatUnread > 9 ? "9+" : String(chatUnread)}
</NativeTabs.Trigger.Badge>
) : null}
</NativeTabs.Trigger>

<MoreTabDropdownAnchor triggerRef={moreTriggerRef} />
</View>
<NativeTabs.Trigger name="more">
<NativeTabs.Trigger.Icon sf="ellipsis" />
<NativeTabs.Trigger.Label>More</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}
2 changes: 2 additions & 0 deletions apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { canAssignAgent } from "@/lib/can-assign-agent";
import { useWorkspaceAgentAvailability } from "@/lib/workspace-agent-availability";
import { useAgentPresence } from "@/lib/use-agent-presence";
import { Header } from "@/components/ui/header";
import { WorkspaceSwitcherButton } from "@/components/workspace/workspace-switcher-button";
import { ChatTitleButton } from "@/components/chat/chat-title-button";
import { ChatSessionActions } from "@/components/chat/chat-session-actions";
import { ChatMessageList } from "@/components/chat/chat-message-list";
Expand Down Expand Up @@ -365,6 +366,7 @@ export default function ChatTab() {
return (
<View className="flex-1 bg-background">
<Header
left={<WorkspaceSwitcherButton />}
center={
<ChatTitleButton
currentSession={activeSession}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/app/(app)/[workspace]/(tabs)/inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Header } from "@/components/ui/header";
import { IconButton } from "@/components/ui/icon-button";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { WorkspaceSwitcherButton } from "@/components/workspace/workspace-switcher-button";
import { SwipeableInboxRow } from "@/components/inbox/swipeable-inbox-row";
import { inboxListOptions } from "@/data/queries/inbox";
import {
Expand Down Expand Up @@ -116,6 +117,7 @@ export default function Inbox() {
<View className="flex-1 bg-background">
<Header
title="Inbox"
left={<WorkspaceSwitcherButton />}
right={
<>
<IconButton
Expand Down
121 changes: 112 additions & 9 deletions apps/mobile/app/(app)/[workspace]/(tabs)/more.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,119 @@
/**
* Stub route. The "More" tab in (tabs)/_layout.tsx intercepts tabPress and
* pushes /[workspace]/menu (formSheet route) instead of navigating here,
* so this screen is never rendered through normal use. expo-router still
* requires a file to exist at this path to register the Tabs.Screen entry.
* More tab — a real pushed screen, replacing the old dropdown-popover hack
* (which only existed to work around the JS tab bar). Now that the tab bar is
* native, "More" is a normal tab destination: an iOS-style list with the
* account (→ settings) and the secondary nav (Pinned / Issues / Projects).
*
* If a deep link or stale tab state somehow lands the user here, bounce
* to inbox so they don't see a blank screen.
* Workspace context lives in the header (WorkspaceSwitcherButton, from the
* workspace-switcher branch this is stacked on), so it's deliberately not
* duplicated here.
*/
import { Redirect } from "expo-router";
import { type ReactNode } from "react";
import { Image, Pressable, ScrollView, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
import { router } from "expo-router";
import { Text } from "@/components/ui/text";
import { Header } from "@/components/ui/header";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";

export default function MoreStub() {
const NAV_ITEMS = [
{ label: "Pinned", icon: "pin", path: "/more/pins" },
{ label: "Issues", icon: "list.bullet", path: "/more/issues" },
{ label: "Projects", icon: "square.stack", path: "/more/projects" },
] as const;

export default function MoreTab() {
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
return <Redirect href={slug ? `/${slug}/inbox` : "/select-workspace"} />;
const user = useAuthStore((s) => s.user);
const { colorScheme } = useColorScheme();
const t = THEME[colorScheme];

const go = (path: string) => {
if (slug) router.push(`/${slug}${path}`);
};
const initial = (user?.name ?? user?.email ?? "U").charAt(0).toUpperCase();

return (
<View className="flex-1 bg-background">
<Header title="More" />
<ScrollView contentContainerClassName="py-3">
{/* Account → settings */}
<Row onPress={() => go("/more/settings")} chevronTint={t.mutedForeground}>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
className="size-9 rounded-full bg-muted"
/>
) : (
<View className="size-9 rounded-full bg-muted items-center justify-center">
<Text className="text-sm font-medium text-muted-foreground">
{initial}
</Text>
</View>
)}
<View className="flex-1 min-w-0">
<Text
className="text-base font-medium text-foreground"
numberOfLines={1}
>
{user?.name ?? "—"}
</Text>
{user?.email ? (
<Text className="text-xs text-muted-foreground" numberOfLines={1}>
{user.email}
</Text>
) : null}
</View>
</Row>

<View className="h-4" />

{NAV_ITEMS.map((item) => (
<Row
key={item.path}
onPress={() => go(item.path)}
chevronTint={t.mutedForeground}
>
<ExpoImage
source={`sf:${item.icon}`}
tintColor={t.foreground}
style={{ width: 22, height: 22 }}
/>
<Text className="flex-1 text-base text-foreground">{item.label}</Text>
</Row>
))}
</ScrollView>
</View>
);
}

/** iOS list row: leading content + trailing disclosure chevron when tappable. */
function Row({
children,
onPress,
chevronTint,
}: {
children: ReactNode;
onPress?: () => void;
chevronTint: string;
}) {
return (
<Pressable
onPress={onPress}
disabled={!onPress}
className="flex-row items-center gap-3 px-4 h-14 border-b border-border active:bg-secondary"
>
{children}
{onPress ? (
<ExpoImage
source="sf:chevron.right"
tintColor={chevronTint}
style={{ width: 12, height: 12 }}
/>
) : null}
</Pressable>
);
}
7 changes: 6 additions & 1 deletion apps/mobile/app/(app)/[workspace]/(tabs)/my-issues.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Header } from "@/components/ui/header";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { WorkspaceSwitcherButton } from "@/components/workspace/workspace-switcher-button";
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssuesLoading } from "@/components/issue/issues-loading";
Expand Down Expand Up @@ -126,7 +127,11 @@ export default function MyIssues() {

return (
<View className="flex-1 bg-background">
<Header title="My Issues" right={<HeaderActions />} />
<Header
title="My Issues"
left={<WorkspaceSwitcherButton />}
right={<HeaderActions />}
/>
<ScopeToolbar
scopes={SCOPES}
scope={scope}
Expand Down
Loading
Loading