Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6d1ee77
fix(mobile): preserve grouped project workspaces
shivamhwp Jul 27, 2026
5a38989
docs: add Android grouping verification screenshots
shivamhwp Jul 27, 2026
5b89b51
refactor(mobile): simplify project grouping settings
shivamhwp Jul 27, 2026
4f75888
refactor(mobile): use global project grouping only
shivamhwp Jul 27, 2026
72bf9c6
Merge branch 'main' into agent/fix-mobile-project-grouping
shivamhwp Jul 27, 2026
7cb3c13
fix mobile project grouping review issues
shivamhwp Jul 28, 2026
89ee06f
fix stale mobile draft project selection
shivamhwp Jul 28, 2026
da8bccd
Merge remote-tracking branch 'upstream/main' into agent/fix-mobile-pr…
shivamhwp Jul 30, 2026
69f4be5
Merge branch 'main' into agent/fix-mobile-project-grouping
shivamhwp Jul 31, 2026
e6a6e85
fix(mobile): gate grouping settings on hydration
shivamhwp Jul 31, 2026
a4b234f
Merge remote-tracking branch 'upstream/main' into agent/fix-mobile-pr…
shivamhwp Jul 31, 2026
b3810f5
chore(mobile): remove verification artifacts
shivamhwp Jul 31, 2026
b64c036
fix(mobile): preserve grouped project workspaces
shivamhwp Jul 27, 2026
5d5b5a8
docs: add Android grouping verification screenshots
shivamhwp Jul 27, 2026
b8380d1
refactor(mobile): simplify project grouping settings
shivamhwp Jul 27, 2026
2c2022d
refactor(mobile): use global project grouping only
shivamhwp Jul 27, 2026
9ffd3c4
fix mobile project grouping review issues
shivamhwp Jul 28, 2026
0ecedae
fix stale mobile draft project selection
shivamhwp Jul 28, 2026
9c6fd58
fix(mobile): gate grouping settings on hydration
shivamhwp Jul 31, 2026
e39c2ee
chore(mobile): remove verification artifacts
shivamhwp Jul 31, 2026
7e1cd27
docs(mobile): refresh grouping verification screenshots
Jul 31, 2026
20b7f06
chore(mobile): remove verification artifacts
Jul 31, 2026
fd21fbc
fix(mobile): polish project selection UI
Jul 31, 2026
4737831
docs(mobile): refresh grouping screenshots
Jul 31, 2026
166040d
chore(mobile): remove screenshot artifacts
Jul 31, 2026
6147257
Merge branch 'main' into agent/fix-mobile-project-grouping
shivamhwp Aug 4, 2026
68550f9
Merge remote-tracking branch 'origin/agent/fix-mobile-project-groupin…
shivamhwp Aug 4, 2026
9ebf027
Merge branch 'main' into agent/fix-mobile-project-grouping
shivamhwp Aug 5, 2026
98d00c3
Merge branch 'main' into agent/fix-mobile-project-grouping
shivamhwp Aug 5, 2026
db0db83
Merge branch 'main' into agent/fix-mobile-project-grouping
shivamhwp Aug 5, 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
8 changes: 8 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
import {
Expand Down Expand Up @@ -175,6 +176,13 @@ const SettingsSheetStack = createNativeStackNavigator({
title: "Appearance",
},
}),
SettingsProjectGrouping: createNativeStackScreen({
screen: SettingsProjectGroupingRouteScreen,
linking: "project-grouping",
options: {
title: "Project Grouping",
},
}),
SettingsClientStorage: createNativeStackScreen({
screen: SettingsClientStorageRouteScreen,
linking: "client-storage",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/AndroidScreenHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function AndroidScreenHeader(props: {
accessibilityRole="button"
hitSlop={8}
onPress={props.onBack}
className="size-11 items-center justify-center"
className="-mr-2 size-11 items-center justify-center"
>
<SymbolView
name="chevron.left"
Expand Down
10 changes: 3 additions & 7 deletions apps/mobile/src/features/home/home-list-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
readonly projectGroupingMode: SidebarProjectGroupingMode;
}

export function resolveProjectGroupingMode(
projectGroupingEnabled: boolean | undefined,
): SidebarProjectGroupingMode {
return projectGroupingEnabled === false ? "separate" : "repository";
}

export const PROJECT_SORT_OPTIONS: ReadonlyArray<{
readonly value: HomeProjectSortOrder;
readonly label: string;
Expand Down Expand Up @@ -76,7 +70,9 @@ const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>
export function HomeListOptionsProvider({
children,
projectGroupingMode,
}: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) {
}: PropsWithChildren<{
Comment thread
cursor[bot] marked this conversation as resolved.
readonly projectGroupingMode: SidebarProjectGroupingMode;
}>) {
const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
const value = useMemo(
() => ({ options, setOptions, projectGroupingMode }),
Expand Down
20 changes: 13 additions & 7 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ describe("buildHomeThreadGroups", () => {
).toHaveLength(2);
});

it("uses the repository label for a singleton repository scope", () => {
it("uses the physical project title for a singleton scope", () => {
const project = makeProject({
environmentId: EnvironmentId.make("environment-1"),
id: ProjectId.make("project-1"),
Expand Down Expand Up @@ -408,8 +408,8 @@ describe("buildHomeThreadGroups", () => {
],
);

expect(scopes[0]?.title).toBe("codething-mvp");
expect(groups[0]?.title).toBe("codething-mvp");
expect(scopes[0]?.title).toBe("local-worktree-name");
expect(groups[0]?.title).toBe("local-worktree-name");
});

it("sorts the newest thread first regardless of snapshot order", () => {
Expand Down Expand Up @@ -565,10 +565,16 @@ describe("buildHomeThreadGroups", () => {
);

expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1);
expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength(
2,
);
expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2);
expect(
buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map(
(group) => group.title,
),
).toEqual(["Mobile", "Web"]);
expect(
buildGroups(projects, threads, { projectGroupingMode: "separate" }).map(
(group) => group.title,
),
).toEqual(["Mobile", "Web"]);
});

it("default view shows only threads from the last 5 days", () => {
Expand Down
81 changes: 18 additions & 63 deletions apps/mobile/src/features/home/homeThreadList.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
deriveLogicalProjectKey,
buildProjectGroups,
derivePhysicalProjectKey,
deriveProjectGroupLabel,
} from "@t3tools/client-runtime/state/project-grouping";
Expand Down Expand Up @@ -37,10 +37,6 @@ export interface HomeProjectScope {
readonly projectRefs: ReadonlyArray<ScopedProjectRef>;
}

function getProjectFreshnessTimestamp(project: EnvironmentProject): number {
return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0;
}

function getProjectSortTimestamp(
project: EnvironmentProject,
sortOrder: HomeProjectSortOrder,
Expand All @@ -60,64 +56,19 @@ export function buildHomeProjectScopes(input: {
const projects = input.projects.filter(
(project) => input.environmentId === null || project.environmentId === input.environmentId,
);
const projectsByPhysicalKey = new Map<string, EnvironmentProject[]>();
for (const project of projects) {
const physicalKey = derivePhysicalProjectKey(project);
const existing = projectsByPhysicalKey.get(physicalKey);
if (existing) existing.push(project);
else projectsByPhysicalKey.set(physicalKey, [project]);
}

const winnersByPhysicalKey = new Map<
string,
{ readonly key: string; readonly project: EnvironmentProject }
>();
for (const [physicalKey, members] of projectsByPhysicalKey) {
const project = members.reduce((winner, candidate) => {
const freshnessDelta =
getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner);
return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id)
? candidate
: winner;
});
const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project;
winnersByPhysicalKey.set(physicalKey, {
key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }),
project,
});
}

const groups = new Map<string, EnvironmentProject[]>();
for (const { key, project } of winnersByPhysicalKey.values()) {
const existing = groups.get(key);
if (existing) existing.push(project);
else groups.set(key, [project]);
}

const projectRefsByGroup = new Map<string, ScopedProjectRef[]>();
const seenProjectRefs = new Set<string>();
for (const project of projects) {
const refKey = scopedProjectKey(project.environmentId, project.id);
if (seenProjectRefs.has(refKey)) continue;
seenProjectRefs.add(refKey);

const key =
winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ??
deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode });
const refs = projectRefsByGroup.get(key);
const projectRef = { environmentId: project.environmentId, projectId: project.id };
if (refs) refs.push(projectRef);
else projectRefsByGroup.set(key, [projectRef]);
}

return Array.from(groups, ([key, projects]) => {
const representative = projects[0]!;
return buildProjectGroups({
projects,
settings: {
sidebarProjectGroupingMode: input.projectGroupingMode,
sidebarProjectGroupingOverrides: {},
},
}).map((group) => {
return {
key,
title: deriveProjectGroupLabel({ representative, members: projects }),
representative,
projects,
projectRefs: projectRefsByGroup.get(key) ?? [],
key: group.key,
title: group.label,
representative: group.representative,
projects: group.members.map((member) => member.project),
projectRefs: group.memberProjectRefs,
};
});
}
Expand Down Expand Up @@ -264,9 +215,11 @@ export function buildHomeThreadGroups(input: {
}): ReadonlyArray<HomeThreadGroup> {
const now = input.now ?? Date.now();
const groups = new Map<string, MutableHomeThreadGroup>();
const groupTitleByKey = new Map<string, string>();
const groupKeyByProjectKey = new Map<string, string>();

for (const scope of buildHomeProjectScopes(input)) {
groupTitleByKey.set(scope.key, scope.title);
groups.set(scope.key, {
key: scope.key,
projects: [...scope.projects],
Expand Down Expand Up @@ -345,7 +298,9 @@ export function buildHomeThreadGroups(input: {
continue;
}

const title = deriveProjectGroupLabel({ representative, members: group.projects });
const title =
groupTitleByKey.get(group.key) ??
deriveProjectGroupLabel({ representative, members: group.projects });
const groupMatches =
query.length === 0 ||
title.toLocaleLowerCase().includes(query) ||
Expand Down
16 changes: 11 additions & 5 deletions apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ import {
import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { mobilePreferencesAtom } from "../../state/preferences";
import {
DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
resolveMobileProjectGroupingSettings,
} from "../../state/project-grouping";
import {
parseActiveThreadPath,
useHardwareKeyboardCommand,
} from "../keyboard/hardwareKeyboardCommands";
import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options";
import { HomeListOptionsProvider } from "../home/home-list-options";
import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
Expand Down Expand Up @@ -190,15 +194,17 @@ export function AdaptiveWorkspaceLayout(props: {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
if (!AsyncResult.isSuccess(preferencesResult)) {
return AsyncResult.isFailure(preferencesResult) ? (
<AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode="repository" />
<AdaptiveWorkspaceLayoutContent
{...props}
projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode}
/>
) : null;
}
const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value);
return (
<AdaptiveWorkspaceLayoutContent
{...props}
projectGroupingMode={resolveProjectGroupingMode(
preferencesResult.value.projectGroupingEnabled,
)}
projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { ScrollView, View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";

export function SettingsAppearanceRouteScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();

return (
<View collapsable={false} className="flex-1 bg-sheet">
{Platform.OS === "android" ? (
<>
<NativeStackScreenOptions options={{ headerShown: false }} />
<AndroidScreenHeader title="Appearance" onBack={() => navigation.goBack()} />
</>
) : null}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { useNavigation } from "@react-navigation/native";
import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
import { AsyncResult } from "effect/unstable/reactivity";
import { Platform, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
import { useThemeColor } from "../../lib/useThemeColor";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import {
mobileProjectGroupingModePatch,
resolveMobileProjectGroupingSettings,
} from "../../state/project-grouping";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { SettingsSection } from "./components/SettingsSection";

const GROUPING_OPTIONS: ReadonlyArray<{
readonly mode: SidebarProjectGroupingMode;
readonly label: string;
readonly description: string;
}> = [
{
mode: "repository",
label: "Group by repository",
description: "Matching repositories appear as one project.",
},
{
mode: "repository_path",
label: "Group by repository path",
description: "Keep monorepo paths separate.",
},
{
mode: "separate",
label: "Keep separate",
description: "Show every workspace as its own project.",
},
];

export function SettingsProjectGroupingRouteScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const checkmarkColor = useThemeColor("--color-icon");
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
const selectedMode = AsyncResult.isSuccess(preferencesResult)
? resolveMobileProjectGroupingSettings(preferencesResult.value).sidebarProjectGroupingMode
: null;

return (
<View collapsable={false} className="flex-1 bg-sheet">
{Platform.OS === "android" ? (
<>
<NativeStackScreenOptions options={{ headerShown: false }} />
<AndroidScreenHeader title="Project Grouping" onBack={() => navigation.goBack()} />
</>
) : null}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
className="flex-1"
contentContainerClassName="gap-3 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
<SettingsSection title="Default grouping">
{GROUPING_OPTIONS.map((option, index) => (
<Pressable
key={option.mode}
accessibilityRole="radio"
accessibilityState={{
checked: selectedMode === option.mode,
disabled: !preferencesReady,
}}
disabled={!preferencesReady}
onPress={() => savePreferences(mobileProjectGroupingModePatch(option.mode))}
Comment thread
cursor[bot] marked this conversation as resolved.
className={
index === 0
? "flex-row items-center gap-4 p-4"
: "flex-row items-center gap-4 border-t border-border-subtle p-4"
}
>
<View className="min-w-0 flex-1 gap-1">
<Text className="text-lg text-foreground">{option.label}</Text>
<Text className="text-sm leading-normal text-foreground-muted">
{option.description}
</Text>
</View>
{selectedMode === option.mode ? (
<SymbolView
name="checkmark"
size={18}
tintColor={checkmarkColor}
type="monochrome"
weight="semibold"
/>
) : null}
</Pressable>
))}
</SettingsSection>
</ScrollView>
</View>
);
}
Loading
Loading