From 72fbad7b1ad303a5e0a896e1de5c6489f8d10fc8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 10 Oct 2026 18:30:54 -0700 Subject: [PATCH] fix: new projects start their first thread in the project folder, not a worktree (#17371) Co-authored-by: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 19 +++++- apps/web/src/hooks/useHandleNewThread.test.ts | 5 ++ apps/web/src/hooks/useHandleNewThread.ts | 31 +++++++--- docs/user/source-control.md | 3 +- packages/shared/src/projectSettings.test.ts | 60 +++++++++++++++++++ packages/shared/src/projectSettings.ts | 37 ++++++++++++ 6 files changed, 143 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 9b0699c0f740ce6996192c5fc3ad78ef83399dd7..9827bdd15254532054a7730e58ff051ca5d62625 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -24,7 +24,7 @@ import { ThreadId, } from "@t3tools/contracts"; import { sanitizeNewRefName } from "@t3tools/shared/git"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -487,7 +487,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const attachments = selectedProjectDraft.attachments; // Default mode until the user picks one explicitly — same resolution web // uses for new draft threads: per-project setting, then the repo's - // checked-in t3.json, then the server's configured default. + // checked-in t3.json, then the server's configured default (which a new + // project with no threads yet replaces with its checkout). const fileAccessSession = useEnvironmentQuery( selectedProject !== null && selectedProject.workspaceRoot !== "" ? environmentSession.sessionStateAtom(selectedProject.environmentId) @@ -535,8 +536,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { // A thread without a project runs in a plain folder, so worktree mode // would leave it unsendable: it is always local and offers no choice. const canChooseWorkspace = !isScratchDraft; + const selectedProjectHasThreads = + selectedProject !== null && + threads.some( + (thread) => + thread.environmentId === selectedProject.environmentId && + thread.projectId === selectedProject.id, + ); const defaultWorkspaceMode: WorkspaceMode = canChooseWorkspace - ? projectSettings.settings.defaultThreadEnvMode + ? resolveNewThreadEnvMode({ + projectSettings, + workspaceRoot: selectedProject?.workspaceRoot ?? null, + newProjectsRoot: selectedEnvironmentServerConfig?.newProjectsRoot, + projectHasThreads: selectedProjectHasThreads, + }) : "local"; // While the file read is pending and nothing above it decided, the // resolved default is provisional. Nothing may write it into the draft diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 3a29155bda6d91be3b2b962cff4e8175acb20ae2..12d13f3fb758f7e219e837fa03984f2c0163bb45 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -118,6 +118,10 @@ vi.mock("@t3tools/shared/projectSettings", () => ({ sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, overrides: {}, }), + // The new-project rule has its own tests in projectSettings.test.ts. + resolveNewThreadEnvMode: (input: { + projectSettings: { settings: { defaultThreadEnvMode: "local" | "worktree" } }; + }) => input.projectSettings.settings.defaultThreadEnvMode, })); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, @@ -166,6 +170,7 @@ vi.mock("../state/entities", () => ({ }, ], readThreadShell: () => null, + readThreadShells: () => [], useProjects: () => [], useThread: () => null, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 17597e213d6539fc2c9fd35ed1da7662cb15f729..0d8d365c432aac411c812138a30d45fec2110c25 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -22,8 +22,14 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities"; +import { resolveNewThreadEnvMode, resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import { + readProjects, + readThreadShell, + readThreadShells, + useProjects, + useThreadShell, +} from "../state/entities"; import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, @@ -154,12 +160,21 @@ export function useNewThreadHandler() { const projectFile = consultProjectFile ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) : null; - return resolveProjectSettings( - targetServerSettings, - project?.id ?? null, - project, - projectFile, - ).settings.defaultThreadEnvMode; + return resolveNewThreadEnvMode({ + projectSettings: resolveProjectSettings( + targetServerSettings, + project?.id ?? null, + project, + projectFile, + ), + workspaceRoot: project?.workspaceRoot ?? null, + newProjectsRoot: environmentServerConfigs.get(projectRef.environmentId)?.newProjectsRoot, + projectHasThreads: readThreadShells().some( + (thread) => + thread.environmentId === projectRef.environmentId && + thread.projectId === projectRef.projectId, + ), + }); }; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 741a90aa128c8664d31c53e3e6e2b8e093177594..b906779021b1fd2bb0b1a4a60c3e0f9df001e811 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -119,7 +119,8 @@ Merging a stack lands it through GitCafe, and updating a branch restacks the lay To start from nothing, choose **New project** in the command palette (`Cmd/Ctrl+K`), or **New project** under **Add Project** on any client, and type a name. T3 Code makes a Git repository in `~/.t3/projects` (the `projects` folder of your T3 data directory) with a README, -an icon, and a first commit, then opens a new thread in it. The folder is named after the project, +an icon, and a first commit, then opens a new thread in it. Until the project has a thread, new +threads work in its folder, even when your default is a new worktree. The folder is named after the project, like `pinball-stats` for "Pinball Stats". Turn on **Create private repository on GitHub** to also publish it. If Git has no name or email on that machine, the project is created without the first commit. diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index f3c73fa2f3efd525cecf39e9b4b6ad6aa454129a..58fa99da4ea43cf6e56001d715f64c020fabc6d9 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -10,6 +10,7 @@ import { createModelSelection } from "./model.ts"; import { clearProjectSettingsOverrides, hasProjectSettingsOverrides, + resolveNewThreadEnvMode, resolveProjectFileBackedSetting, resolveProjectSettings, resolveWorktreeCleanup, @@ -223,6 +224,65 @@ describe("resolveProjectSettings with a t3.json", () => { }); }); +describe("resolveNewThreadEnvMode", () => { + const worktreeEnvironment = resolveProjectSettings( + { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "worktree" }, + projectId, + null, + null, + ); + const newProject = { + projectSettings: worktreeEnvironment, + workspaceRoot: "/data/projects/pinball-stats", + newProjectsRoot: "/data/projects/", + projectHasThreads: false, + }; + + it("starts a new project's first thread in its checkout", () => { + expect(resolveNewThreadEnvMode(newProject)).toBe("local"); + expect( + resolveNewThreadEnvMode({ + ...newProject, + workspaceRoot: "C:\\Data\\Projects\\Pinball-Stats", + newProjectsRoot: "c:/data/projects", + }), + ).toBe("local"); + }); + + it("keeps the configured default once the project has a thread or is not a new project", () => { + expect(resolveNewThreadEnvMode({ ...newProject, projectHasThreads: true })).toBe("worktree"); + expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects" })).toBe( + "worktree", + ); + expect( + resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects/a/nested" }), + ).toBe("worktree"); + expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects-old/a" })).toBe( + "worktree", + ); + expect(resolveNewThreadEnvMode({ ...newProject, newProjectsRoot: undefined })).toBe("worktree"); + }); + + it("lets a project override, or a t3.json the environment defers to, keep worktree mode", () => { + for (const projectSettings of [ + resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } }, + }, + projectId, + null, + null, + ), + resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, { + defaultThreadEnvMode: "worktree", + }), + ]) { + expect(resolveNewThreadEnvMode({ ...newProject, projectSettings })).toBe("worktree"); + } + }); +}); + describe("projectSettingsOverrides patches", () => { it("replaces a project's entry, removes it with null, and drops empty entries", () => { const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index af9a7112c3ea3cdec31dbc84b858a918b09c6bfb..2165fc8310abf18f8c0ab1694094ca081e845dab 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -12,6 +12,7 @@ import { type ThreadEnvMode, type WorktreeCleanupRules, } from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "./path.ts"; import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; /** @@ -188,6 +189,42 @@ function resolveProjectOverrides( return { settings: effective as ServerSettings, sources, overrides }; } +/** + * The workspace mode a new thread starts in. A project the environment made + * from just a name (a folder directly inside `ServerConfig.newProjectsRoot`) + * has only its first commit, so until it has a thread, a new thread works in + * the project checkout even when the environment default is a new worktree. + * Only that environment default is replaced: a project override still wins, + * and so does a t3.json when the environment sets no default (the usual + * precedence; the scaffold writes no t3.json). + */ +export function resolveNewThreadEnvMode(input: { + readonly projectSettings: ResolvedProjectSettings; + readonly workspaceRoot: string | null; + readonly newProjectsRoot: string | null | undefined; + readonly projectHasThreads: boolean; +}): ThreadEnvMode { + const configured = input.projectSettings.settings.defaultThreadEnvMode; + if ( + configured === "local" || + input.projectHasThreads || + input.projectSettings.sources.defaultThreadEnvMode !== "environment" || + input.workspaceRoot === null || + input.newProjectsRoot == null + ) { + return configured; + } + const root = normalizeProjectPathForComparison(input.newProjectsRoot); + const folder = normalizeProjectPathForComparison(input.workspaceRoot); + const separator = root.includes("\\") ? "\\" : "/"; + const prefix = root.endsWith(separator) ? root : `${root}${separator}`; + const isNewProjectFolder = + folder.startsWith(prefix) && + folder.length > prefix.length && + !folder.slice(prefix.length).includes(separator); + return isNewProjectFolder ? "local" : configured; +} + /** Replace the project's entry, dropping it entirely when nothing is overridden. */ export function withProjectSettingsOverrides( settings: Pick, -- 2.54.0