From 4b63233cecefeb989a8a32c0dd583c33f3700a41 Mon Sep 17 00:00:00 2001 From: Joshua Owolabi Date: Wed, 9 Sep 2026 12:12:40 +0100 Subject: [PATCH 1/3] fix(projects): keep nested workspaces out of their repository's row Repository grouping keyed the sidebar row on the git remote alone, so two projects in one monorepo collapsed into a single row named after the repository. That row targets one member and the project picker offers one entry, so a thread started from it runs in a folder you did not choose. Group keys now include the repo-relative path. Checkouts of one folder still share a row across environments and worktrees, which is what the setting is for. This made repository_path identical to repository, so the duplicate choice is gone from the web and mobile pickers. The literal stays in the contract, so stored preferences and per-checkout overrides still decode. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/features/home/homeThreadList.test.ts | 18 ++++------- .../SettingsProjectGroupingRouteScreen.tsx | 7 +--- apps/web/src/components/LegacySidebar.tsx | 8 ++--- .../settings/ProjectDefaultsSettings.tsx | 3 -- .../settings/ProjectSettingsPanel.tsx | 5 +-- docs/user/project-settings.md | 10 ++++++ .../src/state/projectGrouping.test.ts | 32 +++++++++++++++++++ .../src/state/projectGrouping.ts | 13 ++++---- packages/contracts/src/settings.ts | 3 ++ 9 files changed, 62 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e59531fe7ca9..f12bea4904bb 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -524,7 +524,7 @@ describe("buildHomeThreadGroups", () => { expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); }); - it("matches web repository, repository-path, and separate grouping modes", () => { + it("keeps monorepo workspaces separate in every web grouping mode", () => { const environmentId = EnvironmentId.make("environment-1"); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", @@ -564,17 +564,11 @@ describe("buildHomeThreadGroups", () => { }), ); - expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - 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"]); + for (const projectGroupingMode of ["repository", "repository_path", "separate"] as const) { + expect( + buildGroups(projects, threads, { projectGroupingMode }).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/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx index 951168fefcf6..bcc897b64ad0 100644 --- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx @@ -24,12 +24,7 @@ const GROUPING_OPTIONS: ReadonlyArray<{ { 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.", + description: "Matching checkouts appear as one project. Nested workspaces stay separate.", }, { mode: "separate", diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index c0e16c7cce71..7c75a78e182e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -227,7 +227,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { const EMPTY_THREAD_JUMP_LABELS = new Map(); const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", - repository_path: "Group by repository path", + repository_path: "Group by repository", separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = @@ -269,9 +269,8 @@ function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { switch (mode) { case "repository": - return "Projects from the same repository share one sidebar row."; case "repository_path": - return "Projects group only when both the repository and repo-relative path match."; + return "Checkouts of one repository path share a sidebar row. Nested workspaces stay separate."; case "separate": return "Every project path gets its own sidebar row."; } @@ -2561,9 +2560,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {PROJECT_GROUPING_MODE_LABELS.repository} - - {PROJECT_GROUPING_MODE_LABELS.repository_path} - {PROJECT_GROUPING_MODE_LABELS.separate} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 938000e01002..b0101bf0c5e3 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -435,9 +435,6 @@ export function ProjectDefaultsSettings({ {PROJECT_GROUPING_MODE_LABELS.repository} - - {PROJECT_GROUPING_MODE_LABELS.repository_path} - {PROJECT_GROUPING_MODE_LABELS.separate} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index d88644fb7e3c..9e554359bb7d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -124,7 +124,7 @@ const ProjectIconPickerDialog = lazy(() => export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", - repository_path: "Group by repository path", + repository_path: "Group by repository", separate: "Keep separate", }; @@ -1308,9 +1308,6 @@ function ProjectDetail({ {PROJECT_GROUPING_MODE_LABELS.repository} - - {PROJECT_GROUPING_MODE_LABELS.repository_path} - {PROJECT_GROUPING_MODE_LABELS.separate} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index c76c18544df2..e6257f0d1369 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -19,6 +19,16 @@ Reset that list to use shared actions again. Existing project actions are preser Project names, icons, removal, and importing actions from a checkout remain project-specific. When there are several checkouts, the checkout picker selects which actions and grouping to edit. +## Project grouping + +Project grouping combines checkouts of one repository folder into a single row. A project at +`~/code/app` and the same folder on another machine or in a worktree share that row. + +Folders inside a repository stay separate. Add `~/code/app` and `~/code/app/services/api` as two +projects, and each keeps its own row. Start a thread in either one. + +Turn grouping off to give every checkout its own row. + ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 4884c3b99bbc..7e9c2a88e13e 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -136,6 +136,38 @@ describe("buildProjectGroups", () => { } }); + it("keeps a monorepo workspace out of its parent repository's group", () => { + const rootIdentity = { ...repositoryIdentity, rootPath: "/work/t3code" }; + const projects = [ + makeProject("root", "/work/t3code", { repositoryIdentity: rootIdentity }), + makeProject("java", "/work/t3code/java", { repositoryIdentity: rootIdentity }), + makeProject("account_approval", "/work/t3code/python/account_approval", { + repositoryIdentity: rootIdentity, + }), + ]; + + for (const mode of ["repository", "repository_path"] as const) { + const groups = buildProjectGroups({ projects, settings: settings(mode) }); + expect(groups.map((group) => group.label)).toEqual(["root", "java", "account_approval"]); + } + }); + + it("groups checkouts of one monorepo workspace across environments", () => { + const projects = [ + makeProject("local", "/work/t3code/java", { + repositoryIdentity: { ...repositoryIdentity, rootPath: "/work/t3code" }, + }), + makeProject("remote", "/srv/t3code/java", { + environmentId: EnvironmentId.make("remote-environment"), + repositoryIdentity: { ...repositoryIdentity, rootPath: "/srv/t3code" }, + }), + ]; + + const groups = buildProjectGroups({ projects, settings: settings("repository") }); + expect(groups).toHaveLength(1); + expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["local", "remote"]); + }); + it("uses a shared custom title as the repository group's label", () => { const projects = [ makeProject("first", "/work/t3code", { title: "Custom project" }), diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index ce5c984214fd..7c4148cab550 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -96,19 +96,20 @@ export function resolveProjectGroupingMode( ); } +/** + * Groups checkouts of one repository path, so the same workspace opened in + * several environments or worktrees shares a row. Nested workspaces keep their + * repo-relative path in the key: a monorepo package is its own project, and + * collapsing it into the repository row would leave no way to target it. + */ function deriveRepositoryScopedKey( project: Pick, - groupingMode: SidebarProjectGroupingMode, ): string | null { const canonicalKey = project.repositoryIdentity?.canonicalKey; if (!canonicalKey) { return null; } - if (groupingMode === "repository") { - return canonicalKey; - } - const relativeProjectPath = deriveRepositoryRelativeProjectPath(project); if (relativeProjectPath === null) { return canonicalKey; @@ -134,7 +135,7 @@ export function deriveLogicalProjectKey( } return ( - deriveRepositoryScopedKey(project, groupingMode) ?? + deriveRepositoryScopedKey(project) ?? derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3491103da94f..e9a098b2fa3c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -52,6 +52,9 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +// "repository_path" is a legacy alias of "repository": both group checkouts of +// one repository path and keep nested workspaces separate. Stored preferences +// still carry it, so it stays decodable. export const SidebarProjectGroupingMode = Schema.Literals([ "repository", "repository_path", From 946a33eb2095ba943b71174f201fe75e8f3480df Mon Sep 17 00:00:00 2001 From: Joshua Owolabi Date: Wed, 9 Sep 2026 12:20:13 +0100 Subject: [PATCH 2/3] fix(projects): group nested workspaces under filesystem-root repositories A repository rooted at "/" or a Windows drive root already ends with its separator, so appending another one built a "//" prefix that no nested path matched. Every nested workspace fell back to the bare repository key and collapsed into one row, the case this branch set out to fix. Reading the grouping preference now maps the legacy repository_path value onto repository. The mobile settings screen left no radio checked for a stored repository_path, and the web pickers had no matching item. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/state/project-grouping.logic.ts | 10 +++-- .../mobile/src/state/project-grouping.test.ts | 11 ++++- .../settings/ProjectDefaultsSettings.tsx | 9 +++- apps/web/src/logicalProject.ts | 1 + .../src/state/projectGrouping.test.ts | 45 +++++++++++++++++++ .../src/state/projectGrouping.ts | 26 +++++++++-- 6 files changed, 92 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/state/project-grouping.logic.ts b/apps/mobile/src/state/project-grouping.logic.ts index 3cd01174b5dd..5dede4cd66be 100644 --- a/apps/mobile/src/state/project-grouping.logic.ts +++ b/apps/mobile/src/state/project-grouping.logic.ts @@ -1,4 +1,7 @@ -import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + normalizeProjectGroupingMode, + type ProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { Preferences } from "../persistence/mobile-preferences"; @@ -12,9 +15,10 @@ export function resolveMobileProjectGroupingSettings( preferences: Preferences, ): ProjectGroupingSettings { return { - sidebarProjectGroupingMode: + sidebarProjectGroupingMode: normalizeProjectGroupingMode( preferences.projectGroupingMode ?? - (preferences.projectGroupingEnabled === false ? "separate" : "repository"), + (preferences.projectGroupingEnabled === false ? "separate" : "repository"), + ), sidebarProjectGroupingOverrides: {}, }; } diff --git a/apps/mobile/src/state/project-grouping.test.ts b/apps/mobile/src/state/project-grouping.test.ts index 6995ea463ad0..a8b0b8c312ba 100644 --- a/apps/mobile/src/state/project-grouping.test.ts +++ b/apps/mobile/src/state/project-grouping.test.ts @@ -15,9 +15,16 @@ describe("mobile project grouping preferences", () => { expect( resolveMobileProjectGroupingSettings({ projectGroupingEnabled: false, - projectGroupingMode: "repository_path", + projectGroupingMode: "repository", }).sidebarProjectGroupingMode, - ).toBe("repository_path"); + ).toBe("repository"); + }); + + it("reads the legacy repository_path mode as repository", () => { + expect( + resolveMobileProjectGroupingSettings({ projectGroupingMode: "repository_path" }) + .sidebarProjectGroupingMode, + ).toBe("repository"); }); it("dual-writes the legacy boolean for rollback compatibility", () => { diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index b0101bf0c5e3..7fd42e92641a 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -30,6 +30,7 @@ import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { normalizeProjectGroupingMode } from "../../logicalProject"; import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; import { searchableSetting } from "./settingsSearch"; @@ -420,7 +421,7 @@ export function ProjectDefaultsSettings({ } control={