authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-08-05 10:51:35+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 10:51:35+05:30
log47dfc6526521866992f6b50beac4587b45114631
tree1540e39d1b2e0a6b6f15b56d47f93fe56258770a
parent41ebf22eea64e177b11a0709a16d6716581cc78c
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve grouped project workspaces (#4642)

Co-authored-by: T3 Code Test <t3code-test@example.com>

25 files changed, 880 insertions(+), 655 deletions(-)

apps/mobile/src/Stack.tsx+8
......@@ -47,6 +47,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl
4747import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
4848import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
4949import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
50import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
5051import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
5152import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
5253import {
......@@ -175,6 +176,13 @@ const SettingsSheetStack = createNativeStackNavigator({
175176 title: "Appearance",
176177 },
177178 }),
179 SettingsProjectGrouping: createNativeStackScreen({
180 screen: SettingsProjectGroupingRouteScreen,
181 linking: "project-grouping",
182 options: {
183 title: "Project Grouping",
184 },
185 }),
178186 SettingsClientStorage: createNativeStackScreen({
179187 screen: SettingsClientStorageRouteScreen,
180188 linking: "client-storage",
apps/mobile/src/components/AndroidScreenHeader.tsx+1-1
......@@ -70,7 +70,7 @@ export function AndroidScreenHeader(props: {
7070 accessibilityRole="button"
7171 hitSlop={8}
7272 onPress={props.onBack}
73 className="size-11 items-center justify-center"
73 className="-mr-2 size-11 items-center justify-center"
7474 >
7575 <SymbolView
7676 name="chevron.left"
apps/mobile/src/features/home/home-list-options.ts+3-7
......@@ -31,12 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions {
3131 readonly projectGroupingMode: SidebarProjectGroupingMode;
3232}
3333
34export function resolveProjectGroupingMode(
35 projectGroupingEnabled: boolean | undefined,
36): SidebarProjectGroupingMode {
37 return projectGroupingEnabled === false ? "separate" : "repository";
38}
39
4034export const PROJECT_SORT_OPTIONS: ReadonlyArray<{
4135 readonly value: HomeProjectSortOrder;
4236 readonly label: string;
......@@ -76,7 +70,9 @@ const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null>
7670export function HomeListOptionsProvider({
7771 children,
7872 projectGroupingMode,
79}: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) {
73}: PropsWithChildren<{
74 readonly projectGroupingMode: SidebarProjectGroupingMode;
75}>) {
8076 const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions);
8177 const value = useMemo(
8278 () => ({ options, setOptions, projectGroupingMode }),
apps/mobile/src/features/home/homeThreadList.test.ts+13-7
......@@ -375,7 +375,7 @@ describe("buildHomeThreadGroups", () => {
375375 ).toHaveLength(2);
376376 });
377377
378 it("uses the repository label for a singleton repository scope", () => {
378 it("uses the physical project title for a singleton scope", () => {
379379 const project = makeProject({
380380 environmentId: EnvironmentId.make("environment-1"),
381381 id: ProjectId.make("project-1"),
......@@ -408,8 +408,8 @@ describe("buildHomeThreadGroups", () => {
408408 ],
409409 );
410410
411 expect(scopes[0]?.title).toBe("codething-mvp");
412 expect(groups[0]?.title).toBe("codething-mvp");
411 expect(scopes[0]?.title).toBe("local-worktree-name");
412 expect(groups[0]?.title).toBe("local-worktree-name");
413413 });
414414
415415 it("sorts the newest thread first regardless of snapshot order", () => {
......@@ -565,10 +565,16 @@ describe("buildHomeThreadGroups", () => {
565565 );
566566
567567 expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1);
568 expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength(
569 2,
570 );
571 expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2);
568 expect(
569 buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map(
570 (group) => group.title,
571 ),
572 ).toEqual(["Mobile", "Web"]);
573 expect(
574 buildGroups(projects, threads, { projectGroupingMode: "separate" }).map(
575 (group) => group.title,
576 ),
577 ).toEqual(["Mobile", "Web"]);
572578 });
573579
574580 it("default view shows only threads from the last 5 days", () => {
apps/mobile/src/features/home/homeThreadList.ts+18-63
......@@ -1,5 +1,5 @@
11import {
2 deriveLogicalProjectKey,
2 buildProjectGroups,
33 derivePhysicalProjectKey,
44 deriveProjectGroupLabel,
55} from "@t3tools/client-runtime/state/project-grouping";
......@@ -37,10 +37,6 @@ export interface HomeProjectScope {
3737 readonly projectRefs: ReadonlyArray<ScopedProjectRef>;
3838}
3939
40function getProjectFreshnessTimestamp(project: EnvironmentProject): number {
41 return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0;
42}
43
4440function getProjectSortTimestamp(
4541 project: EnvironmentProject,
4642 sortOrder: HomeProjectSortOrder,
......@@ -60,64 +56,19 @@ export function buildHomeProjectScopes(input: {
6056 const projects = input.projects.filter(
6157 (project) => input.environmentId === null || project.environmentId === input.environmentId,
6258 );
63 const projectsByPhysicalKey = new Map<string, EnvironmentProject[]>();
64 for (const project of projects) {
65 const physicalKey = derivePhysicalProjectKey(project);
66 const existing = projectsByPhysicalKey.get(physicalKey);
67 if (existing) existing.push(project);
68 else projectsByPhysicalKey.set(physicalKey, [project]);
69 }
70
71 const winnersByPhysicalKey = new Map<
72 string,
73 { readonly key: string; readonly project: EnvironmentProject }
74 >();
75 for (const [physicalKey, members] of projectsByPhysicalKey) {
76 const project = members.reduce((winner, candidate) => {
77 const freshnessDelta =
78 getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner);
79 return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id)
80 ? candidate
81 : winner;
82 });
83 const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project;
84 winnersByPhysicalKey.set(physicalKey, {
85 key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }),
86 project,
87 });
88 }
89
90 const groups = new Map<string, EnvironmentProject[]>();
91 for (const { key, project } of winnersByPhysicalKey.values()) {
92 const existing = groups.get(key);
93 if (existing) existing.push(project);
94 else groups.set(key, [project]);
95 }
96
97 const projectRefsByGroup = new Map<string, ScopedProjectRef[]>();
98 const seenProjectRefs = new Set<string>();
99 for (const project of projects) {
100 const refKey = scopedProjectKey(project.environmentId, project.id);
101 if (seenProjectRefs.has(refKey)) continue;
102 seenProjectRefs.add(refKey);
103
104 const key =
105 winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ??
106 deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode });
107 const refs = projectRefsByGroup.get(key);
108 const projectRef = { environmentId: project.environmentId, projectId: project.id };
109 if (refs) refs.push(projectRef);
110 else projectRefsByGroup.set(key, [projectRef]);
111 }
112
113 return Array.from(groups, ([key, projects]) => {
114 const representative = projects[0]!;
59 return buildProjectGroups({
60 projects,
61 settings: {
62 sidebarProjectGroupingMode: input.projectGroupingMode,
63 sidebarProjectGroupingOverrides: {},
64 },
65 }).map((group) => {
11566 return {
116 key,
117 title: deriveProjectGroupLabel({ representative, members: projects }),
118 representative,
119 projects,
120 projectRefs: projectRefsByGroup.get(key) ?? [],
67 key: group.key,
68 title: group.label,
69 representative: group.representative,
70 projects: group.members.map((member) => member.project),
71 projectRefs: group.memberProjectRefs,
12172 };
12273 });
12374}
......@@ -264,9 +215,11 @@ export function buildHomeThreadGroups(input: {
264215}): ReadonlyArray<HomeThreadGroup> {
265216 const now = input.now ?? Date.now();
266217 const groups = new Map<string, MutableHomeThreadGroup>();
218 const groupTitleByKey = new Map<string, string>();
267219 const groupKeyByProjectKey = new Map<string, string>();
268220
269221 for (const scope of buildHomeProjectScopes(input)) {
222 groupTitleByKey.set(scope.key, scope.title);
270223 groups.set(scope.key, {
271224 key: scope.key,
272225 projects: [...scope.projects],
......@@ -345,7 +298,9 @@ export function buildHomeThreadGroups(input: {
345298 continue;
346299 }
347300
348 const title = deriveProjectGroupLabel({ representative, members: group.projects });
301 const title =
302 groupTitleByKey.get(group.key) ??
303 deriveProjectGroupLabel({ representative, members: group.projects });
349304 const groupMatches =
350305 query.length === 0 ||
351306 title.toLocaleLowerCase().includes(query) ||
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+11-5
......@@ -37,11 +37,15 @@ import {
3737import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation";
3838import { scopedThreadKey } from "../../lib/scopedEntities";
3939import { mobilePreferencesAtom } from "../../state/preferences";
40import {
41 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
42 resolveMobileProjectGroupingSettings,
43} from "../../state/project-grouping";
4044import {
4145 parseActiveThreadPath,
4246 useHardwareKeyboardCommand,
4347} from "../keyboard/hardwareKeyboardCommands";
44import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options";
48import { HomeListOptionsProvider } from "../home/home-list-options";
4549import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
4650import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
4751import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
......@@ -190,15 +194,17 @@ export function AdaptiveWorkspaceLayout(props: {
190194 const preferencesResult = useAtomValue(mobilePreferencesAtom);
191195 if (!AsyncResult.isSuccess(preferencesResult)) {
192196 return AsyncResult.isFailure(preferencesResult) ? (
193 <AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode="repository" />
197 <AdaptiveWorkspaceLayoutContent
198 {...props}
199 projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode}
200 />
194201 ) : null;
195202 }
203 const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value);
196204 return (
197205 <AdaptiveWorkspaceLayoutContent
198206 {...props}
199 projectGroupingMode={resolveProjectGroupingMode(
200 preferencesResult.value.projectGroupingEnabled,
201 )}
207 projectGroupingMode={groupingSettings.sidebarProjectGroupingMode}
202208 />
203209 );
204210}
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+11-1
......@@ -1,15 +1,25 @@
1import { ScrollView, View } from "react-native";
1import { useNavigation } from "@react-navigation/native";
2import { Platform, ScrollView, View } from "react-native";
23import { useSafeAreaInsets } from "react-native-safe-area-context";
34
5import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
6import { NativeStackScreenOptions } from "../../native/StackHeader";
47import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection";
58import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection";
69import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection";
710
811export function SettingsAppearanceRouteScreen() {
12 const navigation = useNavigation();
913 const insets = useSafeAreaInsets();
1014
1115 return (
1216 <View collapsable={false} className="flex-1 bg-sheet">
17 {Platform.OS === "android" ? (
18 <>
19 <NativeStackScreenOptions options={{ headerShown: false }} />
20 <AndroidScreenHeader title="Appearance" onBack={() => navigation.goBack()} />
21 </>
22 ) : null}
1323 <ScrollView
1424 contentInsetAdjustmentBehavior="automatic"
1525 showsVerticalScrollIndicator={false}
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx created+106
......@@ -0,0 +1,106 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { useNavigation } from "@react-navigation/native";
3import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
4import { AsyncResult } from "effect/unstable/reactivity";
5import { Platform, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
9import { AppText as Text } from "../../components/AppText";
10import { SymbolView } from "../../components/AppSymbol";
11import { useThemeColor } from "../../lib/useThemeColor";
12import { NativeStackScreenOptions } from "../../native/StackHeader";
13import {
14 mobileProjectGroupingModePatch,
15 resolveMobileProjectGroupingSettings,
16} from "../../state/project-grouping";
17import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
18import { SettingsSection } from "./components/SettingsSection";
19
20const GROUPING_OPTIONS: ReadonlyArray<{
21 readonly mode: SidebarProjectGroupingMode;
22 readonly label: string;
23 readonly description: string;
24}> = [
25 {
26 mode: "repository",
27 label: "Group by repository",
28 description: "Matching repositories appear as one project.",
29 },
30 {
31 mode: "repository_path",
32 label: "Group by repository path",
33 description: "Keep monorepo paths separate.",
34 },
35 {
36 mode: "separate",
37 label: "Keep separate",
38 description: "Show every workspace as its own project.",
39 },
40];
41
42export function SettingsProjectGroupingRouteScreen() {
43 const navigation = useNavigation();
44 const insets = useSafeAreaInsets();
45 const checkmarkColor = useThemeColor("--color-icon");
46 const preferencesResult = useAtomValue(mobilePreferencesAtom);
47 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
48 const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
49 const selectedMode = AsyncResult.isSuccess(preferencesResult)
50 ? resolveMobileProjectGroupingSettings(preferencesResult.value).sidebarProjectGroupingMode
51 : null;
52
53 return (
54 <View collapsable={false} className="flex-1 bg-sheet">
55 {Platform.OS === "android" ? (
56 <>
57 <NativeStackScreenOptions options={{ headerShown: false }} />
58 <AndroidScreenHeader title="Project Grouping" onBack={() => navigation.goBack()} />
59 </>
60 ) : null}
61 <ScrollView
62 contentInsetAdjustmentBehavior="automatic"
63 showsVerticalScrollIndicator={false}
64 className="flex-1"
65 contentContainerClassName="gap-3 px-5 pt-4"
66 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
67 >
68 <SettingsSection title="Default grouping">
69 {GROUPING_OPTIONS.map((option, index) => (
70 <Pressable
71 key={option.mode}
72 accessibilityRole="radio"
73 accessibilityState={{
74 checked: selectedMode === option.mode,
75 disabled: !preferencesReady,
76 }}
77 disabled={!preferencesReady}
78 onPress={() => savePreferences(mobileProjectGroupingModePatch(option.mode))}
79 className={
80 index === 0
81 ? "flex-row items-center gap-4 p-4"
82 : "flex-row items-center gap-4 border-t border-border-subtle p-4"
83 }
84 >
85 <View className="min-w-0 flex-1 gap-1">
86 <Text className="text-lg text-foreground">{option.label}</Text>
87 <Text className="text-sm leading-normal text-foreground-muted">
88 {option.description}
89 </Text>
90 </View>
91 {selectedMode === option.mode ? (
92 <SymbolView
93 name="checkmark"
94 size={18}
95 tintColor={checkmarkColor}
96 type="monochrome"
97 weight="semibold"
98 />
99 ) : null}
100 </Pressable>
101 ))}
102 </SettingsSection>
103 </ScrollView>
104 </View>
105 );
106}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+1-12
......@@ -530,20 +530,9 @@ function ConfiguredSettingsRouteScreen() {
530530}
531531
532532function GeneralSettingsSection() {
533 const preferencesResult = useAtomValue(mobilePreferencesAtom);
534 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
535 const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult)
536 ? preferencesResult.value.projectGroupingEnabled !== false
537 : true;
538
539533 return (
540534 <SettingsSection title="General">
541 <SettingsSwitchRow
542 icon="folder"
543 label="Project Grouping"
544 value={projectGroupingEnabled}
545 onValueChange={(value) => savePreferences({ projectGroupingEnabled: value })}
546 />
535 <SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
547536 </SettingsSection>
548537 );
549538}
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1
......@@ -2,6 +2,7 @@ export type SettingsSheetTarget =
22 | "SettingsEnvironments"
33 | "SettingsArchive"
44 | "SettingsAppearance"
5 | "SettingsProjectGrouping"
56 | "SettingsClientStorage";
67
78export type SettingsLegalDocumentTarget = "SettingsLegal";
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+8-6
......@@ -50,6 +50,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta
5050import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry";
5151import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider";
5252import { useCreateProjectThread } from "./use-project-actions";
53import { resolveDraftProjectSelection } from "./new-task-project-selection";
5354import { useIncomingShare } from "../sharing/IncomingShareProvider";
5455
5556function formatWorkspaceLabel(input: {
......@@ -89,7 +90,7 @@ export function NewTaskDraftScreen(props: {
8990 const colorScheme = useColorScheme();
9091 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
9192 const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10);
92 const { logicalProjects, selectedProject, setProject } = flow;
93 const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow;
9394 const { connectedEnvironments } = useRemoteConnectionStatus();
9495 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
9596 selectedProject?.environmentId ?? null,
......@@ -277,24 +278,25 @@ export function NewTaskDraftScreen(props: {
277278 return;
278279 }
279280
280 if (selectedProject) {
281 const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes);
282 if (selection.kind === "preserve") {
281283 return;
282284 }
283
284 if (logicalProjects.length === 1) {
285 setProject(logicalProjects[0]!.project);
285 if (selection.kind === "select") {
286 setProject(selection.project);
286287 return;
287288 }
288289
289290 navigation.dispatch(StackActions.replace("NewTask"));
290291 }, [
291 logicalProjects,
292 projectScopes,
292293 projects,
293294 props.initialProjectRef,
294295 props.incomingShareId,
295296 props.pendingTaskId,
296297 navigation,
297298 selectedProject,
299 selectedProjectKey,
298300 setProject,
299301 ]);
300302
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+95-60
......@@ -1,8 +1,8 @@
11import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
22import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
33import { SymbolView } from "../../components/AppSymbol";
4import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
5import { useEffect, useMemo, useRef } from "react";
4import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import { useEffect, useRef, useState } from "react";
66import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native";
77import { useSafeAreaInsets } from "react-native-safe-area-context";
88import { useThemeColor } from "../../lib/useThemeColor";
......@@ -11,12 +11,13 @@ import { cn } from "../../lib/cn";
1111import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
1212import { AppText as Text } from "../../components/AppText";
1313import { ProjectFavicon } from "../../components/ProjectFavicon";
14import { useProjects, useThreadShells } from "../../state/entities";
14import { useProjects } from "../../state/entities";
1515import type { WorkspaceState } from "../../state/workspaceModel";
1616import { useWorkspaceState } from "../../state/workspace";
17import { groupProjectsByRepository } from "../../lib/repositoryGroups";
17import { scopedProjectKey } from "../../lib/scopedEntities";
1818import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1919import { useIncomingShare } from "../sharing/IncomingShareProvider";
20import { useNewTaskFlow } from "./new-task-flow-provider";
2021
2122type NewTaskRouteParams = {
2223 readonly incomingShareId?: string | string[];
......@@ -79,7 +80,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): {
7980
8081export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) {
8182 const projects = useProjects();
82 const threads = useThreadShells();
83 const { projectScopes } = useNewTaskFlow();
8384 const { state: catalogState } = useWorkspaceState();
8485 const navigation = useNavigation();
8586 const isFocused = useIsFocused();
......@@ -87,6 +88,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
8788 const insets = useSafeAreaInsets();
8889 const chevronColor = useThemeColor("--color-chevron");
8990 const accentColor = useThemeColor("--color-icon-muted");
91 const [expandedGroupKeys, setExpandedGroupKeys] = useState<ReadonlySet<string>>(() => new Set());
9092 const { getShare, releaseShareReservation } = useIncomingShare();
9193 const routeShareId = Array.isArray(route.params?.incomingShareId)
9294 ? route.params.incomingShareId[0]
......@@ -100,33 +102,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
100102 : `Choose a project for the ${incomingShare.attachments.length} images you shared`
101103 : null;
102104 const screenTitle = incomingShare ? "Start a task" : "Choose project";
103 const repositoryGroups = useMemo(
104 () => groupProjectsByRepository({ projects, threads }),
105 [projects, threads],
106 );
107 const items = useMemo(() => {
108 const nextItems: Array<{
109 readonly environmentId: EnvironmentId;
110 readonly id: ProjectId;
111 readonly key: string;
112 readonly title: string;
113 readonly workspaceRoot: string;
114 }> = [];
115 for (const group of repositoryGroups) {
116 const project = group.projects[0]?.project;
117 if (!project) {
118 continue;
119 }
120 nextItems.push({
121 environmentId: project.environmentId,
122 id: project.id,
123 key: group.key,
124 title: project.title,
125 workspaceRoot: project.workspaceRoot,
126 });
127 }
128 return nextItems;
129 }, [repositoryGroups]);
130105 const projectEmptyState = deriveProjectEmptyState(catalogState);
131106 const resumedDestinationKeyRef = useRef<string | null>(null);
132107 const reservedDestinationProject = incomingShare?.destination
......@@ -137,7 +112,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
137112 ) ?? null)
138113 : null;
139114
140 async function selectProject(item: (typeof items)[number]): Promise<void> {
115 async function selectProject(project: EnvironmentProject): Promise<void> {
141116 if (incomingShare?.destination && !reservedDestinationProject) {
142117 try {
143118 await releaseShareReservation(incomingShare.id, incomingShare.destination);
......@@ -154,14 +129,26 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
154129 navigation.navigate("NewTaskSheet", {
155130 screen: "NewTaskDraft",
156131 params: {
157 environmentId: item.environmentId,
158 projectId: item.id,
159 title: item.title,
132 environmentId: project.environmentId,
133 projectId: project.id,
134 title: project.title,
160135 incomingShareId: incomingShare?.id,
161136 },
162137 });
163138 }
164139
140 function toggleGroup(groupKey: string): void {
141 setExpandedGroupKeys((current) => {
142 const next = new Set(current);
143 if (next.has(groupKey)) {
144 next.delete(groupKey);
145 } else {
146 next.add(groupKey);
147 }
148 return next;
149 });
150 }
151
165152 useEffect(() => {
166153 const destination = incomingShare?.destination;
167154 if (!destination) {
......@@ -251,11 +238,12 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
251238 contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
252239 contentContainerStyle={{
253240 gap: 12,
241 paddingBottom: 8,
254242 paddingHorizontal: 20,
255243 paddingTop: 8,
256244 }}
257245 >
258 {items.length === 0 ? (
246 {projectScopes.length === 0 ? (
259247 <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8">
260248 {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null}
261249 <Text className="text-center text-lg font-t3-bold text-foreground">
......@@ -286,42 +274,89 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara
286274 </View>
287275 ) : (
288276 <View collapsable={false} className="overflow-hidden rounded-[24px] bg-card">
289 {items.map((item, index) => {
290 const isFirst = index === 0;
291 const isLast = index === items.length - 1;
292
277 {projectScopes.map((scope, scopeIndex) => {
278 const hasMultipleProjects = scope.projects.length > 1;
279 const expanded = expandedGroupKeys.has(scope.key);
280 const singleProject = hasMultipleProjects ? null : scope.projects[0];
293281 return (
294 <Pressable
295 key={item.key}
296 disabled={reservedDestinationProject !== null}
297 onPress={() => void selectProject(item)}
298 className={cn(
299 "bg-card px-4 py-3.5",
300 !isFirst && "border-t border-border-subtle",
301 isFirst && "rounded-t-[24px]",
302 isLast && "rounded-b-[24px]",
303 )}
282 <View
283 key={scope.key}
284 className={cn(scopeIndex > 0 && "border-t border-border-subtle")}
304285 >
305 <View className="flex-row items-center justify-between gap-3">
286 <Pressable
287 disabled={singleProject !== null && reservedDestinationProject !== null}
288 onPress={() => {
289 if (singleProject) {
290 void selectProject(singleProject);
291 } else {
292 toggleGroup(scope.key);
293 }
294 }}
295 className="flex-row items-center gap-3 bg-card px-4 py-3.5"
296 >
306297 <View className="h-7 w-7 items-center justify-center">
307298 <ProjectFavicon
308 environmentId={item.environmentId}
299 environmentId={scope.representative.environmentId}
309300 size={20}
310 projectTitle={item.title}
311 workspaceRoot={item.workspaceRoot}
301 projectTitle={scope.title}
302 workspaceRoot={scope.representative.workspaceRoot}
312303 />
313304 </View>
314 <View className="flex-1">
315 <Text className="text-base leading-snug font-t3-bold">{item.title}</Text>
305 <View className="min-w-0 flex-1">
306 <Text className="text-base leading-snug font-t3-bold">{scope.title}</Text>
307 <Text
308 className="text-xs leading-snug text-foreground-muted"
309 ellipsizeMode="middle"
310 numberOfLines={1}
311 >
312 {hasMultipleProjects
313 ? `${scope.projects.length} workspaces`
314 : singleProject?.workspaceRoot}
315 </Text>
316316 </View>
317317 <SymbolView
318 name="chevron.right"
318 name={hasMultipleProjects && expanded ? "chevron.down" : "chevron.right"}
319319 size={14}
320320 tintColor={chevronColor}
321321 type="monochrome"
322322 />
323 </View>
324 </Pressable>
323 </Pressable>
324 {hasMultipleProjects && expanded
325 ? scope.projects.map((project) => (
326 <Pressable
327 key={scopedProjectKey(project.environmentId, project.id)}
328 disabled={reservedDestinationProject !== null}
329 onPress={() => void selectProject(project)}
330 className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10"
331 >
332 <ProjectFavicon
333 environmentId={project.environmentId}
334 size={18}
335 projectTitle={project.title}
336 workspaceRoot={project.workspaceRoot}
337 />
338 <View className="min-w-0 flex-1">
339 <Text className="text-sm font-t3-bold text-foreground">
340 {project.title}
341 </Text>
342 <Text
343 className="text-xs text-foreground-muted"
344 ellipsizeMode="middle"
345 numberOfLines={1}
346 >
347 {project.workspaceRoot}
348 </Text>
349 </View>
350 <SymbolView
351 name="chevron.right"
352 size={14}
353 tintColor={chevronColor}
354 type="monochrome"
355 />
356 </Pressable>
357 ))
358 : null}
359 </View>
325360 );
326361 })}
327362 </View>
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-31
......@@ -27,7 +27,6 @@ import {
2727 groupByProvider,
2828 resolveSelectableModelSelection,
2929} from "../../lib/modelOptions";
30import { groupProjectsByRepository } from "../../lib/repositoryGroups";
3130import { scopedProjectKey } from "../../lib/scopedEntities";
3231import { appAtomRegistry } from "../../state/atom-registry";
3332import {
......@@ -59,6 +58,12 @@ import {
5958} from "../../state/use-remote-environment-registry";
6059import { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
6160import { type VcsRef } from "@t3tools/client-runtime/state/vcs";
61import {
62 buildHomeProjectScopes,
63 sortHomeProjectScopes,
64 type HomeProjectScope,
65} from "../home/homeThreadList";
66import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
6267
6368type WorkspaceMode = "local" | "worktree";
6469
......@@ -109,10 +114,7 @@ export function branchBadgeLabel(input: {
109114}
110115
111116type NewTaskFlowContextValue = {
112 readonly logicalProjects: ReadonlyArray<{
113 readonly key: string;
114 readonly project: EnvironmentProject;
115 }>;
117 readonly projectScopes: ReadonlyArray<HomeProjectScope>;
116118 readonly selectedEnvironmentId: EnvironmentId | null;
117119 readonly selectedProjectKey: string | null;
118120 readonly selectedModelKey: string | null;
......@@ -175,32 +177,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
175177 const projects = useProjects();
176178 const threads = useThreadShells();
177179 const { savedConnectionsById } = useSavedRemoteConnections();
178
179 const repositoryGroups = useMemo(
180 () => groupProjectsByRepository({ projects, threads }),
181 [projects, threads],
182 );
183 const logicalProjects = useMemo(
180 const groupingSettings = useMobileProjectGroupingSettings();
181 const projectScopes = useMemo(
184182 () =>
185 pipe(
186 repositoryGroups,
187 Arr.map((group) => {
188 const primaryProject = group.projects[0]?.project;
189 if (!primaryProject) {
190 return null;
191 }
192 return { key: group.key, project: primaryProject };
183 sortHomeProjectScopes({
184 scopes: buildHomeProjectScopes({
185 projects,
186 environmentId: null,
187 projectGroupingMode: groupingSettings.sidebarProjectGroupingMode,
193188 }),
194 Arr.filter(
195 (
196 entry,
197 ): entry is {
198 readonly key: string;
199 readonly project: EnvironmentProject;
200 } => entry !== null,
201 ),
202 ),
203 [repositoryGroups],
189 threads,
190 pendingTasks: [],
191 projectSortOrder: "updated_at",
192 }),
193 [groupingSettings.sidebarProjectGroupingMode, projects, threads],
204194 );
205195
206196 const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
......@@ -846,7 +836,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
846836
847837 const value = useMemo<NewTaskFlowContextValue>(
848838 () => ({
849 logicalProjects,
839 projectScopes,
850840 selectedEnvironmentId,
851841 selectedProjectKey,
852842 selectedModelKey,
......@@ -912,7 +902,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
912902 finishEditingPendingTask,
913903 interactionMode,
914904 loadBranches,
915 logicalProjects,
905 projectScopes,
916906 modelOptions,
917907 prompt,
918908 providerGroups,
apps/mobile/src/features/threads/new-task-project-selection.test.ts created+82
......@@ -0,0 +1,82 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
5import type { HomeProjectScope } from "../home/homeThreadList";
6import {
7 getOnlySelectableProject,
8 resolveDraftProjectSelection,
9} from "./new-task-project-selection";
10
11function makeProject(id: string): EnvironmentProject {
12 return {
13 environmentId: EnvironmentId.make("environment"),
14 id: ProjectId.make(id),
15 title: id,
16 workspaceRoot: `/work/${id}`,
17 repositoryIdentity: null,
18 defaultModelSelection: null,
19 scripts: [],
20 createdAt: "2026-07-01T00:00:00.000Z",
21 updatedAt: "2026-07-01T00:00:00.000Z",
22 };
23}
24
25function makeScope(projects: ReadonlyArray<EnvironmentProject>): HomeProjectScope {
26 return {
27 key: "github.com/t3tools/t3code",
28 title: "T3 Code",
29 representative: projects[0]!,
30 projects,
31 projectRefs: projects.map((project) => ({
32 environmentId: project.environmentId,
33 projectId: project.id,
34 })),
35 };
36}
37
38describe("getOnlySelectableProject", () => {
39 it("auto-selects when there is exactly one physical project", () => {
40 const project = makeProject("t3code");
41 expect(getOnlySelectableProject([makeScope([project])])).toBe(project);
42 });
43
44 it("does not auto-select a representative when one group has multiple clones", () => {
45 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
46 expect(getOnlySelectableProject([makeScope(projects)])).toBeNull();
47 });
48});
49
50describe("resolveDraftProjectSelection", () => {
51 it("preserves an explicit project selection", () => {
52 const project = makeProject("t3code");
53 expect(
54 resolveDraftProjectSelection("environment:t3code", [project], [makeScope([project])]),
55 ).toEqual({ kind: "preserve" });
56 });
57
58 it("selects the only physical project when no project was explicitly selected", () => {
59 const project = makeProject("t3code");
60 expect(resolveDraftProjectSelection(null, [project], [makeScope([project])])).toEqual({
61 kind: "select",
62 project,
63 });
64 });
65
66 it("opens the picker for multiple physical projects in one logical group", () => {
67 const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")];
68 expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({
69 kind: "pick",
70 });
71 });
72
73 it("does not preserve a project key that is missing from the catalog", () => {
74 const project = makeProject("t3code");
75 expect(
76 resolveDraftProjectSelection("environment:removed", [project], [makeScope([project])]),
77 ).toEqual({
78 kind: "select",
79 project,
80 });
81 });
82});
apps/mobile/src/features/threads/new-task-project-selection.ts created+34
......@@ -0,0 +1,34 @@
1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2
3import { scopedProjectKey } from "../../lib/scopedEntities";
4import type { HomeProjectScope } from "../home/homeThreadList";
5
6export type DraftProjectSelectionResolution =
7 | { readonly kind: "preserve" }
8 | { readonly kind: "select"; readonly project: EnvironmentProject }
9 | { readonly kind: "pick" };
10
11export function getOnlySelectableProject(
12 projectScopes: ReadonlyArray<HomeProjectScope>,
13): EnvironmentProject | null {
14 const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null;
15 return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null;
16}
17
18export function resolveDraftProjectSelection(
19 selectedProjectKey: string | null,
20 projects: ReadonlyArray<EnvironmentProject>,
21 projectScopes: ReadonlyArray<HomeProjectScope>,
22): DraftProjectSelectionResolution {
23 const hasExplicitProjectSelection =
24 selectedProjectKey !== null &&
25 projects.some(
26 (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey,
27 );
28 if (hasExplicitProjectSelection) {
29 return { kind: "preserve" };
30 }
31
32 const onlyProject = getOnlySelectableProject(projectScopes);
33 return onlyProject ? { kind: "select", project: onlyProject } : { kind: "pick" };
34}
apps/mobile/src/lib/repositoryGroups.test.ts deleted-192
......@@ -1,192 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4
5import { groupProjectsByRepository } from "./repositoryGroups";
6import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
7
8function makeProject(
9 input: Partial<EnvironmentProject> & Pick<EnvironmentProject, "environmentId" | "id" | "title">,
10): EnvironmentProject {
11 return {
12 workspaceRoot: `/workspaces/${input.id}`,
13 repositoryIdentity: null,
14 defaultModelSelection: null,
15 scripts: [],
16 createdAt: "2026-04-01T00:00:00.000Z",
17 updatedAt: "2026-04-01T00:00:00.000Z",
18 ...input,
19 };
20}
21
22function makeThread(
23 input: Partial<EnvironmentThreadShell> &
24 Pick<EnvironmentThreadShell, "environmentId" | "id" | "projectId" | "title" | "modelSelection">,
25): EnvironmentThreadShell {
26 return {
27 runtimeMode: "full-access",
28 interactionMode: "default",
29 branch: null,
30 worktreePath: null,
31 latestTurn: null,
32 createdAt: "2026-04-01T00:00:00.000Z",
33 updatedAt: "2026-04-01T00:00:00.000Z",
34 archivedAt: null,
35 session: null,
36 latestUserMessageAt: null,
37 hasPendingApprovals: false,
38 hasPendingUserInput: false,
39 hasActionableProposedPlan: false,
40 ...input,
41 settledOverride: input.settledOverride ?? null,
42 settledAt: input.settledAt ?? null,
43 };
44}
45
46describe("groupProjectsByRepository", () => {
47 it("groups projects across environments by repository identity", () => {
48 const repoIdentity = {
49 canonicalKey: "github.com/t3tools/t3code",
50 locator: {
51 source: "git-remote" as const,
52 remoteName: "origin",
53 remoteUrl: "git@github.com:t3tools/t3code.git",
54 },
55 provider: "github",
56 owner: "t3tools",
57 name: "t3code",
58 displayName: "T3 Code",
59 };
60
61 const projects = [
62 makeProject({
63 environmentId: EnvironmentId.make("env-local"),
64 id: ProjectId.make("project-local"),
65 title: "T3 Code",
66 repositoryIdentity: repoIdentity,
67 }),
68 makeProject({
69 environmentId: EnvironmentId.make("env-staging"),
70 id: ProjectId.make("project-staging"),
71 title: "T3 Code",
72 repositoryIdentity: repoIdentity,
73 }),
74 ];
75
76 const threads = [
77 makeThread({
78 environmentId: EnvironmentId.make("env-staging"),
79 id: ThreadId.make("thread-2"),
80 projectId: ProjectId.make("project-staging"),
81 title: "Fix reconnect flow",
82 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
83 updatedAt: "2026-04-02T12:00:00.000Z",
84 }),
85 makeThread({
86 environmentId: EnvironmentId.make("env-local"),
87 id: ThreadId.make("thread-1"),
88 projectId: ProjectId.make("project-local"),
89 title: "Polish mobile shell",
90 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
91 updatedAt: "2026-04-03T12:00:00.000Z",
92 }),
93 ];
94
95 const groups = groupProjectsByRepository({ projects, threads });
96
97 expect(groups).toHaveLength(1);
98 expect(groups[0]).toMatchObject({
99 key: "github.com/t3tools/t3code",
100 title: "T3 Code",
101 subtitle: "t3tools/t3code",
102 projectCount: 2,
103 threadCount: 2,
104 });
105 expect(
106 groups[0]?.projects.map((entry) => ({
107 environmentId: entry.project.environmentId,
108 latestActivityAt: entry.latestActivityAt,
109 threads: entry.threads.map((thread) => thread.id),
110 })),
111 ).toEqual([
112 {
113 environmentId: "env-local",
114 latestActivityAt: "2026-04-03T12:00:00.000Z",
115 threads: ["thread-1"],
116 },
117 {
118 environmentId: "env-staging",
119 latestActivityAt: "2026-04-02T12:00:00.000Z",
120 threads: ["thread-2"],
121 },
122 ]);
123 expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z");
124 });
125
126 it("orders threads, projects, and repository groups by latest activity", () => {
127 const projects = [
128 makeProject({
129 environmentId: EnvironmentId.make("env-local"),
130 id: ProjectId.make("older-project"),
131 title: "Older",
132 }),
133 makeProject({
134 environmentId: EnvironmentId.make("env-local"),
135 id: ProjectId.make("newer-project"),
136 title: "Newer",
137 }),
138 ];
139
140 const threads = [
141 makeThread({
142 environmentId: EnvironmentId.make("env-local"),
143 id: ThreadId.make("older-thread"),
144 projectId: ProjectId.make("older-project"),
145 title: "Older thread",
146 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
147 updatedAt: "2026-04-02T12:00:00.000Z",
148 }),
149 makeThread({
150 environmentId: EnvironmentId.make("env-local"),
151 id: ThreadId.make("newer-thread"),
152 projectId: ProjectId.make("older-project"),
153 title: "Newer thread",
154 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
155 updatedAt: "2026-04-04T12:00:00.000Z",
156 }),
157 makeThread({
158 environmentId: EnvironmentId.make("env-local"),
159 id: ThreadId.make("newest-thread"),
160 projectId: ProjectId.make("newer-project"),
161 title: "Newest thread",
162 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
163 updatedAt: "2026-04-05T12:00:00.000Z",
164 }),
165 ];
166
167 const groups = groupProjectsByRepository({ projects, threads });
168
169 expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]);
170 expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([
171 "newer-thread",
172 "older-thread",
173 ]);
174 });
175
176 it("falls back to a scoped project key when repository identity is unavailable", () => {
177 const projects = [
178 makeProject({
179 environmentId: EnvironmentId.make("env-local"),
180 id: ProjectId.make("project-local"),
181 title: "Scratchpad",
182 }),
183 ];
184
185 const groups = groupProjectsByRepository({ projects, threads: [] });
186
187 expect(groups).toHaveLength(1);
188 expect(groups[0]?.key).toBe("env-local:project-local");
189 expect(groups[0]?.title).toBe("Scratchpad");
190 expect(groups[0]?.subtitle).toBeNull();
191 });
192});
apps/mobile/src/lib/repositoryGroups.ts deleted-131
......@@ -1,131 +0,0 @@
1import * as Order from "effect/Order";
2import * as Arr from "effect/Array";
3import type { RepositoryIdentity } from "@t3tools/contracts";
4
5import { scopedProjectKey } from "./scopedEntities";
6import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
7
8const DateDescending = Order.flip(Order.Date);
9
10export interface RepositoryProjectGroup {
11 readonly key: string;
12 readonly project: EnvironmentProject;
13 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
14 readonly latestActivityAt: string;
15}
16
17export interface RepositoryGroup {
18 readonly key: string;
19 readonly title: string;
20 readonly subtitle: string | null;
21 readonly repositoryIdentity: RepositoryIdentity | null;
22 readonly projectCount: number;
23 readonly threadCount: number;
24 readonly latestActivityAt: string;
25 readonly projects: ReadonlyArray<RepositoryProjectGroup>;
26}
27
28function compareIsoDateDescending(left: string, right: string): number {
29 return new Date(right).getTime() - new Date(left).getTime();
30}
31
32function deriveRepositoryGroupKey(project: EnvironmentProject): string {
33 return (
34 project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id)
35 );
36}
37
38function deriveRepositoryTitle(project: EnvironmentProject): string {
39 const identity = project.repositoryIdentity;
40 return identity?.displayName ?? identity?.name ?? project.title;
41}
42
43function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null {
44 if (!identity) {
45 return null;
46 }
47 if (identity.owner && identity.name) {
48 return `${identity.owner}/${identity.name}`;
49 }
50 return identity.canonicalKey;
51}
52
53function deriveProjectLatestActivity(
54 project: EnvironmentProject,
55 threads: ReadonlyArray<EnvironmentThreadShell>,
56): string {
57 const latestThread = threads[0];
58 return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt;
59}
60
61export function groupProjectsByRepository(input: {
62 readonly projects: ReadonlyArray<EnvironmentProject>;
63 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
64}): ReadonlyArray<RepositoryGroup> {
65 const threadsByProjectKey = new Map<string, EnvironmentThreadShell[]>();
66
67 for (const thread of input.threads) {
68 const key = scopedProjectKey(thread.environmentId, thread.projectId);
69 const existing = threadsByProjectKey.get(key);
70 if (existing) {
71 existing.push(thread);
72 } else {
73 threadsByProjectKey.set(key, [thread]);
74 }
75 }
76
77 const grouped = new Map<string, RepositoryGroup>();
78
79 for (const project of input.projects) {
80 const key = deriveRepositoryGroupKey(project);
81 const projectKey = scopedProjectKey(project.environmentId, project.id);
82 const threads = Arr.sortWith(
83 threadsByProjectKey.get(projectKey) ?? [],
84 (s) => new Date(s.updatedAt ?? s.createdAt),
85 DateDescending,
86 );
87
88 const latestActivityAt = deriveProjectLatestActivity(project, threads);
89 const projectGroup: RepositoryProjectGroup = {
90 key: projectKey,
91 project,
92 threads,
93 latestActivityAt,
94 };
95
96 const existing = grouped.get(key);
97 if (!existing) {
98 grouped.set(key, {
99 key,
100 title: deriveRepositoryTitle(project),
101 subtitle: deriveRepositorySubtitle(project.repositoryIdentity),
102 repositoryIdentity: project.repositoryIdentity ?? null,
103 projectCount: 1,
104 threadCount: threads.length,
105 latestActivityAt,
106 projects: [projectGroup],
107 });
108 continue;
109 }
110
111 grouped.set(key, {
112 ...existing,
113 title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project),
114 subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity),
115 repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null,
116 projectCount: existing.projectCount + 1,
117 threadCount: existing.threadCount + threads.length,
118 latestActivityAt:
119 compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0
120 ? latestActivityAt
121 : existing.latestActivityAt,
122 projects: Arr.sortWith(
123 [...existing.projects, projectGroup],
124 (s) => new Date(s.latestActivityAt),
125 DateDescending,
126 ),
127 });
128 }
129
130 return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending);
131}
apps/mobile/src/persistence/mobile-preferences.ts+11
......@@ -5,6 +5,7 @@ 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 { SidebarProjectGroupingMode } from "@t3tools/contracts";
89
910import * as MobileDatabase from "./mobile-database";
1011import * as MobileSecureStorage from "./mobile-secure-storage";
......@@ -22,7 +23,9 @@ export interface Preferences {
2223 readonly codeWordBreak?: boolean;
2324 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
2425 readonly collapsedProjectGroups?: readonly string[];
26 /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
2527 readonly projectGroupingEnabled?: boolean;
28 readonly projectGroupingMode?: SidebarProjectGroupingMode;
2629 /**
2730 * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no
2831 * client-settings sync, so the flat v2 thread list is opted out of per
......@@ -80,6 +83,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
8083 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
8184 collapsedProjectGroups?: readonly string[];
8285 projectGroupingEnabled?: boolean;
86 projectGroupingMode?: SidebarProjectGroupingMode;
8387 threadListV2Enabled?: boolean;
8488 } = {};
8589
......@@ -110,6 +114,13 @@ function sanitizePreferences(parsed: Preferences): Preferences {
110114 if (typeof parsed.projectGroupingEnabled === "boolean") {
111115 preferences.projectGroupingEnabled = parsed.projectGroupingEnabled;
112116 }
117 if (
118 parsed.projectGroupingMode === "repository" ||
119 parsed.projectGroupingMode === "repository_path" ||
120 parsed.projectGroupingMode === "separate"
121 ) {
122 preferences.projectGroupingMode = parsed.projectGroupingMode;
123 }
113124 if (typeof parsed.threadListV2Enabled === "boolean") {
114125 preferences.threadListV2Enabled = parsed.threadListV2Enabled;
115126 }
apps/mobile/src/state/project-grouping.logic.ts created+33
......@@ -0,0 +1,33 @@
1import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping";
2import type { SidebarProjectGroupingMode } from "@t3tools/contracts";
3
4import type { Preferences } from "../persistence/mobile-preferences";
5
6export const DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS: ProjectGroupingSettings = {
7 sidebarProjectGroupingMode: "repository",
8 sidebarProjectGroupingOverrides: {},
9};
10
11export function resolveMobileProjectGroupingSettings(
12 preferences: Preferences,
13): ProjectGroupingSettings {
14 return {
15 sidebarProjectGroupingMode:
16 preferences.projectGroupingMode ??
17 (preferences.projectGroupingEnabled === false ? "separate" : "repository"),
18 sidebarProjectGroupingOverrides: {},
19 };
20}
21
22/**
23 * Dual-writes the legacy boolean for one release so an OTA rollback to an
24 * older mobile bundle preserves the user's grouping choice.
25 */
26export function mobileProjectGroupingModePatch(
27 mode: SidebarProjectGroupingMode,
28): Partial<Preferences> {
29 return {
30 projectGroupingMode: mode,
31 projectGroupingEnabled: mode !== "separate",
32 };
33}
apps/mobile/src/state/project-grouping.test.ts created+33
......@@ -0,0 +1,33 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 mobileProjectGroupingModePatch,
5 resolveMobileProjectGroupingSettings,
6} from "./project-grouping.logic";
7
8describe("mobile project grouping preferences", () => {
9 it("maps the legacy boolean while preferring the new mode", () => {
10 expect(resolveMobileProjectGroupingSettings({}).sidebarProjectGroupingMode).toBe("repository");
11 expect(
12 resolveMobileProjectGroupingSettings({ projectGroupingEnabled: false })
13 .sidebarProjectGroupingMode,
14 ).toBe("separate");
15 expect(
16 resolveMobileProjectGroupingSettings({
17 projectGroupingEnabled: false,
18 projectGroupingMode: "repository_path",
19 }).sidebarProjectGroupingMode,
20 ).toBe("repository_path");
21 });
22
23 it("dual-writes the legacy boolean for rollback compatibility", () => {
24 expect(mobileProjectGroupingModePatch("separate")).toEqual({
25 projectGroupingMode: "separate",
26 projectGroupingEnabled: false,
27 });
28 expect(mobileProjectGroupingModePatch("repository_path")).toEqual({
29 projectGroupingMode: "repository_path",
30 projectGroupingEnabled: true,
31 });
32 });
33});
apps/mobile/src/state/project-grouping.ts created+17
......@@ -0,0 +1,17 @@
1import { useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3
4import { mobilePreferencesAtom } from "./preferences";
5import {
6 DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS,
7 resolveMobileProjectGroupingSettings,
8} from "./project-grouping.logic";
9
10export * from "./project-grouping.logic";
11
12export function useMobileProjectGroupingSettings() {
13 const preferencesResult = useAtomValue(mobilePreferencesAtom);
14 return AsyncResult.isSuccess(preferencesResult)
15 ? resolveMobileProjectGroupingSettings(preferencesResult.value)
16 : DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS;
17}
apps/web/src/logicalProject.ts+3
......@@ -1,4 +1,5 @@
11export {
2 buildProjectGroups,
23 deriveLogicalProjectKey,
34 deriveLogicalProjectKeyFromRef,
45 deriveLogicalProjectKeyFromSettings,
......@@ -11,4 +12,6 @@ export {
1112 selectProjectGroupingSettings,
1213 type ProjectGroupingMode,
1314 type ProjectGroupingSettings,
15 type ProjectGroup,
16 type ProjectGroupMember,
1417} from "@t3tools/client-runtime/state/project-grouping";
apps/web/src/sidebarProjectGrouping.ts+33-138
......@@ -1,11 +1,5 @@
1import { scopeProjectRef } from "@t3tools/client-runtime/environment";
21import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts";
3import {
4 deriveLogicalProjectKeyFromSettings,
5 derivePhysicalProjectKey,
6 deriveProjectGroupLabel,
7 type ProjectGroupingSettings,
8} from "./logicalProject";
2import { buildProjectGroups, type ProjectGroupingSettings } from "./logicalProject";
93import type { Project } from "./types";
104
115export type EnvironmentPresence = "local-only" | "remote-only" | "mixed";
......@@ -37,77 +31,21 @@ export interface SidebarProjectPickerEntry {
3731 isPreferred: boolean;
3832}
3933
40interface SidebarProjectGroupCandidate {
41 readonly logicalKey: string;
42 readonly project: Project;
43}
44
45function getProjectFreshnessTime(project: Project): number {
46 const updatedAtTime = Date.parse(project.updatedAt);
47 if (Number.isFinite(updatedAtTime)) {
48 return updatedAtTime;
49 }
50 const createdAtTime = Date.parse(project.createdAt);
51 return Number.isFinite(createdAtTime) ? createdAtTime : 0;
52}
53
54function shouldReplaceDuplicateMember(input: {
55 existingMember: Project;
56 candidateMember: Project;
57 primaryEnvironmentId: EnvironmentId | null;
58}): boolean {
59 if (
60 input.primaryEnvironmentId !== null &&
61 input.existingMember.environmentId !== input.primaryEnvironmentId &&
62 input.candidateMember.environmentId === input.primaryEnvironmentId
63 ) {
64 return true;
65 }
66
67 const existingFreshness = getProjectFreshnessTime(input.existingMember);
68 const candidateFreshness = getProjectFreshnessTime(input.candidateMember);
69 if (candidateFreshness !== existingFreshness) {
70 return candidateFreshness > existingFreshness;
71 }
72
73 return input.candidateMember.id > input.existingMember.id;
74}
75
76function collectProjectWinnersByPhysicalKey(input: {
77 projects: ReadonlyArray<Project>;
78 settings: ProjectGroupingSettings;
79 primaryEnvironmentId: EnvironmentId | null;
80}): Map<string, SidebarProjectGroupCandidate> {
81 const winnersByPhysicalKey = new Map<string, SidebarProjectGroupCandidate>();
82 for (const project of input.projects) {
83 const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
84 const physicalProjectKey = derivePhysicalProjectKey(project);
85 const existing = winnersByPhysicalKey.get(physicalProjectKey);
86 if (!existing) {
87 winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
88 continue;
89 }
90 if (
91 shouldReplaceDuplicateMember({
92 existingMember: existing.project,
93 candidateMember: project,
94 primaryEnvironmentId: input.primaryEnvironmentId,
95 })
96 ) {
97 winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project });
98 }
99 }
100 return winnersByPhysicalKey;
101}
102
10334export function buildPhysicalToLogicalProjectKeyMap(input: {
10435 projects: ReadonlyArray<Project>;
10536 settings: ProjectGroupingSettings;
10637 primaryEnvironmentId: EnvironmentId | null;
10738}): Map<string, string> {
10839 const mapping = new Map<string, string>();
109 for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) {
110 mapping.set(physicalProjectKey, winner.logicalKey);
40 const groups = buildProjectGroups({
41 projects: input.projects,
42 settings: input.settings,
43 preferredEnvironmentId: input.primaryEnvironmentId,
44 });
45 for (const group of groups) {
46 for (const member of group.members) {
47 mapping.set(member.physicalProjectKey, group.key);
48 }
11149 }
11250 return mapping;
11351}
......@@ -123,59 +61,24 @@ export function buildSidebarProjectSnapshots(input: {
12361 // legacy behavior.
12462 isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean;
12563}): SidebarProjectSnapshot[] {
126 const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input);
127 const groupedMembers = new Map<string, SidebarProjectGroupMember[]>();
128 for (const { logicalKey, project } of winnersByPhysicalKey.values()) {
129 const member: SidebarProjectGroupMember = {
130 ...project,
131 physicalProjectKey: derivePhysicalProjectKey(project),
132 environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
133 };
134 const existingMembers = groupedMembers.get(logicalKey);
135 if (existingMembers) {
136 existingMembers.push(member);
137 } else {
138 groupedMembers.set(logicalKey, [member]);
139 }
140 }
141
142 const projectRefsByLogicalKey = new Map<string, ScopedProjectRef[]>();
143 const seenProjectRefs = new Set<string>();
144 for (const project of input.projects) {
145 const physicalProjectKey = derivePhysicalProjectKey(project);
146 const logicalKey =
147 winnersByPhysicalKey.get(physicalProjectKey)?.logicalKey ??
148 deriveLogicalProjectKeyFromSettings(project, input.settings);
149 const projectRefKey = `${project.environmentId}:${project.id}`;
150 if (seenProjectRefs.has(projectRefKey)) continue;
151 seenProjectRefs.add(projectRefKey);
152
153 const projectRef = scopeProjectRef(project.environmentId, project.id);
154 const existingRefs = projectRefsByLogicalKey.get(logicalKey);
155 if (existingRefs) {
156 existingRefs.push(projectRef);
157 } else {
158 projectRefsByLogicalKey.set(logicalKey, [projectRef]);
159 }
160 }
161
162 const result: SidebarProjectSnapshot[] = [];
163 const seen = new Set<string>();
164 for (const project of input.projects) {
165 const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings);
166 if (seen.has(logicalKey)) {
167 continue;
168 }
169 seen.add(logicalKey);
170
171 const members = groupedMembers.get(logicalKey) ?? [];
64 return buildProjectGroups({
65 projects: input.projects,
66 settings: input.settings,
67 preferredEnvironmentId: input.primaryEnvironmentId,
68 }).map((group): SidebarProjectSnapshot => {
69 const members = group.members.map(
70 ({ physicalProjectKey, project }): SidebarProjectGroupMember => ({
71 ...project,
72 physicalProjectKey,
73 environmentLabel: input.resolveEnvironmentLabel(project.environmentId),
74 }),
75 );
17276 const representative =
173 (input.primaryEnvironmentId
174 ? members.find((member) => member.environmentId === input.primaryEnvironmentId)
175 : null) ?? members[0];
176 if (!representative) {
177 continue;
178 }
77 members.find(
78 (member) =>
79 member.environmentId === group.representative.environmentId &&
80 member.id === group.representative.id,
81 ) ?? members[0]!;
17982
18083 const hasLocal =
18184 input.primaryEnvironmentId !== null &&
......@@ -196,27 +99,19 @@ export function buildSidebarProjectSnapshots(input: {
19699 remoteMembers.length > 0 &&
197100 remoteMembers.every((member) => isDesktopLocal(member.environmentId));
198101
199 result.push({
102 return {
200103 ...representative,
201 projectKey: logicalKey,
202 displayName:
203 members.length > 1
204 ? deriveProjectGroupLabel({
205 representative,
206 members,
207 })
208 : representative.title,
104 projectKey: group.key,
105 displayName: group.label,
209106 groupedProjectCount: members.length,
210107 environmentPresence:
211108 hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only",
212109 allRemoteMembersAreDesktopLocal,
213110 memberProjects: members,
214 memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [],
111 memberProjectRefs: group.memberProjectRefs,
215112 remoteEnvironmentLabels,
216 });
217 }
218
219 return result;
113 };
114 });
220115}
221116
222117export function buildSidebarProjectPickerEntries(input: {
packages/client-runtime/src/state/projectGrouping.test.ts created+194
......@@ -0,0 +1,194 @@
1import { EnvironmentId, ProjectId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import type { EnvironmentProject } from "./models.ts";
5import {
6 buildProjectGroups,
7 derivePhysicalProjectKey,
8 type ProjectGroupingSettings,
9} from "./projectGrouping.ts";
10
11const environmentId = EnvironmentId.make("environment");
12const repositoryIdentity = {
13 canonicalKey: "github.com/t3tools/t3code",
14 locator: {
15 source: "git-remote" as const,
16 remoteName: "upstream",
17 remoteUrl: "https://github.com/t3tools/t3code.git",
18 },
19 provider: "github",
20 owner: "t3tools",
21 name: "t3code",
22 displayName: "T3 Code",
23};
24
25function makeProject(
26 id: string,
27 workspaceRoot: string,
28 overrides: Partial<EnvironmentProject> = {},
29): EnvironmentProject {
30 return {
31 environmentId,
32 id: ProjectId.make(id),
33 title: id,
34 workspaceRoot,
35 repositoryIdentity,
36 defaultModelSelection: null,
37 scripts: [],
38 createdAt: "2026-07-01T00:00:00.000Z",
39 updatedAt: "2026-07-01T00:00:00.000Z",
40 ...overrides,
41 };
42}
43
44function settings(
45 mode: ProjectGroupingSettings["sidebarProjectGroupingMode"],
46 overrides: ProjectGroupingSettings["sidebarProjectGroupingOverrides"] = {},
47): ProjectGroupingSettings {
48 return {
49 sidebarProjectGroupingMode: mode,
50 sidebarProjectGroupingOverrides: overrides,
51 };
52}
53
54describe("buildProjectGroups", () => {
55 it("preserves every physical clone as a selectable member in repository modes", () => {
56 const projects = [
57 makeProject("t3code", "/work/t3code"),
58 makeProject("t3code-2", "/work/t3code-2"),
59 makeProject("t3code-3", "/work/t3code-3"),
60 ];
61
62 for (const mode of ["repository", "repository_path"] as const) {
63 const groups = buildProjectGroups({ projects, settings: settings(mode) });
64 expect(groups).toHaveLength(1);
65 expect(groups[0]?.members.map((member) => member.project.id)).toEqual([
66 "t3code",
67 "t3code-2",
68 "t3code-3",
69 ]);
70 expect(groups[0]?.memberProjectRefs).toHaveLength(3);
71 }
72 });
73
74 it("keeps physical clones in separate groups when requested", () => {
75 const projects = [
76 makeProject("t3code", "/work/t3code"),
77 makeProject("t3code-2", "/work/t3code-2"),
78 makeProject("t3code-3", "/work/t3code-3"),
79 ];
80
81 const groups = buildProjectGroups({ projects, settings: settings("separate") });
82 expect(groups).toHaveLength(3);
83 expect(groups.flatMap((group) => group.members)).toHaveLength(3);
84 expect(groups.map((group) => group.label)).toEqual(["t3code", "t3code-2", "t3code-3"]);
85 });
86
87 it("applies a physical-project override without dropping its siblings", () => {
88 const first = makeProject("t3code", "/work/t3code");
89 const second = makeProject("t3code-2", "/work/t3code-2");
90 const third = makeProject("t3code-3", "/work/t3code-3");
91 const groups = buildProjectGroups({
92 projects: [first, second, third],
93 settings: settings("repository", {
94 [derivePhysicalProjectKey(second)]: "separate",
95 }),
96 });
97
98 expect(groups).toHaveLength(2);
99 expect(groups.flatMap((group) => group.members.map((member) => member.project.id))).toEqual([
100 "t3code",
101 "t3code-3",
102 "t3code-2",
103 ]);
104 });
105
106 it("dedupes stale registrations at one physical path using the freshest project", () => {
107 const stale = makeProject("stale", "/work/t3code", {
108 repositoryIdentity: null,
109 updatedAt: "2026-07-01T00:00:00.000Z",
110 });
111 const fresh = makeProject("fresh", "/work/t3code/", {
112 updatedAt: "2026-07-02T00:00:00.000Z",
113 });
114
115 const groups = buildProjectGroups({
116 projects: [stale, fresh],
117 settings: settings("repository"),
118 });
119 expect(groups).toHaveLength(1);
120 expect(groups[0]?.members).toHaveLength(1);
121 expect(groups[0]?.representative.id).toBe("fresh");
122 expect(groups[0]?.memberProjectRefs).toHaveLength(2);
123 });
124
125 it("uses repository identity from a duplicate registration when the winner lacks it", () => {
126 const identified = makeProject("identified", "/work/t3code", {
127 updatedAt: "2026-07-01T00:00:00.000Z",
128 });
129 const freshUnidentified = makeProject("fresh", "/work/t3code/", {
130 repositoryIdentity: null,
131 updatedAt: "2026-07-02T00:00:00.000Z",
132 });
133 const sibling = makeProject("sibling", "/work/t3code-2");
134
135 const groups = buildProjectGroups({
136 projects: [identified, freshUnidentified, sibling],
137 settings: settings("repository"),
138 });
139 expect(groups).toHaveLength(1);
140 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["fresh", "sibling"]);
141 });
142
143 it("uses the freshest winner's repository identity when stale duplicates disagree", () => {
144 const staleIdentity = {
145 ...repositoryIdentity,
146 canonicalKey: "github.com/t3tools/old-repository",
147 name: "old-repository",
148 displayName: "Old Repository",
149 };
150 const stale = makeProject("stale", "/work/t3code", {
151 repositoryIdentity: staleIdentity,
152 updatedAt: "2026-07-01T00:00:00.000Z",
153 });
154 const fresh = makeProject("fresh", "/work/t3code/", {
155 updatedAt: "2026-07-02T00:00:00.000Z",
156 });
157 const sibling = makeProject("sibling", "/work/t3code-2");
158
159 const groups = buildProjectGroups({
160 projects: [stale, fresh, sibling],
161 settings: settings("repository"),
162 });
163 expect(groups).toHaveLength(1);
164 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["fresh", "sibling"]);
165 });
166
167 it("uses the freshest identity-bearing duplicate when the winner lacks identity", () => {
168 const staleIdentity = {
169 ...repositoryIdentity,
170 canonicalKey: "github.com/t3tools/old-repository",
171 name: "old-repository",
172 displayName: "Old Repository",
173 };
174 const staleIdentified = makeProject("stale-identified", "/work/t3code", {
175 repositoryIdentity: staleIdentity,
176 updatedAt: "2026-07-01T00:00:00.000Z",
177 });
178 const freshIdentified = makeProject("fresh-identified", "/work/t3code/", {
179 updatedAt: "2026-07-02T00:00:00.000Z",
180 });
181 const winner = makeProject("winner", "/work/t3code", {
182 repositoryIdentity: null,
183 updatedAt: "2026-07-03T00:00:00.000Z",
184 });
185 const sibling = makeProject("sibling", "/work/t3code-2");
186
187 const groups = buildProjectGroups({
188 projects: [staleIdentified, freshIdentified, winner, sibling],
189 settings: settings("repository"),
190 });
191 expect(groups).toHaveLength(1);
192 expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]);
193 });
194});
packages/client-runtime/src/state/projectGrouping.ts+143-1
......@@ -1,5 +1,9 @@
11import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts";
2import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts";
2import type {
3 EnvironmentId,
4 ScopedProjectRef,
5 SidebarProjectGroupingMode,
6} from "@t3tools/contracts";
37import type { ClientSettings } from "@t3tools/contracts/settings";
48
59import type { EnvironmentProject } from "./models.ts";
......@@ -181,3 +185,141 @@ export function deriveProjectGroupLabel(input: {
181185
182186 return input.representative.title;
183187}
188
189export interface ProjectGroupMember<TProject extends EnvironmentProject = EnvironmentProject> {
190 readonly physicalProjectKey: string;
191 readonly project: TProject;
192}
193
194export interface ProjectGroup<TProject extends EnvironmentProject = EnvironmentProject> {
195 readonly key: string;
196 readonly label: string;
197 readonly representative: TProject;
198 readonly members: ReadonlyArray<ProjectGroupMember<TProject>>;
199 readonly memberProjectRefs: ReadonlyArray<ScopedProjectRef>;
200}
201
202function projectFreshnessTime(project: EnvironmentProject): number {
203 const updatedAtTime = Date.parse(project.updatedAt);
204 if (Number.isFinite(updatedAtTime)) {
205 return updatedAtTime;
206 }
207 const createdAtTime = Date.parse(project.createdAt);
208 return Number.isFinite(createdAtTime) ? createdAtTime : 0;
209}
210
211function shouldReplacePhysicalProjectWinner<TProject extends EnvironmentProject>(
212 existing: TProject,
213 candidate: TProject,
214): boolean {
215 const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing);
216 return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id);
217}
218
219function selectProjectIdentitySource<TProject extends EnvironmentProject>(
220 projects: ReadonlyArray<TProject>,
221 winner: TProject,
222): TProject {
223 if (winner.repositoryIdentity !== null) {
224 return winner;
225 }
226
227 let freshestIdentifiedProject: TProject | null = null;
228 for (const project of projects) {
229 if (project.repositoryIdentity === null) {
230 continue;
231 }
232 if (
233 freshestIdentifiedProject === null ||
234 shouldReplacePhysicalProjectWinner(freshestIdentifiedProject, project)
235 ) {
236 freshestIdentifiedProject = project;
237 }
238 }
239 return freshestIdentifiedProject ?? winner;
240}
241
242/**
243 * Builds logical project groups without losing the physical projects that
244 * remain the actual navigation and task-creation targets.
245 *
246 * Presentation-specific metadata, filtering, and activity sorting stay in
247 * each client. Grouping modes, overrides, physical deduplication, labels, and
248 * member preservation live here so web and mobile cannot drift.
249 */
250export function buildProjectGroups<TProject extends EnvironmentProject>(input: {
251 readonly projects: ReadonlyArray<TProject>;
252 readonly settings: ProjectGroupingSettings;
253 readonly preferredEnvironmentId?: EnvironmentId | null;
254}): ReadonlyArray<ProjectGroup<TProject>> {
255 const projectsByPhysicalKey = new Map<string, TProject[]>();
256 for (const project of input.projects) {
257 const physicalProjectKey = derivePhysicalProjectKey(project);
258 const existing = projectsByPhysicalKey.get(physicalProjectKey);
259 if (existing) {
260 existing.push(project);
261 } else {
262 projectsByPhysicalKey.set(physicalProjectKey, [project]);
263 }
264 }
265
266 const logicalKeyByPhysicalKey = new Map<string, string>();
267 const groupedMembers = new Map<string, ProjectGroupMember<TProject>[]>();
268 for (const [physicalProjectKey, physicalProjects] of projectsByPhysicalKey) {
269 const winner = physicalProjects.reduce((current, candidate) =>
270 shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current,
271 );
272 const identitySource = selectProjectIdentitySource(physicalProjects, winner);
273 const logicalKey = deriveLogicalProjectKey(identitySource, {
274 groupingMode: resolveProjectGroupingMode(winner, input.settings),
275 });
276 logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey);
277 const member = { physicalProjectKey, project: winner };
278 const existing = groupedMembers.get(logicalKey);
279 if (existing) {
280 existing.push(member);
281 } else {
282 groupedMembers.set(logicalKey, [member]);
283 }
284 }
285
286 const projectRefsByLogicalKey = new Map<string, ScopedProjectRef[]>();
287 const seenProjectRefs = new Set<string>();
288 for (const project of input.projects) {
289 const physicalProjectKey = derivePhysicalProjectKey(project);
290 const logicalKey =
291 logicalKeyByPhysicalKey.get(physicalProjectKey) ??
292 deriveLogicalProjectKeyFromSettings(project, input.settings);
293 const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
294 if (seenProjectRefs.has(projectRefKey)) continue;
295 seenProjectRefs.add(projectRefKey);
296 const projectRef = scopeProjectRef(project.environmentId, project.id);
297 const existing = projectRefsByLogicalKey.get(logicalKey);
298 if (existing) {
299 existing.push(projectRef);
300 } else {
301 projectRefsByLogicalKey.set(logicalKey, [projectRef]);
302 }
303 }
304
305 const preferredEnvironmentId = input.preferredEnvironmentId ?? null;
306 return Array.from(groupedMembers, ([key, members]) => {
307 const representative =
308 (preferredEnvironmentId
309 ? members.find((member) => member.project.environmentId === preferredEnvironmentId)?.project
310 : null) ?? members[0]!.project;
311 return {
312 key,
313 label:
314 members.length > 1
315 ? deriveProjectGroupLabel({
316 representative,
317 members: members.map((member) => member.project),
318 })
319 : representative.title,
320 representative,
321 members,
322 memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [],
323 };
324 });
325}