diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 719a6a4ad59..1516b7cbc73 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,6 +47,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; +import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { @@ -175,6 +176,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsProjectGrouping: createNativeStackScreen({ + screen: SettingsProjectGroupingRouteScreen, + linking: "project-grouping", + options: { + title: "Project Grouping", + }, + }), SettingsClientStorage: createNativeStackScreen({ screen: SettingsClientStorageRouteScreen, linking: "client-storage", diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index ef5319a1b6f..7fe21fb44ff 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -70,7 +70,7 @@ export function AndroidScreenHeader(props: { accessibilityRole="button" hitSlop={8} onPress={props.onBack} - className="size-11 items-center justify-center" + className="-mr-2 size-11 items-center justify-center" > export function HomeListOptionsProvider({ children, projectGroupingMode, -}: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) { +}: PropsWithChildren<{ + readonly projectGroupingMode: SidebarProjectGroupingMode; +}>) { const [options, setOptions] = useState(defaultHomeListOptions); const value = useMemo( () => ({ options, setOptions, projectGroupingMode }), diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 75964aa23d2..60d3ab2c867 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -375,7 +375,7 @@ describe("buildHomeThreadGroups", () => { ).toHaveLength(2); }); - it("uses the repository label for a singleton repository scope", () => { + it("uses the physical project title for a singleton scope", () => { const project = makeProject({ environmentId: EnvironmentId.make("environment-1"), id: ProjectId.make("project-1"), @@ -408,8 +408,8 @@ describe("buildHomeThreadGroups", () => { ], ); - expect(scopes[0]?.title).toBe("codething-mvp"); - expect(groups[0]?.title).toBe("codething-mvp"); + expect(scopes[0]?.title).toBe("local-worktree-name"); + expect(groups[0]?.title).toBe("local-worktree-name"); }); it("sorts the newest thread first regardless of snapshot order", () => { @@ -565,10 +565,16 @@ describe("buildHomeThreadGroups", () => { ); expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength( - 2, - ); - expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); + expect( + buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( + (group) => group.title, + ), + ).toEqual(["Mobile", "Web"]); + expect( + buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( + (group) => group.title, + ), + ).toEqual(["Mobile", "Web"]); }); it("default view shows only threads from the last 5 days", () => { diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 5bd14086e29..2a9e0ec2cb8 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,5 +1,5 @@ import { - deriveLogicalProjectKey, + buildProjectGroups, derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; @@ -37,10 +37,6 @@ export interface HomeProjectScope { readonly projectRefs: ReadonlyArray; } -function getProjectFreshnessTimestamp(project: EnvironmentProject): number { - return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0; -} - function getProjectSortTimestamp( project: EnvironmentProject, sortOrder: HomeProjectSortOrder, @@ -60,64 +56,19 @@ export function buildHomeProjectScopes(input: { const projects = input.projects.filter( (project) => input.environmentId === null || project.environmentId === input.environmentId, ); - const projectsByPhysicalKey = new Map(); - for (const project of projects) { - const physicalKey = derivePhysicalProjectKey(project); - const existing = projectsByPhysicalKey.get(physicalKey); - if (existing) existing.push(project); - else projectsByPhysicalKey.set(physicalKey, [project]); - } - - const winnersByPhysicalKey = new Map< - string, - { readonly key: string; readonly project: EnvironmentProject } - >(); - for (const [physicalKey, members] of projectsByPhysicalKey) { - const project = members.reduce((winner, candidate) => { - const freshnessDelta = - getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner); - return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id) - ? candidate - : winner; - }); - const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project; - winnersByPhysicalKey.set(physicalKey, { - key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }), - project, - }); - } - - const groups = new Map(); - for (const { key, project } of winnersByPhysicalKey.values()) { - const existing = groups.get(key); - if (existing) existing.push(project); - else groups.set(key, [project]); - } - - const projectRefsByGroup = new Map(); - const seenProjectRefs = new Set(); - for (const project of projects) { - const refKey = scopedProjectKey(project.environmentId, project.id); - if (seenProjectRefs.has(refKey)) continue; - seenProjectRefs.add(refKey); - - const key = - winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ?? - deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode }); - const refs = projectRefsByGroup.get(key); - const projectRef = { environmentId: project.environmentId, projectId: project.id }; - if (refs) refs.push(projectRef); - else projectRefsByGroup.set(key, [projectRef]); - } - - return Array.from(groups, ([key, projects]) => { - const representative = projects[0]!; + return buildProjectGroups({ + projects, + settings: { + sidebarProjectGroupingMode: input.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + }).map((group) => { return { - key, - title: deriveProjectGroupLabel({ representative, members: projects }), - representative, - projects, - projectRefs: projectRefsByGroup.get(key) ?? [], + key: group.key, + title: group.label, + representative: group.representative, + projects: group.members.map((member) => member.project), + projectRefs: group.memberProjectRefs, }; }); } @@ -264,9 +215,11 @@ export function buildHomeThreadGroups(input: { }): ReadonlyArray { const now = input.now ?? Date.now(); const groups = new Map(); + const groupTitleByKey = new Map(); const groupKeyByProjectKey = new Map(); for (const scope of buildHomeProjectScopes(input)) { + groupTitleByKey.set(scope.key, scope.title); groups.set(scope.key, { key: scope.key, projects: [...scope.projects], @@ -345,7 +298,9 @@ export function buildHomeThreadGroups(input: { continue; } - const title = deriveProjectGroupLabel({ representative, members: group.projects }); + const title = + groupTitleByKey.get(group.key) ?? + deriveProjectGroupLabel({ representative, members: group.projects }); const groupMatches = query.length === 0 || title.toLocaleLowerCase().includes(query) || diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9c068c6249c..a93268d0da6 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -37,11 +37,15 @@ import { import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { mobilePreferencesAtom } from "../../state/preferences"; +import { + DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, + resolveMobileProjectGroupingSettings, +} from "../../state/project-grouping"; import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; +import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -190,15 +194,17 @@ export function AdaptiveWorkspaceLayout(props: { const preferencesResult = useAtomValue(mobilePreferencesAtom); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( - + ) : null; } + const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); return ( ); } diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 21bcad35e20..5b62942bb69 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,15 +1,25 @@ -import { ScrollView, View } from "react-native"; +import { useNavigation } from "@react-navigation/native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; export function SettingsAppearanceRouteScreen() { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); return ( + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} = [ + { + mode: "repository", + label: "Group by repository", + description: "Matching repositories appear as one project.", + }, + { + mode: "repository_path", + label: "Group by repository path", + description: "Keep monorepo paths separate.", + }, + { + mode: "separate", + label: "Keep separate", + description: "Show every workspace as its own project.", + }, +]; + +export function SettingsProjectGroupingRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const checkmarkColor = useThemeColor("--color-icon"); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const selectedMode = AsyncResult.isSuccess(preferencesResult) + ? resolveMobileProjectGroupingSettings(preferencesResult.value).sidebarProjectGroupingMode + : null; + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + {GROUPING_OPTIONS.map((option, index) => ( + savePreferences(mobileProjectGroupingModePatch(option.mode))} + className={ + index === 0 + ? "flex-row items-center gap-4 p-4" + : "flex-row items-center gap-4 border-t border-border-subtle p-4" + } + > + + {option.label} + + {option.description} + + + {selectedMode === option.mode ? ( + + ) : null} + + ))} + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 49adfe75cb2..8547859adde 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -530,20 +530,9 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.projectGroupingEnabled !== false - : true; - return ( - savePreferences({ projectGroupingEnabled: value })} - /> + ); } diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 71c059bedb4..df012c90325 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -2,6 +2,7 @@ export type SettingsSheetTarget = | "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance" + | "SettingsProjectGrouping" | "SettingsClientStorage"; export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6d204e3a8e6..6b121d85108 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -50,6 +50,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; +import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; function formatWorkspaceLabel(input: { @@ -89,7 +90,7 @@ export function NewTaskDraftScreen(props: { const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); - const { logicalProjects, selectedProject, setProject } = flow; + const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, @@ -277,24 +278,25 @@ export function NewTaskDraftScreen(props: { return; } - if (selectedProject) { + const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes); + if (selection.kind === "preserve") { return; } - - if (logicalProjects.length === 1) { - setProject(logicalProjects[0]!.project); + if (selection.kind === "select") { + setProject(selection.project); return; } navigation.dispatch(StackActions.replace("NewTask")); }, [ - logicalProjects, + projectScopes, projects, props.initialProjectRef, props.incomingShareId, props.pendingTaskId, navigation, selectedProject, + selectedProjectKey, setProject, ]); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 2005196f6bb..f5921825928 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,8 +1,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { useEffect, useMemo, useRef } from "react"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -11,12 +11,13 @@ import { cn } from "../../lib/cn"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { useProjects } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { useNewTaskFlow } from "./new-task-flow-provider"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; @@ -79,7 +80,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); - const threads = useThreadShells(); + const { projectScopes } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -87,6 +88,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps>(() => new Set()); const { getShare, releaseShareReservation } = useIncomingShare(); const routeShareId = Array.isArray(route.params?.incomingShareId) ? route.params.incomingShareId[0] @@ -100,33 +102,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps groupProjectsByRepository({ projects, threads }), - [projects, threads], - ); - const items = useMemo(() => { - const nextItems: Array<{ - readonly environmentId: EnvironmentId; - readonly id: ProjectId; - readonly key: string; - readonly title: string; - readonly workspaceRoot: string; - }> = []; - for (const group of repositoryGroups) { - const project = group.projects[0]?.project; - if (!project) { - continue; - } - nextItems.push({ - environmentId: project.environmentId, - id: project.id, - key: group.key, - title: project.title, - workspaceRoot: project.workspaceRoot, - }); - } - return nextItems; - }, [repositoryGroups]); const projectEmptyState = deriveProjectEmptyState(catalogState); const resumedDestinationKeyRef = useRef(null); const reservedDestinationProject = incomingShare?.destination @@ -137,7 +112,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + async function selectProject(project: EnvironmentProject): Promise { if (incomingShare?.destination && !reservedDestinationProject) { try { await releaseShareReservation(incomingShare.id, incomingShare.destination); @@ -154,14 +129,26 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + const next = new Set(current); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + } + useEffect(() => { const destination = incomingShare?.destination; if (!destination) { @@ -251,11 +238,12 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - {items.length === 0 ? ( + {projectScopes.length === 0 ? ( {projectEmptyState.loading ? : null} @@ -286,42 +274,89 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps ) : ( - {items.map((item, index) => { - const isFirst = index === 0; - const isLast = index === items.length - 1; - + {projectScopes.map((scope, scopeIndex) => { + const hasMultipleProjects = scope.projects.length > 1; + const expanded = expandedGroupKeys.has(scope.key); + const singleProject = hasMultipleProjects ? null : scope.projects[0]; return ( - void selectProject(item)} - className={cn( - "bg-card px-4 py-3.5", - !isFirst && "border-t border-border-subtle", - isFirst && "rounded-t-[24px]", - isLast && "rounded-b-[24px]", - )} + 0 && "border-t border-border-subtle")} > - + { + if (singleProject) { + void selectProject(singleProject); + } else { + toggleGroup(scope.key); + } + }} + className="flex-row items-center gap-3 bg-card px-4 py-3.5" + > - - {item.title} + + {scope.title} + + {hasMultipleProjects + ? `${scope.projects.length} workspaces` + : singleProject?.workspaceRoot} + - - + + {hasMultipleProjects && expanded + ? scope.projects.map((project) => ( + void selectProject(project)} + className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10" + > + + + + {project.title} + + + {project.workspaceRoot} + + + + + )) + : null} + ); })} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 8d4ce7a7fed..18bacd12577 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -27,7 +27,6 @@ import { groupByProvider, resolveSelectableModelSelection, } from "../../lib/modelOptions"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; import { @@ -59,6 +58,12 @@ import { } from "../../state/use-remote-environment-registry"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { + buildHomeProjectScopes, + sortHomeProjectScopes, + type HomeProjectScope, +} from "../home/homeThreadList"; +import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; type WorkspaceMode = "local" | "worktree"; @@ -109,10 +114,7 @@ export function branchBadgeLabel(input: { } type NewTaskFlowContextValue = { - readonly logicalProjects: ReadonlyArray<{ - readonly key: string; - readonly project: EnvironmentProject; - }>; + readonly projectScopes: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; @@ -175,32 +177,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projects = useProjects(); const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); - - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects, threads }), - [projects, threads], - ); - const logicalProjects = useMemo( + const groupingSettings = useMobileProjectGroupingSettings(); + const projectScopes = useMemo( () => - pipe( - repositoryGroups, - Arr.map((group) => { - const primaryProject = group.projects[0]?.project; - if (!primaryProject) { - return null; - } - return { key: group.key, project: primaryProject }; + sortHomeProjectScopes({ + scopes: buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: groupingSettings.sidebarProjectGroupingMode, }), - Arr.filter( - ( - entry, - ): entry is { - readonly key: string; - readonly project: EnvironmentProject; - } => entry !== null, - ), - ), - [repositoryGroups], + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + [groupingSettings.sidebarProjectGroupingMode, projects, threads], ); const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState( @@ -846,7 +836,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const value = useMemo( () => ({ - logicalProjects, + projectScopes, selectedEnvironmentId, selectedProjectKey, selectedModelKey, @@ -912,7 +902,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { finishEditingPendingTask, interactionMode, loadBranches, - logicalProjects, + projectScopes, modelOptions, prompt, providerGroups, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts new file mode 100644 index 00000000000..d8ed12bcc73 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -0,0 +1,82 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { HomeProjectScope } from "../home/homeThreadList"; +import { + getOnlySelectableProject, + resolveDraftProjectSelection, +} from "./new-task-project-selection"; + +function makeProject(id: string): EnvironmentProject { + return { + environmentId: EnvironmentId.make("environment"), + id: ProjectId.make(id), + title: id, + workspaceRoot: `/work/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + }; +} + +function makeScope(projects: ReadonlyArray): HomeProjectScope { + return { + key: "github.com/t3tools/t3code", + title: "T3 Code", + representative: projects[0]!, + projects, + projectRefs: projects.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + }; +} + +describe("getOnlySelectableProject", () => { + it("auto-selects when there is exactly one physical project", () => { + const project = makeProject("t3code"); + expect(getOnlySelectableProject([makeScope([project])])).toBe(project); + }); + + it("does not auto-select a representative when one group has multiple clones", () => { + const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; + expect(getOnlySelectableProject([makeScope(projects)])).toBeNull(); + }); +}); + +describe("resolveDraftProjectSelection", () => { + it("preserves an explicit project selection", () => { + const project = makeProject("t3code"); + expect( + resolveDraftProjectSelection("environment:t3code", [project], [makeScope([project])]), + ).toEqual({ kind: "preserve" }); + }); + + it("selects the only physical project when no project was explicitly selected", () => { + const project = makeProject("t3code"); + expect(resolveDraftProjectSelection(null, [project], [makeScope([project])])).toEqual({ + kind: "select", + project, + }); + }); + + it("opens the picker for multiple physical projects in one logical group", () => { + const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; + expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ + kind: "pick", + }); + }); + + it("does not preserve a project key that is missing from the catalog", () => { + const project = makeProject("t3code"); + expect( + resolveDraftProjectSelection("environment:removed", [project], [makeScope([project])]), + ).toEqual({ + kind: "select", + project, + }); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts new file mode 100644 index 00000000000..29ae3cf4f54 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -0,0 +1,34 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import type { HomeProjectScope } from "../home/homeThreadList"; + +export type DraftProjectSelectionResolution = + | { readonly kind: "preserve" } + | { readonly kind: "select"; readonly project: EnvironmentProject } + | { readonly kind: "pick" }; + +export function getOnlySelectableProject( + projectScopes: ReadonlyArray, +): EnvironmentProject | null { + const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; + return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null; +} + +export function resolveDraftProjectSelection( + selectedProjectKey: string | null, + projects: ReadonlyArray, + projectScopes: ReadonlyArray, +): DraftProjectSelectionResolution { + const hasExplicitProjectSelection = + selectedProjectKey !== null && + projects.some( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ); + if (hasExplicitProjectSelection) { + return { kind: "preserve" }; + } + + const onlyProject = getOnlySelectableProject(projectScopes); + return onlyProject ? { kind: "select", project: onlyProject } : { kind: "pick" }; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts deleted file mode 100644 index ab4311524ce..00000000000 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; - -import { groupProjectsByRepository } from "./repositoryGroups"; -import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -function makeProject( - input: Partial & Pick, -): EnvironmentProject { - return { - workspaceRoot: `/workspaces/${input.id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - ...input, - }; -} - -function makeThread( - input: Partial & - Pick, -): EnvironmentThreadShell { - return { - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, - }; -} - -describe("groupProjectsByRepository", () => { - it("groups projects across environments by repository identity", () => { - const repoIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - provider: "github", - owner: "t3tools", - name: "t3code", - displayName: "T3 Code", - }; - - const projects = [ - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("project-local"), - title: "T3 Code", - repositoryIdentity: repoIdentity, - }), - makeProject({ - environmentId: EnvironmentId.make("env-staging"), - id: ProjectId.make("project-staging"), - title: "T3 Code", - repositoryIdentity: repoIdentity, - }), - ]; - - const threads = [ - makeThread({ - environmentId: EnvironmentId.make("env-staging"), - id: ThreadId.make("thread-2"), - projectId: ProjectId.make("project-staging"), - title: "Fix reconnect flow", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-02T12:00:00.000Z", - }), - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-local"), - title: "Polish mobile shell", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-03T12:00:00.000Z", - }), - ]; - - const groups = groupProjectsByRepository({ projects, threads }); - - expect(groups).toHaveLength(1); - expect(groups[0]).toMatchObject({ - key: "github.com/t3tools/t3code", - title: "T3 Code", - subtitle: "t3tools/t3code", - projectCount: 2, - threadCount: 2, - }); - expect( - groups[0]?.projects.map((entry) => ({ - environmentId: entry.project.environmentId, - latestActivityAt: entry.latestActivityAt, - threads: entry.threads.map((thread) => thread.id), - })), - ).toEqual([ - { - environmentId: "env-local", - latestActivityAt: "2026-04-03T12:00:00.000Z", - threads: ["thread-1"], - }, - { - environmentId: "env-staging", - latestActivityAt: "2026-04-02T12:00:00.000Z", - threads: ["thread-2"], - }, - ]); - expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z"); - }); - - it("orders threads, projects, and repository groups by latest activity", () => { - const projects = [ - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("older-project"), - title: "Older", - }), - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("newer-project"), - title: "Newer", - }), - ]; - - const threads = [ - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("older-thread"), - projectId: ProjectId.make("older-project"), - title: "Older thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-02T12:00:00.000Z", - }), - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("newer-thread"), - projectId: ProjectId.make("older-project"), - title: "Newer thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-04T12:00:00.000Z", - }), - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("newest-thread"), - projectId: ProjectId.make("newer-project"), - title: "Newest thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-05T12:00:00.000Z", - }), - ]; - - const groups = groupProjectsByRepository({ projects, threads }); - - expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]); - expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([ - "newer-thread", - "older-thread", - ]); - }); - - it("falls back to a scoped project key when repository identity is unavailable", () => { - const projects = [ - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("project-local"), - title: "Scratchpad", - }), - ]; - - const groups = groupProjectsByRepository({ projects, threads: [] }); - - expect(groups).toHaveLength(1); - expect(groups[0]?.key).toBe("env-local:project-local"); - expect(groups[0]?.title).toBe("Scratchpad"); - expect(groups[0]?.subtitle).toBeNull(); - }); -}); diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts deleted file mode 100644 index bf4c2f3fccd..00000000000 --- a/apps/mobile/src/lib/repositoryGroups.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; -import type { RepositoryIdentity } from "@t3tools/contracts"; - -import { scopedProjectKey } from "./scopedEntities"; -import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -const DateDescending = Order.flip(Order.Date); - -export interface RepositoryProjectGroup { - readonly key: string; - readonly project: EnvironmentProject; - readonly threads: ReadonlyArray; - readonly latestActivityAt: string; -} - -export interface RepositoryGroup { - readonly key: string; - readonly title: string; - readonly subtitle: string | null; - readonly repositoryIdentity: RepositoryIdentity | null; - readonly projectCount: number; - readonly threadCount: number; - readonly latestActivityAt: string; - readonly projects: ReadonlyArray; -} - -function compareIsoDateDescending(left: string, right: string): number { - return new Date(right).getTime() - new Date(left).getTime(); -} - -function deriveRepositoryGroupKey(project: EnvironmentProject): string { - return ( - project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) - ); -} - -function deriveRepositoryTitle(project: EnvironmentProject): string { - const identity = project.repositoryIdentity; - return identity?.displayName ?? identity?.name ?? project.title; -} - -function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null { - if (!identity) { - return null; - } - if (identity.owner && identity.name) { - return `${identity.owner}/${identity.name}`; - } - return identity.canonicalKey; -} - -function deriveProjectLatestActivity( - project: EnvironmentProject, - threads: ReadonlyArray, -): string { - const latestThread = threads[0]; - return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; -} - -export function groupProjectsByRepository(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; -}): ReadonlyArray { - const threadsByProjectKey = new Map(); - - for (const thread of input.threads) { - const key = scopedProjectKey(thread.environmentId, thread.projectId); - const existing = threadsByProjectKey.get(key); - if (existing) { - existing.push(thread); - } else { - threadsByProjectKey.set(key, [thread]); - } - } - - const grouped = new Map(); - - for (const project of input.projects) { - const key = deriveRepositoryGroupKey(project); - const projectKey = scopedProjectKey(project.environmentId, project.id); - const threads = Arr.sortWith( - threadsByProjectKey.get(projectKey) ?? [], - (s) => new Date(s.updatedAt ?? s.createdAt), - DateDescending, - ); - - const latestActivityAt = deriveProjectLatestActivity(project, threads); - const projectGroup: RepositoryProjectGroup = { - key: projectKey, - project, - threads, - latestActivityAt, - }; - - const existing = grouped.get(key); - if (!existing) { - grouped.set(key, { - key, - title: deriveRepositoryTitle(project), - subtitle: deriveRepositorySubtitle(project.repositoryIdentity), - repositoryIdentity: project.repositoryIdentity ?? null, - projectCount: 1, - threadCount: threads.length, - latestActivityAt, - projects: [projectGroup], - }); - continue; - } - - grouped.set(key, { - ...existing, - title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project), - subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity), - repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null, - projectCount: existing.projectCount + 1, - threadCount: existing.threadCount + threads.length, - latestActivityAt: - compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0 - ? latestActivityAt - : existing.latestActivityAt, - projects: Arr.sortWith( - [...existing.projects, projectGroup], - (s) => new Date(s.latestActivityAt), - DateDescending, - ), - }); - } - - return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending); -} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6b1018e2a0a..9a5ed82b3b8 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; @@ -22,7 +23,9 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; + readonly projectGroupingMode?: SidebarProjectGroupingMode; /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no * client-settings sync, so the flat v2 thread list is opted out of per @@ -80,6 +83,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; + projectGroupingMode?: SidebarProjectGroupingMode; threadListV2Enabled?: boolean; } = {}; @@ -110,6 +114,13 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } + if ( + parsed.projectGroupingMode === "repository" || + parsed.projectGroupingMode === "repository_path" || + parsed.projectGroupingMode === "separate" + ) { + preferences.projectGroupingMode = parsed.projectGroupingMode; + } if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } diff --git a/apps/mobile/src/state/project-grouping.logic.ts b/apps/mobile/src/state/project-grouping.logic.ts new file mode 100644 index 00000000000..3cd01174b5d --- /dev/null +++ b/apps/mobile/src/state/project-grouping.logic.ts @@ -0,0 +1,33 @@ +import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; + +import type { Preferences } from "../persistence/mobile-preferences"; + +export const DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, +}; + +export function resolveMobileProjectGroupingSettings( + preferences: Preferences, +): ProjectGroupingSettings { + return { + sidebarProjectGroupingMode: + preferences.projectGroupingMode ?? + (preferences.projectGroupingEnabled === false ? "separate" : "repository"), + sidebarProjectGroupingOverrides: {}, + }; +} + +/** + * Dual-writes the legacy boolean for one release so an OTA rollback to an + * older mobile bundle preserves the user's grouping choice. + */ +export function mobileProjectGroupingModePatch( + mode: SidebarProjectGroupingMode, +): Partial { + return { + projectGroupingMode: mode, + projectGroupingEnabled: mode !== "separate", + }; +} diff --git a/apps/mobile/src/state/project-grouping.test.ts b/apps/mobile/src/state/project-grouping.test.ts new file mode 100644 index 00000000000..6995ea463ad --- /dev/null +++ b/apps/mobile/src/state/project-grouping.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileProjectGroupingModePatch, + resolveMobileProjectGroupingSettings, +} from "./project-grouping.logic"; + +describe("mobile project grouping preferences", () => { + it("maps the legacy boolean while preferring the new mode", () => { + expect(resolveMobileProjectGroupingSettings({}).sidebarProjectGroupingMode).toBe("repository"); + expect( + resolveMobileProjectGroupingSettings({ projectGroupingEnabled: false }) + .sidebarProjectGroupingMode, + ).toBe("separate"); + expect( + resolveMobileProjectGroupingSettings({ + projectGroupingEnabled: false, + projectGroupingMode: "repository_path", + }).sidebarProjectGroupingMode, + ).toBe("repository_path"); + }); + + it("dual-writes the legacy boolean for rollback compatibility", () => { + expect(mobileProjectGroupingModePatch("separate")).toEqual({ + projectGroupingMode: "separate", + projectGroupingEnabled: false, + }); + expect(mobileProjectGroupingModePatch("repository_path")).toEqual({ + projectGroupingMode: "repository_path", + projectGroupingEnabled: true, + }); + }); +}); diff --git a/apps/mobile/src/state/project-grouping.ts b/apps/mobile/src/state/project-grouping.ts new file mode 100644 index 00000000000..c012c59d5b6 --- /dev/null +++ b/apps/mobile/src/state/project-grouping.ts @@ -0,0 +1,17 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "./preferences"; +import { + DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, + resolveMobileProjectGroupingSettings, +} from "./project-grouping.logic"; + +export * from "./project-grouping.logic"; + +export function useMobileProjectGroupingSettings() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + return AsyncResult.isSuccess(preferencesResult) + ? resolveMobileProjectGroupingSettings(preferencesResult.value) + : DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS; +} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 8204222b3b0..f696cb46224 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,4 +1,5 @@ export { + buildProjectGroups, deriveLogicalProjectKey, deriveLogicalProjectKeyFromRef, deriveLogicalProjectKeyFromSettings, @@ -11,4 +12,6 @@ export { selectProjectGroupingSettings, type ProjectGroupingMode, type ProjectGroupingSettings, + type ProjectGroup, + type ProjectGroupMember, } from "@t3tools/client-runtime/state/project-grouping"; diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..be92fcaee84 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,11 +1,5 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; -import { - deriveLogicalProjectKeyFromSettings, - derivePhysicalProjectKey, - deriveProjectGroupLabel, - type ProjectGroupingSettings, -} from "./logicalProject"; +import { buildProjectGroups, type ProjectGroupingSettings } from "./logicalProject"; import type { Project } from "./types"; export type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; @@ -37,77 +31,21 @@ export interface SidebarProjectPickerEntry { isPreferred: boolean; } -interface SidebarProjectGroupCandidate { - readonly logicalKey: string; - readonly project: Project; -} - -function getProjectFreshnessTime(project: Project): number { - const updatedAtTime = Date.parse(project.updatedAt); - if (Number.isFinite(updatedAtTime)) { - return updatedAtTime; - } - const createdAtTime = Date.parse(project.createdAt); - return Number.isFinite(createdAtTime) ? createdAtTime : 0; -} - -function shouldReplaceDuplicateMember(input: { - existingMember: Project; - candidateMember: Project; - primaryEnvironmentId: EnvironmentId | null; -}): boolean { - if ( - input.primaryEnvironmentId !== null && - input.existingMember.environmentId !== input.primaryEnvironmentId && - input.candidateMember.environmentId === input.primaryEnvironmentId - ) { - return true; - } - - const existingFreshness = getProjectFreshnessTime(input.existingMember); - const candidateFreshness = getProjectFreshnessTime(input.candidateMember); - if (candidateFreshness !== existingFreshness) { - return candidateFreshness > existingFreshness; - } - - return input.candidateMember.id > input.existingMember.id; -} - -function collectProjectWinnersByPhysicalKey(input: { - projects: ReadonlyArray; - settings: ProjectGroupingSettings; - primaryEnvironmentId: EnvironmentId | null; -}): Map { - const winnersByPhysicalKey = new Map(); - for (const project of input.projects) { - const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); - const physicalProjectKey = derivePhysicalProjectKey(project); - const existing = winnersByPhysicalKey.get(physicalProjectKey); - if (!existing) { - winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project }); - continue; - } - if ( - shouldReplaceDuplicateMember({ - existingMember: existing.project, - candidateMember: project, - primaryEnvironmentId: input.primaryEnvironmentId, - }) - ) { - winnersByPhysicalKey.set(physicalProjectKey, { logicalKey, project }); - } - } - return winnersByPhysicalKey; -} - export function buildPhysicalToLogicalProjectKeyMap(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; primaryEnvironmentId: EnvironmentId | null; }): Map { const mapping = new Map(); - for (const [physicalProjectKey, winner] of collectProjectWinnersByPhysicalKey(input)) { - mapping.set(physicalProjectKey, winner.logicalKey); + const groups = buildProjectGroups({ + projects: input.projects, + settings: input.settings, + preferredEnvironmentId: input.primaryEnvironmentId, + }); + for (const group of groups) { + for (const member of group.members) { + mapping.set(member.physicalProjectKey, group.key); + } } return mapping; } @@ -123,59 +61,24 @@ export function buildSidebarProjectSnapshots(input: { // legacy behavior. isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; }): SidebarProjectSnapshot[] { - const winnersByPhysicalKey = collectProjectWinnersByPhysicalKey(input); - const groupedMembers = new Map(); - for (const { logicalKey, project } of winnersByPhysicalKey.values()) { - const member: SidebarProjectGroupMember = { - ...project, - physicalProjectKey: derivePhysicalProjectKey(project), - environmentLabel: input.resolveEnvironmentLabel(project.environmentId), - }; - const existingMembers = groupedMembers.get(logicalKey); - if (existingMembers) { - existingMembers.push(member); - } else { - groupedMembers.set(logicalKey, [member]); - } - } - - const projectRefsByLogicalKey = new Map(); - const seenProjectRefs = new Set(); - for (const project of input.projects) { - const physicalProjectKey = derivePhysicalProjectKey(project); - const logicalKey = - winnersByPhysicalKey.get(physicalProjectKey)?.logicalKey ?? - deriveLogicalProjectKeyFromSettings(project, input.settings); - const projectRefKey = `${project.environmentId}:${project.id}`; - if (seenProjectRefs.has(projectRefKey)) continue; - seenProjectRefs.add(projectRefKey); - - const projectRef = scopeProjectRef(project.environmentId, project.id); - const existingRefs = projectRefsByLogicalKey.get(logicalKey); - if (existingRefs) { - existingRefs.push(projectRef); - } else { - projectRefsByLogicalKey.set(logicalKey, [projectRef]); - } - } - - const result: SidebarProjectSnapshot[] = []; - const seen = new Set(); - for (const project of input.projects) { - const logicalKey = deriveLogicalProjectKeyFromSettings(project, input.settings); - if (seen.has(logicalKey)) { - continue; - } - seen.add(logicalKey); - - const members = groupedMembers.get(logicalKey) ?? []; + return buildProjectGroups({ + projects: input.projects, + settings: input.settings, + preferredEnvironmentId: input.primaryEnvironmentId, + }).map((group): SidebarProjectSnapshot => { + const members = group.members.map( + ({ physicalProjectKey, project }): SidebarProjectGroupMember => ({ + ...project, + physicalProjectKey, + environmentLabel: input.resolveEnvironmentLabel(project.environmentId), + }), + ); const representative = - (input.primaryEnvironmentId - ? members.find((member) => member.environmentId === input.primaryEnvironmentId) - : null) ?? members[0]; - if (!representative) { - continue; - } + members.find( + (member) => + member.environmentId === group.representative.environmentId && + member.id === group.representative.id, + ) ?? members[0]!; const hasLocal = input.primaryEnvironmentId !== null && @@ -196,27 +99,19 @@ export function buildSidebarProjectSnapshots(input: { remoteMembers.length > 0 && remoteMembers.every((member) => isDesktopLocal(member.environmentId)); - result.push({ + return { ...representative, - projectKey: logicalKey, - displayName: - members.length > 1 - ? deriveProjectGroupLabel({ - representative, - members, - }) - : representative.title, + projectKey: group.key, + displayName: group.label, groupedProjectCount: members.length, environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", allRemoteMembersAreDesktopLocal, memberProjects: members, - memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [], + memberProjectRefs: group.memberProjectRefs, remoteEnvironmentLabels, - }); - } - - return result; + }; + }); } export function buildSidebarProjectPickerEntries(input: { diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts new file mode 100644 index 00000000000..1c2621b2a83 --- /dev/null +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -0,0 +1,194 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentProject } from "./models.ts"; +import { + buildProjectGroups, + derivePhysicalProjectKey, + type ProjectGroupingSettings, +} from "./projectGrouping.ts"; + +const environmentId = EnvironmentId.make("environment"); +const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "upstream", + remoteUrl: "https://github.com/t3tools/t3code.git", + }, + provider: "github", + owner: "t3tools", + name: "t3code", + displayName: "T3 Code", +}; + +function makeProject( + id: string, + workspaceRoot: string, + overrides: Partial = {}, +): EnvironmentProject { + return { + environmentId, + id: ProjectId.make(id), + title: id, + workspaceRoot, + repositoryIdentity, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + ...overrides, + }; +} + +function settings( + mode: ProjectGroupingSettings["sidebarProjectGroupingMode"], + overrides: ProjectGroupingSettings["sidebarProjectGroupingOverrides"] = {}, +): ProjectGroupingSettings { + return { + sidebarProjectGroupingMode: mode, + sidebarProjectGroupingOverrides: overrides, + }; +} + +describe("buildProjectGroups", () => { + it("preserves every physical clone as a selectable member in repository modes", () => { + const projects = [ + makeProject("t3code", "/work/t3code"), + makeProject("t3code-2", "/work/t3code-2"), + makeProject("t3code-3", "/work/t3code-3"), + ]; + + for (const mode of ["repository", "repository_path"] as const) { + const groups = buildProjectGroups({ projects, settings: settings(mode) }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members.map((member) => member.project.id)).toEqual([ + "t3code", + "t3code-2", + "t3code-3", + ]); + expect(groups[0]?.memberProjectRefs).toHaveLength(3); + } + }); + + it("keeps physical clones in separate groups when requested", () => { + const projects = [ + makeProject("t3code", "/work/t3code"), + makeProject("t3code-2", "/work/t3code-2"), + makeProject("t3code-3", "/work/t3code-3"), + ]; + + const groups = buildProjectGroups({ projects, settings: settings("separate") }); + expect(groups).toHaveLength(3); + expect(groups.flatMap((group) => group.members)).toHaveLength(3); + expect(groups.map((group) => group.label)).toEqual(["t3code", "t3code-2", "t3code-3"]); + }); + + it("applies a physical-project override without dropping its siblings", () => { + const first = makeProject("t3code", "/work/t3code"); + const second = makeProject("t3code-2", "/work/t3code-2"); + const third = makeProject("t3code-3", "/work/t3code-3"); + const groups = buildProjectGroups({ + projects: [first, second, third], + settings: settings("repository", { + [derivePhysicalProjectKey(second)]: "separate", + }), + }); + + expect(groups).toHaveLength(2); + expect(groups.flatMap((group) => group.members.map((member) => member.project.id))).toEqual([ + "t3code", + "t3code-3", + "t3code-2", + ]); + }); + + it("dedupes stale registrations at one physical path using the freshest project", () => { + const stale = makeProject("stale", "/work/t3code", { + repositoryIdentity: null, + updatedAt: "2026-07-01T00:00:00.000Z", + }); + const fresh = makeProject("fresh", "/work/t3code/", { + updatedAt: "2026-07-02T00:00:00.000Z", + }); + + const groups = buildProjectGroups({ + projects: [stale, fresh], + settings: settings("repository"), + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members).toHaveLength(1); + expect(groups[0]?.representative.id).toBe("fresh"); + expect(groups[0]?.memberProjectRefs).toHaveLength(2); + }); + + it("uses repository identity from a duplicate registration when the winner lacks it", () => { + const identified = makeProject("identified", "/work/t3code", { + updatedAt: "2026-07-01T00:00:00.000Z", + }); + const freshUnidentified = makeProject("fresh", "/work/t3code/", { + repositoryIdentity: null, + updatedAt: "2026-07-02T00:00:00.000Z", + }); + const sibling = makeProject("sibling", "/work/t3code-2"); + + const groups = buildProjectGroups({ + projects: [identified, freshUnidentified, sibling], + settings: settings("repository"), + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["fresh", "sibling"]); + }); + + it("uses the freshest winner's repository identity when stale duplicates disagree", () => { + const staleIdentity = { + ...repositoryIdentity, + canonicalKey: "github.com/t3tools/old-repository", + name: "old-repository", + displayName: "Old Repository", + }; + const stale = makeProject("stale", "/work/t3code", { + repositoryIdentity: staleIdentity, + updatedAt: "2026-07-01T00:00:00.000Z", + }); + const fresh = makeProject("fresh", "/work/t3code/", { + updatedAt: "2026-07-02T00:00:00.000Z", + }); + const sibling = makeProject("sibling", "/work/t3code-2"); + + const groups = buildProjectGroups({ + projects: [stale, fresh, sibling], + settings: settings("repository"), + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["fresh", "sibling"]); + }); + + it("uses the freshest identity-bearing duplicate when the winner lacks identity", () => { + const staleIdentity = { + ...repositoryIdentity, + canonicalKey: "github.com/t3tools/old-repository", + name: "old-repository", + displayName: "Old Repository", + }; + const staleIdentified = makeProject("stale-identified", "/work/t3code", { + repositoryIdentity: staleIdentity, + updatedAt: "2026-07-01T00:00:00.000Z", + }); + const freshIdentified = makeProject("fresh-identified", "/work/t3code/", { + updatedAt: "2026-07-02T00:00:00.000Z", + }); + const winner = makeProject("winner", "/work/t3code", { + repositoryIdentity: null, + updatedAt: "2026-07-03T00:00:00.000Z", + }); + const sibling = makeProject("sibling", "/work/t3code-2"); + + const groups = buildProjectGroups({ + projects: [staleIdentified, freshIdentified, winner, sibling], + settings: settings("repository"), + }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]); + }); +}); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index ca804c13809..8606c4855f2 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -1,5 +1,9 @@ import { scopedProjectKey, scopeProjectRef } from "../environment/scoped.ts"; -import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { + EnvironmentId, + ScopedProjectRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import type { EnvironmentProject } from "./models.ts"; @@ -181,3 +185,141 @@ export function deriveProjectGroupLabel(input: { return input.representative.title; } + +export interface ProjectGroupMember { + readonly physicalProjectKey: string; + readonly project: TProject; +} + +export interface ProjectGroup { + readonly key: string; + readonly label: string; + readonly representative: TProject; + readonly members: ReadonlyArray>; + readonly memberProjectRefs: ReadonlyArray; +} + +function projectFreshnessTime(project: EnvironmentProject): number { + const updatedAtTime = Date.parse(project.updatedAt); + if (Number.isFinite(updatedAtTime)) { + return updatedAtTime; + } + const createdAtTime = Date.parse(project.createdAt); + return Number.isFinite(createdAtTime) ? createdAtTime : 0; +} + +function shouldReplacePhysicalProjectWinner( + existing: TProject, + candidate: TProject, +): boolean { + const freshnessDelta = projectFreshnessTime(candidate) - projectFreshnessTime(existing); + return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > existing.id); +} + +function selectProjectIdentitySource( + projects: ReadonlyArray, + winner: TProject, +): TProject { + if (winner.repositoryIdentity !== null) { + return winner; + } + + let freshestIdentifiedProject: TProject | null = null; + for (const project of projects) { + if (project.repositoryIdentity === null) { + continue; + } + if ( + freshestIdentifiedProject === null || + shouldReplacePhysicalProjectWinner(freshestIdentifiedProject, project) + ) { + freshestIdentifiedProject = project; + } + } + return freshestIdentifiedProject ?? winner; +} + +/** + * Builds logical project groups without losing the physical projects that + * remain the actual navigation and task-creation targets. + * + * Presentation-specific metadata, filtering, and activity sorting stay in + * each client. Grouping modes, overrides, physical deduplication, labels, and + * member preservation live here so web and mobile cannot drift. + */ +export function buildProjectGroups(input: { + readonly projects: ReadonlyArray; + readonly settings: ProjectGroupingSettings; + readonly preferredEnvironmentId?: EnvironmentId | null; +}): ReadonlyArray> { + const projectsByPhysicalKey = new Map(); + for (const project of input.projects) { + const physicalProjectKey = derivePhysicalProjectKey(project); + const existing = projectsByPhysicalKey.get(physicalProjectKey); + if (existing) { + existing.push(project); + } else { + projectsByPhysicalKey.set(physicalProjectKey, [project]); + } + } + + const logicalKeyByPhysicalKey = new Map(); + const groupedMembers = new Map[]>(); + for (const [physicalProjectKey, physicalProjects] of projectsByPhysicalKey) { + const winner = physicalProjects.reduce((current, candidate) => + shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current, + ); + const identitySource = selectProjectIdentitySource(physicalProjects, winner); + const logicalKey = deriveLogicalProjectKey(identitySource, { + groupingMode: resolveProjectGroupingMode(winner, input.settings), + }); + logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); + const member = { physicalProjectKey, project: winner }; + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(member); + } else { + groupedMembers.set(logicalKey, [member]); + } + } + + const projectRefsByLogicalKey = new Map(); + const seenProjectRefs = new Set(); + for (const project of input.projects) { + const physicalProjectKey = derivePhysicalProjectKey(project); + const logicalKey = + logicalKeyByPhysicalKey.get(physicalProjectKey) ?? + deriveLogicalProjectKeyFromSettings(project, input.settings); + const projectRefKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + if (seenProjectRefs.has(projectRefKey)) continue; + seenProjectRefs.add(projectRefKey); + const projectRef = scopeProjectRef(project.environmentId, project.id); + const existing = projectRefsByLogicalKey.get(logicalKey); + if (existing) { + existing.push(projectRef); + } else { + projectRefsByLogicalKey.set(logicalKey, [projectRef]); + } + } + + const preferredEnvironmentId = input.preferredEnvironmentId ?? null; + return Array.from(groupedMembers, ([key, members]) => { + const representative = + (preferredEnvironmentId + ? members.find((member) => member.project.environmentId === preferredEnvironmentId)?.project + : null) ?? members[0]!.project; + return { + key, + label: + members.length > 1 + ? deriveProjectGroupLabel({ + representative, + members: members.map((member) => member.project), + }) + : representative.title, + representative, + members, + memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], + }; + }); +}