authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 20:27:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 20:27:39-07:00
logc32ade41390be10783246fbbeed45de7e3add79b
tree0d5f8b3391f6e3268efe979ec5952f88859cae1f
parentb9ad3daf8d489604e0eca9d32a7e08af7c4e9fb7
signature Signed by PGP key B5690EEEBB952194

feat(mobile): stock tab bar, glass filter pills, and saved list filters (#18476)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

21 files changed, 326 insertions(+), 144 deletions(-)

apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz deleted
Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz and /dev/null differ
apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz created
Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz differ
apps/mobile/package.json+1-1
......@@ -126,7 +126,7 @@
126126 "react-native-keyboard-controller": "1.22.6",
127127 "react-native-reanimated": "4.7.0",
128128 "react-native-safe-area-context": "~5.9.1",
129 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.25.tgz",
129 "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.26.tgz",
130130 "react-native-shiki-engine": "^0.3.12",
131131 "react-native-svg": "15.15.5",
132132 "react-native-webview": "14.0.1",
apps/mobile/src/features/home/HomeRouteScreen.tsx+10-12
......@@ -127,15 +127,15 @@ export function HomeRouteScreen() {
127127 } = useHomeListOptions(availableEnvironmentIds);
128128 const selectedEnvironmentId = listOptions.selectedEnvironmentId;
129129 const projectScopeKey = listOptions.selectedProjectKey;
130 const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
130 const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
131131 sidebarProjectGroupingMode: listOptions.projectGroupingMode,
132132 sidebarProjectGroupingOverrides: {},
133133 });
134134 useEffect(() => {
135 if (selectedProjectKey !== projectScopeKey) {
136 setSelectedProjectKey(selectedProjectKey);
135 if (resolvedProjectKey !== projectScopeKey) {
136 setSelectedProjectKey(resolvedProjectKey);
137137 }
138 }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);
138 }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]);
139139 const projectFilterOptions = useMemo(
140140 () =>
141141 buildHomeProjectScopes({
......@@ -148,14 +148,12 @@ export function HomeRouteScreen() {
148148 })),
149149 [listOptions.projectGroupingMode, projects, selectedEnvironmentId],
150150 );
151 useEffect(() => {
152 if (
153 selectedProjectKey !== null &&
154 !projectFilterOptions.some((project) => project.key === selectedProjectKey)
155 ) {
156 setSelectedProjectKey(null);
157 }
158 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
151 // A saved project stays selected while its environment is offline or filtered out.
152 const selectedProjectKey = projectFilterOptions.some(
153 (project) => project.key === resolvedProjectKey,
154 )
155 ? resolvedProjectKey
156 : null;
159157
160158 // In split layouts the persistent sidebar IS the thread list — Home becomes
161159 // an empty detail pane so selecting a thread never transitions layouts.
apps/mobile/src/features/home/ThreadListFilters.tsx+2-2
......@@ -24,7 +24,7 @@ export function ThreadListFilters(
2424 <ScrollView
2525 horizontal
2626 showsHorizontalScrollIndicator={false}
27 contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}
27 contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 }}
2828 >
2929 <ListFilterMenu
3030 label={selectedEnvironment?.label ?? "All environments"}
......@@ -85,7 +85,7 @@ export function ThreadListFilters(
8585 <Pressable
8686 accessibilityRole="button"
8787 accessibilityLabel="Reset thread filters"
88 className="h-11 w-8 items-center justify-center"
88 className="h-8 w-8 items-center justify-center"
8989 onPress={() => {
9090 props.onEnvironmentChange(null);
9191 props.onProjectChange(null);
apps/mobile/src/features/home/home-list-options.test.ts+40-5
......@@ -1,8 +1,24 @@
1import { RegistryContext } from "@effect/atom-react";
12import { EnvironmentId } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/reactivity";
24import { act, createElement, useLayoutEffect } from "react";
35import { createRoot, type Root } from "react-dom/client";
46import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
57
8const saved = vi.hoisted(() => [] as unknown[]);
9vi.mock("../../state/preferences", async () => {
10 const { Atom } = await import("effect/reactivity");
11 return {
12 updateMobilePreferencesAtom: Atom.writable(
13 () => null,
14 (_ctx, patch: unknown) => {
15 saved.push(patch);
16 },
17 ),
18 };
19});
20
21import type { ThreadListFilters } from "../../persistence/mobile-preferences";
622import { HomeListOptionsProvider, useHomeListOptions } from "./home-list-options";
723
824describe("home list options across layout changes", () => {
......@@ -19,14 +35,20 @@ describe("home list options across layout changes", () => {
1935 return null;
2036 }
2137
22 async function showLayout(layout: "compact" | "sidebar") {
38 const registry = AtomRegistry.make();
39
40 async function showLayout(layout: "compact" | "sidebar", savedFilters?: ThreadListFilters) {
2341 await act(() => {
2442 root.render(
2543 createElement(
26 HomeListOptionsProvider,
27 { projectGroupingMode: "repository" },
28 // Replace the list while keeping the workspace provider mounted.
29 createElement(ThreadList, { key: layout }),
44 RegistryContext,
45 { value: registry },
46 createElement(
47 HomeListOptionsProvider,
48 { projectGroupingMode: "repository", savedFilters },
49 // Replace the list while keeping the workspace provider mounted.
50 createElement(ThreadList, { key: layout }),
51 ),
3052 ),
3153 );
3254 });
......@@ -51,6 +73,7 @@ describe("home list options across layout changes", () => {
5173 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
5274 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
5375 root = createRoot(container as unknown as HTMLElement);
76 saved.length = 0;
5477 });
5578
5679 afterEach(async () => {
......@@ -92,4 +115,16 @@ describe("home list options across layout changes", () => {
92115 }
93116 },
94117 );
118
119 it("restores saved filters on launch and saves only real changes", async () => {
120 await showLayout("compact", { environmentId, projectKey: "repository:one" });
121 expect(latest.options).toMatchObject({
122 selectedEnvironmentId: environmentId,
123 selectedProjectKey: "repository:one",
124 });
125 expect(saved).toEqual([]);
126
127 await act(() => latest.setSelectedProjectKey(null));
128 expect(saved).toEqual([{ threadListFilters: { environmentId, projectKey: null } }]);
129 });
95130});
apps/mobile/src/features/home/home-list-options.ts+27-5
......@@ -1,17 +1,22 @@
11import type { EnvironmentId, SidebarProjectGroupingMode } from "@t3tools/contracts";
22import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER } from "@t3tools/contracts";
3import { useAtomSet } from "@effect/atom-react";
34import {
45 createContext,
56 createElement,
67 useCallback,
78 useContext,
9 useEffect,
810 useMemo,
11 useRef,
912 useState,
1013 type PropsWithChildren,
1114 type Dispatch,
1215 type SetStateAction,
1316} from "react";
1417
18import type { ThreadListFilters } from "../../persistence/mobile-preferences";
19import { updateMobilePreferencesAtom } from "../../state/preferences";
1520import type { HomeProjectSortOrder } from "./homeThreadList";
1621
1722export interface HomeListOptions {
......@@ -24,10 +29,10 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
2429 readonly projectGroupingMode: SidebarProjectGroupingMode;
2530}
2631
27function defaultHomeListOptions(): HomeListOptions {
32function defaultHomeListOptions(filters?: ThreadListFilters): HomeListOptions {
2833 return {
29 selectedEnvironmentId: null,
30 selectedProjectKey: null,
34 selectedEnvironmentId: filters?.environmentId ?? null,
35 selectedProjectKey: filters?.projectKey ?? null,
3136 projectSortOrder:
3237 DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual"
3338 ? "updated_at"
......@@ -43,14 +48,31 @@ interface HomeListOptionsContextValue {
4348
4449const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>(null);
4550
46/** Keeps list preferences stable while the app moves between compact and split shells. */
51/**
52 * Keeps list preferences stable while the app moves between compact and split shells.
53 * Filters start from the saved preference, and changes are saved for the next launch.
54 */
4755export function HomeListOptionsProvider({
4856 children,
4957 projectGroupingMode,
58 savedFilters,
5059}: PropsWithChildren<{
5160 readonly projectGroupingMode: SidebarProjectGroupingMode;
61 readonly savedFilters?: ThreadListFilters | undefined;
5262}>) {
53 const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
63 const [options, setOptions] = useState(() => defaultHomeListOptions(savedFilters));
64 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
65 const saved = useRef(savedFilters);
66 const { selectedEnvironmentId: environmentId, selectedProjectKey: projectKey } = options;
67 useEffect(() => {
68 if (
69 (saved.current?.environmentId ?? null) === environmentId &&
70 (saved.current?.projectKey ?? null) === projectKey
71 )
72 return;
73 saved.current = { environmentId, projectKey };
74 savePreferences({ threadListFilters: saved.current });
75 }, [environmentId, projectKey, savePreferences]);
5476 const value = useMemo(
5577 () => ({ options, setOptions, projectGroupingMode }),
5678 [options, projectGroupingMode],
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+11-2
......@@ -46,6 +46,7 @@ import {
4646 resolveThreadSelectionOverlayState,
4747} from "../../lib/adaptive-navigation";
4848import { scopedThreadKey } from "../../lib/scopedEntities";
49import type { ThreadListFilters } from "../../persistence/mobile-preferences";
4950import { mobilePreferencesAtom } from "../../state/preferences";
5051import {
5152 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
......@@ -234,6 +235,7 @@ export function AdaptiveWorkspaceLayout(props: {
234235 <AdaptiveWorkspaceLayoutContent
235236 {...props}
236237 projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}
238 savedThreadListFilters={preferencesResult.value.threadListFilters}
237239 />
238240 );
239241}
......@@ -245,6 +247,7 @@ function AdaptiveWorkspaceLayoutContent(
245247 readonly workspaceRouteKey: string | undefined;
246248 } & {
247249 readonly projectGroupingMode: SidebarProjectGroupingMode;
250 readonly savedThreadListFilters?: ThreadListFilters | undefined;
248251 },
249252) {
250253 const projectGroupingMode = props.projectGroupingMode;
......@@ -680,7 +683,10 @@ function AdaptiveWorkspaceLayoutContent(
680683
681684 if (nativeWorkspace) {
682685 return (
683 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
686 <HomeListOptionsProvider
687 projectGroupingMode={projectGroupingMode}
688 savedFilters={props.savedThreadListFilters}
689 >
684690 <AdaptiveWorkspaceContext value={contextValue}>
685691 <NativeWorkspaceInspectorContext
686692 value={{ render: workspaceInspector?.render, visible: inspectorColumnTargetWidth > 0 }}
......@@ -692,7 +698,10 @@ function AdaptiveWorkspaceLayoutContent(
692698 );
693699 }
694700 return (
695 <HomeListOptionsProvider projectGroupingMode={projectGroupingMode}>
701 <HomeListOptionsProvider
702 projectGroupingMode={projectGroupingMode}
703 savedFilters={props.savedThreadListFilters}
704 >
696705 <AdaptiveWorkspaceContext.Provider value={contextValue}>
697706 <View
698707 testID="adaptive-workspace-layout"
apps/mobile/src/features/pull-requests/PullRequestListFilters.tsx+1-1
......@@ -196,7 +196,7 @@ export function PullRequestListFilters(props: {
196196 <ScrollView
197197 horizontal
198198 showsHorizontalScrollIndicator={false}
199 contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}
199 contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 2, paddingBottom: 10, gap: 8 }}
200200 >
201201 <ListFilterMenu
202202 label={count ? String(count) : "Filters"}
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+8-8
......@@ -2,7 +2,7 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react-native";
22import { useNavigation } from "@react-navigation/native";
33import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
44import { ProjectId } from "@t3tools/contracts";
5import { useAtomSet, useAtomValue } from "@effect/atom-react";
5import { useAtomValue } from "@effect/atom-react";
66import {
77 ActivityIndicator,
88 Keyboard,
......@@ -24,7 +24,8 @@ import { useProjects } from "../../state/entities";
2424import { environmentServerConfigsAtom } from "../../state/server";
2525import { PullRequestListRow } from "./PullRequestListRow";
2626import { PullRequestListFilters } from "./PullRequestListFilters";
27import { pullRequestListControlsAtom, usePullRequestList } from "./usePullRequestList";
27import { usePullRequestList } from "./usePullRequestList";
28import { usePullRequestListControls } from "./usePullRequestListControls";
2829import { usePullRequestListActions } from "./usePullRequestListActions";
2930import {
3031 filterPullRequestList,
......@@ -74,7 +75,8 @@ export function PullRequestsScreen() {
7475 const debouncedQuery = useDebouncedValue(tab.query.trim(), 180);
7576 // Dismissal must restore the cached unfiltered list before restoring its offset.
7677 const query = tab.searchMode === "resting" ? "" : debouncedQuery;
77 const selectedEnvironmentId = workspace.pullRequestEnvironmentId;
78 const listControls = usePullRequestListControls();
79 const selectedEnvironmentId = listControls.environmentId;
7880 const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
7981 ? selectedEnvironmentId
8082 : null;
......@@ -95,9 +97,7 @@ export function PullRequestsScreen() {
9597 },
9698 [navigation, selectAndroidWorkspace],
9799 );
98 const controlsAtom = pullRequestListControlsAtom(environmentId ?? "");
99 const controls = useAtomValue(controlsAtom);
100 const setControls = useAtomSet(controlsAtom);
100 const controls = listControls.controlsFor(environmentId);
101101 const targets = useMemo(
102102 () =>
103103 environments
......@@ -269,10 +269,10 @@ export function PullRequestsScreen() {
269269 label: item.environmentLabel,
270270 }))}
271271 environmentId={environmentId}
272 onEnvironmentChange={workspace.setPullRequestEnvironmentId}
272 onEnvironmentChange={listControls.setEnvironmentId}
273273 value={controls}
274274 onChange={(next) => {
275 setControls(next);
275 listControls.setControls(environmentId, next);
276276 void listRef.current?.scrollToOffset({
277277 offset: Platform.OS === "ios" ? -insets.top : 0,
278278 animated: false,
apps/mobile/src/features/pull-requests/pullRequestList.ts+2-11
......@@ -1,22 +1,13 @@
11import type {
22 EnvironmentId,
33 PullRequestListEntry,
4 PullRequestListFilters,
54 PullRequestListInput,
65 PullRequestListResult,
7 PullRequestInvolvement,
8 PullRequestListState,
96} from "@t3tools/contracts";
7import type { PullRequestListControls } from "../../persistence/mobile-preferences";
108
119export const PR_LIST_PAGE_SIZE = 30;
12export interface PullRequestListControls {
13 readonly state: PullRequestListState;
14 readonly involvement: PullRequestInvolvement;
15 readonly filters: PullRequestListFilters;
16 readonly projectId?: string;
17 readonly projectEnvironmentId?: string;
18 readonly sort: "updated" | "created" | "oldest";
19}
10export type { PullRequestListControls } from "../../persistence/mobile-preferences";
2011export const DEFAULT_PR_LIST_CONTROLS: PullRequestListControls = {
2112 state: "open",
2213 involvement: "all",
apps/mobile/src/features/pull-requests/usePullRequestList.ts-5
......@@ -8,7 +8,6 @@ import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect
88import { composerPullRequests } from "../../state/pull-requests";
99import {
1010 appendPullRequestListPage,
11 DEFAULT_PR_LIST_CONTROLS,
1211 mergeEnvironmentPullRequestPages,
1312 nextPullRequestListInput,
1413 pullRequestListHead,
......@@ -28,10 +27,6 @@ const statsFamily = Atom.family((_environmentId: string) =>
2827const statKey = (row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">) =>
2928 `${row.projectId}:${row.repository}:${row.number}`;
3029
31export const pullRequestListControlsAtom = Atom.family((_environmentId: string) =>
32 Atom.make(DEFAULT_PR_LIST_CONTROLS),
33);
34
3530type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput };
3631const sourcesFamily = Atom.family((key: string) => {
3732 const targets: readonly ListTarget[] = JSON.parse(key);
apps/mobile/src/features/pull-requests/usePullRequestListControls.ts created+48
......@@ -0,0 +1,48 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/reactivity";
3import { useCallback } from "react";
4
5import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
6import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
7
8/** The pull request list's environment and its filters, restored on the next launch. */
9export function usePullRequestListControls() {
10 const preferences = useAtomValue(mobilePreferencesAtom);
11 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
12 const saved = AsyncResult.isSuccess(preferences)
13 ? preferences.value.pullRequestListFilters
14 : undefined;
15 const environmentId = saved?.environmentId ?? null;
16 const controlsFor = useCallback(
17 (environment: string | null) => saved?.controls[environment ?? ""] ?? DEFAULT_PR_LIST_CONTROLS,
18 [saved],
19 );
20 const setEnvironmentId = useCallback(
21 (next: string | null) =>
22 savePreferences({
23 transform: (current) => ({
24 pullRequestListFilters: {
25 controls: current.pullRequestListFilters?.controls ?? {},
26 environmentId: next,
27 },
28 }),
29 }),
30 [savePreferences],
31 );
32 const setControls = useCallback(
33 (environment: string | null, controls: PullRequestListControls) =>
34 savePreferences({
35 transform: (current) => ({
36 pullRequestListFilters: {
37 environmentId: current.pullRequestListFilters?.environmentId ?? null,
38 controls: {
39 ...current.pullRequestListFilters?.controls,
40 [environment ?? ""]: controls,
41 },
42 },
43 }),
44 }),
45 [savePreferences],
46 );
47 return { environmentId, controlsFor, setEnvironmentId, setControls } as const;
48}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+8-17
......@@ -208,15 +208,15 @@ function ThreadNavigationSidebarPane(
208208 [threadSearch.matches],
209209 );
210210 const projectScopeKey = options.selectedProjectKey;
211 const selectedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
211 const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
212212 sidebarProjectGroupingMode: options.projectGroupingMode,
213213 sidebarProjectGroupingOverrides: {},
214214 });
215215 useEffect(() => {
216 if (selectedProjectKey !== projectScopeKey) {
217 setSelectedProjectKey(selectedProjectKey);
216 if (resolvedProjectKey !== projectScopeKey) {
217 setSelectedProjectKey(resolvedProjectKey);
218218 }
219 }, [projectScopeKey, selectedProjectKey, setSelectedProjectKey]);
219 }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]);
220220 const projectScopes = useMemo(
221221 () =>
222222 buildHomeProjectScopes({
......@@ -250,20 +250,11 @@ function ThreadNavigationSidebarPane(
250250 [projectScopes],
251251 );
252252 const selectedProjectScope = useMemo(
253 () =>
254 selectedProjectKey === null
255 ? null
256 : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null),
257 [projectScopes, selectedProjectKey],
253 () => projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null,
254 [projectScopes, resolvedProjectKey],
258255 );
259 useEffect(() => {
260 if (
261 selectedProjectKey !== null &&
262 !projectFilterOptions.some((project) => project.key === selectedProjectKey)
263 ) {
264 setSelectedProjectKey(null);
265 }
266 }, [projectFilterOptions, selectedProjectKey, setSelectedProjectKey]);
256 // A saved project stays selected while its environment is offline or filtered out.
257 const selectedProjectKey = selectedProjectScope?.key ?? null;
267258 const selectedProjectRefs = useMemo(
268259 () =>
269260 selectedProjectScope === null
apps/mobile/src/features/workspace/ListFilterMenu.tsx+51-30
......@@ -1,9 +1,20 @@
1import { GlassView } from "expo-glass-effect";
12import { Pressable, View } from "react-native";
3import { withUniwind } from "uniwind";
24import { AppText as Text } from "../../components/AppText";
35import { SymbolView } from "../../components/AppSymbol";
46import { ControlPillMenu } from "../../components/ControlPillMenu";
57import type { ControlPillMenuProps } from "../../components/ControlPillMenu.types";
68import { cn } from "../../lib/cn";
9import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
10import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
11
12const FilterGlass = withUniwind(GlassView, {
13 style: { fromClassName: "className" },
14 tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" },
15});
16
17const PILL = "h-8 flex-row items-center gap-1.5 rounded-full px-3";
718
819/** A visible filter with a native menu containing only that filter's choices. */
920export function ListFilterMenu(props: {
......@@ -14,6 +25,34 @@ export function ListFilterMenu(props: {
1425 readonly actions: ControlPillMenuProps["actions"];
1526 readonly onSelect: (id: string) => void;
1627}) {
28 const { themeAppearance } = useAppearancePreferences();
29 const content = (
30 <>
31 {props.icon ? (
32 <SymbolView
33 name="line.3.horizontal.decrease"
34 size={15}
35 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
36 />
37 ) : null}
38 <Text
39 numberOfLines={1}
40 className={cn(
41 "max-w-[180px] text-sm font-t3-medium",
42 props.active ? "text-primary-foreground" : "text-foreground-muted",
43 )}
44 >
45 {props.label}
46 </Text>
47 {!props.icon ? (
48 <SymbolView
49 name="chevron.down"
50 size={11}
51 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
52 />
53 ) : null}
54 </>
55 );
1756 return (
1857 <ControlPillMenu
1958 title={props.accessibilityLabel}
......@@ -23,38 +62,20 @@ export function ListFilterMenu(props: {
2362 <Pressable
2463 accessibilityRole="button"
2564 accessibilityLabel={props.accessibilityLabel}
26 className="h-11 justify-center"
65 hitSlop={6}
2766 >
28 <View
29 className={cn(
30 "h-8 flex-row items-center gap-1.5 rounded-full px-3",
31 props.active ? "bg-primary" : "bg-subtle",
32 )}
33 >
34 {props.icon ? (
35 <SymbolView
36 name="line.3.horizontal.decrease"
37 size={15}
38 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
39 />
40 ) : null}
41 <Text
42 numberOfLines={1}
43 className={cn(
44 "max-w-[180px] text-xs font-t3-medium",
45 props.active ? "text-primary-foreground" : "text-foreground-muted",
46 )}
67 {NATIVE_LIQUID_GLASS_SUPPORTED ? (
68 <FilterGlass
69 isInteractive
70 colorScheme={themeAppearance}
71 tintColorClassName={props.active ? "accent-primary" : undefined}
72 className={PILL}
4773 >
48 {props.label}
49 </Text>
50 {!props.icon ? (
51 <SymbolView
52 name="chevron.down"
53 size={10}
54 tintColorClassName={props.active ? "accent-primary-foreground" : "accent-icon-muted"}
55 />
56 ) : null}
57 </View>
74 {content}
75 </FilterGlass>
76 ) : (
77 <View className={cn(PILL, props.active ? "bg-primary" : "bg-subtle")}>{content}</View>
78 )}
5879 </Pressable>
5980 </ControlPillMenu>
6081 );
apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx+34-10
......@@ -1,7 +1,12 @@
11import { useNativeLayoutMetrics } from "../../native/native-layout-metrics";
22import { useCallback, useMemo, useState, type ReactNode } from "react";
33import { Keyboard, Platform, View, type NativeSyntheticEvent } from "react-native";
4import { Tabs, type TabSelectedEvent, type TabSelectionRejectedEvent } from "react-native-screens";
4import {
5 Tabs,
6 type TabSelectedEvent,
7 type TabSelectionPreventedEvent,
8 type TabSelectionRejectedEvent,
9} from "react-native-screens";
510
611import { WorkspaceTabsContext, useWorkspaceTabs } from "./WorkspaceTabsContext";
712import type { WorkspaceTab } from "./workspace-tabs-state";
......@@ -10,6 +15,7 @@ import { useSystemWorkspaceTabBar } from "./useSystemWorkspaceTabBar";
1015
1116// Tabler's git-pull-request geometry, rasterized at 1x/2x/3x for UIKit template images.
1217const PULL_REQUEST_ICON = require("../../../assets/icons/pull-request.png");
18const COMPOSE_TAB = "compose";
1319
1420/** Retain each native controller tree; removing its root can leave RNS's stack empty. */
1521function RetainedWorkspaceTab(props: {
......@@ -39,7 +45,11 @@ function RetainedWorkspaceTab(props: {
3945 ) : null;
4046}
4147
42/** UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class. */
48/**
49 * UIKit retains both controller trees and displays the system tab bar or sidebar for the current size class.
50 * On phones, compose is UIKit's own search-role tab, detached like Photos' search button; it never
51 * becomes selected and opens a new chat instead.
52 */
4353export function WorkspaceNativeTabs(props: {
4454 readonly children: ReactNode;
4555 readonly hidden: boolean;
......@@ -62,13 +72,11 @@ export function WorkspaceNativeTabs(props: {
6272 <Tabs.Host
6373 navStateRequest={request}
6474 tabBarHidden={props.hidden || !systemTabBar}
65 ios={{
66 tabBarControllerMode: "tabBar",
67 tabBarTrailingActionSystemImage: phoneBar ? "square.and.pencil" : undefined,
68 tabBarTrailingActionAccessibilityLabel: "New chat",
69 onTabBarTrailingActionPress: props.onNewTask,
70 tabBarMinimizeBehavior: "never",
71 tabBarLeadingWidth: phoneBar ? 220 : 0,
75 ios={{ tabBarControllerMode: "tabBar", tabBarMinimizeBehavior: "never" }}
76 onTabSelectionPrevented={({
77 nativeEvent,
78 }: NativeSyntheticEvent<TabSelectionPreventedEvent>) => {
79 if (nativeEvent.preventedScreenKey === COMPOSE_TAB) props.onNewTask();
7280 }}
7381 onTabSelectionRejected={({
7482 nativeEvent,
......@@ -89,7 +97,7 @@ export function WorkspaceNativeTabs(props: {
8997 <Tabs.Screen
9098 key={tab}
9199 screenKey={tab}
92 title=""
100 title={tab === "threads" ? "Threads" : "Pulls"}
93101 tabBarItemAccessibilityLabel={tab === "threads" ? "Threads" : "Pull requests"}
94102 ios={{
95103 icon:
......@@ -106,6 +114,22 @@ export function WorkspaceNativeTabs(props: {
106114 </RetainedWorkspaceTab>
107115 </Tabs.Screen>
108116 ))}
117 {phoneBar ? (
118 <Tabs.Screen
119 key={COMPOSE_TAB}
120 screenKey={COMPOSE_TAB}
121 title="New chat"
122 preventNativeSelection
123 ios={{
124 role: "search",
125 // iOS 27 detaches a search tab only while it may activate search.
126 automaticallyActivatesSearch: true,
127 icon: { type: "sfSymbol", name: "square.and.pencil" },
128 }}
129 >
130 <View />
131 </Tabs.Screen>
132 ) : null}
109133 </Tabs.Host>
110134 </View>
111135 );
apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx+1-21
......@@ -6,7 +6,6 @@ import {
66 useMemo,
77 useReducer,
88 useRef,
9 useState,
109 type Dispatch,
1110 type ReactNode,
1211} from "react";
......@@ -32,10 +31,6 @@ export const WorkspaceTabsContext = createContext<{
3231 readonly recordScrollOffset: (tab: WorkspaceTab, offset: number) => void;
3332 readonly getScrollAnchor: (tab: WorkspaceTab) => WorkspaceScrollAnchor | undefined;
3433 readonly recordScrollAnchor: (tab: WorkspaceTab, anchor: WorkspaceScrollAnchor) => void;
35 readonly threadsProjectKey: string | null;
36 readonly setThreadsProjectKey: (key: string | null) => void;
37 readonly pullRequestEnvironmentId: string | null;
38 readonly setPullRequestEnvironmentId: (id: string | null) => void;
3934} | null>(null);
4035
4136export function useWorkspaceTabs() {
......@@ -50,8 +45,6 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) {
5045 activeTab.current = state.selectedTab;
5146 }, [state.selectedTab]);
5247 const isTabActive = useCallback((tab: WorkspaceTab) => activeTab.current === tab, []);
53 const [threadsProjectKey, setThreadsProjectKey] = useState<string | null>(null);
54 const [pullRequestEnvironmentId, setPullRequestEnvironmentId] = useState<string | null>(null);
5548 const scrollOffsets = useRef({ threads: 0, pullRequests: 0 });
5649 const scrollAnchors = useRef<Partial<Record<WorkspaceTab, WorkspaceScrollAnchor>>>({});
5750 const getScrollOffset = useCallback((tab: WorkspaceTab) => scrollOffsets.current[tab], []);
......@@ -72,21 +65,8 @@ export function WorkspaceTabsProvider(props: { readonly children: ReactNode }) {
7265 recordScrollOffset,
7366 getScrollAnchor,
7467 recordScrollAnchor,
75 threadsProjectKey,
76 setThreadsProjectKey,
77 pullRequestEnvironmentId,
78 setPullRequestEnvironmentId,
7968 }),
80 [
81 state,
82 isTabActive,
83 getScrollOffset,
84 recordScrollOffset,
85 getScrollAnchor,
86 recordScrollAnchor,
87 threadsProjectKey,
88 pullRequestEnvironmentId,
89 ],
69 [state, isTabActive, getScrollOffset, recordScrollOffset, getScrollAnchor, recordScrollAnchor],
9070 );
9171 return <WorkspaceTabsContext value={context}>{props.children}</WorkspaceTabsContext>;
9272}
apps/mobile/src/lib/storage.test.ts+26
......@@ -276,6 +276,32 @@ describe("mobile connection storage", () => {
276276 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
277277 });
278278
279 it("keeps valid list filters and drops malformed ones", async () => {
280 const threadListFilters = { environmentId: "environment-1", projectKey: null };
281 const pullRequestListFilters = {
282 environmentId: null,
283 controls: {
284 "": {
285 state: "all",
286 involvement: "reviewing",
287 filters: { labels: [["size:S"]] },
288 sort: "oldest",
289 },
290 },
291 };
292 mocks.setPreferencesJson(JSON.stringify({ threadListFilters, pullRequestListFilters }), 10);
293 await expect(loadPreferences()).resolves.toEqual({ threadListFilters, pullRequestListFilters });
294
295 mocks.setPreferencesJson(
296 JSON.stringify({
297 threadListFilters: { environmentId: 1, projectKey: null },
298 pullRequestListFilters: { environmentId: null, controls: { "": { sort: "newest" } } },
299 }),
300 11,
301 );
302 await expect(loadPreferences()).resolves.toEqual({});
303 });
304
279305 it("reconciles fallback preferences after SQLite recovers", async () => {
280306 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
281307 await mocks.setItemAsync(
apps/mobile/src/persistence/mobile-preferences.ts+46-4
......@@ -5,10 +5,14 @@ import * as Option from "effect/Option";
55import * as Ref from "effect/Ref";
66import * as Schema from "effect/Schema";
77import * as Semaphore from "effect/Semaphore";
8import type {
9 ProviderInstanceId,
10 PullRequestMergeMethod,
11 SidebarProjectGroupingMode,
8import {
9 EnvironmentId,
10 PullRequestInvolvement,
11 PullRequestListFilters,
12 PullRequestListState,
13 type ProviderInstanceId,
14 type PullRequestMergeMethod,
15 type SidebarProjectGroupingMode,
1216} from "@t3tools/contracts";
1317import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior";
1418import type { FollowUpBehavior } from "../lib/followUpBehavior";
......@@ -21,6 +25,33 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto
2125const PREFERENCES_KEY = "t3code.preferences";
2226const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
2327
28/** The pull request list's filters and sort for one environment scope. */
29export const PullRequestListControls = Schema.Struct({
30 state: PullRequestListState,
31 involvement: PullRequestInvolvement,
32 filters: PullRequestListFilters,
33 projectId: Schema.optional(Schema.String),
34 projectEnvironmentId: Schema.optional(Schema.String),
35 sort: Schema.Literals(["updated", "created", "oldest"]),
36});
37export type PullRequestListControls = typeof PullRequestListControls.Type;
38
39const ThreadListFilters = Schema.Struct({
40 environmentId: Schema.NullOr(EnvironmentId),
41 projectKey: Schema.NullOr(Schema.String),
42});
43export type ThreadListFilters = typeof ThreadListFilters.Type;
44
45/** Controls are keyed by environment id, with "" for all environments. */
46const PullRequestListPreferences = Schema.Struct({
47 environmentId: Schema.NullOr(Schema.String),
48 controls: Schema.Record(Schema.String, PullRequestListControls),
49});
50export type PullRequestListPreferences = typeof PullRequestListPreferences.Type;
51
52const isThreadListFilters = Schema.is(ThreadListFilters);
53const isPullRequestListPreferences = Schema.is(PullRequestListPreferences);
54
2455export interface Preferences {
2556 readonly pullRequestHideTestFiles?: boolean;
2657 readonly pullRequestMergeMethod?: PullRequestMergeMethod;
......@@ -61,6 +92,9 @@ export interface Preferences {
6192 readonly threadListSettledShelfExpanded?: boolean;
6293 readonly threadListSnoozedShelfExpanded?: boolean;
6394 readonly threadListWorkingShelfExpanded?: boolean;
95 /** Home list filters, restored on the next launch. */
96 readonly threadListFilters?: ThreadListFilters;
97 readonly pullRequestListFilters?: PullRequestListPreferences;
6498}
6599
66100export class MobilePreferencesLoadError extends Schema.TaggedError<MobilePreferencesLoadError>()(
......@@ -127,6 +161,8 @@ function sanitizePreferences(parsed: Preferences): Preferences {
127161 threadListSettledShelfExpanded?: boolean;
128162 threadListSnoozedShelfExpanded?: boolean;
129163 threadListWorkingShelfExpanded?: boolean;
164 threadListFilters?: ThreadListFilters;
165 pullRequestListFilters?: PullRequestListPreferences;
130166 } = {};
131167
132168 if (typeof parsed.pullRequestHideTestFiles === "boolean") {
......@@ -241,6 +277,12 @@ function sanitizePreferences(parsed: Preferences): Preferences {
241277 if (typeof parsed.threadListWorkingShelfExpanded === "boolean") {
242278 preferences.threadListWorkingShelfExpanded = parsed.threadListWorkingShelfExpanded;
243279 }
280 if (isThreadListFilters(parsed.threadListFilters)) {
281 preferences.threadListFilters = parsed.threadListFilters;
282 }
283 if (isPullRequestListPreferences(parsed.pullRequestListFilters)) {
284 preferences.pullRequestListFilters = parsed.pullRequestListFilters;
285 }
244286 return preferences;
245287}
246288
docs/operations/mobile-screens-fork.md+1-1
......@@ -1,7 +1,7 @@
11# Building the mobile Screens dependency
22
33The mobile v5 stack uses a committed tarball from the
4[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.25).
4[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.26).
55The fork owns the integration changes; the app does not patch this package.
66Each archive includes compiled JavaScript, declarations, native source and
77`t3-fork.json` recording its version, source commit and upstream base.
pnpm-lock.yaml+9-9
......@@ -348,7 +348,7 @@ importers:
348348 version: 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
349349 '@react-navigation/native-stack':
350350 specifier: 7.17.6
351 version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(db03c4b8a562e81843953ea133ec37d5)
351 version: 7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(02486f76aa3d9c7d88119682fcbddcde)
352352 '@shikijs/core':
353353 specifier: 4.2.0
354354 version: 4.2.0
......@@ -548,8 +548,8 @@ importers:
548548 specifier: ~5.9.1
549549 version: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
550550 react-native-screens:
551 specifier: file:deps/react-native-screens-5.0.0-t3.25.tgz
552 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
551 specifier: file:deps/react-native-screens-5.0.0-t3.26.tgz
552 version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
553553 react-native-shiki-engine:
554554 specifier: ^0.3.12
555555 version: 0.3.12(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -10891,9 +10891,9 @@ packages:
1089110891 react: '*'
1089210892 react-native: '*'
1089310893
10894 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz:
10895 resolution: {integrity: sha512-ACFyV8nA8zECX5EjmenA3ABTnxKRjb+r5I1ZCX4+EALjhsYdqEEDoQ8/G7OAMjzdp3WEOvIyGmGDOinjGOjPWg==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz}
10896 version: 5.0.0-t3.25
10894 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz:
10895 resolution: {integrity: sha512-OD1yeZu1HKuvBaroReihFROaofzuF2w4ORtPJXJrpAGk8AgQNusy3KaRRpeiCwIzH2KQU52SnWxrtKaYMzzQ+A==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz}
10896 version: 5.0.0-t3.26
1089710897 peerDependencies:
1089810898 react: '*'
1089910899 react-native: '*'
......@@ -16096,7 +16096,7 @@ snapshots:
1609616096 optionalDependencies:
1609716097 '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1609816098
16099 '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(db03c4b8a562e81843953ea133ec37d5)':
16099 '@react-navigation/native-stack@7.17.6(patch_hash=d2d52f3776e30765519748fd5e177592dc3b9cc778c52fb00e02674dfa9042c5)(02486f76aa3d9c7d88119682fcbddcde)':
1610016100 dependencies:
1610116101 '@react-navigation/elements': 2.9.26(83de68753219ecf7f3321e065bd378be)
1610216102 '@react-navigation/native': 7.3.4(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
......@@ -16104,7 +16104,7 @@ snapshots:
1610416104 react: 19.3.0
1610516105 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
1610616106 react-native-safe-area-context: 5.9.1(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16107 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
16107 react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)
1610816108 sf-symbols-typescript: 2.2.0
1610916109 warn-once: 0.1.1
1611016110 transitivePeerDependencies:
......@@ -22097,7 +22097,7 @@ snapshots:
2209722097 react: 19.3.0
2209822098 react-native: 0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)
2209922099
22100 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.25.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
22100 react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.26.tgz(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0):
2210122101 dependencies:
2210222102 react: 19.3.0
2210322103 react-freeze: 1.0.4(react@19.3.0)