Skip to content

Commit 4b63233

Browse files
VIPlearnerclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 6c58362 commit 4b63233

9 files changed

Lines changed: 62 additions & 37 deletions

File tree

apps/mobile/src/features/home/homeThreadList.test.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ describe("buildHomeThreadGroups", () => {
524524
expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]);
525525
});
526526

527-
it("matches web repository, repository-path, and separate grouping modes", () => {
527+
it("keeps monorepo workspaces separate in every web grouping mode", () => {
528528
const environmentId = EnvironmentId.make("environment-1");
529529
const repositoryIdentity = {
530530
canonicalKey: "github.com/t3tools/t3code",
@@ -564,17 +564,11 @@ describe("buildHomeThreadGroups", () => {
564564
}),
565565
);
566566

567-
expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1);
568-
expect(
569-
buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map(
570-
(group) => group.title,
571-
),
572-
).toEqual(["Mobile", "Web"]);
573-
expect(
574-
buildGroups(projects, threads, { projectGroupingMode: "separate" }).map(
575-
(group) => group.title,
576-
),
577-
).toEqual(["Mobile", "Web"]);
567+
for (const projectGroupingMode of ["repository", "repository_path", "separate"] as const) {
568+
expect(
569+
buildGroups(projects, threads, { projectGroupingMode }).map((group) => group.title),
570+
).toEqual(["Mobile", "Web"]);
571+
}
578572
});
579573

580574
it("default view shows only threads from the last 5 days", () => {

apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,7 @@ const GROUPING_OPTIONS: ReadonlyArray<{
2424
{
2525
mode: "repository",
2626
label: "Group by repository",
27-
description: "Matching repositories appear as one project.",
28-
},
29-
{
30-
mode: "repository_path",
31-
label: "Group by repository path",
32-
description: "Keep monorepo paths separate.",
27+
description: "Matching checkouts appear as one project. Nested workspaces stay separate.",
3328
},
3429
{
3530
mode: "separate",

apps/web/src/components/LegacySidebar.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
227227
const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
228228
const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
229229
repository: "Group by repository",
230-
repository_path: "Group by repository path",
230+
repository_path: "Group by repository",
231231
separate: "Keep separate",
232232
};
233233
const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
@@ -269,9 +269,8 @@ function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string
269269
function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string {
270270
switch (mode) {
271271
case "repository":
272-
return "Projects from the same repository share one sidebar row.";
273272
case "repository_path":
274-
return "Projects group only when both the repository and repo-relative path match.";
273+
return "Checkouts of one repository path share a sidebar row. Nested workspaces stay separate.";
275274
case "separate":
276275
return "Every project path gets its own sidebar row.";
277276
}
@@ -2561,9 +2560,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
25612560
<SelectItem hideIndicator value="repository">
25622561
{PROJECT_GROUPING_MODE_LABELS.repository}
25632562
</SelectItem>
2564-
<SelectItem hideIndicator value="repository_path">
2565-
{PROJECT_GROUPING_MODE_LABELS.repository_path}
2566-
</SelectItem>
25672563
<SelectItem hideIndicator value="separate">
25682564
{PROJECT_GROUPING_MODE_LABELS.separate}
25692565
</SelectItem>

apps/web/src/components/settings/ProjectDefaultsSettings.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,9 +435,6 @@ export function ProjectDefaultsSettings({
435435
<SelectItem value="repository">
436436
{PROJECT_GROUPING_MODE_LABELS.repository}
437437
</SelectItem>
438-
<SelectItem value="repository_path">
439-
{PROJECT_GROUPING_MODE_LABELS.repository_path}
440-
</SelectItem>
441438
<SelectItem value="separate">{PROJECT_GROUPING_MODE_LABELS.separate}</SelectItem>
442439
</SelectPopup>
443440
</Select>

apps/web/src/components/settings/ProjectSettingsPanel.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ const ProjectIconPickerDialog = lazy(() =>
124124

125125
export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
126126
repository: "Group by repository",
127-
repository_path: "Group by repository path",
127+
repository_path: "Group by repository",
128128
separate: "Keep separate",
129129
};
130130

@@ -1308,9 +1308,6 @@ function ProjectDetail({
13081308
<SelectItem hideIndicator value="repository">
13091309
{PROJECT_GROUPING_MODE_LABELS.repository}
13101310
</SelectItem>
1311-
<SelectItem hideIndicator value="repository_path">
1312-
{PROJECT_GROUPING_MODE_LABELS.repository_path}
1313-
</SelectItem>
13141311
<SelectItem hideIndicator value="separate">
13151312
{PROJECT_GROUPING_MODE_LABELS.separate}
13161313
</SelectItem>

docs/user/project-settings.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ Reset that list to use shared actions again. Existing project actions are preser
1919
Project names, icons, removal, and importing actions from a checkout remain project-specific.
2020
When there are several checkouts, the checkout picker selects which actions and grouping to edit.
2121

22+
## Project grouping
23+
24+
Project grouping combines checkouts of one repository folder into a single row. A project at
25+
`~/code/app` and the same folder on another machine or in a worktree share that row.
26+
27+
Folders inside a repository stay separate. Add `~/code/app` and `~/code/app/services/api` as two
28+
projects, and each keeps its own row. Start a thread in either one.
29+
30+
Turn grouping off to give every checkout its own row.
31+
2232
## Project icons
2333

2434
Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies

packages/client-runtime/src/state/projectGrouping.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,38 @@ describe("buildProjectGroups", () => {
136136
}
137137
});
138138

139+
it("keeps a monorepo workspace out of its parent repository's group", () => {
140+
const rootIdentity = { ...repositoryIdentity, rootPath: "/work/t3code" };
141+
const projects = [
142+
makeProject("root", "/work/t3code", { repositoryIdentity: rootIdentity }),
143+
makeProject("java", "/work/t3code/java", { repositoryIdentity: rootIdentity }),
144+
makeProject("account_approval", "/work/t3code/python/account_approval", {
145+
repositoryIdentity: rootIdentity,
146+
}),
147+
];
148+
149+
for (const mode of ["repository", "repository_path"] as const) {
150+
const groups = buildProjectGroups({ projects, settings: settings(mode) });
151+
expect(groups.map((group) => group.label)).toEqual(["root", "java", "account_approval"]);
152+
}
153+
});
154+
155+
it("groups checkouts of one monorepo workspace across environments", () => {
156+
const projects = [
157+
makeProject("local", "/work/t3code/java", {
158+
repositoryIdentity: { ...repositoryIdentity, rootPath: "/work/t3code" },
159+
}),
160+
makeProject("remote", "/srv/t3code/java", {
161+
environmentId: EnvironmentId.make("remote-environment"),
162+
repositoryIdentity: { ...repositoryIdentity, rootPath: "/srv/t3code" },
163+
}),
164+
];
165+
166+
const groups = buildProjectGroups({ projects, settings: settings("repository") });
167+
expect(groups).toHaveLength(1);
168+
expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["local", "remote"]);
169+
});
170+
139171
it("uses a shared custom title as the repository group's label", () => {
140172
const projects = [
141173
makeProject("first", "/work/t3code", { title: "Custom project" }),

packages/client-runtime/src/state/projectGrouping.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,19 +96,20 @@ export function resolveProjectGroupingMode(
9696
);
9797
}
9898

99+
/**
100+
* Groups checkouts of one repository path, so the same workspace opened in
101+
* several environments or worktrees shares a row. Nested workspaces keep their
102+
* repo-relative path in the key: a monorepo package is its own project, and
103+
* collapsing it into the repository row would leave no way to target it.
104+
*/
99105
function deriveRepositoryScopedKey(
100106
project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
101-
groupingMode: SidebarProjectGroupingMode,
102107
): string | null {
103108
const canonicalKey = project.repositoryIdentity?.canonicalKey;
104109
if (!canonicalKey) {
105110
return null;
106111
}
107112

108-
if (groupingMode === "repository") {
109-
return canonicalKey;
110-
}
111-
112113
const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);
113114
if (relativeProjectPath === null) {
114115
return canonicalKey;
@@ -134,7 +135,7 @@ export function deriveLogicalProjectKey(
134135
}
135136

136137
return (
137-
deriveRepositoryScopedKey(project, groupingMode) ??
138+
deriveRepositoryScopedKey(project) ??
138139
derivePhysicalProjectKey(project) ??
139140
scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
140141
);

packages/contracts/src/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at
5252
export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type;
5353
export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at";
5454

55+
// "repository_path" is a legacy alias of "repository": both group checkouts of
56+
// one repository path and keep nested workspaces separate. Stored preferences
57+
// still carry it, so it stays decodable.
5558
export const SidebarProjectGroupingMode = Schema.Literals([
5659
"repository",
5760
"repository_path",

0 commit comments

Comments
 (0)