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