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 {...@@ -24,7 +24,7 @@ import {
24 ThreadId,24 ThreadId,
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import { sanitizeNewRefName } from "@t3tools/shared/git";26import { sanitizeNewRefName } from "@t3tools/shared/git";
27import { resolveProjectSettings } from "@t3tools/shared/projectSettings";27import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
28import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";28import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
29import * as Arr from "effect/Array";29import * as Arr from "effect/Array";
30import { pipe } from "effect/Function";30import { pipe } from "effect/Function";
...@@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
487 const attachments = selectedProjectDraft.attachments;487 const attachments = selectedProjectDraft.attachments;
488 // Default mode until the user picks one explicitly — same resolution web488 // Default mode until the user picks one explicitly — same resolution web
489 // uses for new draft threads: per-project setting, then the repo's489 // 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).
491 const fileAccessSession = useEnvironmentQuery(492 const fileAccessSession = useEnvironmentQuery(
492 selectedProject !== null && selectedProject.workspaceRoot !== ""493 selectedProject !== null && selectedProject.workspaceRoot !== ""
493 ? environmentSession.sessionStateAtom(selectedProject.environmentId)494 ? environmentSession.sessionStateAtom(selectedProject.environmentId)
...@@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
535 // A thread without a project runs in a plain folder, so worktree mode536 // A thread without a project runs in a plain folder, so worktree mode
536 // would leave it unsendable: it is always local and offers no choice.537 // would leave it unsendable: it is always local and offers no choice.
537 const canChooseWorkspace = !isScratchDraft;538 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 );
538 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace546 const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace
539 ? projectSettings.settings.defaultThreadEnvMode547 ? resolveNewThreadEnvMode({
548 projectSettings,
549 workspaceRoot: selectedProject?.workspaceRoot ?? null,
550 newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot,
551 projectHasThreads: selectedProjectHasThreads,
552 })
540 : "local";553 : "local";
541 // While the file read is pending and nothing above it decided, the554 // While the file read is pending and nothing above it decided, the
542 // resolved default is provisional. Nothing may write it into the draft555 // 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", () => ({...@@ -118,6 +118,10 @@ vi.mock("@t3tools/shared/projectSettings", () => ({
118 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },118 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
119 overrides: {},119 overrides: {},
120 }),120 }),
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,
121}));125}));
122vi.mock("@tanstack/react-router", () => ({126vi.mock("@tanstack/react-router", () => ({
123 useParams: () => null,127 useParams: () => null,
...@@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({...@@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({
166 },170 },
167 ],171 ],
168 readThreadShell: () => null,172 readThreadShell: () => null,
173 readThreadShells: () => [],
169 useProjects: () => [],174 useProjects: () => [],
170 useThread: () => null,175 useThread: () => null,
171}));176}));
apps/web/src/hooks/useHandleNewThread.ts+23-8
...@@ -22,8 +22,14 @@ import {...@@ -22,8 +22,14 @@ import {
22 getProjectOrderKey,22 getProjectOrderKey,
23 selectProjectGroupingSettings,23 selectProjectGroupingSettings,
24} from "../logicalProject";24} from "../logicalProject";
25import { resolveProjectSettings } from "@t3tools/shared/projectSettings";25import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings";
26import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities";26import {
27 readProjects,
28 readThreadShell,
29 readThreadShells,
30 useProjects,
31 useThreadShell,
32} from "../state/entities";
27import {33import {
28 hasExplicitComposerModelSelection,34 hasExplicitComposerModelSelection,
29 resolveNewDraftStartFromOrigin,35 resolveNewDraftStartFromOrigin,
...@@ -154,12 +160,21 @@ export function useNewThreadHandler() {...@@ -154,12 +160,21 @@ export function useNewThreadHandler() {
154 const projectFile = consultProjectFile160 const projectFile = consultProjectFile
155 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)161 ? await readT3ProjectFile(project.environmentId, project.workspaceRoot)
156 : null;162 : null;
157 return resolveProjectSettings(163 return resolveNewThreadEnvMode({
158 targetServerSettings,164 projectSettings: resolveProjectSettings(
159 project?.id ?? null,165 targetServerSettings,
160 project,166 project?.id ?? null,
161 projectFile,167 project,
162 ).settings.defaultThreadEnvMode;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 });
163 };178 };
164 const logicalProjectKey = project179 const logicalProjectKey = project
165 ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)180 ? 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...@@ -119,7 +119,8 @@ Merging a stack lands it through GitCafe, and updating a branch restacks the lay
119To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or119To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or
120**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git120**New project** under **Add Project** on any client, and type a name. T3 Code makes a Git
121repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README,121repository 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,
123like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also124like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also
124publish it. If Git has no name or email on that machine, the project is created without the125publish it. If Git has no name or email on that machine, the project is created without the
125first commit.126first commit.
packages/shared/src/projectSettings.test.ts+60
...@@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts";...@@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts";
10import {10import {
11 clearProjectSettingsOverrides,11 clearProjectSettingsOverrides,
12 hasProjectSettingsOverrides,12 hasProjectSettingsOverrides,
13 resolveNewThreadEnvMode,
13 resolveProjectFileBackedSetting,14 resolveProjectFileBackedSetting,
14 resolveProjectSettings,15 resolveProjectSettings,
15 resolveWorktreeCleanup,16 resolveWorktreeCleanup,
...@@ -223,6 +224,65 @@ describe("resolveProjectSettings with a t3.json", () => {...@@ -223,6 +224,65 @@ describe("resolveProjectSettings with a t3.json", () => {
223 });224 });
224});225});
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
226describe("projectSettingsOverrides patches", () => {286describe("projectSettingsOverrides patches", () => {
227 it("replaces a project's entry, removes it with null, and drops empty entries", () => {287 it("replaces a project's entry, removes it with null, and drops empty entries", () => {
228 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {288 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
packages/shared/src/projectSettings.ts+37
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 type ThreadEnvMode,12 type ThreadEnvMode,
13 type WorktreeCleanupRules,13 type WorktreeCleanupRules,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { normalizeProjectPathForComparison } from "./path.ts";
15import { isModelSelectionProviderEnabled } from "./serverSettings.ts";16import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
1617
17/**18/**
...@@ -188,6 +189,42 @@ function resolveProjectOverrides(...@@ -188,6 +189,42 @@ function resolveProjectOverrides(
188 return { settings: effective as ServerSettings, sources, overrides };189 return { settings: effective as ServerSettings, sources, overrides };
189}190}
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
191/** Replace the project's entry, dropping it entirely when nothing is overridden. */228/** Replace the project's entry, dropping it entirely when nothing is overridden. */
192export function withProjectSettingsOverrides(229export function withProjectSettingsOverrides(
193 settings: Pick<ServerSettings, "projectSettingsOverrides">,230 settings: Pick<ServerSettings, "projectSettingsOverrides">,