authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-10 18:30:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 18:30:54-07:00
log72fbad7b1ad303a5e0a896e1de5c6489f8d10fc8
treec486c195088d4be0d7932bf5b0c4f2db7aa8b2f3
parent0f36a1176921691440e4c5b790c33f439faab98c
signature Signed by PGP key B5690EEEBB952194

fix: new projects start their first thread in the project folder, not a worktree (#17371)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

6 files changed, 143 insertions(+), 12 deletions(-)

apps/mobile/src/features/threads/new-task-flow-provider.tsx+16-3
......@@ -24,7 +24,7 @@ import {
2424 ThreadId,
2525} from "@t3tools/contracts";
2626import { sanitizeNewRefName } from "@t3tools/shared/git";
27import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
27import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
2828import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
2929import * as Arr from "effect/Array";
3030import { pipe } from "effect/Function";
......@@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
487487 const attachments = selectedProjectDraft.attachments;
488488 // Default mode until the user picks one explicitly — same resolution web
489489 // uses for new draft threads: per-project setting, then the repo's
490 // checked-in t3.json, then the server's configured default.
490 // checked-in t3.json, then the server's configured default (which a new
491 // project with no threads yet replaces with its checkout).
491492 const fileAccessSession = useEnvironmentQuery(
492493 selectedProject !== null && selectedProject.workspaceRoot !== ""
493494 ? environmentSession.sessionStateAtom(selectedProject.environmentId)
......@@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
535536 // A thread without a project runs in a plain folder, so worktree mode
536537 // would leave it unsendable: it is always local and offers no choice.
537538 const canChooseWorkspace = !isScratchDraft;
539 const selectedProjectHasThreads =
540 selectedProject !== null &&
541 threads.some(
542 (thread) =>
543 thread.environmentId === selectedProject.environmentId &&
544 thread.projectId === selectedProject.id,
545 );
538546 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace
539 ? projectSettings.settings.defaultThreadEnvMode
547 ? resolveNewThreadEnvMode({
548 projectSettings,
549 workspaceRoot: selectedProject?.workspaceRoot ?? null,
550 newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot,
551 projectHasThreads: selectedProjectHasThreads,
552 })
540553 : "local";
541554 // While the file read is pending and nothing above it decided, the
542555 // resolved default is provisional. Nothing may write it into the draft
apps/web/src/hooks/useHandleNewThread.test.ts+5
......@@ -118,6 +118,10 @@ vi.mock("@t3tools/shared/projectSettings", () => ({
118118 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
119119 overrides: {},
120120 }),
121 // The new-project rule has its own tests in projectSettings.test.ts.
122 resolveNewThreadEnvMode: (input: {
123 projectSettings: { settings: { defaultThreadEnvMode: "local" | "worktree" } };
124 }) => input.projectSettings.settings.defaultThreadEnvMode,
121125}));
122126vi.mock("@tanstack/react-router", () => ({
123127 useParams: () => null,
......@@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({
166170 },
167171 ],
168172 readThreadShell: () => null,
173 readThreadShells: () => [],
169174 useProjects: () => [],
170175 useThread: () => null,
171176}));
apps/web/src/hooks/useHandleNewThread.ts+23-8
......@@ -22,8 +22,14 @@ import {
2222 getProjectOrderKey,
2323 selectProjectGroupingSettings,
2424} from "../logicalProject";
25import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities";
25import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import {
27 readProjects,
28 readThreadShell,
29 readThreadShells,
30 useProjects,
31 useThreadShell,
32} from "../state/entities";
2733import {
2834 hasExplicitComposerModelSelection,
2935 resolveNewDraftStartFromOrigin,
......@@ -154,12 +160,21 @@ export function useNewThreadHandler() {
154160 const projectFile = consultProjectFile
155161 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)
156162 : null;
157 return resolveProjectSettings(
158 targetServerSettings,
159 project?.id ?? null,
160 project,
161 projectFile,
162 ).settings.defaultThreadEnvMode;
163 return resolveNewThreadEnvMode({
164 projectSettings: resolveProjectSettings(
165 targetServerSettings,
166 project?.id ?? null,
167 project,
168 projectFile,
169 ),
170 workspaceRoot: project?.workspaceRoot ?? null,
171 newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot,
172 projectHasThreads: readThreadShells().some(
173 (thread) =>
174 thread.environmentId === projectRef.environmentId &&
175 thread.projectId === projectRef.projectId,
176 ),
177 });
163178 };
164179 const logicalProjectKey = project
165180 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)
docs/user/source-control.md+2-1
......@@ -119,7 +119,8 @@ Merging a stack lands it through GitCafe, and updating a branch restacks the lay
119119To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or
120120**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git
121121repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README,
122an icon, and a first commit, then opens a new thread in it. The folder is named after the project,
122an icon, and a first commit, then opens a new thread in it. Until the project has a thread, new
123threads work in its folder, even when your default is a new worktree. The folder is named after the project,
123124like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also
124125publish it. If Git has no name or email on that machine, the project is created without the
125126first commit.
packages/shared/src/projectSettings.test.ts+60
......@@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts";
1010import {
1111 clearProjectSettingsOverrides,
1212 hasProjectSettingsOverrides,
13 resolveNewThreadEnvMode,
1314 resolveProjectFileBackedSetting,
1415 resolveProjectSettings,
1516 resolveWorktreeCleanup,
......@@ -223,6 +224,65 @@ describe("resolveProjectSettings with a t3.json", () => {
223224 });
224225});
225226
227describe("resolveNewThreadEnvMode", () => {
228 const worktreeEnvironment = resolveProjectSettings(
229 { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "worktree" },
230 projectId,
231 null,
232 null,
233 );
234 const newProject = {
235 projectSettings: worktreeEnvironment,
236 workspaceRoot: "/data/projects/pinball-stats",
237 newProjectsRoot: "/data/projects/",
238 projectHasThreads: false,
239 };
240
241 it("starts a new project's first thread in its checkout", () => {
242 expect(resolveNewThreadEnvMode(newProject)).toBe("local");
243 expect(
244 resolveNewThreadEnvMode({
245 ...newProject,
246 workspaceRoot: "C:\\Data\\Projects\\Pinball-Stats",
247 newProjectsRoot: "c:/data/projects",
248 }),
249 ).toBe("local");
250 });
251
252 it("keeps the configured default once the project has a thread or is not a new project", () => {
253 expect(resolveNewThreadEnvMode({ ...newProject, projectHasThreads: true })).toBe("worktree");
254 expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects" })).toBe(
255 "worktree",
256 );
257 expect(
258 resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects/a/nested" }),
259 ).toBe("worktree");
260 expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects-old/a" })).toBe(
261 "worktree",
262 );
263 expect(resolveNewThreadEnvMode({ ...newProject, newProjectsRoot: undefined })).toBe("worktree");
264 });
265
266 it("lets a project override, or a t3.json the environment defers to, keep worktree mode", () => {
267 for (const projectSettings of [
268 resolveProjectSettings(
269 {
270 ...DEFAULT_SERVER_SETTINGS,
271 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } },
272 },
273 projectId,
274 null,
275 null,
276 ),
277 resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, {
278 defaultThreadEnvMode: "worktree",
279 }),
280 ]) {
281 expect(resolveNewThreadEnvMode({ ...newProject, projectSettings })).toBe("worktree");
282 }
283 });
284});
285
226286describe("projectSettingsOverrides patches", () => {
227287 it("replaces a project's entry, removes it with null, and drops empty entries", () => {
228288 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
packages/shared/src/projectSettings.ts+37
......@@ -12,6 +12,7 @@ import {
1212 type ThreadEnvMode,
1313 type WorktreeCleanupRules,
1414} from "@t3tools/contracts";
15import { normalizeProjectPathForComparison } from "./path.ts";
1516import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
1617
1718/**
......@@ -188,6 +189,42 @@ function resolveProjectOverrides(
188189 return { settings: effective as ServerSettings, sources, overrides };
189190}
190191
192/**
193 * The workspace mode a new thread starts in. A project the environment made
194 * from just a name (a folder directly inside `ServerConfig.newProjectsRoot`)
195 * has only its first commit, so until it has a thread, a new thread works in
196 * the project checkout even when the environment default is a new worktree.
197 * Only that environment default is replaced: a project override still wins,
198 * and so does a t3.json when the environment sets no default (the usual
199 * precedence; the scaffold writes no t3.json).
200 */
201export function resolveNewThreadEnvMode(input: {
202 readonly projectSettings: ResolvedProjectSettings<ResolvedServerSettings>;
203 readonly workspaceRoot: string | null;
204 readonly newProjectsRoot: string | null | undefined;
205 readonly projectHasThreads: boolean;
206}): ThreadEnvMode {
207 const configured = input.projectSettings.settings.defaultThreadEnvMode;
208 if (
209 configured === "local" ||
210 input.projectHasThreads ||
211 input.projectSettings.sources.defaultThreadEnvMode !== "environment" ||
212 input.workspaceRoot === null ||
213 input.newProjectsRoot == null
214 ) {
215 return configured;
216 }
217 const root = normalizeProjectPathForComparison(input.newProjectsRoot);
218 const folder = normalizeProjectPathForComparison(input.workspaceRoot);
219 const separator = root.includes("\\") ? "\\" : "/";
220 const prefix = root.endsWith(separator) ? root : `${root}${separator}`;
221 const isNewProjectFolder =
222 folder.startsWith(prefix) &&
223 folder.length > prefix.length &&
224 !folder.slice(prefix.length).includes(separator);
225 return isNewProjectFolder ? "local" : configured;
226}
227
191228/** Replace the project's entry, dropping it entirely when nothing is overridden. */
192229export function withProjectSettingsOverrides(
193230 settings: Pick<ServerSettings, "projectSettingsOverrides">,