From 9dc6632dd295fdee7ea96b0e88dce04dbfd4f970 Mon Sep 17 00:00:00 2001 From: Marat Fattakhov <161203mar@gmail.com> Date: Thu, 20 Aug 2026 17:17:14 +0300 Subject: [PATCH] feat(projects): stop collapsing a nested workspace into its parent repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project is identified by its workspace root, but default sidebar grouping collapsed every project sharing a git remote into one row. Adding ~/delta/commerce-pricing next to ~/delta produced a single "owner/delta" row, the new-thread picker offered one entry, and typing the package name found nothing — so an agent could not be aimed at the subdirectory on purpose. Default `repository` grouping now keys a nested workspace separately from its parent while sibling clones of one remote still share a row, which is the whole change on the grouping seam. Rows that would collide on the git name gain the repo-relative path as a disambiguator, project search terms grow to cover the git owner/name, the full workspace root, and every path segment, and a new default-off setting flips the primary label from the git name to the path. Wired through the web sidebar, both command-palette project sections, Settings, and the mobile home list, thread sidebar, and grouping screen. --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/features/home/HomeRouteScreen.tsx | 6 +- apps/mobile/src/features/home/HomeScreen.tsx | 17 +-- .../src/features/home/home-list-options.ts | 23 ++-- .../src/features/home/homeThreadList.test.ts | 103 +++++++++++++-- .../src/features/home/homeThreadList.ts | 23 ++-- .../layout/AdaptiveWorkspaceLayout.tsx | 18 +-- .../SettingsProjectGroupingRouteScreen.tsx | 16 ++- .../threads/ThreadNavigationSidebar.tsx | 6 +- .../threads/new-task-flow-provider.tsx | 4 +- .../src/persistence/mobile-preferences.ts | 6 + .../src/state/project-grouping.logic.ts | 2 + .../mobile/src/state/project-grouping.test.ts | 8 ++ .../orchestration/commandInvariants.test.ts | 35 +++++ apps/web/src/components/CommandPalette.tsx | 21 ++- apps/web/src/components/LegacySidebar.tsx | 5 + apps/web/src/components/Sidebar.tsx | 14 +- .../src/components/chat/DraftHeroHeadline.tsx | 9 +- .../settings/ProjectSettingsPanel.tsx | 7 +- .../components/settings/SettingsPanels.tsx | 33 +++++ .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/environmentGrouping.test.ts | 92 ++++++++++++++ apps/web/src/logicalProject.ts | 4 + apps/web/src/sidebarProjectGrouping.ts | 24 +++- docs/README.md | 1 + docs/internals/glossary.md | 4 + docs/internals/remote.md | 11 ++ docs/user/nested-projects.md | 17 +++ .../src/state/projectGrouping.test.ts | 90 +++++++++++++ .../src/state/projectGrouping.ts | 120 +++++++++++++++--- packages/contracts/src/settings.test.ts | 13 ++ packages/contracts/src/settings.ts | 6 + 32 files changed, 640 insertions(+), 104 deletions(-) create mode 100644 docs/user/nested-projects.md diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 11030fcc5fa4..32eaf8d7e34a 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", }, + sidebarProjectNamesUsePath: false, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index beabf66d9ea9..3cc6971924ef 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -85,12 +85,12 @@ export function HomeRouteScreen() { buildHomeProjectScopes({ projects, environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, + projectGroupingSettings: listOptions.projectGroupingSettings, }).map((scope) => ({ key: scope.key, label: scope.title, })), - [listOptions.projectGroupingMode, projects, selectedEnvironmentId], + [listOptions.projectGroupingSettings, projects, selectedEnvironmentId], ); useEffect(() => { if ( @@ -229,7 +229,7 @@ export function HomeRouteScreen() { onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} - projectGroupingMode={listOptions.projectGroupingMode} + projectGroupingSettings={listOptions.projectGroupingSettings} projects={projects} projectSortOrder={listOptions.projectSortOrder} savedConnectionsById={savedConnectionsById} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 3a92131bb2bc..0cec0be456f0 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -3,6 +3,7 @@ import { type LegendListRef, type LegendListRenderItemProps, } from "@legendapp/list/react-native"; +import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -12,11 +13,7 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -91,7 +88,7 @@ interface HomeScreenProps { readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; @@ -311,9 +308,9 @@ export function HomeScreen(props: HomeScreenProps) { buildHomeProjectScopes({ projects: props.projects, environmentId: props.selectedEnvironmentId, - projectGroupingMode: props.projectGroupingMode, + projectGroupingSettings: props.projectGroupingSettings, }), - [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], + [props.projectGroupingSettings, props.projects, props.selectedEnvironmentId], ); const selectedProjectScope = useMemo( () => @@ -382,10 +379,10 @@ export function HomeScreen(props: HomeScreenProps) { matchedThreadKeys, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, - projectGroupingMode: props.projectGroupingMode, + projectGroupingSettings: props.projectGroupingSettings, }), [ - props.projectGroupingMode, + props.projectGroupingSettings, props.projectSortOrder, props.searchQuery, props.selectedEnvironmentId, diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 14f842d73e2c..5d47a2c8e2ec 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -1,8 +1,5 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; +import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import { DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, @@ -19,6 +16,7 @@ import { type SetStateAction, } from "react"; +import { DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS } from "../../state/project-grouping.logic"; import type { HomeProjectSortOrder } from "./homeThreadList"; export interface HomeListOptions { @@ -28,7 +26,7 @@ export interface HomeListOptions { } export interface ResolvedHomeListOptions extends HomeListOptions { - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; } export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ @@ -61,7 +59,7 @@ function defaultHomeListOptions(): HomeListOptions { interface HomeListOptionsContextValue { readonly options: HomeListOptions; readonly setOptions: Dispatch>; - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; } const HomeListOptionsContext = createContext(null); @@ -69,14 +67,14 @@ const HomeListOptionsContext = createContext /** Keeps list preferences stable while the app moves between compact and split shells. */ export function HomeListOptionsProvider({ children, - projectGroupingMode, + projectGroupingSettings, }: PropsWithChildren<{ - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; }>) { const [options, setOptions] = useState(defaultHomeListOptions); const value = useMemo( - () => ({ options, setOptions, projectGroupingMode }), - [options, projectGroupingMode], + () => ({ options, setOptions, projectGroupingSettings }), + [options, projectGroupingSettings], ); return createElement(HomeListOptionsContext, { value }, children); } @@ -114,7 +112,8 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 60d3ab2c867a..d4d8105a3477 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -1,3 +1,4 @@ +import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -12,6 +13,17 @@ import { sortHomeProjectScopes, } from "./homeThreadList"; +function groupingSettings( + mode: ProjectGroupingSettings["sidebarProjectGroupingMode"], + namesUsePath = false, +): ProjectGroupingSettings { + return { + sidebarProjectGroupingMode: mode, + sidebarProjectGroupingOverrides: {}, + sidebarProjectNamesUsePath: namesUsePath, + }; +} + function makeProject( input: Partial & Pick, ): EnvironmentProject { @@ -65,7 +77,7 @@ function buildGroups( searchQuery: "", projectSortOrder: "updated_at", threadSortOrder: "updated_at", - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), now: NOW, ...overrides, }); @@ -101,7 +113,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects, environmentId: null, - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), }); expect(scopes).toHaveLength(1); @@ -160,7 +172,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects, environmentId: null, - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), }); const groups = buildGroups(projects, [staleThread]); @@ -217,7 +229,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects, environmentId: null, - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), }); expect(scopes).toHaveLength(1); @@ -243,7 +255,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects: [newerProject, olderProject], environmentId: null, - projectGroupingMode: "separate", + projectGroupingSettings: groupingSettings("separate"), }); expect( @@ -288,7 +300,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects: [invalidProject, validProject], environmentId: null, - projectGroupingMode: "separate", + projectGroupingSettings: groupingSettings("separate"), }); expect( @@ -335,7 +347,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects: [olderMember, newerMember, otherProject], environmentId: null, - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), }); expect( @@ -370,7 +382,7 @@ describe("buildHomeThreadGroups", () => { buildHomeProjectScopes({ projects, environmentId: null, - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), }), ).toHaveLength(2); }); @@ -394,7 +406,7 @@ describe("buildHomeThreadGroups", () => { const scopes = buildHomeProjectScopes({ projects: [project], environmentId: null, - projectGroupingMode: "repository", + projectGroupingSettings: groupingSettings("repository"), }); const groups = buildGroups( [project], @@ -483,7 +495,7 @@ describe("buildHomeThreadGroups", () => { const groups = buildGroups([olderProject, newerProject], threads, { projectSortOrder: "created_at", threadSortOrder: "created_at", - projectGroupingMode: "separate", + projectGroupingSettings: groupingSettings("separate"), }); expect(groups.map((group) => group.representative.id)).toEqual([ @@ -564,17 +576,80 @@ describe("buildHomeThreadGroups", () => { }), ); - expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); + // Nested workspaces of one repo are separate projects in every mode. + expect( + buildGroups(projects, threads, { + projectGroupingSettings: groupingSettings("repository"), + }).map((group) => group.title), + ).toEqual(["Mobile", "Web"]); + expect( + buildGroups(projects, threads, { + projectGroupingSettings: groupingSettings("repository", true), + }).map((group) => group.title), + ).toEqual(["apps/mobile", "apps/web"]); expect( - buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( + buildGroups(projects, threads, { + projectGroupingSettings: groupingSettings("repository_path"), + }).map((group) => group.title), + ).toEqual(["Mobile", "Web"]); + expect( + buildGroups(projects, threads, { projectGroupingSettings: groupingSettings("separate") }).map( (group) => group.title, ), ).toEqual(["Mobile", "Web"]); + }); + + it("finds a nested workspace by its own path segment", () => { + const environmentId = EnvironmentId.make("environment-1"); + const repositoryIdentity = { + canonicalKey: "github.com/kosyanmedia/delta", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:kosyanmedia/delta.git", + }, + provider: "github", + owner: "kosyanmedia", + name: "delta", + displayName: "kosyanmedia/delta", + rootPath: "/home/dev/delta", + }; + const projects = [ + makeProject({ + environmentId, + id: ProjectId.make("project-root"), + title: "kosyanmedia/delta", + workspaceRoot: "/home/dev/delta", + repositoryIdentity, + }), + makeProject({ + environmentId, + id: ProjectId.make("project-nested"), + title: "kosyanmedia/delta", + workspaceRoot: "/home/dev/delta/commerce-pricing", + repositoryIdentity, + }), + ]; + const threads = projects.map((project) => + makeThread({ + environmentId, + id: ThreadId.make(`thread-${project.id}`), + projectId: project.id, + title: project.title, + }), + ); + + // Same git name on both rows, so the path disambiguates them. expect( - buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( + buildGroups(projects, threads) + .map((group) => group.title) + .sort(), + ).toEqual(["kosyanmedia/delta · .", "kosyanmedia/delta · commerce-pricing"]); + expect( + buildGroups(projects, threads, { searchQuery: "commerce-pricing" }).map( (group) => group.title, ), - ).toEqual(["Mobile", "Web"]); + ).toEqual(["kosyanmedia/delta · commerce-pricing"]); }); 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 2a9e0ec2cb86..e61fdc998959 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -2,6 +2,9 @@ import { buildProjectGroups, derivePhysicalProjectKey, deriveProjectGroupLabel, + deriveProjectSearchTerms, + formatProjectGroupLabel, + type ProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, @@ -16,7 +19,6 @@ import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-searc import type { EnvironmentId, ScopedProjectRef, - SidebarProjectGroupingMode, SidebarProjectSortOrder, SidebarThreadSortOrder, } from "@t3tools/contracts"; @@ -31,6 +33,7 @@ export type HomeProjectSortOrder = Exclude; export interface HomeProjectScope { readonly key: string; + /** Row label: the project's name, plus its path when two rows share a name. */ readonly title: string; readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; @@ -51,21 +54,18 @@ function getProjectSortTimestamp( export function buildHomeProjectScopes(input: { readonly projects: ReadonlyArray; readonly environmentId: EnvironmentId | null; - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; }): ReadonlyArray { const projects = input.projects.filter( (project) => input.environmentId === null || project.environmentId === input.environmentId, ); return buildProjectGroups({ projects, - settings: { - sidebarProjectGroupingMode: input.projectGroupingMode, - sidebarProjectGroupingOverrides: {}, - }, + settings: input.projectGroupingSettings, }).map((group) => { return { key: group.key, - title: group.label, + title: formatProjectGroupLabel(group), representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, @@ -209,7 +209,7 @@ export function buildHomeThreadGroups(input: { readonly matchedThreadKeys?: ReadonlySet; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; /** Current time used for the recency window; defaults to now. Injectable for tests. */ readonly now?: number; }): ReadonlyArray { @@ -301,10 +301,15 @@ export function buildHomeThreadGroups(input: { const title = groupTitleByKey.get(group.key) ?? deriveProjectGroupLabel({ representative, members: group.projects }); + // Search matches the row label plus every term a project is findable by + // (git names, workspace path, and each of its segments), so a nested + // workspace answers to its own folder name. const groupMatches = query.length === 0 || title.toLocaleLowerCase().includes(query) || - group.projects.some((project) => project.title.toLocaleLowerCase().includes(query)); + group.projects.some((project) => + deriveProjectSearchTerms(project).some((term) => term.toLocaleLowerCase().includes(query)), + ); const matchingThreads = groupMatches ? group.threads : group.threads.filter( diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9ea8eb88d42a..c3f40b431654 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -2,7 +2,8 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { @@ -197,17 +198,12 @@ export function AdaptiveWorkspaceLayout(props: { return AsyncResult.isFailure(preferencesResult) ? ( ) : null; } const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); - return ( - - ); + return ; } function AdaptiveWorkspaceLayoutContent( @@ -215,10 +211,10 @@ function AdaptiveWorkspaceLayoutContent( readonly children: ReactNode; readonly pathname: string; } & { - readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly projectGroupingSettings: ProjectGroupingSettings; }, ) { - const projectGroupingMode = props.projectGroupingMode; + const projectGroupingSettings = props.projectGroupingSettings; const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -519,7 +515,7 @@ function AdaptiveWorkspaceLayoutContent( ); return ( - + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index a594240167c6..8bdd16ac4671 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -16,6 +16,7 @@ import { } from "../../state/project-grouping"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { SettingsSection } from "./components/SettingsSection"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; const GROUPING_OPTIONS: ReadonlyArray<{ readonly mode: SidebarProjectGroupingMode; @@ -46,9 +47,10 @@ export function SettingsProjectGroupingRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; - const selectedMode = AsyncResult.isSuccess(preferencesResult) - ? resolveMobileProjectGroupingSettings(preferencesResult.value).sidebarProjectGroupingMode + const groupingSettings = AsyncResult.isSuccess(preferencesResult) + ? resolveMobileProjectGroupingSettings(preferencesResult.value) : null; + const selectedMode = groupingSettings?.sidebarProjectGroupingMode ?? null; return ( @@ -100,6 +102,16 @@ export function SettingsProjectGroupingRouteScreen() { ))} + + savePreferences({ projectNamesUsePath: value })} + /> + ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 0e74e27f8743..ca57a2dd4b0d 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -230,9 +230,9 @@ function ThreadNavigationSidebarPane( buildHomeProjectScopes({ projects, environmentId: options.selectedEnvironmentId, - projectGroupingMode: options.projectGroupingMode, + projectGroupingSettings: options.projectGroupingSettings, }), - [options.projectGroupingMode, options.selectedEnvironmentId, projects], + [options.projectGroupingSettings, options.selectedEnvironmentId, projects], ); const projectFilterOptions = useMemo( () => @@ -323,7 +323,7 @@ function ThreadNavigationSidebarPane( matchedThreadKeys, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, + projectGroupingSettings: options.projectGroupingSettings, }), [ matchedThreadKeys, 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 14f0fcc95a22..a505dbf32394 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -201,13 +201,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { scopes: buildHomeProjectScopes({ projects, environmentId: null, - projectGroupingMode: groupingSettings.sidebarProjectGroupingMode, + projectGroupingSettings: groupingSettings, }), threads, pendingTasks: [], projectSortOrder: "updated_at", }), - [groupingSettings.sidebarProjectGroupingMode, projects, threads], + [groupingSettings, projects, threads], ); const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState( diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5d0bd8a3c9dc..40c2cb38ed89 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -31,6 +31,8 @@ export interface Preferences { /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; + /** Device-local counterpart of web's `sidebarProjectNamesUsePath`. */ + readonly projectNamesUsePath?: boolean; readonly autoSettleOnMerge?: boolean; /** * Device-local mirror of the web `legacySidebarEnabled` setting. Mobile has @@ -101,6 +103,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; + projectNamesUsePath?: boolean; autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; @@ -167,6 +170,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { ) { preferences.projectGroupingMode = parsed.projectGroupingMode; } + if (typeof parsed.projectNamesUsePath === "boolean") { + preferences.projectNamesUsePath = parsed.projectNamesUsePath; + } if (typeof parsed.autoSettleOnMerge === "boolean") { preferences.autoSettleOnMerge = parsed.autoSettleOnMerge; } diff --git a/apps/mobile/src/state/project-grouping.logic.ts b/apps/mobile/src/state/project-grouping.logic.ts index 3cd01174b5dd..18402950f998 100644 --- a/apps/mobile/src/state/project-grouping.logic.ts +++ b/apps/mobile/src/state/project-grouping.logic.ts @@ -6,6 +6,7 @@ import type { Preferences } from "../persistence/mobile-preferences"; export const DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS: ProjectGroupingSettings = { sidebarProjectGroupingMode: "repository", sidebarProjectGroupingOverrides: {}, + sidebarProjectNamesUsePath: false, }; export function resolveMobileProjectGroupingSettings( @@ -16,6 +17,7 @@ export function resolveMobileProjectGroupingSettings( preferences.projectGroupingMode ?? (preferences.projectGroupingEnabled === false ? "separate" : "repository"), sidebarProjectGroupingOverrides: {}, + sidebarProjectNamesUsePath: preferences.projectNamesUsePath ?? false, }; } diff --git a/apps/mobile/src/state/project-grouping.test.ts b/apps/mobile/src/state/project-grouping.test.ts index 6995ea463ad0..0f8b01788252 100644 --- a/apps/mobile/src/state/project-grouping.test.ts +++ b/apps/mobile/src/state/project-grouping.test.ts @@ -20,6 +20,14 @@ describe("mobile project grouping preferences", () => { ).toBe("repository_path"); }); + it("defaults path names off and reads the device preference", () => { + expect(resolveMobileProjectGroupingSettings({}).sidebarProjectNamesUsePath).toBe(false); + expect( + resolveMobileProjectGroupingSettings({ projectNamesUsePath: true }) + .sidebarProjectNamesUsePath, + ).toBe(true); + }); + it("dual-writes the legacy boolean for rollback compatibility", () => { expect(mobileProjectGroupingModePatch("separate")).toEqual({ projectGroupingMode: "separate", diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 52aac1f0c105..fe3a37052b13 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -14,6 +14,7 @@ import * as Effect from "effect/Effect"; import { findThreadById, listThreadsByProjectId, + requireActiveProjectWorkspaceRootAbsent, requireThread, requireThreadAbsent, } from "./commandInvariants.ts"; @@ -200,3 +201,37 @@ describe("commandInvariants", () => { ).rejects.toThrow("already exists"); }); }); + +describe("requireActiveProjectWorkspaceRootAbsent", () => { + const command = { + type: "project.create" as const, + commandId: CommandId.make("cmd-project"), + projectId: ProjectId.make("project-nested"), + title: "Nested", + workspaceRoot: "/tmp/project-a/packages/pricing", + defaultModelSelection: null, + createdAt: now, + }; + + it("allows a workspace nested inside an existing project", async () => { + await Effect.runPromise( + requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: "/tmp/project-a/packages/pricing", + }), + ); + }); + + it("still rejects the exact workspace root of an existing project", async () => { + await expect( + Effect.runPromise( + requireActiveProjectWorkspaceRootAbsent({ + readModel, + command, + workspaceRoot: "/tmp/project-a/", + }), + ), + ).rejects.toThrow("already exists"); + }); +}); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c5ec3f095167..9b0c672d9fad 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -165,6 +165,7 @@ import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore" import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, + formatSidebarProjectLabel, } from "../sidebarProjectGrouping"; import type { Project } from "../types"; @@ -736,7 +737,7 @@ function OpenCommandPaletteDialog(props: { () => projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, - title: group.displayName, + title: formatSidebarProjectLabel(group), })), [projectPickerEntries], ); @@ -1011,9 +1012,7 @@ function OpenCommandPaletteDialog(props: { valuePrefix: "project", searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); - return ( - group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] - ); + return group ? [...group.searchTerms] : []; }, icon: projectFavicon, runProject: openProjectFromSearch, @@ -1030,11 +1029,7 @@ function OpenCommandPaletteDialog(props: { searchTerms: (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const location = projectEnvironmentLocationById.get(project.environmentId); - return [ - ...(group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? - []), - ...(location ? [location.label] : []), - ]; + return [...(group?.searchTerms ?? []), ...(location ? [location.label] : [])]; }, renderDescription: (project) => { const location = projectEnvironmentLocationById.get(project.environmentId) ?? { @@ -1488,8 +1483,10 @@ function OpenCommandPaletteDialog(props: { if (projects.length > 0) { const activeProjectTitle = - projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? - (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); + (() => { + const preferred = projectPickerEntries.find((entry) => entry.isPreferred)?.group; + return preferred ? formatSidebarProjectLabel(preferred) : null; + })() ?? (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); if (activeProjectTitle) { actionItems.push({ @@ -1639,7 +1636,7 @@ function OpenCommandPaletteDialog(props: { value: "action:project-settings", searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], title: "Project settings", - description: contextualProjectGroup.displayName, + description: formatSidebarProjectLabel(contextualProjectGroup), icon: , run: async () => { await navigate({ diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 6d6e26c18e35..225c6f60fe5c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2303,6 +2303,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {project.displayName} + {project.pathLabel ? ( + + {project.pathLabel} + + ) : null} {project.groupedProjectCount > 1 ? ( {project.groupedProjectCount} projects diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63681a54813a..a9388c8e3ed5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -94,6 +94,7 @@ import { readLocalApi } from "../localApi"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, + formatSidebarProjectLabel, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -1919,7 +1920,8 @@ export default function Sidebar() { new Map( projectGroups.flatMap((group) => group.memberProjects.map( - (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, + (project) => + [`${project.environmentId}:${project.id}`, formatSidebarProjectLabel(group)] as const, ), ), ), @@ -3510,7 +3512,9 @@ export default function Sidebar() { )} - {scopedProjectGroup?.displayName ?? "All projects"} + {scopedProjectGroup + ? formatSidebarProjectLabel(scopedProjectGroup) + : "All projects"} @@ -3544,7 +3548,9 @@ export default function Sidebar() { faviconPath={project.faviconPath} className="size-4 shrink-0" /> - {project.displayName} + + {formatSidebarProjectLabel(project)} +