| author | |
| committer | |
| log | 47dfc6526521866992f6b50beac4587b45114631 |
| tree | 1540e39d1b2e0a6b6f15b56d47f93fe56258770a |
| parent | 41ebf22eea64e177b11a0709a16d6716581cc78c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 47 | 47 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 48 | 48 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 49 | 49 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 50 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; | |
| 50 | 51 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 51 | 52 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; |
| 52 | 53 | import { |
| ... | ... | @@ -175,6 +176,13 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 175 | 176 | title: "Appearance", |
| 176 | 177 | }, |
| 177 | 178 | }), |
| 179 | SettingsProjectGrouping: createNativeStackScreen({ | |
| 180 | screen: SettingsProjectGroupingRouteScreen, | |
| 181 | linking: "project-grouping", | |
| 182 | options: { | |
| 183 | title: "Project Grouping", | |
| 184 | }, | |
| 185 | }), | |
| 178 | 186 | SettingsClientStorage: createNativeStackScreen({ |
| 179 | 187 | screen: SettingsClientStorageRouteScreen, |
| 180 | 188 | linking: "client-storage", |
apps/mobile/src/components/AndroidScreenHeader.tsx+1-1| ... | ... | @@ -70,7 +70,7 @@ export function AndroidScreenHeader(props: { |
| 70 | 70 | accessibilityRole="button" |
| 71 | 71 | hitSlop={8} |
| 72 | 72 | onPress={props.onBack} |
| 73 | className="size-11 items-center justify-center" | |
| 73 | className="-mr-2 size-11 items-center justify-center" | |
| 74 | 74 | > |
| 75 | 75 | <SymbolView |
| 76 | 76 | name="chevron.left" |
apps/mobile/src/features/home/home-list-options.ts+3-7| ... | ... | @@ -31,12 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions { |
| 31 | 31 | readonly projectGroupingMode: SidebarProjectGroupingMode; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | export function resolveProjectGroupingMode( | |
| 35 | projectGroupingEnabled: boolean | undefined, | |
| 36 | ): SidebarProjectGroupingMode { | |
| 37 | return projectGroupingEnabled === false ? "separate" : "repository"; | |
| 38 | } | |
| 39 | ||
| 40 | 34 | export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ |
| 41 | 35 | readonly value: HomeProjectSortOrder; |
| 42 | 36 | readonly label: string; |
| ... | ... | @@ -76,7 +70,9 @@ const HomeListOptionsContext = createContext<HomeListOptionsContextValue | null> |
| 76 | 70 | export function HomeListOptionsProvider({ |
| 77 | 71 | children, |
| 78 | 72 | projectGroupingMode, |
| 79 | }: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) { | |
| 73 | }: PropsWithChildren<{ | |
| 74 | readonly projectGroupingMode: SidebarProjectGroupingMode; | |
| 75 | }>) { | |
| 80 | 76 | const [options, setOptions] = useState<HomeListOptions>(defaultHomeListOptions); |
| 81 | 77 | const value = useMemo( |
| 82 | 78 | () => ({ options, setOptions, projectGroupingMode }), |
apps/mobile/src/features/home/homeThreadList.test.ts+13-7| ... | ... | @@ -375,7 +375,7 @@ describe("buildHomeThreadGroups", () => { |
| 375 | 375 | ).toHaveLength(2); |
| 376 | 376 | }); |
| 377 | 377 | |
| 378 | it("uses the repository label for a singleton repository scope", () => { | |
| 378 | it("uses the physical project title for a singleton scope", () => { | |
| 379 | 379 | const project = makeProject({ |
| 380 | 380 | environmentId: EnvironmentId.make("environment-1"), |
| 381 | 381 | id: ProjectId.make("project-1"), |
| ... | ... | @@ -408,8 +408,8 @@ describe("buildHomeThreadGroups", () => { |
| 408 | 408 | ], |
| 409 | 409 | ); |
| 410 | 410 | |
| 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"); | |
| 413 | 413 | }); |
| 414 | 414 | |
| 415 | 415 | it("sorts the newest thread first regardless of snapshot order", () => { |
| ... | ... | @@ -565,10 +565,16 @@ describe("buildHomeThreadGroups", () => { |
| 565 | 565 | ); |
| 566 | 566 | |
| 567 | 567 | 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"]); | |
| 572 | 578 | }); |
| 573 | 579 | |
| 574 | 580 | it("default view shows only threads from the last 5 days", () => { |
apps/mobile/src/features/home/homeThreadList.ts+18-63| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { |
| 2 | deriveLogicalProjectKey, | |
| 2 | buildProjectGroups, | |
| 3 | 3 | derivePhysicalProjectKey, |
| 4 | 4 | deriveProjectGroupLabel, |
| 5 | 5 | } from "@t3tools/client-runtime/state/project-grouping"; |
| ... | ... | @@ -37,10 +37,6 @@ export interface HomeProjectScope { |
| 37 | 37 | readonly projectRefs: ReadonlyArray<ScopedProjectRef>; |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | function getProjectFreshnessTimestamp(project: EnvironmentProject): number { | |
| 41 | return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0; | |
| 42 | } | |
| 43 | ||
| 44 | 40 | function getProjectSortTimestamp( |
| 45 | 41 | project: EnvironmentProject, |
| 46 | 42 | sortOrder: HomeProjectSortOrder, |
| ... | ... | @@ -60,64 +56,19 @@ export function buildHomeProjectScopes(input: { |
| 60 | 56 | const projects = input.projects.filter( |
| 61 | 57 | (project) => input.environmentId === null || project.environmentId === input.environmentId, |
| 62 | 58 | ); |
| 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) => { | |
| 115 | 66 | 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, | |
| 121 | 72 | }; |
| 122 | 73 | }); |
| 123 | 74 | } |
| ... | ... | @@ -264,9 +215,11 @@ export function buildHomeThreadGroups(input: { |
| 264 | 215 | }): ReadonlyArray<HomeThreadGroup> { |
| 265 | 216 | const now = input.now ?? Date.now(); |
| 266 | 217 | const groups = new Map<string, MutableHomeThreadGroup>(); |
| 218 | const groupTitleByKey = new Map<string, string>(); | |
| 267 | 219 | const groupKeyByProjectKey = new Map<string, string>(); |
| 268 | 220 | |
| 269 | 221 | for (const scope of buildHomeProjectScopes(input)) { |
| 222 | groupTitleByKey.set(scope.key, scope.title); | |
| 270 | 223 | groups.set(scope.key, { |
| 271 | 224 | key: scope.key, |
| 272 | 225 | projects: [...scope.projects], |
| ... | ... | @@ -345,7 +298,9 @@ export function buildHomeThreadGroups(input: { |
| 345 | 298 | continue; |
| 346 | 299 | } |
| 347 | 300 | |
| 348 | const title = deriveProjectGroupLabel({ representative, members: group.projects }); | |
| 301 | const title = | |
| 302 | groupTitleByKey.get(group.key) ?? | |
| 303 | deriveProjectGroupLabel({ representative, members: group.projects }); | |
| 349 | 304 | const groupMatches = |
| 350 | 305 | query.length === 0 || |
| 351 | 306 | title.toLocaleLowerCase().includes(query) || |
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+11-5| ... | ... | @@ -37,11 +37,15 @@ import { |
| 37 | 37 | import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; |
| 38 | 38 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 39 | 39 | import { mobilePreferencesAtom } from "../../state/preferences"; |
| 40 | import { | |
| 41 | DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, | |
| 42 | resolveMobileProjectGroupingSettings, | |
| 43 | } from "../../state/project-grouping"; | |
| 40 | 44 | import { |
| 41 | 45 | parseActiveThreadPath, |
| 42 | 46 | useHardwareKeyboardCommand, |
| 43 | 47 | } from "../keyboard/hardwareKeyboardCommands"; |
| 44 | import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; | |
| 48 | import { HomeListOptionsProvider } from "../home/home-list-options"; | |
| 45 | 49 | import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; |
| 46 | 50 | import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; |
| 47 | 51 | import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; |
| ... | ... | @@ -190,15 +194,17 @@ export function AdaptiveWorkspaceLayout(props: { |
| 190 | 194 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 191 | 195 | if (!AsyncResult.isSuccess(preferencesResult)) { |
| 192 | 196 | return AsyncResult.isFailure(preferencesResult) ? ( |
| 193 | <AdaptiveWorkspaceLayoutContent {...props} projectGroupingMode="repository" /> | |
| 197 | <AdaptiveWorkspaceLayoutContent | |
| 198 | {...props} | |
| 199 | projectGroupingMode={DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS.sidebarProjectGroupingMode} | |
| 200 | /> | |
| 194 | 201 | ) : null; |
| 195 | 202 | } |
| 203 | const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); | |
| 196 | 204 | return ( |
| 197 | 205 | <AdaptiveWorkspaceLayoutContent |
| 198 | 206 | {...props} |
| 199 | projectGroupingMode={resolveProjectGroupingMode( | |
| 200 | preferencesResult.value.projectGroupingEnabled, | |
| 201 | )} | |
| 207 | projectGroupingMode={groupingSettings.sidebarProjectGroupingMode} | |
| 202 | 208 | /> |
| 203 | 209 | ); |
| 204 | 210 | } |
apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx+11-1| ... | ... | @@ -1,15 +1,25 @@ |
| 1 | import { ScrollView, View } from "react-native"; | |
| 1 | import { useNavigation } from "@react-navigation/native"; | |
| 2 | import { Platform, ScrollView, View } from "react-native"; | |
| 2 | 3 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 3 | 4 | |
| 5 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 4 | 7 | import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; |
| 5 | 8 | import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; |
| 6 | 9 | import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; |
| 7 | 10 | |
| 8 | 11 | export function SettingsAppearanceRouteScreen() { |
| 12 | const navigation = useNavigation(); | |
| 9 | 13 | const insets = useSafeAreaInsets(); |
| 10 | 14 | |
| 11 | 15 | return ( |
| 12 | 16 | <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} | |
| 13 | 23 | <ScrollView |
| 14 | 24 | contentInsetAdjustmentBehavior="automatic" |
| 15 | 25 | showsVerticalScrollIndicator={false} |
apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 2 | import { useNavigation } from "@react-navigation/native"; | |
| 3 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 5 | import { Platform, Pressable, ScrollView, View } from "react-native"; | |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 7 | ||
| 8 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | |
| 9 | import { AppText as Text } from "../../components/AppText"; | |
| 10 | import { SymbolView } from "../../components/AppSymbol"; | |
| 11 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 12 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | |
| 13 | import { | |
| 14 | mobileProjectGroupingModePatch, | |
| 15 | resolveMobileProjectGroupingSettings, | |
| 16 | } from "../../state/project-grouping"; | |
| 17 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 18 | import { SettingsSection } from "./components/SettingsSection"; | |
| 19 | ||
| 20 | const 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 | ||
| 42 | export 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() { |
| 530 | 530 | } |
| 531 | 531 | |
| 532 | 532 | function 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 | ||
| 539 | 533 | return ( |
| 540 | 534 | <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" /> | |
| 547 | 536 | </SettingsSection> |
| 548 | 537 | ); |
| 549 | 538 | } |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+1| ... | ... | @@ -2,6 +2,7 @@ export type SettingsSheetTarget = |
| 2 | 2 | | "SettingsEnvironments" |
| 3 | 3 | | "SettingsArchive" |
| 4 | 4 | | "SettingsAppearance" |
| 5 | | "SettingsProjectGrouping" | |
| 5 | 6 | | "SettingsClientStorage"; |
| 6 | 7 | |
| 7 | 8 | export type SettingsLegalDocumentTarget = "SettingsLegal"; |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+8-6| ... | ... | @@ -50,6 +50,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta |
| 50 | 50 | import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; |
| 51 | 51 | import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; |
| 52 | 52 | import { useCreateProjectThread } from "./use-project-actions"; |
| 53 | import { resolveDraftProjectSelection } from "./new-task-project-selection"; | |
| 53 | 54 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 54 | 55 | |
| 55 | 56 | function formatWorkspaceLabel(input: { |
| ... | ... | @@ -89,7 +90,7 @@ export function NewTaskDraftScreen(props: { |
| 89 | 90 | const colorScheme = useColorScheme(); |
| 90 | 91 | const isKeyboardVisible = useKeyboardState((state) => state.isVisible); |
| 91 | 92 | const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); |
| 92 | const { logicalProjects, selectedProject, setProject } = flow; | |
| 93 | const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; | |
| 93 | 94 | const { connectedEnvironments } = useRemoteConnectionStatus(); |
| 94 | 95 | const selectedEnvironmentServerConfig = useEnvironmentServerConfig( |
| 95 | 96 | selectedProject?.environmentId ?? null, |
| ... | ... | @@ -277,24 +278,25 @@ export function NewTaskDraftScreen(props: { |
| 277 | 278 | return; |
| 278 | 279 | } |
| 279 | 280 | |
| 280 | if (selectedProject) { | |
| 281 | const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes); | |
| 282 | if (selection.kind === "preserve") { | |
| 281 | 283 | return; |
| 282 | 284 | } |
| 283 | ||
| 284 | if (logicalProjects.length === 1) { | |
| 285 | setProject(logicalProjects[0]!.project); | |
| 285 | if (selection.kind === "select") { | |
| 286 | setProject(selection.project); | |
| 286 | 287 | return; |
| 287 | 288 | } |
| 288 | 289 | |
| 289 | 290 | navigation.dispatch(StackActions.replace("NewTask")); |
| 290 | 291 | }, [ |
| 291 | logicalProjects, | |
| 292 | projectScopes, | |
| 292 | 293 | projects, |
| 293 | 294 | props.initialProjectRef, |
| 294 | 295 | props.incomingShareId, |
| 295 | 296 | props.pendingTaskId, |
| 296 | 297 | navigation, |
| 297 | 298 | selectedProject, |
| 299 | selectedProjectKey, | |
| 298 | 300 | setProject, |
| 299 | 301 | ]); |
| 300 | 302 |
apps/mobile/src/features/threads/NewTaskRouteScreen.tsx+95-60| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 2 | 2 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 3 | import { SymbolView } from "../../components/AppSymbol"; |
| 4 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 5 | import { useEffect, useMemo, useRef } from "react"; | |
| 4 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import { useEffect, useRef, useState } from "react"; | |
| 6 | 6 | import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; |
| 7 | 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 8 | 8 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | ... | @@ -11,12 +11,13 @@ import { cn } from "../../lib/cn"; |
| 11 | 11 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; |
| 12 | 12 | import { AppText as Text } from "../../components/AppText"; |
| 13 | 13 | import { ProjectFavicon } from "../../components/ProjectFavicon"; |
| 14 | import { useProjects, useThreadShells } from "../../state/entities"; | |
| 14 | import { useProjects } from "../../state/entities"; | |
| 15 | 15 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 16 | 16 | import { useWorkspaceState } from "../../state/workspace"; |
| 17 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | |
| 17 | import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 18 | 18 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 19 | 19 | import { useIncomingShare } from "../sharing/IncomingShareProvider"; |
| 20 | import { useNewTaskFlow } from "./new-task-flow-provider"; | |
| 20 | 21 | |
| 21 | 22 | type NewTaskRouteParams = { |
| 22 | 23 | readonly incomingShareId?: string | string[]; |
| ... | ... | @@ -79,7 +80,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { |
| 79 | 80 | |
| 80 | 81 | export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRouteParams | undefined>) { |
| 81 | 82 | const projects = useProjects(); |
| 82 | const threads = useThreadShells(); | |
| 83 | const { projectScopes } = useNewTaskFlow(); | |
| 83 | 84 | const { state: catalogState } = useWorkspaceState(); |
| 84 | 85 | const navigation = useNavigation(); |
| 85 | 86 | const isFocused = useIsFocused(); |
| ... | ... | @@ -87,6 +88,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 87 | 88 | const insets = useSafeAreaInsets(); |
| 88 | 89 | const chevronColor = useThemeColor("--color-chevron"); |
| 89 | 90 | const accentColor = useThemeColor("--color-icon-muted"); |
| 91 | const [expandedGroupKeys, setExpandedGroupKeys] = useState<ReadonlySet<string>>(() => new Set()); | |
| 90 | 92 | const { getShare, releaseShareReservation } = useIncomingShare(); |
| 91 | 93 | const routeShareId = Array.isArray(route.params?.incomingShareId) |
| 92 | 94 | ? route.params.incomingShareId[0] |
| ... | ... | @@ -100,33 +102,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 100 | 102 | : `Choose a project for the ${incomingShare.attachments.length} images you shared` |
| 101 | 103 | : null; |
| 102 | 104 | 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]); | |
| 130 | 105 | const projectEmptyState = deriveProjectEmptyState(catalogState); |
| 131 | 106 | const resumedDestinationKeyRef = useRef<string | null>(null); |
| 132 | 107 | const reservedDestinationProject = incomingShare?.destination |
| ... | ... | @@ -137,7 +112,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 137 | 112 | ) ?? null) |
| 138 | 113 | : null; |
| 139 | 114 | |
| 140 | async function selectProject(item: (typeof items)[number]): Promise<void> { | |
| 115 | async function selectProject(project: EnvironmentProject): Promise<void> { | |
| 141 | 116 | if (incomingShare?.destination && !reservedDestinationProject) { |
| 142 | 117 | try { |
| 143 | 118 | await releaseShareReservation(incomingShare.id, incomingShare.destination); |
| ... | ... | @@ -154,14 +129,26 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 154 | 129 | navigation.navigate("NewTaskSheet", { |
| 155 | 130 | screen: "NewTaskDraft", |
| 156 | 131 | 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, | |
| 160 | 135 | incomingShareId: incomingShare?.id, |
| 161 | 136 | }, |
| 162 | 137 | }); |
| 163 | 138 | } |
| 164 | 139 | |
| 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 | ||
| 165 | 152 | useEffect(() => { |
| 166 | 153 | const destination = incomingShare?.destination; |
| 167 | 154 | if (!destination) { |
| ... | ... | @@ -251,11 +238,12 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 251 | 238 | contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} |
| 252 | 239 | contentContainerStyle={{ |
| 253 | 240 | gap: 12, |
| 241 | paddingBottom: 8, | |
| 254 | 242 | paddingHorizontal: 20, |
| 255 | 243 | paddingTop: 8, |
| 256 | 244 | }} |
| 257 | 245 | > |
| 258 | {items.length === 0 ? ( | |
| 246 | {projectScopes.length === 0 ? ( | |
| 259 | 247 | <View collapsable={false} className="items-center gap-3 rounded-[24px] bg-card px-6 py-8"> |
| 260 | 248 | {projectEmptyState.loading ? <ActivityIndicator color={accentColor} /> : null} |
| 261 | 249 | <Text className="text-center text-lg font-t3-bold text-foreground"> |
| ... | ... | @@ -286,42 +274,89 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps<NewTaskRoutePara |
| 286 | 274 | </View> |
| 287 | 275 | ) : ( |
| 288 | 276 | <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]; | |
| 293 | 281 | 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")} | |
| 304 | 285 | > |
| 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 | > | |
| 306 | 297 | <View className="h-7 w-7 items-center justify-center"> |
| 307 | 298 | <ProjectFavicon |
| 308 | environmentId={item.environmentId} | |
| 299 | environmentId={scope.representative.environmentId} | |
| 309 | 300 | size={20} |
| 310 | projectTitle={item.title} | |
| 311 | workspaceRoot={item.workspaceRoot} | |
| 301 | projectTitle={scope.title} | |
| 302 | workspaceRoot={scope.representative.workspaceRoot} | |
| 312 | 303 | /> |
| 313 | 304 | </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> | |
| 316 | 316 | </View> |
| 317 | 317 | <SymbolView |
| 318 | name="chevron.right" | |
| 318 | name={hasMultipleProjects && expanded ? "chevron.down" : "chevron.right"} | |
| 319 | 319 | size={14} |
| 320 | 320 | tintColor={chevronColor} |
| 321 | 321 | type="monochrome" |
| 322 | 322 | /> |
| 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> | |
| 325 | 360 | ); |
| 326 | 361 | })} |
| 327 | 362 | </View> |
apps/mobile/src/features/threads/new-task-flow-provider.tsx+21-31| ... | ... | @@ -27,7 +27,6 @@ import { |
| 27 | 27 | groupByProvider, |
| 28 | 28 | resolveSelectableModelSelection, |
| 29 | 29 | } from "../../lib/modelOptions"; |
| 30 | import { groupProjectsByRepository } from "../../lib/repositoryGroups"; | |
| 31 | 30 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 32 | 31 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 33 | 32 | import { |
| ... | ... | @@ -59,6 +58,12 @@ import { |
| 59 | 58 | } from "../../state/use-remote-environment-registry"; |
| 60 | 59 | import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; |
| 61 | 60 | import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; |
| 61 | import { | |
| 62 | buildHomeProjectScopes, | |
| 63 | sortHomeProjectScopes, | |
| 64 | type HomeProjectScope, | |
| 65 | } from "../home/homeThreadList"; | |
| 66 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; | |
| 62 | 67 | |
| 63 | 68 | type WorkspaceMode = "local" | "worktree"; |
| 64 | 69 | |
| ... | ... | @@ -109,10 +114,7 @@ export function branchBadgeLabel(input: { |
| 109 | 114 | } |
| 110 | 115 | |
| 111 | 116 | type NewTaskFlowContextValue = { |
| 112 | readonly logicalProjects: ReadonlyArray<{ | |
| 113 | readonly key: string; | |
| 114 | readonly project: EnvironmentProject; | |
| 115 | }>; | |
| 117 | readonly projectScopes: ReadonlyArray<HomeProjectScope>; | |
| 116 | 118 | readonly selectedEnvironmentId: EnvironmentId | null; |
| 117 | 119 | readonly selectedProjectKey: string | null; |
| 118 | 120 | readonly selectedModelKey: string | null; |
| ... | ... | @@ -175,32 +177,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 175 | 177 | const projects = useProjects(); |
| 176 | 178 | const threads = useThreadShells(); |
| 177 | 179 | 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( | |
| 184 | 182 | () => |
| 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, | |
| 193 | 188 | }), |
| 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], | |
| 204 | 194 | ); |
| 205 | 195 | |
| 206 | 196 | const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState<EnvironmentId | null>( |
| ... | ... | @@ -846,7 +836,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 846 | 836 | |
| 847 | 837 | const value = useMemo<NewTaskFlowContextValue>( |
| 848 | 838 | () => ({ |
| 849 | logicalProjects, | |
| 839 | projectScopes, | |
| 850 | 840 | selectedEnvironmentId, |
| 851 | 841 | selectedProjectKey, |
| 852 | 842 | selectedModelKey, |
| ... | ... | @@ -912,7 +902,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { |
| 912 | 902 | finishEditingPendingTask, |
| 913 | 903 | interactionMode, |
| 914 | 904 | loadBranches, |
| 915 | logicalProjects, | |
| 905 | projectScopes, | |
| 916 | 906 | modelOptions, |
| 917 | 907 | prompt, |
| 918 | 908 | providerGroups, |
apps/mobile/src/features/threads/new-task-project-selection.test.ts created+82| ... | ... | @@ -0,0 +1,82 @@ |
| 1 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 5 | import type { HomeProjectScope } from "../home/homeThreadList"; | |
| 6 | import { | |
| 7 | getOnlySelectableProject, | |
| 8 | resolveDraftProjectSelection, | |
| 9 | } from "./new-task-project-selection"; | |
| 10 | ||
| 11 | function 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 | ||
| 25 | function 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 | ||
| 38 | describe("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 | ||
| 50 | describe("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 @@ |
| 1 | import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; | |
| 2 | ||
| 3 | import { scopedProjectKey } from "../../lib/scopedEntities"; | |
| 4 | import type { HomeProjectScope } from "../home/homeThreadList"; | |
| 5 | ||
| 6 | export type DraftProjectSelectionResolution = | |
| 7 | | { readonly kind: "preserve" } | |
| 8 | | { readonly kind: "select"; readonly project: EnvironmentProject } | |
| 9 | | { readonly kind: "pick" }; | |
| 10 | ||
| 11 | export 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 | ||
| 18 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { groupProjectsByRepository } from "./repositoryGroups"; | |
| 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 7 | ||
| 8 | function 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 | ||
| 22 | function 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 | ||
| 46 | describe("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 @@ |
| 1 | import * as Order from "effect/Order"; | |
| 2 | import * as Arr from "effect/Array"; | |
| 3 | import type { RepositoryIdentity } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { scopedProjectKey } from "./scopedEntities"; | |
| 6 | import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | |
| 7 | ||
| 8 | const DateDescending = Order.flip(Order.Date); | |
| 9 | ||
| 10 | export interface RepositoryProjectGroup { | |
| 11 | readonly key: string; | |
| 12 | readonly project: EnvironmentProject; | |
| 13 | readonly threads: ReadonlyArray<EnvironmentThreadShell>; | |
| 14 | readonly latestActivityAt: string; | |
| 15 | } | |
| 16 | ||
| 17 | export 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 | ||
| 28 | function compareIsoDateDescending(left: string, right: string): number { | |
| 29 | return new Date(right).getTime() - new Date(left).getTime(); | |
| 30 | } | |
| 31 | ||
| 32 | function deriveRepositoryGroupKey(project: EnvironmentProject): string { | |
| 33 | return ( | |
| 34 | project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) | |
| 35 | ); | |
| 36 | } | |
| 37 | ||
| 38 | function deriveRepositoryTitle(project: EnvironmentProject): string { | |
| 39 | const identity = project.repositoryIdentity; | |
| 40 | return identity?.displayName ?? identity?.name ?? project.title; | |
| 41 | } | |
| 42 | ||
| 43 | function 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 | ||
| 53 | function 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 | ||
| 61 | export 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"; |
| 5 | 5 | import * as Ref from "effect/Ref"; |
| 6 | 6 | import * as Schema from "effect/Schema"; |
| 7 | 7 | import * as Semaphore from "effect/Semaphore"; |
| 8 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | |
| 8 | 9 | |
| 9 | 10 | import * as MobileDatabase from "./mobile-database"; |
| 10 | 11 | import * as MobileSecureStorage from "./mobile-secure-storage"; |
| ... | ... | @@ -22,7 +23,9 @@ export interface Preferences { |
| 22 | 23 | readonly codeWordBreak?: boolean; |
| 23 | 24 | readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>; |
| 24 | 25 | readonly collapsedProjectGroups?: readonly string[]; |
| 26 | /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ | |
| 25 | 27 | readonly projectGroupingEnabled?: boolean; |
| 28 | readonly projectGroupingMode?: SidebarProjectGroupingMode; | |
| 26 | 29 | /** |
| 27 | 30 | * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no |
| 28 | 31 | * client-settings sync, so the flat v2 thread list is opted out of per |
| ... | ... | @@ -80,6 +83,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 80 | 83 | connectOnboardingOptOutAccounts?: ReadonlyArray<string>; |
| 81 | 84 | collapsedProjectGroups?: readonly string[]; |
| 82 | 85 | projectGroupingEnabled?: boolean; |
| 86 | projectGroupingMode?: SidebarProjectGroupingMode; | |
| 83 | 87 | threadListV2Enabled?: boolean; |
| 84 | 88 | } = {}; |
| 85 | 89 | |
| ... | ... | @@ -110,6 +114,13 @@ function sanitizePreferences(parsed: Preferences): Preferences { |
| 110 | 114 | if (typeof parsed.projectGroupingEnabled === "boolean") { |
| 111 | 115 | preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; |
| 112 | 116 | } |
| 117 | if ( | |
| 118 | parsed.projectGroupingMode === "repository" || | |
| 119 | parsed.projectGroupingMode === "repository_path" || | |
| 120 | parsed.projectGroupingMode === "separate" | |
| 121 | ) { | |
| 122 | preferences.projectGroupingMode = parsed.projectGroupingMode; | |
| 123 | } | |
| 113 | 124 | if (typeof parsed.threadListV2Enabled === "boolean") { |
| 114 | 125 | preferences.threadListV2Enabled = parsed.threadListV2Enabled; |
| 115 | 126 | } |
apps/mobile/src/state/project-grouping.logic.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; | |
| 2 | import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import type { Preferences } from "../persistence/mobile-preferences"; | |
| 5 | ||
| 6 | export const DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS: ProjectGroupingSettings = { | |
| 7 | sidebarProjectGroupingMode: "repository", | |
| 8 | sidebarProjectGroupingOverrides: {}, | |
| 9 | }; | |
| 10 | ||
| 11 | export 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 | */ | |
| 26 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | mobileProjectGroupingModePatch, | |
| 5 | resolveMobileProjectGroupingSettings, | |
| 6 | } from "./project-grouping.logic"; | |
| 7 | ||
| 8 | describe("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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 3 | ||
| 4 | import { mobilePreferencesAtom } from "./preferences"; | |
| 5 | import { | |
| 6 | DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, | |
| 7 | resolveMobileProjectGroupingSettings, | |
| 8 | } from "./project-grouping.logic"; | |
| 9 | ||
| 10 | export * from "./project-grouping.logic"; | |
| 11 | ||
| 12 | export 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 @@ |
| 1 | 1 | export { |
| 2 | buildProjectGroups, | |
| 2 | 3 | deriveLogicalProjectKey, |
| 3 | 4 | deriveLogicalProjectKeyFromRef, |
| 4 | 5 | deriveLogicalProjectKeyFromSettings, |
| ... | ... | @@ -11,4 +12,6 @@ export { |
| 11 | 12 | selectProjectGroupingSettings, |
| 12 | 13 | type ProjectGroupingMode, |
| 13 | 14 | type ProjectGroupingSettings, |
| 15 | type ProjectGroup, | |
| 16 | type ProjectGroupMember, | |
| 14 | 17 | } from "@t3tools/client-runtime/state/project-grouping"; |
apps/web/src/sidebarProjectGrouping.ts+33-138| ... | ... | @@ -1,11 +1,5 @@ |
| 1 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 2 | 1 | import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; |
| 3 | import { | |
| 4 | deriveLogicalProjectKeyFromSettings, | |
| 5 | derivePhysicalProjectKey, | |
| 6 | deriveProjectGroupLabel, | |
| 7 | type ProjectGroupingSettings, | |
| 8 | } from "./logicalProject"; | |
| 2 | import { buildProjectGroups, type ProjectGroupingSettings } from "./logicalProject"; | |
| 9 | 3 | import type { Project } from "./types"; |
| 10 | 4 | |
| 11 | 5 | export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; |
| ... | ... | @@ -37,77 +31,21 @@ export interface SidebarProjectPickerEntry { |
| 37 | 31 | isPreferred: boolean; |
| 38 | 32 | } |
| 39 | 33 | |
| 40 | interface SidebarProjectGroupCandidate { | |
| 41 | readonly logicalKey: string; | |
| 42 | readonly project: Project; | |
| 43 | } | |
| 44 | ||
| 45 | function 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 | ||
| 54 | function 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 | ||
| 76 | function 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 | ||
| 103 | 34 | export function buildPhysicalToLogicalProjectKeyMap(input: { |
| 104 | 35 | projects: ReadonlyArray<Project>; |
| 105 | 36 | settings: ProjectGroupingSettings; |
| 106 | 37 | primaryEnvironmentId: EnvironmentId | null; |
| 107 | 38 | }): Map<string, string> { |
| 108 | 39 | 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 | } | |
| 111 | 49 | } |
| 112 | 50 | return mapping; |
| 113 | 51 | } |
| ... | ... | @@ -123,59 +61,24 @@ export function buildSidebarProjectSnapshots(input: { |
| 123 | 61 | // legacy behavior. |
| 124 | 62 | isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; |
| 125 | 63 | }): 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 | ); | |
| 172 | 76 | 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]!; | |
| 179 | 82 | |
| 180 | 83 | const hasLocal = |
| 181 | 84 | input.primaryEnvironmentId !== null && |
| ... | ... | @@ -196,27 +99,19 @@ export function buildSidebarProjectSnapshots(input: { |
| 196 | 99 | remoteMembers.length > 0 && |
| 197 | 100 | remoteMembers.every((member) => isDesktopLocal(member.environmentId)); |
| 198 | 101 | |
| 199 | result.push({ | |
| 102 | return { | |
| 200 | 103 | ...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, | |
| 209 | 106 | groupedProjectCount: members.length, |
| 210 | 107 | environmentPresence: |
| 211 | 108 | hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", |
| 212 | 109 | allRemoteMembersAreDesktopLocal, |
| 213 | 110 | memberProjects: members, |
| 214 | memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [], | |
| 111 | memberProjectRefs: group.memberProjectRefs, | |
| 215 | 112 | remoteEnvironmentLabels, |
| 216 | }); | |
| 217 | } | |
| 218 | ||
| 219 | return result; | |
| 113 | }; | |
| 114 | }); | |
| 220 | 115 | } |
| 221 | 116 | |
| 222 | 117 | export function buildSidebarProjectPickerEntries(input: { |
packages/client-runtime/src/state/projectGrouping.test.ts created+194| ... | ... | @@ -0,0 +1,194 @@ |
| 1 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import type { EnvironmentProject } from "./models.ts"; | |
| 5 | import { | |
| 6 | buildProjectGroups, | |
| 7 | derivePhysicalProjectKey, | |
| 8 | type ProjectGroupingSettings, | |
| 9 | } from "./projectGrouping.ts"; | |
| 10 | ||
| 11 | const environmentId = EnvironmentId.make("environment"); | |
| 12 | const 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 | ||
| 25 | function 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 | ||
| 44 | function settings( | |
| 45 | mode: ProjectGroupingSettings["sidebarProjectGroupingMode"], | |
| 46 | overrides: ProjectGroupingSettings["sidebarProjectGroupingOverrides"] = {}, | |
| 47 | ): ProjectGroupingSettings { | |
| 48 | return { | |
| 49 | sidebarProjectGroupingMode: mode, | |
| 50 | sidebarProjectGroupingOverrides: overrides, | |
| 51 | }; | |
| 52 | } | |
| 53 | ||
| 54 | describe("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 @@ |
| 1 | 1 | import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; |
| 2 | import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; | |
| 2 | import type { | |
| 3 | EnvironmentId, | |
| 4 | ScopedProjectRef, | |
| 5 | SidebarProjectGroupingMode, | |
| 6 | } from "@t3tools/contracts"; | |
| 3 | 7 | import type { ClientSettings } from "@t3tools/contracts/settings"; |
| 4 | 8 | |
| 5 | 9 | import type { EnvironmentProject } from "./models.ts"; |
| ... | ... | @@ -181,3 +185,141 @@ export function deriveProjectGroupLabel(input: { |
| 181 | 185 | |
| 182 | 186 | return input.representative.title; |
| 183 | 187 | } |
| 188 | ||
| 189 | export interface ProjectGroupMember<TProject extends EnvironmentProject = EnvironmentProject> { | |
| 190 | readonly physicalProjectKey: string; | |
| 191 | readonly project: TProject; | |
| 192 | } | |
| 193 | ||
| 194 | export 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 | ||
| 202 | function 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 | ||
| 211 | function 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 | ||
| 219 | function 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 | */ | |
| 250 | export 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 | } |