Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
df64886
fix(projects): group non-Git copies across environments explicitly
djgilcrease Oct 22, 2026
1844794
fix(projects): preserve scratch selections and queued task hosts
djgilcrease Oct 22, 2026
9f73168
fix(cli): validate project grouping identities before execution
djgilcrease Oct 22, 2026
4132949
fix(mobile): preserve queued project when selecting an unloaded host
djgilcrease Oct 22, 2026
8b151a0
test(server): include grouping in preview migration upgrades
djgilcrease Oct 22, 2026
3551db6
fix(projects): route environments through canonical project groups
djgilcrease Oct 24, 2026
4f2f516
fix(mobile): align project grouping with the current draft flow
djgilcrease Oct 27, 2026
808329e
fix(server): keep project grouping compatible with linked environments
djgilcrease Oct 27, 2026
02a2f1f
fix(mcp): preserve project creation guidance and add grouping evals
djgilcrease Oct 29, 2026
23f297e
fix(evals): enforce project mutation and disclosure checks
djgilcrease Oct 29, 2026
d02a3ad
fix(evals): keep preview negations within one sentence
djgilcrease Oct 29, 2026
bbf472b
test(server): upgrade grouping after active-turn indexes
djgilcrease Oct 29, 2026
fc8a5ea
test(server): upgrade grouping after attachment search cleanup
djgilcrease Oct 30, 2026
632600c
test(server): upgrade grouping after subagent delivery state
djgilcrease Oct 30, 2026
a8d6396
test(mobile): verify grouped host environments stay cached
djgilcrease Oct 30, 2026
51a627c
fix(mcp): name grouping identity in project creation errors
djgilcrease Oct 31, 2026
63bb83c
test(server): upgrade grouping after project pin and archive metadata
djgilcrease Oct 31, 2026
fd62f5c
test(evals): require correct project explanations
djgilcrease Oct 31, 2026
8a5ddad
test(mobile): give draft host copies an explicit grouping identity
djgilcrease Nov 1, 2026
7af4c88
test(mobile): distinguish the target copy during draft handoff
djgilcrease Nov 1, 2026
ea0333b
test(cli): verify grouping inspection JSON output
djgilcrease Nov 1, 2026
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
16 changes: 12 additions & 4 deletions apps/mobile/src/features/threads/new-task-flow-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ vi.mock("../../state/threads", () => ({ olderSettledThreads: {}, threadFamilies:
vi.mock("../../state/pull-requests", () => ({ composerPullRequests: {} }));
vi.mock("../../state/olderSettledShelf", () => ({ useOlderSettledCount: () => 0 }));
vi.mock("../../state/project-grouping", () => ({
useMobileProjectGroupingSettings: () => ({ sidebarProjectGroupingMode: "none" }),
useMobileProjectGroupingSettings: () => ({
sidebarProjectGroupingMode: "repository",
sidebarProjectGroupingOverrides: {},
}),
}));
vi.mock("./use-legacy-plan-mode-enabled", () => ({
useLegacyPlanModeState: () => ({ enabled: false, loaded: true }),
Expand Down Expand Up @@ -132,7 +135,12 @@ function setup() {
vi.clearAllMocks();
const registry = AtomRegistry.make();
mocks.registry.mockReturnValue(registry);
const projects = [project("one", "a"), project("one", "b"), project("two", "b")];
const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52";
const projects = [
project("one", "a"),
{ ...project("one", "b"), groupingIdentity },
{ ...project("two", "copy"), groupingIdentity },
];
mocks.projects.mockReturnValue(projects);
mocks.presentation.mockReturnValue({
isReady: true,
Expand Down Expand Up @@ -288,9 +296,9 @@ it("owns a status stream only while the draft is active, retaining draft choices
expect(screen.current.selectedProject).toEqual(screen.projects[2]);
expect(screen.snapshot(draftKey).project).toEqual({
environmentId: "two",
projectId: "b",
projectId: "copy",
});
expect(screen.events).toEqual(["start:/a", "start:/b", "start:/b"]);
expect(screen.events).toEqual(["start:/a", "start:/b", "start:/copy"]);
// A late old-project result cannot replace the current environment's checkout.
await screen.emit("/a", "stale-branch");
expect(screen.current.currentCheckoutBranchName).toBe("main");
Expand Down
67 changes: 17 additions & 50 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
MessageId,
repositoryGroupingKeyOf,
ThreadId,
} from "@t3tools/contracts";
import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
Expand Down Expand Up @@ -93,7 +92,10 @@ import {
resolveNewTaskBranchWorktreePath,
resolveNewTaskLocalWorkspaceSelection,
} from "./new-task-context-presentation";
import { resolveEnvironmentProjectMatch } from "./new-task-project-selection";
import {
resolveEnvironmentProjectMatch,
resolveNewTaskEnvironments,
} from "./new-task-project-selection";
import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults";
import { resolveNewThreadRuntimeMode } from "./thread-settings-options";
import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation";
Expand Down Expand Up @@ -589,12 +591,16 @@ export function NewTaskFlowProvider(
const match = resolveEnvironmentProjectMatch(
projects.filter((project) => project.environmentId === environmentId),
selectedProject,
groupingSettings,
projects,
);
if (!match) return false;
updateFlow({
draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey,
draftKey: carryDraftContentTo(match),
environmentId,
projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null,
projectKey: scopedProjectKey(match.environmentId, match.id),
});
return true;
};

const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false });
Expand All @@ -609,8 +615,7 @@ export function NewTaskFlowProvider(
return true;
}
if (!isScratchDraft) {
selectEnvironment(environmentId);
return true;
return selectEnvironment(environmentId);
}
const request = {};
latestSwitchRef.current = request;
Expand Down Expand Up @@ -799,57 +804,19 @@ export function NewTaskFlowProvider(
) !== null,
);

const selectedRepositoryKey = selectedProject?.repositoryIdentity
? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
: null;
// Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts.
const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
const selectedProjectTitle = selectedProject?.title ?? null;
const environments = (() => {
if (isScratchDraft) {
return scratchEnvironments.map((environment) => ({
environmentId: environment.environmentId,
environmentLabel: environment.environmentLabel,
}));
}
const seen = new Set<EnvironmentId>();
const result: Array<{
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
}> = [];
const hostsSelectedRepository = (project: EnvironmentProject) => {
if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
return true;
}
const projectKey = project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null;
if (selectedRepositoryKey !== null && projectKey !== null) {
return projectKey === selectedRepositoryKey;
}
return (
project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename ||
(selectedProjectTitle !== null && project.title === selectedProjectTitle)
);
};
for (const project of projects) {
if (!hostsSelectedRepository(project)) {
continue;
}
if (seen.has(project.environmentId)) {
continue;
}
const environment = savedConnectionsById[project.environmentId];
if (!environment) {
continue;
}
seen.add(project.environmentId);
result.push({
environmentId: project.environmentId,
environmentLabel: environment.environmentLabel,
});
}
return result;
return resolveNewTaskEnvironments({
projects,
selectedProject,
settings: groupingSettings,
savedConnectionsById,
});
})();

const value = {
Expand Down
131 changes: 106 additions & 25 deletions apps/mobile/src/features/threads/new-task-project-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveDraftProjectSelection,
resolveEnvironmentProjectMatch,
resolveNewTaskScratchOption,
resolveNewTaskEnvironments,
} from "./new-task-project-selection";

function makeProject(
Expand Down Expand Up @@ -87,34 +88,114 @@ describe("resolveEnvironmentProjectMatch", () => {
expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]);
});

it("falls back to workspace basename, then title, for unindexed projects", () => {
const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" });
const byBasename = [
makeProject("other", "server"),
makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }),
];
expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]);

const byTitle = [
makeProject("other", "server"),
makeProject("srv", "server", { title: "t3code" }),
];
expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]);
it("requires explicit identity for non-Git copies and respects separate mode", () => {
const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52";
const selected = { ...makeProject("sdk", "mac"), groupingIdentity };
const unrelated = makeProject("sdk", "server");
const target = { ...makeProject("copy", "server"), groupingIdentity };
expect(resolveEnvironmentProjectMatch([unrelated], selected)).toBeNull();
expect(resolveEnvironmentProjectMatch([unrelated, target], selected)).toBe(target);
expect(
resolveEnvironmentProjectMatch([target], selected, {
sidebarProjectGroupingMode: "separate",
sidebarProjectGroupingOverrides: {},
}),
).toBeNull();
expect(
resolveEnvironmentProjectMatch([unrelated], { ...selected, groupingIdentity: null }),
).toBeNull();
});
it("does not switch to unrelated repositories or first-project fallbacks", () => {
const selected = makeProject("sdk", "mac", { repositoryKey: "github.com/g/sdk" });
expect(
resolveEnvironmentProjectMatch(
[makeProject("sdk", "server", { repositoryKey: "github.com/other/sdk" })],
selected,
),
).toBeNull();
expect(resolveEnvironmentProjectMatch([], selected)).toBeNull();
});
});

