Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

When persisted projectGroupingMode is "repository_path", no radio button is checked, so the settings screen shows grouping enabled with no selected choice. resolveMobileProjectGroupingSettings preserves this legacy value, but GROUPING_OPTIONS no longer includes it; normalize "repository_path" to "repository" before comparing or render it as the repository selection.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx around line 34:

When persisted `projectGroupingMode` is `"repository_path"`, no radio button is checked, so the settings screen shows grouping enabled with no selected choice. `resolveMobileProjectGroupingSettings` preserves this legacy value, but `GROUPING_OPTIONS` no longer includes it; normalize `"repository_path"` to `"repository"` before comparing or render it as the `repository` selection.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 946a33e.

Rather than patch the comparison in the mobile screen, the legacy value is normalized where the preference is read: selectProjectGroupingSettings on web (mode and per-checkout overrides) and resolveMobileProjectGroupingSettings on mobile both map repository_path onto repository. The pickers then only ever see the two modes that still differ, and ProjectDefaultsSettings, which reads client settings directly, normalizes its select value too.

Covered by a new client-runtime test for the selector and a mobile test for the persisted legacy value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/state/project-grouping.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {},
};
}
Expand Down
11 changes: 9 additions & 2 deletions apps/mobile/src/state/project-grouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = {
const EMPTY_THREAD_JUMP_LABELS = new Map<string, string>();
const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
repository: "Group by repository",
repository_path: "Group by repository path",
repository_path: "Group by repository",
separate: "Keep separate",
};
const SIDEBAR_ICON_ACTION_BUTTON_CLASS =
Expand Down Expand Up @@ -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.";
}
Expand Down Expand Up @@ -2561,9 +2560,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
<SelectItem hideIndicator value="repository">
{PROJECT_GROUPING_MODE_LABELS.repository}
</SelectItem>
<SelectItem hideIndicator value="repository_path">
{PROJECT_GROUPING_MODE_LABELS.repository_path}
</SelectItem>
<SelectItem hideIndicator value="separate">
{PROJECT_GROUPING_MODE_LABELS.separate}
</SelectItem>
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/components/settings/ProjectDefaultsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -420,24 +421,25 @@ export function ProjectDefaultsSettings({
}
control={
<Select
value={clientSettings.sidebarProjectGroupingMode}
value={normalizeProjectGroupingMode(clientSettings.sidebarProjectGroupingMode)}
onValueChange={(value) => {
if (value === "repository" || value === "repository_path" || value === "separate")
void updateClientSettings({ sidebarProjectGroupingMode: value });
}}
>
<SelectTrigger size="sm" aria-label="Default project grouping">
<SelectValue>
{PROJECT_GROUPING_MODE_LABELS[clientSettings.sidebarProjectGroupingMode]}
{
PROJECT_GROUPING_MODE_LABELS[
normalizeProjectGroupingMode(clientSettings.sidebarProjectGroupingMode)
]
}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem value="repository">
{PROJECT_GROUPING_MODE_LABELS.repository}
</SelectItem>
<SelectItem value="repository_path">
{PROJECT_GROUPING_MODE_LABELS.repository_path}
</SelectItem>
<SelectItem value="separate">{PROJECT_GROUPING_MODE_LABELS.separate}</SelectItem>
</SelectPopup>
</Select>
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/components/settings/ProjectSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const ProjectIconPickerDialog = lazy(() =>

export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = {
repository: "Group by repository",
repository_path: "Group by repository path",
repository_path: "Group by repository",
separate: "Keep separate",
};

Expand Down Expand Up @@ -1308,9 +1308,6 @@ function ProjectDetail({
<SelectItem hideIndicator value="repository">
{PROJECT_GROUPING_MODE_LABELS.repository}
</SelectItem>
<SelectItem hideIndicator value="repository_path">
{PROJECT_GROUPING_MODE_LABELS.repository_path}
</SelectItem>
<SelectItem hideIndicator value="separate">
{PROJECT_GROUPING_MODE_LABELS.separate}
</SelectItem>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/logicalProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
derivePhysicalProjectKeyFromPath,
deriveProjectGroupingOverrideKey,
getProjectOrderKey,
normalizeProjectGroupingMode,
resolveProjectGroupingMode,
selectProjectGroupingSettings,
type ProjectGroupingMode,
Expand Down
10 changes: 10 additions & 0 deletions docs/user/project-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions packages/client-runtime/src/state/projectGrouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { chooseLoadBalancedEnvironment } from "../load-balancing.ts";
import {
buildProjectGroups,
derivePhysicalProjectKey,
normalizeProjectGroupingMode,
selectProjectGroupingSettings,
type ProjectGroupingSettings,
} from "./projectGrouping.ts";

Expand Down Expand Up @@ -136,6 +138,62 @@ 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("web", "/work/t3code/apps/web", { repositoryIdentity: rootIdentity }),
makeProject("api", "/work/t3code/services/api", { 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", "web", "api"]);
}
});

it("keeps a nested workspace separate when the repository is a filesystem root", () => {
const rootIdentity = { ...repositoryIdentity, rootPath: "/" };
const groups = buildProjectGroups({
projects: [
makeProject("root", "/", { repositoryIdentity: rootIdentity }),
makeProject("api", "/services/api", { repositoryIdentity: rootIdentity }),
],
settings: settings("repository"),
});

expect(groups.map((group) => group.label)).toEqual(["root", "api"]);
});

it("keeps a nested workspace separate when the repository is a Windows drive root", () => {
const driveIdentity = { ...repositoryIdentity, rootPath: "C:\\" };
const groups = buildProjectGroups({
projects: [
makeProject("drive", "C:\\", { repositoryIdentity: driveIdentity }),
makeProject("drive-api", "C:\\services\\api", { repositoryIdentity: driveIdentity }),
],
settings: settings("repository"),
});

expect(groups.map((group) => group.label)).toEqual(["drive", "drive-api"]);
});

it("groups checkouts of one monorepo workspace across environments", () => {
const projects = [
makeProject("local", "/work/t3code/apps/web", {
repositoryIdentity: { ...repositoryIdentity, rootPath: "/work/t3code" },
}),
makeProject("remote", "/srv/t3code/apps/web", {
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" }),
Expand Down Expand Up @@ -279,3 +337,20 @@ describe("buildProjectGroups", () => {
expect(groups[0]?.members.map((member) => member.project.id)).toEqual(["winner", "sibling"]);
});
});

describe("selectProjectGroupingSettings", () => {
it("reads the legacy repository_path preference as repository", () => {
expect(normalizeProjectGroupingMode("repository_path")).toBe("repository");
expect(normalizeProjectGroupingMode("separate")).toBe("separate");

const settings = selectProjectGroupingSettings({
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: { "environment:/work/t3code": "repository_path" },
} as never);

expect(settings.sidebarProjectGroupingMode).toBe("repository");
expect(settings.sidebarProjectGroupingOverrides).toEqual({
"environment:/work/t3code": "repository",
});
});
});
39 changes: 30 additions & 9 deletions packages/client-runtime/src/state/projectGrouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,26 @@ export interface ProjectGroupingSettings {

export type ProjectGroupingMode = SidebarProjectGroupingMode;

/**
* Maps the legacy "repository_path" preference onto "repository". Both group
* checkouts of one repository path, so callers and pickers only ever see the
* two modes that still differ.
*/
export function normalizeProjectGroupingMode(
mode: SidebarProjectGroupingMode,
): SidebarProjectGroupingMode {
return mode === "repository_path" ? "repository" : mode;
}

export function selectProjectGroupingSettings(settings: ClientSettings): ProjectGroupingSettings {
return {
sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
sidebarProjectGroupingMode: normalizeProjectGroupingMode(settings.sidebarProjectGroupingMode),
sidebarProjectGroupingOverrides: Object.fromEntries(
Object.entries(settings.sidebarProjectGroupingOverrides).map(([key, mode]) => [
key,
normalizeProjectGroupingMode(mode),
]),
),
};
}

Expand Down Expand Up @@ -55,8 +71,12 @@ function deriveRepositoryRelativeProjectPath(
return "";
}

// A repository rooted at a filesystem root ("/" or "c:\\") already ends with
// its separator; appending another one stops every nested path from matching.
const separator = normalizedRootPath.includes("\\") ? "\\" : "/";
const rootPrefix = `${normalizedRootPath}${separator}`;
const rootPrefix = normalizedRootPath.endsWith(separator)
? normalizedRootPath
: `${normalizedRootPath}${separator}`;
if (!normalizedProjectPath.startsWith(rootPrefix)) {
return null;
}
Expand Down Expand Up @@ -96,19 +116,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<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">,
groupingMode: SidebarProjectGroupingMode,
): string | null {
const canonicalKey = project.repositoryIdentity?.canonicalKey;
if (!canonicalKey) {
return null;
}

if (groupingMode === "repository") {
return canonicalKey;
}

const relativeProjectPath = deriveRepositoryRelativeProjectPath(project);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High state/projectGrouping.ts:113

For repositories rooted at / or a Windows drive root, nested workspaces such as /services/api receive the bare canonicalKey, so all nested projects collapse into one sidebar row. deriveRepositoryRelativeProjectPath builds a rootPrefix with a duplicate separator (// or c:\), causing the nested path check to fail; handle filesystem roots without appending another separator.

🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/projectGrouping.ts around line 113:

For repositories rooted at `/` or a Windows drive root, nested workspaces such as `/services/api` receive the bare `canonicalKey`, so all nested projects collapse into one sidebar row. `deriveRepositoryRelativeProjectPath` builds a `rootPrefix` with a duplicate separator (`//` or `c:\`), causing the nested path check to fail; handle filesystem roots without appending another separator.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 946a33e.

trimTrailingPathSeparators returns root paths unchanged, so / produced the prefix // and c:\ produced c:\\, and no nested path matched. rootPrefix now appends the separator only when the root does not already end with one.

Two tests cover it, one for / and one for a Windows drive root. Both fail against the previous rootPrefix line and pass now.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

if (relativeProjectPath === null) {
return canonicalKey;
Expand All @@ -134,7 +155,7 @@ export function deriveLogicalProjectKey(
}

return (
deriveRepositoryScopedKey(project, groupingMode) ??
deriveRepositoryScopedKey(project) ??
derivePhysicalProjectKey(project) ??
scopedProjectKey(scopeProjectRef(project.environmentId, project.id))
);
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading