Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
c216c3a
fix(projects): group non-Git copies across environments explicitly
djgilcrease Oct 22, 2026
497ec42
fix(projects): preserve scratch selections and queued task hosts
djgilcrease Oct 22, 2026
3d91f89
fix(cli): validate project grouping identities before execution
djgilcrease Oct 22, 2026
2c68827
fix(mobile): preserve queued project when selecting an unloaded host
djgilcrease Oct 22, 2026
acaaac6
test(server): include grouping in preview migration upgrades
djgilcrease Oct 22, 2026
e9fb293
fix(projects): route environments through canonical project groups
djgilcrease Oct 24, 2026
b3bac39
fix(mobile): align project grouping with the current draft flow
djgilcrease Oct 27, 2026
0795157
fix(server): keep project grouping compatible with linked environments
djgilcrease Oct 27, 2026
7ff783e
fix(mcp): preserve project creation guidance and add grouping evals
djgilcrease Oct 29, 2026
8cf04ee
fix(evals): enforce project mutation and disclosure checks
djgilcrease Oct 29, 2026
72ee575
fix(evals): keep preview negations within one sentence
djgilcrease Oct 29, 2026
b8ac98e
test(server): upgrade grouping after active-turn indexes
djgilcrease Oct 29, 2026
b3b3030
test(server): upgrade grouping after attachment search cleanup
djgilcrease Oct 30, 2026
f197810
test(server): upgrade grouping after subagent delivery state
djgilcrease Oct 30, 2026
ab3ea3e
test(mobile): verify grouped host environments stay cached
djgilcrease Oct 30, 2026
c6e8b4e
fix(mcp): name grouping identity in project creation errors
djgilcrease Oct 31, 2026
60658fe
test(server): upgrade grouping after project pin and archive metadata
djgilcrease Oct 31, 2026
75fc7d3
test(evals): require correct project explanations
djgilcrease Oct 31, 2026
8ec0d55
test(mobile): give draft host copies an explicit grouping identity
djgilcrease Nov 1, 2026
2e25da9
test(mobile): distinguish the target copy during draft handoff
djgilcrease Nov 1, 2026
c1851bc
test(cli): verify grouping inspection JSON output
djgilcrease Nov 1, 2026
cb8e5fb
test(server): upgrade grouping after settled override repair
djgilcrease Nov 2, 2026
29c8bb0
test(server): upgrade grouping after event index cleanup
djgilcrease Nov 3, 2026
643e38f
test(server): upgrade grouping after delegated recovery indexes
djgilcrease Nov 3, 2026
34d86a8
fix(evals): require an explicit project grouping clear argument
djgilcrease Nov 5, 2026
952cd63
test(server): upgrade grouping after scheduled task thread modes
djgilcrease Nov 5, 2026
5ac6341
docs(projects): separate optional grouping changes from setup
djgilcrease Nov 5, 2026
36ad51b
test(cli): consolidate malformed grouping identity coverage
djgilcrease Nov 6, 2026
fe570d8
docs(projects): link desktop CLI setup before grouping commands
djgilcrease Nov 8, 2026
2b28c4c
docs(projects): explain connections required for grouping
djgilcrease Nov 9, 2026
cf47dde
test(server): upgrade grouping after message search migration
djgilcrease Nov 11, 2026
e28a022
fix(projects): move CLI grouping workflows into ProjectService
djgilcrease Nov 11, 2026
f0590d9
test(server): upgrade grouping after conversation index migration
djgilcrease Nov 11, 2026
c70a263
fix(mcp): clarify project updates for fenced callers
djgilcrease Nov 11, 2026
0f8cc01
fix(evals): enforce argument constraints for distinct calls
djgilcrease Nov 11, 2026
f20112e
test(server): upgrade grouping after command read indexes
djgilcrease Nov 12, 2026
ba58a93
fix(server): reconcile grouping context budget after rebase
djgilcrease Nov 12, 2026
3c3406d
test(server): upgrade grouping after turn item counts
djgilcrease Nov 12, 2026
bd01612
test(server): preserve custom titles across grouping migration upgrades
djgilcrease Nov 13, 2026
59be928
test(server): upgrade grouping after shell thread read indexes
djgilcrease Nov 13, 2026
d5c0f55
test(server): upgrade grouping after Connect auth migrations
djgilcrease Nov 14, 2026
543ba44
docs: clarify grouping setup on each computer
djgilcrease Nov 14, 2026
92dfa7e
test(server): upgrade grouping after queued-input index repair
djgilcrease Nov 14, 2026
d7a1248
test(server): upgrade grouping after provider-turn index
djgilcrease Nov 15, 2026
9db1da7
test(server): upgrade grouping after message-ordinal index
djgilcrease Nov 15, 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 @@ -67,7 +67,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("../../components/ui/Dialog", () => ({ alert: vi.fn() }));
vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.openScratch }));
Expand Down Expand Up @@ -134,7 +137,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 @@ -290,9 +298,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
75 changes: 20 additions & 55 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
MessageId,
repositoryGroupingKeyOf,
ThreadId,
} from "@t3tools/contracts";
import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences";
Expand Down Expand Up @@ -89,7 +88,11 @@ import {
resolveNewTaskBranchWorktreePath,
resolveNewTaskLocalWorkspaceSelection,
} from "./new-task-context-presentation";
import { isSameRepository, resolveEnvironmentProjectMatch } from "./new-task-project-selection";
import {
isSameRepository,
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 @@ -585,17 +588,18 @@ export function NewTaskFlowProvider(
const match = resolveEnvironmentProjectMatch(
projects.filter((project) => project.environmentId === environmentId),
selectedProject,
groupingSettings,
projects,
);
if (!match) return false;
updateFlow({
// A name or title match may be another repository, which must not receive the PR checkout.
draftKey: match
? carryDraftContentTo(match, {
keepPullRequestWorkspace: isSameRepository(match, selectedProject),
})
: readFlow().draftKey,
draftKey: carryDraftContentTo(match, {
keepPullRequestWorkspace: isSameRepository(match, selectedProject),
}),
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 @@ -611,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 @@ -806,57 +809,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: 104 additions & 27 deletions apps/mobile/src/features/threads/new-task-project-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveDraftProjectSelection,
resolveEnvironmentProjectMatch,
resolveNewTaskScratchOption,
resolveNewTaskEnvironments,
} from "./new-task-project-selection";

function makeProject(
Expand Down Expand Up @@ -121,36 +122,112 @@ describe("resolveEnvironmentProjectMatch", () => {
expect(isSameRepository(target[1]!, selected)).toBe(true);
});

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]);
expect(isSameRepository(byBasename[1]!, selected)).toBe(false);

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(isSameRepository(target, selected)).toBe(false);
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" });
const fork = makeProject("sdk", "server", { repositoryKey: "github.com/other/sdk" });
expect(resolveEnvironmentProjectMatch([fork], selected)).toBeNull();
expect(isSameRepository(fork, selected)).toBe(false);
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);
expect(isSameRepository(fork, selected)).toBe(false);
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
69 changes: 35 additions & 34 deletions apps/mobile/src/features/threads/new-task-project-selection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
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";

Expand Down Expand Up @@ -78,42 +83,18 @@ 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
);
}

Expand All @@ -130,6 +111,26 @@ export function isSameRepository(
);
}

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
Loading
Loading