it("does not treat a known different repository as a basename or title match", () => {
const selected = makeProject("t3code", "mac", {
repositoryKey: "github.com/t3tools/t3code",
workspaceRoot: "/Users/me/t3code",
});
const fork = makeProject("fork", "server", {
repositoryKey: "github.com/someone/t3code",
title: "t3code",
workspaceRoot: "/home/me/t3code",
describe("collection-aware environment routing", () => {
const settings = {
sidebarProjectGroupingMode: "repository" as const,
sidebarProjectGroupingOverrides: {},
};

it("routes duplicate physical records using the same inherited Git identity as project grouping", () => {
const older = makeProject("older", "mac", {
workspaceRoot: "/sdk",
repositoryKey: "github.com/g/sdk",
});
const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" });
expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed);
expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork);
const selectedProject = {
...makeProject("newer", "mac", { workspaceRoot: "/sdk" }),
groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52",
updatedAt: "2026-07-02T00:00:00.000Z",
};
const remote = makeProject("copy", "server", { repositoryKey: "github.com/g/sdk" });
const projects = [selectedProject, older, remote];
expect(resolveEnvironmentProjectMatch([remote], selectedProject, settings, projects)).toBe(
remote,
);
expect(
resolveNewTaskEnvironments({
projects,
selectedProject,
settings,
savedConnectionsById: {
mac: { environmentLabel: "Mac" },
server: { environmentLabel: "Server" },
},
}).map((environment) => environment.environmentId),
).toEqual(["mac", "server"]);
});
});

describe("resolveNewTaskEnvironments", () => {
const settings = {
sidebarProjectGroupingMode: "repository" as const,
sidebarProjectGroupingOverrides: {},
};
const savedConnectionsById = {
mac: { environmentLabel: "Mac" },
server: { environmentLabel: "Server" },
};

it("lists only registered hosts of the explicit group", () => {
const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52";
const selectedProject = { ...makeProject("sdk", "mac"), groupingIdentity };
const input = {
projects: [
selectedProject,
{ ...makeProject("copy", "server"), groupingIdentity },
{ ...makeProject("copy", "unsaved"), groupingIdentity },
],
selectedProject,
settings,
savedConnectionsById,
};
expect(resolveNewTaskEnvironments(input)).toEqual([
{ environmentId: "mac", environmentLabel: "Mac" },
{ environmentId: "server", environmentLabel: "Server" },
]);
expect(
resolveNewTaskEnvironments({
...input,
settings: { ...settings, sidebarProjectGroupingMode: "separate" },
}),
).toEqual([{ environmentId: "mac", environmentLabel: "Mac" }]);
});

it("does not offer unrelated machines for an ordinary draft with a missing project", () => {
expect(
resolveNewTaskEnvironments({
projects: [makeProject("other", "server")],
selectedProject: makeProject("missing", "mac"),
settings,
savedConnectionsById,
}),
).toEqual([]);
});
});

Expand Down
71 changes: 36 additions & 35 deletions apps/mobile/src/features/threads/new-task-project-selection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects";
import {
derivePhysicalProjectKey,
resolveProjectGroup,
type ProjectGroupingSettings,
} from "@t3tools/client-runtime/state/project-grouping";
import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { repositoryGroupingKeyOf, type EnvironmentId, type ServerConfig } from "@t3tools/contracts";
import { type EnvironmentId, type ServerConfig } from "@t3tools/contracts";

import { scopedProjectKey } from "../../lib/scopedEntities";
import type { HomeProjectScope } from "../home/homeThreadList";
Expand Down Expand Up @@ -78,45 +83,41 @@ export function resolveNewTaskScratchOption(input: {
export function resolveEnvironmentProjectMatch(
projectsOnTarget: ReadonlyArray<EnvironmentProject>,
selectedProject: EnvironmentProject | null,
settings: ProjectGroupingSettings = {
sidebarProjectGroupingMode: "repository",
sidebarProjectGroupingOverrides: {},
},
allProjects: ReadonlyArray<EnvironmentProject> = projectsOnTarget,
): EnvironmentProject | null {
const repositoryKey = selectedProject?.repositoryIdentity
? repositoryGroupingKeyOf(selectedProject.repositoryIdentity)
: null;
// Use || null: placeholder workspaceRoot can be empty, which supplies no useful basename.
const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
// Use weaker signals only when identity is unknown; different known repositories never match by basename or title.
const isKnownMismatch = (project: EnvironmentProject) => {
const projectKey = project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null;
return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey;
};
if (selectedProject === null) return projectsOnTarget[0] ?? null;
const targetKeys = new Set(projectsOnTarget.map(derivePhysicalProjectKey));
return (
(repositoryKey !== null
? projectsOnTarget.find(
(project) =>
(project.repositoryIdentity
? repositoryGroupingKeyOf(project.repositoryIdentity)
: null) === repositoryKey,
)
: undefined) ??
(workspaceBasename !== null
? projectsOnTarget.find(
(project) =>
!isKnownMismatch(project) &&
project.workspaceRoot.split("/").at(-1) === workspaceBasename,
)
: undefined) ??
(selectedProject !== null
? projectsOnTarget.find(
(project) => !isKnownMismatch(project) && project.title === selectedProject.title,
)
: undefined) ??
projectsOnTarget[0] ??
null
resolveProjectGroup(selectedProject, allProjects, settings)?.members.find((member) =>
targetKeys.has(member.physicalProjectKey),
)?.project ?? null
);
}

export function resolveNewTaskEnvironments(input: {
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly selectedProject: EnvironmentProject | null;
readonly settings: ProjectGroupingSettings;
readonly savedConnectionsById: Readonly<
Record<string, { readonly environmentLabel: string } | undefined>
>;
}): Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string }> {
const matchingProjects = input.selectedProject
? (resolveProjectGroup(input.selectedProject, input.projects, input.settings)?.members.map(
(member) => member.project,
) ?? [])
: input.projects;
const candidates = matchingProjects.map((project) => project.environmentId);
return [...new Set(candidates)].flatMap((environmentId) => {
const environment = input.savedConnectionsById[environmentId];
return environment ? [{ environmentId, environmentLabel: environment.environmentLabel }] : [];
});
}

export function resolveDraftProjectSelection(
selectedProjectKey: string | null,
projects: ReadonlyArray<EnvironmentProject>,
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/reactCompilerCoverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ describe("React Compiler coverage", () => {
it("caches the new-task model options and host environments", () => {
const file = "src/features/threads/new-task-flow-provider.tsx";
expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]);
expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]);
expect(compilerCachedCalls(file, "resolveNewTaskEnvironments")).toEqual([true]);
});

// An uncached registration would re-register the workspace inspector on every render.
Expand Down
Loading
Loading