From 970f2b1347b6f9a65c7fbba190209eab8468b8c0 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sat, 7 Nov 2026 13:24:22 +0100 Subject: [PATCH 1/5] feat: choose where New project creates repositories New project always created folders under /projects, so people who keep repositories elsewhere ended up with projects in two places. A new per-environment "New project location" setting overrides that root (with ~ expansion); empty keeps today's behaviour. The server derives ServerConfig.newProjectsRoot from it, so the existing path previews on web, desktop and mobile follow the setting. Editable on web and mobile. Refs https://github.com/pingdotgg/t3code/discussions/14973 Co-Authored-By: Claude Opus 5.5 --- ...SettingsServerControlsRouteScreen.test.tsx | 2 +- .../SettingsServerControlsRouteScreen.tsx | 40 ++++++++++++++++++- .../src/project/ManagedProjectFolders.test.ts | 14 ++++++- .../src/project/ManagedProjectFolders.ts | 35 ++++++++++------ apps/server/src/ws.test.ts | 2 + apps/server/src/ws.ts | 4 +- .../components/settings/SettingsPanels.tsx | 22 ++++++++++ .../src/components/settings/settingsSearch.ts | 7 ++++ docs/user/project-settings.md | 7 ++++ packages/contracts/src/settings.ts | 2 + 10 files changed, 117 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx index 7d22b2e27eac..4c0052938976 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.test.tsx @@ -69,7 +69,7 @@ vi.mock("../threads/thread-settings-options", () => ({ withManagedRuntimeModeLimits: (choices: readonly unknown[]) => choices, })); vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children })); -vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children, AppTextInput: () => null })); vi.mock("../../components/ui/Button", () => ({ Button: ({ label, diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 793e3ae75412..2fad2ec84efb 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -3,7 +3,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { AuthSettingsWriteScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session"; import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; -import { AppText as Text } from "../../components/ui/AppText"; +import { AppText as Text, AppTextInput } from "../../components/ui/AppText"; import { type ResponseStreamingMode, type ServerSettings, @@ -398,6 +398,44 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null} + {props.page === "threads" ? ( + + {(["newProjectDirectory", "addProjectBaseDirectory"] as const).map((key) => { + const saved = uniform(key); + const value = typeof saved === "string" ? saved : ""; + const label = + key === "newProjectDirectory" + ? "New project location" + : "Add project starts in"; + return ( + + {label} + + {key === "newProjectDirectory" + ? "Leave empty to use T3 home's projects folder." + : "Leave empty to start in your home folder."} + + { + const text = event.nativeEvent.text.trim(); + if (text === value) return; + write({ [key]: text }); + }} + /> + + ); + })} + + ) : null} + {props.page === "source-control" ? ( <> {projectSelected ? null : ( diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index bf6f43a3fdb3..19e686ff3f77 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -87,6 +87,7 @@ const withScratch = ( }) => Effect.Effect< A, E, + | ServerSettings.ServerSettingsService | ManagedProjectFolders.ManagedProjectFolders | ProjectService.ProjectService | NodeServices.NodeServices @@ -338,8 +339,11 @@ it.effect("starts a named project as a committed repository, and suffixes a take const projects = yield* ProjectService.ProjectService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = path.resolve(baseDir, "projects"); - assert.equal(folders.namedProjectsRoot, root); + assert.equal(folders.namedProjectsRoot, path.resolve(baseDir, "projects")); + const root = path.resolve(baseDir, "custom-projects"); + const settings = yield* ServerSettings.ServerSettingsService; + yield* settings.updateSettings({ newProjectDirectory: root }); + assert.equal(yield* folders.resolveNamedProjectsRoot(root), root); const attributes = path.join(baseDir, "crlf.gitattributes"); const crlfConfig = path.join(baseDir, "crlf.gitconfig"); yield* fileSystem.writeFileString(attributes, "* text=auto eol=crlf\n"); @@ -352,6 +356,12 @@ it.effect("starts a named project as a committed repository, and suffixes a take assert.equal(first.workspaceRoot, path.join(root, "pinball-stats")); assert.equal(second.workspaceRoot, path.join(root, "pinball-stats-2")); + yield* settings.updateSettings({ newProjectDirectory: "" }); + const restored = yield* folders.createNamedProject({ name: "Default location" }); + assert.equal( + restored.workspaceRoot, + path.join(folders.namedProjectsRoot, "default-location"), + ); assert.isUndefined(first.commitError); const project = Option.getOrThrow(yield* projects.getById(first.projectId)); assert.equal(project.title, "Pinball Stats"); diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 4b0991e1a64c..f6addfe9934e 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -5,6 +5,9 @@ import { type ThreadId, VcsProcessSpawnError, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import * as ServerSettings from "../serverSettings.ts"; import { newProjectFolderName } from "@t3tools/shared/path"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -98,6 +101,7 @@ export class ManagedProjectFolders extends Context.Service< readonly text: string; }) => Effect.Effect, ScratchFolderError>; readonly namedProjectsRoot: string; + readonly resolveNamedProjectsRoot: (directory: string) => Effect.Effect; /** Removes failed/cancelled creations' folders only while no project owns them. */ readonly createNamedProject: (input: { readonly name: string }) => Effect.Effect< { @@ -209,6 +213,8 @@ const make = Effect.gen(function* () { const git = yield* GitVcsDriver.GitVcsDriver; const projects = yield* ProjectService.ProjectService; const random = yield* SecureRandom.SecureRandom; + const settings = yield* ServerSettings.ServerSettingsService; + const home = yield* HostProcess.HomeDirectory; // Non-recursive mkdir atomically claims a name; racers move on after AlreadyExists. const claimFreeFolder = Effect.fnUntraced(function* ( @@ -343,34 +349,36 @@ const make = Effect.gen(function* () { const namedProjectsRoot = path.resolve(config.baseDir, "projects"); + const resolveNamedProjectsRoot = Effect.fn("ManagedProjectFolders.resolveNamedProjectsRoot")( + (directory: string) => + Effect.succeed( + directory.trim() ? path.resolve(expandHomePath(directory.trim(), home)) : namedProjectsRoot, + ), + ); + const claimNamedFolder = Effect.fn("ManagedProjectFolders.claimNamedFolder")(function* ( name: string, ) { + const currentSettings = yield* settings.getSettings.pipe( + Effect.mapError((cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause })), + ); + const root = yield* resolveNamedProjectsRoot(currentSettings.newProjectDirectory); yield* fileSystem - .makeDirectory(namedProjectsRoot, { recursive: true }) - .pipe( - Effect.mapError( - (cause) => new NamedProjectFolderError({ folder: namedProjectsRoot, cause }), - ), - ); + .makeDirectory(root, { recursive: true }) + .pipe(Effect.mapError((cause) => new NamedProjectFolderError({ folder: root, cause }))); const folderName = newProjectFolderName(name); const claimed = yield* claimFreeFolder( (attempt) => Effect.succeed( attempt > MAX_NAMED_FOLDER_ATTEMPTS ? Option.none() - : Option.some( - path.join( - namedProjectsRoot, - attempt === 1 ? folderName : `${folderName}-${attempt}`, - ), - ), + : Option.some(path.join(root, attempt === 1 ? folderName : `${folderName}-${attempt}`)), ), (folder, cause) => new NamedProjectFolderError({ folder, cause }), ); if (Option.isSome(claimed)) return claimed.value; return yield* new NamedProjectFolderError({ - folder: path.join(namedProjectsRoot, folderName), + folder: path.join(root, folderName), cause: `Every folder name for "${folderName}" is taken.`, }); }); @@ -465,6 +473,7 @@ const make = Effect.gen(function* () { isScratchProject, folderForThread, namedProjectsRoot, + resolveNamedProjectsRoot, createNamedProject, }); }); diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2be9e61c7c91..4800f17a06ee 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -938,6 +938,7 @@ it.effect.each([ Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), + resolveNamedProjectsRoot: () => Effect.succeed(process.cwd()), }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ @@ -1190,6 +1191,7 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ scratchRoot: Effect.succeedNone, namedProjectsRoot: process.cwd(), + resolveNamedProjectsRoot: () => Effect.succeed(process.cwd()), }), Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), Layer.mock(ServerEnvironment.ServerEnvironment)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 035f8b1688d4..366e43795080 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1977,7 +1977,9 @@ export const layerWsRpc = ( onNone: () => ({}), onSome: (root) => ({ scratchWorkspaceRoot: root }), }), - newProjectsRoot: managedFolders.namedProjectsRoot, + newProjectsRoot: yield* managedFolders.resolveNamedProjectsRoot( + settings.newProjectDirectory, + ), ...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }), }; }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c53ca8a2dc71..5040a50fbc27 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -552,6 +552,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), + ...(settings.newProjectDirectory ? ["New project location"] : []), ...(settings.ghqCloneLayout !== DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout ? ["Clone into host folders"] : []), @@ -668,6 +669,7 @@ export function useSettingsRestore(onRestored?: () => void) { defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + newProjectDirectory: DEFAULT_UNIFIED_SETTINGS.newProjectDirectory, ghqCloneLayout: DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, @@ -1552,12 +1554,32 @@ export function ThreadsSettingsPanel() { settings, textGenerationModelInstanceEntries, ); + const mixedNewProjectDirectory = useScopedSettingsMixed(["newProjectDirectory"]); const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]); const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]); return ( + updateSettings({ newProjectDirectory: next })} + normalizeDraft={resolvePastedPath} + placeholder={mixedNewProjectDirectory ? "Mixed" : "Default projects folder"} + spellCheck={false} + aria-label="New project location" + /> + } + /> + Threads > New projects > New project location to choose where New project +creates repositories. Leave it empty to use T3 home's projects folder. Add project starts in +controls the folder browser's initial location. These settings belong to each environment, +including when you connect remotely. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b98ebe0fd7e1..2b9f12275972 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1394,6 +1394,7 @@ export const ServerSettings = Schema.Struct({ worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + newProjectDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), /** Clones default to `///`, like ghq. */ ghqCloneLayout: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1768,6 +1769,7 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), + newProjectDirectory: Schema.optionalKey(TrimmedString), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), ghqCloneLayout: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), From f6d3b1cdd35f5f6ae46d285f65636663708b3f92 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sun, 8 Nov 2026 08:23:54 +0100 Subject: [PATCH 2/5] fix(projects): update destination previews when settings change --- apps/server/src/ws.ts | 16 ++++++++++------ .../src/state/serverConfigProjection.test.ts | 7 +++++++ .../src/state/serverConfigProjection.ts | 10 ++++++++-- packages/contracts/src/server.ts | 1 + 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 366e43795080..20212a5edf6e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -392,7 +392,7 @@ export const withLateConfigDiscovery = ( case "providerStatuses": return [{ ...current, providers: event.payload.providers }, [event]]; case "settingsUpdated": - return [{ ...current, settings: event.payload.settings }, [event]]; + return [{ ...current, ...event.payload }, [event]]; // Themes and usage-limit sources survive snapshots because snapshots never include them. default: return [current, [event]]; @@ -3380,11 +3380,15 @@ export const layerWsRpc = ( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings, settingsClient), ), - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), + Stream.mapEffect((settings) => + managedFolders.resolveNamedProjectsRoot(settings.newProjectDirectory).pipe( + Effect.map((newProjectsRoot) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings, newProjectsRoot }, + })), + ), + ), ); const liveUpdates = Stream.merge( diff --git a/packages/client-runtime/src/state/serverConfigProjection.test.ts b/packages/client-runtime/src/state/serverConfigProjection.test.ts index 33921ecc2976..d6abc38f02a2 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.test.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.test.ts @@ -77,5 +77,12 @@ describe("applyServerConfigProjection settingsUpdated", () => { payload: { settings: decode() }, } as ServerConfigStreamEvent); expect(Option.getOrThrow(next).config).toBe(Option.getOrThrow(current).config); + const moved = applyServerConfigProjection(next, { + version: 1, + type: "settingsUpdated", + payload: { settings: decode(), newProjectsRoot: "/work/projects" }, + } as unknown as ServerConfigStreamEvent); + expect(Option.getOrThrow(moved).config.newProjectsRoot).toBe("/work/projects"); + expect(Option.getOrThrow(moved).config.settings).toBe(settings); }); }); diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts index 86a7f5cb8a57..6a469abfb3c5 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -109,11 +109,17 @@ export function applyServerConfigProjection( case "settingsUpdated": return Option.map(current, (projection) => { const settings = reuseUnchanged(projection.config.settings, event.payload.settings); + const newProjectsRoot = event.payload.newProjectsRoot ?? projection.config.newProjectsRoot; return { config: - settings === projection.config.settings + settings === projection.config.settings && + newProjectsRoot === projection.config.newProjectsRoot ? projection.config - : { ...projection.config, settings }, + : { + ...projection.config, + settings, + ...(newProjectsRoot === undefined ? {} : { newProjectsRoot }), + }, latestEvent: event, source: "live", }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d45dfb87310..246455c89ca4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -722,6 +722,7 @@ export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderSta export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ settings: ServerSettings, + newProjectsRoot: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; From 7bfd1cfb6ef83d7d25fb9d91c96ed57c953c9fb3 Mon Sep 17 00:00:00 2001 From: Fabio Plunser Date: Sun, 8 Nov 2026 17:18:38 +0100 Subject: [PATCH 3/5] fix(projects): save directory drafts on exit and reset mixed project locations Co-Authored-By: Claude Sonnet 5.5 --- .../SettingsServerControlsRouteScreen.tsx | 33 +++++++++++++++++-- .../components/settings/SettingsPanels.tsx | 13 ++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 2fad2ec84efb..bcbf854c4146 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -13,7 +13,7 @@ import { PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; -import { useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -207,6 +207,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const isMixed = (key: keyof ServerSettings) => reference === null || displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); + const uniformDirectory = (key: "newProjectDirectory" | "addProjectBaseDirectory") => { + const saved = uniform(key); + return typeof saved === "string" ? saved : ""; + }; const writeSettings = useSettingsWriter(); const write = (patch: ServerSettingsPatch) => { if (writeInFlight.current) { @@ -240,6 +244,26 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { if (queued?.key === targetsKey) write({ ...patch, ...queued.patch }); }); }; + // Save on exit too: Android Back hides the keyboard without ending editing. + type DirectoryKey = "newProjectDirectory" | "addProjectBaseDirectory"; + const directoryDrafts = useRef>>({}); + const takeDirectoryDraft = (key: DirectoryKey): ServerSettingsPatch => { + const draft = directoryDrafts.current[key]; + delete directoryDrafts.current[key]; + // A draft typed over an older saved value is stale: the new value already remounted its input. + if (draft === undefined || draft.over !== uniformDirectory(key)) return {}; + const text = draft.text.trim(); + return text === draft.over ? {} : { [key]: text }; + }; + const saveDirectoryDrafts = useEffectEvent(() => { + const patch = { + ...takeDirectoryDraft("newProjectDirectory"), + ...takeDirectoryDraft("addProjectBaseDirectory"), + }; + if (Object.keys(patch).length > 0) write(patch); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- saves unsent directory drafts to server settings when the screen unmounts + useEffect(() => () => saveDirectoryDrafts(), []); const clearProjectOverrides = () => { if ( writeInFlight.current || @@ -401,8 +425,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { {props.page === "threads" ? ( {(["newProjectDirectory", "addProjectBaseDirectory"] as const).map((key) => { - const saved = uniform(key); - const value = typeof saved === "string" ? saved : ""; + const value = uniformDirectory(key); const label = key === "newProjectDirectory" ? "New project location" @@ -424,7 +447,11 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { autoCapitalize="none" autoCorrect={false} className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground" + onChangeText={(text) => { + directoryDrafts.current[key] = { text, over: value }; + }} onEndEditing={(event) => { + delete directoryDrafts.current[key]; const text = event.nativeEvent.text.trim(); if (text === value) return; write({ [key]: text }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5040a50fbc27..ca042393615f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1566,6 +1566,19 @@ export function ThreadsSettingsPanel() { settingKeys={["newProjectDirectory"]} {...searchableSetting("new-project-directory")} description="Create new projects in this folder. Leave empty to use the projects folder in T3 home." + resetAction={ + mixedNewProjectDirectory || + settings.newProjectDirectory !== DEFAULT_UNIFIED_SETTINGS.newProjectDirectory ? ( + + updateSettings({ + newProjectDirectory: DEFAULT_UNIFIED_SETTINGS.newProjectDirectory, + }) + } + /> + ) : null + } control={ Date: Sun, 8 Nov 2026 22:07:33 +0100 Subject: [PATCH 4/5] fix(web): include mixed project locations in restore confirmation --- .../SettingsPanels.restore.browser.test.tsx | 28 +++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 6 +++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx diff --git a/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx b/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx new file mode 100644 index 000000000000..c68ae80eb563 --- /dev/null +++ b/apps/web/src/components/settings/SettingsPanels.restore.browser.test.tsx @@ -0,0 +1,28 @@ +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { render } from "../../../test/render"; + +const state = vi.hoisted(() => ({ mixedDirectory: false })); +vi.mock(import("./useScopedSettings"), async (original) => ({ + ...(await original()), + useScopedSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, + useScopedSettingsMixed: (keys) => keys.includes("newProjectDirectory") && state.mixedDirectory, + useUpdateScopedSettings: () => vi.fn(), +})); + +import { useSettingsRestore } from "./SettingsPanels"; + +function RestoreSummary() { + const { changedSettingLabels } = useSettingsRestore(); + return {changedSettingLabels.join(", ")}; +} + +it("includes a custom project location on another environment in restore confirmation", async () => { + state.mixedDirectory = false; + await using screen = await render(); + await expect.element(screen.getByRole("status")).not.toHaveTextContent("New project location"); + state.mixedDirectory = true; + await screen.rerender(); + await expect.element(screen.getByRole("status")).toHaveTextContent("New project location"); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ca042393615f..fd9e654e79e2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -439,6 +439,7 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); + const mixedNewProjectDirectory = useScopedSettingsMixed(["newProjectDirectory"]); const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), @@ -552,7 +553,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), - ...(settings.newProjectDirectory ? ["New project location"] : []), + ...(mixedNewProjectDirectory || + settings.newProjectDirectory !== DEFAULT_UNIFIED_SETTINGS.newProjectDirectory + ? ["New project location"] + : []), ...(settings.ghqCloneLayout !== DEFAULT_UNIFIED_SETTINGS.ghqCloneLayout ? ["Clone into host folders"] : []), From 77cc09929e89cc0aab21bc84248fcd7f20dd34df Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 15 Nov 2026 05:15:59 +0000 Subject: [PATCH 5/5] fix(projects): resolve relative creation locations from home --- apps/server/src/project/ManagedProjectFolders.test.ts | 9 +++++++++ apps/server/src/project/ManagedProjectFolders.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index 096e2746dc8e..e1abaef10af1 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -439,6 +439,15 @@ it.effect("starts a named project as a committed repository, and suffixes a take const settings = yield* ServerSettings.ServerSettingsService; yield* settings.updateSettings({ newProjectDirectory: root }); assert.equal(yield* folders.resolveNamedProjectsRoot(root), root); + const home = yield* HostProcess.HomeDirectory; + assert.equal( + yield* folders.resolveNamedProjectsRoot("~/custom-projects"), + path.resolve(home, "custom-projects"), + ); + assert.equal( + yield* folders.resolveNamedProjectsRoot("custom-projects"), + path.resolve(home, "custom-projects"), + ); const attributes = path.join(baseDir, "crlf.gitattributes"); const crlfConfig = path.join(baseDir, "crlf.gitconfig"); yield* fileSystem.writeFileString(attributes, "* text=auto eol=crlf\n"); diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 3e2d9fd01ded..7be7134067a8 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -401,7 +401,9 @@ const make = Effect.gen(function* () { const resolveNamedProjectsRoot = Effect.fn("ManagedProjectFolders.resolveNamedProjectsRoot")( (directory: string) => Effect.succeed( - directory.trim() ? path.resolve(expandHomePath(directory.trim(), home)) : namedProjectsRoot, + directory.trim() + ? path.resolve(home, expandHomePath(directory.trim(), home)) + : namedProjectsRoot, ), );