diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 11fb377145e3..aef812ce354b 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -214,7 +214,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.2.1", + version: "1.3.0", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index 090467a8cd90..8aff022910d0 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -6,6 +6,7 @@ import { type ServerSettings, type ServerSettingsPatch, type ThreadEnvMode, + type WorktreeSubmodules, PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectScopedServerSettingKey, } from "@t3tools/contracts"; @@ -43,17 +44,43 @@ const PAGE_TITLES: Record = { }; const PAGE_PROJECT_KEYS: Record = { - "new-threads": ["defaultThreadEnvMode", "defaultRuntimeMode"], + "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; +const SUBMODULE_CHOICES: ReadonlyArray<{ + readonly mode: WorktreeSubmodules | null; + readonly label: string; + readonly description: string; +}> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or initialize recursively.", + }, + { mode: "recursive", label: "Recursive", description: "Initialize nested submodules too." }, + { + mode: "top-level", + label: "Top level only", + description: "Skip submodules declared inside other submodules.", + }, + { mode: "none", label: "Skip", description: "Leave submodules empty for a setup script." }, +]; + const WORKSPACE_CHOICES: ReadonlyArray<{ - readonly mode: ThreadEnvMode; + readonly mode: ThreadEnvMode | null; readonly label: string; readonly description: string; }> = [ + // Only offered at environment scope; a project falls back through "Use defaults". + { + mode: null, + label: "Inherit", + description: "Use the repository's t3.json, or the current checkout.", + }, { mode: "local", label: "Current checkout", @@ -126,6 +153,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { const value = reference.settings[key]; return displayTargets.every((entry) => entry.settings[key] === value) ? value : null; }; + // `uniform` folds a real null into "mixed"; nullable keys need the distinction. + const isMixed = (key: keyof ServerSettings) => + reference === null || + displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { label: "environment settings update", reportFailure: true, @@ -219,23 +250,53 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { ) : null } > - {WORKSPACE_CHOICES.map((choice, index) => ( + {WORKSPACE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( 0} disabled={disabledFor("defaultThreadEnvMode")} onPress={() => write({ defaultThreadEnvMode: choice.mode })} /> ))} + + ) : null + } + > + {SUBMODULE_CHOICES.filter( + (choice) => choice.mode !== null || !projectSelected, + ).map((choice, index) => ( + 0} + disabled={disabledFor("worktreeSubmodules")} + onPress={() => write({ worktreeSubmodules: choice.mode })} + /> + ))} + { expect(secondSettings.responseStreamingMode).toBe("token"); }); + it("removes a project override when a picker sends null for a key that cannot store it", () => { + const settings: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [firstProject]: { defaultThreadEnvMode: "worktree", defaultAutoPull: true }, + }, + }; + const targets = resolveMobileSettingsTargets( + [environment(firstId, settings)], + [{ environmentId: firstId, id: firstProject }], + ); + expect(planMobileScopedSettingsPatch(targets, true, { defaultThreadEnvMode: null })).toEqual([ + { + environmentId: firstId, + patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } }, + }, + ]); + expect(planMobileScopedSettingsPatch(targets, true, { defaultModelSelection: null })).toEqual([ + { + environmentId: firstId, + patch: { + projectSettingsOverrides: { + [firstProject]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }, + }, + ]); + }); + it("resets only the selected page's override and rejects environment-wide writes", () => { const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, diff --git a/apps/mobile/src/features/settings/settings-scoped-server.ts b/apps/mobile/src/features/settings/settings-scoped-server.ts index 53cc44a7ee1c..01020a3eb221 100644 --- a/apps/mobile/src/features/settings/settings-scoped-server.ts +++ b/apps/mobile/src/features/settings/settings-scoped-server.ts @@ -1,4 +1,5 @@ import { + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type EnvironmentId, type ProjectId, @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch( continue; const current = target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {}; - const next = { ...current, ...patch }; + const next: Record = { ...current }; + for (const [key, value] of Object.entries(patch)) { + // A picker's "Inherit" sends null; for keys whose override cannot + // store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + } else { + next[key] = value; + } + } const overrides = writes.get(target.environment.environmentId) ?? {}; overrides[target.projectId] = next; writes.set(target.environment.environmentId, overrides); 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 4a912f171a55..f2801255b3bc 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -20,10 +20,6 @@ import { } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; -import { - isDefaultThreadEnvModeSettled, - resolveDefaultThreadEnvMode, -} from "@t3tools/shared/threadEnvMode"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; @@ -431,38 +427,35 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { : null, ); const t3ProjectFileData = t3ProjectFileQuery.data as ProjectReadFileResult | null; - const t3ProjectFileDefaultMode = useMemo(() => { - if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; - return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; - }, [t3ProjectFileData]); - // Environment settings with the project's overrides applied; the - // aggregate's own legacy fields still count until the server folds them. + const t3ProjectFile = useMemo( + () => + t3ProjectFileData === null || t3ProjectFileData.truncated + ? null + : parseT3ProjectFile(t3ProjectFileData.contents), + [t3ProjectFileData], + ); + // Environment settings with the project's overrides and its t3.json + // applied; the aggregate's own legacy fields still count until the server + // folds them. const projectSettings = useMemo( () => resolveProjectSettings( selectedEnvironmentServerConfig?.settings ?? DEFAULT_SERVER_SETTINGS, selectedProject?.id ?? null, selectedProject, + t3ProjectFile, ), - [selectedEnvironmentServerConfig?.settings, selectedProject], + [selectedEnvironmentServerConfig?.settings, selectedProject, t3ProjectFile], ); - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: projectThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); + const defaultWorkspaceMode: WorkspaceMode = projectSettings.settings.defaultThreadEnvMode; + // While the file read is pending and nothing above it decided, the + // resolved default is provisional. Nothing may write it into the draft + // during that window (the auto-branch effect does), or the frozen interim + // value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = + selectedProjectDraft.workspaceSelection?.mode !== undefined || + projectSettings.sources.defaultThreadEnvMode !== "environment" || + !t3ProjectFileQuery.isPending; const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 033cc192ccfe..820c6a781f8b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2543,11 +2543,20 @@ export const make = Effect.gen(function* () { }); } - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - refName: localPullRequestBranch, - path: null, - }); + const worktree = yield* gitCore.createWorktree( + { + cwd: input.cwd, + refName: localPullRequestBranch, + path: null, + }, + { + // Best effort: a settings read failure falls back to the checkout's t3.json. + submodules: yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ), + }, + ); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 9bc701af0837..c7d9417bc75b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2637,11 +2637,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-project", - refName: "feature/restore", - path: worktreePath, - }); + expect(harness.createWorktree).toHaveBeenCalledWith( + { + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }, + { submodules: null }, + ); expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( harness.startSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6a42b7c67ae1..bdf4fe8e69d9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -501,8 +501,16 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. + // Best effort like the rest of this recovery: a settings read failure + // falls back to the checkout's t3.json. + const submodules = yield* projectSettingsForThread(thread.id).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.andThen( + gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2aaabeed3d7a..f450eef0dc24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,6 +30,7 @@ import { type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { makeGitVcsDriverCore, @@ -128,6 +129,10 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + /** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */ + readonly onSubmodulesDisabled?: (input: { + source: "settings" | "t3.json"; + }) => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; @@ -137,6 +142,12 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; + /** + * The project-over-environment `worktreeSubmodules` setting. Null (or + * omitted, for callers without settings access) defers to the checkout's + * own t3.json. + */ + readonly submodules?: WorktreeSubmodules | null; } export interface GitCommitProgress { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f798abba74b6..98dec86522de 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; @@ -2353,6 +2354,100 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("resolves the submodule mode from the option, then t3.json", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: WorktreeSubmodules, + branch: string, + submodules: WorktreeSubmodules | null = null, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + // Consecutive cases may reuse a file mode to test the option alone. + yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { + submodules, + progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) }, + }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + // A resolved setting outranks the file in both directions. + assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), { + disabled: "settings", + inner: false, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: "t3.json", + inner: false, + nested: false, + }); + }), + ); + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 56cff0cb9a58..9f91264ee623 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,8 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -3092,11 +3095,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // skills, tooling or source in one gets a worktree that is quietly missing // them. Best-effort: the objects are usually already in the parent's // `.git/modules`, but a first-ever clone needs the network, and failing to - // populate a submodule must not roll back the caller's thread. + // populate a submodule must not roll back the caller's thread. Repos with + // hundreds of nested submodules opt out or stop at the top level; the + // caller resolves that from settings, or the checkout's t3.json decides. const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleMode = !hasSubmodules + ? { value: "none" as const, source: "environment" as const } + : resolveProjectFileBackedSetting( + "worktreeSubmodules", + options?.submodules ?? null, + options?.submodules != null + ? null + : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as(null)) + : Effect.succeed(file); + }), + Effect.orElseSucceed(() => null), + ), + ); + if (hasSubmodules && submoduleMode.value === "none" && progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleMode.source === "t3.json" ? "t3.json" : "settings", + }); + } + if (submoduleMode.value !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3104,7 +3133,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode.value === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 90271e00adfb..652b1ddf3f52 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -81,6 +81,7 @@ import { type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -1044,6 +1045,35 @@ const makeWsRpcLayer = ( return output; }); + // Project setting > environment setting; null when neither is set so + // the driver reads the freshly created checkout's own t3.json (the + // branch being checked out may declare something the project root does + // not). Settings that fail to load fall through the same way. + const resolveBootstrapWorktreeSubmodules = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId | null; + }) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (!settings) return null; + // A worktree can also be prepared for an existing thread, whose + // project is only known through its shell. + const resolvedProjectId = + input.projectId ?? + (yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.map((thread) => Option.getOrNull(thread)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); + const project = + resolvedProjectId === null + ? null + : yield* projectionSnapshotQuery.getProjectShellById(resolvedProjectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + return resolveProjectSettings(settings, resolvedProjectId, project).settings + .worktreeSubmodules; + }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -1462,6 +1492,10 @@ const makeWsRpcLayer = ( } yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; + const submodules = yield* resolveBootstrapWorktreeSubmodules({ + threadId, + projectId: targetProjectId ?? null, + }); const worktree = yield* gitWorkflow.createWorktree( { cwd: prepareWorktree.projectCwd, @@ -1471,6 +1505,7 @@ const makeWsRpcLayer = ( path: null, }, { + submodules, progress: { // Git has registered the directory at this point, so a // cancel during the submodule step can still remove it. @@ -1500,6 +1535,13 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: ({ source }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0577f5e8dd1f..5b046ed6a686 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { + EnvironmentId, + EnvironmentMachineKind, + VcsRef, + ProjectId, + WorktreeSubmodules, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -86,6 +92,12 @@ export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } +export const WORKTREE_SUBMODULES_LABELS: Record = { + recursive: "Recursive", + "top-level": "Top level only", + none: "Skip", +}; + export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index d80ebf0d36fb..453efb1cbe53 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -10,6 +10,7 @@ import { import { Button } from "./ui/button"; import { CalendarIcon } from "lucide-react"; import { Calendar } from "./ui/calendar"; +import { weekStartsOn } from "../timestampFormat"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; @@ -149,6 +150,7 @@ function CustomSnoozeDialog() { required selected={date} defaultMonth={date} + {...(weekStartsOn === undefined ? {} : { weekStartsOn })} disabled={{ before: new Date(new Date().setHours(0, 0, 0, 0)) }} onSelect={(selected) => { setDate(selected); diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index 4a5c3f170d20..aaaa171f10de 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -37,7 +37,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ copyToClipboard(text)} ref={ref} @@ -51,7 +51,7 @@ export const MessageCopyButton = memo(function MessageCopyButton({ {isCopied ? : } -

Copy to clipboard

+

Copy message

); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dae27ccdba47..016e621be1dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1320,7 +1320,7 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain('aria-label="Copy link"'); + expect(markup).toContain('aria-label="Copy message"'); expect(markup).toContain('data-user-message-collapsed="true"'); expect(markup).toContain('data-user-message-footer="true"'); }); diff --git a/apps/web/src/components/contextChipParts.tsx b/apps/web/src/components/contextChipParts.tsx index 9f8fa9998b4e..f0a47b22e3c3 100644 --- a/apps/web/src/components/contextChipParts.tsx +++ b/apps/web/src/components/contextChipParts.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { cn } from "~/lib/utils"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { PULL_REQUEST_STATE_PRESENTATION } from "~/components/pullRequest/pullRequestIcons"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, @@ -123,6 +123,9 @@ export function PullRequestChip(props: { onOpen: (event: MouseEvent, url: string) => void; }) { const previewTarget = usePullRequestPreviewTarget(props.environmentId, props.metadata.url); + const displayState = + props.metadata.state === "open" && props.metadata.isDraft ? "draft" : props.metadata.state; + const StateIcon = PULL_REQUEST_STATE_PRESENTATION[displayState].Icon; const button = ( ); diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index b9a880301831..a6a556231e18 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -20,7 +20,12 @@ const EMPTY_PROJECT_FILE_PATH = ""; const EMPTY_PROJECT_FILE_QUERY_ATOM = Atom.make( AsyncResult.initial(false), ).pipe(Atom.withLabel("project-file-query:empty")); -function optimisticFileAtom(environmentId: EnvironmentId, cwd: string, relativePath: string) { +/** A pending in-app write to the file, overlaying the query until confirmed. */ +export function optimisticFileAtom( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +) { return projectEnvironment.optimisticFile({ environmentId, cwd, relativePath }); } diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index a08b918be4c5..25a3f5d1fa26 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -1,13 +1,13 @@ import { DEFAULT_SERVER_SETTINGS, - EnvironmentId, type ModelSelection, type ProviderInstanceId, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; -import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { applyProviderInstanceSettings, @@ -17,7 +17,7 @@ import { } from "../../providerInstances"; import { useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; -import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { runtimeModeConfig, runtimeModeOptions } from "../chat/runtimeModeConfig"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; @@ -46,6 +46,11 @@ import { * environment defaults at an environment scope and project overrides at a * project or checkout scope; the scoped hooks route the write. */ +const WORKTREE_SUBMODULES_OPTIONS = ["recursive", "top-level", "none"] as const; +function isWorktreeSubmodules(value: string | null): value is WorktreeSubmodules { + return value !== null && (WORKTREE_SUBMODULES_OPTIONS as readonly string[]).includes(value); +} + export function ProjectDefaultsSettings({ category }: { category: ProjectSettingsCategory }) { const { scope, target, targets, connectedEnvironments } = useSettingsScope(); const settings = useScopedSettings(); @@ -71,29 +76,21 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedPermissions = useScopedSettingsMixed(["defaultRuntimeMode"]); const PermissionIcon = runtimeModeConfig[settings.defaultRuntimeMode].icon; const mixedWorkspace = useScopedSettingsMixed(["defaultThreadEnvMode"]); + const mixedSubmodules = useScopedSettingsMixed(["worktreeSubmodules"]); const mixedBrowser = useScopedSettingsMixed(["enableAgentBrowserAccess"]); const mixedAutoPull = useScopedSettingsMixed(["defaultAutoPull"]); const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); - const workspaceSource = useScopedSettingSource(["defaultThreadEnvMode"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const unavailable = connectedEnvironments.length === 0; - - // A checkout's t3.json wins over the environment default when the project - // has no override of its own; show which one "inherit" resolves to. - const checkout = scope.kind === "checkout" ? scope.checkout : null; - // The query is disabled without a checkout, so any id satisfies the hook. - const t3File = useT3ProjectFileState( - checkout?.environmentId ?? EnvironmentId.make("none"), - category === "general" && checkout ? checkout.workspaceRoot : null, - ); - const repositoryEnvMode = t3File.file?.defaultThreadEnvMode ?? null; - const inheritedEnvModeLabel = - workspaceSource === "project" - ? null - : repositoryEnvMode - ? `${resolveEnvModeLabel(repositoryEnvMode)} (t3.json)` - : null; + // File-backed keys show their effective value; the target already carries + // the checkout's t3.json, and a null file here only fills the built-in. + // The reset arrow beside the title clears the tier (SettingsRow handles a + // project override, the environment value is cleared here), so the picker + // has no "inherit" item. + const effective = target + ? resolveProjectSettings(target.settings, null, null, null).settings + : null; function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); @@ -292,27 +289,20 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting title="Workspace" description={ isProjectScope - ? "Where new threads in this project start. A t3.json preference applies when the project has no override." - : "Where new threads start, unless overridden by the project or t3.json." - } - status={ - inheritedEnvModeLabel ? `Repository default: ${inheritedEnvModeLabel}` : undefined + ? "Where new threads in this project start." + : "Where new threads start. Projects and their t3.json can override it." } resetAction={ - settings.defaultThreadEnvMode !== DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode ? ( + !isProjectScope && settings.defaultThreadEnvMode !== null ? ( - updateSettings({ - defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode, - }) - } + onClick={() => updateSettings({ defaultThreadEnvMode: null })} /> ) : null } control={ } /> + updateSettings({ worktreeSubmodules: null })} + /> + ) : null + } + control={ + + } + /> ) : category === "source-control" ? ( <> diff --git a/apps/web/src/components/settings/SettingInheritance.test.ts b/apps/web/src/components/settings/SettingInheritance.test.ts index 384dc353cb95..3b3d28e01a26 100644 --- a/apps/web/src/components/settings/SettingInheritance.test.ts +++ b/apps/web/src/components/settings/SettingInheritance.test.ts @@ -54,4 +54,41 @@ describe("settingInheritanceLayers", () => { ["Off", false], ]); }); + + it("shows the checkout's t3.json as a layer for file-backed keys", () => { + const file = { defaultThreadEnvMode: "worktree" as const }; + const fromFile = settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId, + ...resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file), + }, + DEFAULT_SERVER_SETTINGS, + "defaultThreadEnvMode", + ); + expect(fromFile.map((layer) => [layer.label, layer.value, layer.effective])).toEqual([ + ["Project", "Inherits", false], + ["Laptop", "Inherits", false], + ["t3.json", "New worktree", true], + ["Default", "Current checkout", false], + ]); + const settings = { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" as const }; + const fromEnvironment = settingInheritanceLayers( + { + environmentId, + label: "Laptop", + projectId, + ...resolveProjectSettings(settings, projectId, null, file), + }, + settings, + "defaultThreadEnvMode", + ); + expect(fromEnvironment.map((layer) => [layer.value, layer.effective])).toEqual([ + ["Inherits", false], + ["Current checkout", true], + ["Inherits", false], + ["Current checkout", false], + ]); + }); }); diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index 7e0ed7edfca4..a351fa5fa25b 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -1,7 +1,10 @@ import { DEFAULT_SERVER_SETTINGS, + PROJECT_FILE_BACKED_SETTINGS, + type ProjectFileBackedSettingKey, resolveEnvironmentMachineKind, type ServerSettings, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { CheckIcon, LayersIcon } from "lucide-react"; import * as Equal from "effect/Equal"; @@ -9,7 +12,7 @@ import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import type { EnvironmentPresentation } from "../../state/environments"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; -import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; import { Button, InlineButton } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -18,7 +21,7 @@ import type { ProjectOverrideEntry, ScopedSettingsTarget } from "./scopedSetting import { isProjectScopedSettingKey } from "./scopedSettings"; interface InheritanceLayer { - readonly key: "project" | "environment" | "built-in"; + readonly key: "project" | "environment" | "t3.json" | "built-in"; readonly label: string; readonly value: string; readonly effective: boolean; @@ -42,7 +45,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { ? "Automatic" : key === "sourceControlWriterModelSelection" ? "Text generation model" - : "Not set"; + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" + ? "Inherit" + : "Not set"; } if (typeof value === "boolean") return value ? "On" : "Off"; if (typeof value === "number") { @@ -54,6 +59,9 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (key === "defaultThreadEnvMode" && (value === "local" || value === "worktree")) { return resolveEnvModeLabel(value); } + if (key === "worktreeSubmodules" && value in WORKTREE_SUBMODULES_LABELS) { + return WORKTREE_SUBMODULES_LABELS[value as WorktreeSubmodules]; + } if (key === "pullRequestMergeMethod" && value in PULL_REQUEST_MERGE_METHOD_LABELS) { return PULL_REQUEST_MERGE_METHOD_LABELS[ value as keyof typeof PULL_REQUEST_MERGE_METHOD_LABELS @@ -74,44 +82,65 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { /** * The layers a setting resolves through for one target, top-down: the * project override when the target is a project, the environment's value, - * and the built-in default. The first layer that is set wins. + * the checkout's t3.json for file-backed keys, and the built-in default. The + * first layer that is set wins. Same order as `resolveProjectSettings`. */ export function settingInheritanceLayers( target: ScopedSettingsTarget, environmentSettings: ServerSettings, key: keyof ServerSettings, ): readonly InheritanceLayer[] { - const builtIn = DEFAULT_SERVER_SETTINGS[key]; const environmentValue = environmentSettings[key]; - const projectSource = isProjectScopedSettingKey(key) ? target.sources[key] : "environment"; - const environmentSet = !Equal.equals(environmentValue, builtIn); + const source = isProjectScopedSettingKey(key) ? target.sources[key] : "environment"; + const environmentSet = !Equal.equals(environmentValue, DEFAULT_SERVER_SETTINGS[key]); + const fileBacked = isProjectFileBackedSettingKey(key); const layers: InheritanceLayer[] = []; if (target.projectId !== null && isProjectScopedSettingKey(key)) { layers.push({ key: "project", label: "Project", - value: projectSource === "project" ? formatValue(key, target.settings[key]) : "Inherits", - effective: projectSource === "project", - set: projectSource === "project", + value: source === "project" ? formatValue(key, target.settings[key]) : "Inherits", + effective: source === "project", + set: source === "project", }); } layers.push({ key: "environment", label: target.label, value: environmentSet ? formatValue(key, environmentValue) : "Inherits", - effective: projectSource !== "project" && environmentSet, + effective: source === "environment" && environmentSet, set: environmentSet, }); + if (fileBacked && target.projectId !== null) { + layers.push({ + key: "t3.json", + label: "t3.json", + value: source === "t3.json" ? formatValue(key, target.settings[key]) : "Inherits", + effective: source === "t3.json", + set: source === "t3.json", + }); + } + // For a file-backed key the built-in is what the resolver produced with + // nothing set, not the null the schema decodes to. + const builtIn = fileBacked + ? PROJECT_FILE_BACKED_SETTINGS[key].builtIn + : DEFAULT_SERVER_SETTINGS[key]; layers.push({ key: "built-in", label: "Default", value: formatValue(key, builtIn), - effective: projectSource !== "project" && !environmentSet, + effective: source === "environment" && !environmentSet, set: true, }); return layers; } +function isProjectFileBackedSettingKey( + key: keyof ServerSettings, +): key is ProjectFileBackedSettingKey { + return Object.hasOwn(PROJECT_FILE_BACKED_SETTINGS, key); +} + export type SettingInheritanceState = | "default" | "environment" diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx index c758b29272dd..1602c29d76e2 100644 --- a/apps/web/src/components/settings/SettingsScopeContext.tsx +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -1,22 +1,78 @@ +import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; +import { useAtomValue } from "@effect/atom-react"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createContext, type ReactNode, useContext, useMemo } from "react"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { getProjectFileQueryAtom, optimisticFileAtom } from "../files/projectFilesQueryState"; import { useSettingsProjectGroups } from "./useSettingsProjectGroups"; import { resolveScopedSettingsTargets, selectScopedSettingsEnvironments } from "./scopedSettings"; import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope"; +/** + * Each member's decoded t3.json, so file-backed settings show the file as a + * layer in the inheritance chain. A member is only present once its read has + * settled; the query atom caches per (environment, cwd). + */ +function useMemberProjectFiles(scope: ReturnType) { + const members = scope.kind === "project" || scope.kind === "checkout" ? scope.members : []; + return useAtomValue( + useMemo( + () => + Atom.make((get) => { + const files = new Map(); + for (const member of members) { + const result = get( + getProjectFileQueryAtom( + member.environmentId, + member.workspaceRoot, + T3_PROJECT_FILE_NAME, + ), + ); + if (result.waiting) continue; + // A pending in-app save overlays the query, like useProjectFileQuery. + const data = + get( + optimisticFileAtom( + member.environmentId, + member.workspaceRoot, + T3_PROJECT_FILE_NAME, + ), + )?.data ?? Option.getOrNull(AsyncResult.value(result)); + files.set( + member.physicalProjectKey, + data === null || data.truncated ? null : parseT3ProjectFile(data.contents), + ); + } + return files; + }), + [members], + ), + ); +} + function useResolvedSettingsScope(search: SettingsScopeSearch) { const groups = useSettingsProjectGroups(); const { environments: availableEnvironments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const scope = useMemo( + () => resolveSettingsScope(search, groups, availableEnvironments), + [availableEnvironments, groups, search], + ); + const projectFiles = useMemberProjectFiles(scope); return useMemo(() => { - const scope = resolveSettingsScope(search, groups, availableEnvironments); const selected = selectScopedSettingsEnvironments( scope, availableEnvironments, primaryEnvironmentId, ); - const targets = resolveScopedSettingsTargets(scope, selected.connectedEnvironments); + const targets = resolveScopedSettingsTargets( + scope, + selected.connectedEnvironments, + projectFiles, + ); // The representative target supplies display values; project scopes // prefer the member on the primary environment, like environments do. const target = @@ -26,7 +82,7 @@ function useResolvedSettingsScope(search: SettingsScopeSearch) { targets[0] ?? null; return { scope, groups, ...selected, targets, target }; - }, [availableEnvironments, groups, primaryEnvironmentId, search]); + }, [availableEnvironments, groups, primaryEnvironmentId, projectFiles, scope]); } const SettingsScopeContext = createContext< diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index ceb615d3511e..7e15b4e5a26f 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -510,6 +510,53 @@ describe("project overrides at environment scope", () => { }); }); +describe("null patches at project scope", () => { + it("removes the override for keys that cannot store null and keeps it for keys that can", () => { + const environmentId = EnvironmentId.make("laptop"); + const projectId = ProjectId.make("fleet"); + const scope = { + kind: "project" as const, + group: {} as never, + environmentId: null, + label: "fleet", + members: [{ id: projectId, environmentId } as never], + environmentIds: [environmentId], + }; + const environments = [ + { + environmentId, + label: "Laptop", + connection: { phase: "connected" as const }, + serverConfig: { + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { defaultThreadEnvMode: "worktree" as const, defaultAutoPull: true }, + }, + }, + environment: { capabilities: { projectSettingsOverrides: true } }, + }, + }, + ]; + expect( + planScopedSettingsPatch(scope, environments, { defaultThreadEnvMode: null }).serverWrites[0] + ?.patch, + ).toEqual({ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } } }); + expect( + planScopedSettingsPatch(scope, environments, { defaultModelSelection: null }).serverWrites[0] + ?.patch, + ).toEqual({ + projectSettingsOverrides: { + [projectId]: { + defaultThreadEnvMode: "worktree", + defaultAutoPull: true, + defaultModelSelection: null, + }, + }, + }); + }); +}); + describe("partial object patches at project scope", () => { it("completes a writing style field patch from the target's effective value", () => { const environmentId = EnvironmentId.make("laptop"); diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 0ef90dbacd4f..e1e884632358 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -2,11 +2,13 @@ import { ClientSettingsSchema, type ClientSettingsPatch, type EnvironmentId, + isNullableProjectSettingsOverride, PROJECT_SCOPED_SERVER_SETTING_KEYS, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, ServerSettings, + type T3ProjectFile, type ServerSettingsPatch, } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; @@ -85,6 +87,9 @@ export interface ScopedSettingsTarget { export function resolveScopedSettingsTargets( scope: ResolvedSettingsScope, connectedEnvironments: readonly ScopedSettingsEnvironment[], + // Each member's decoded t3.json, keyed by physical project key, once read. + // A member absent here has no file tier yet; null is a missing or invalid file. + projectFiles?: ReadonlyMap, ): readonly ScopedSettingsTarget[] { const byId = new Map( connectedEnvironments.map((environment) => [environment.environmentId, environment]), @@ -93,7 +98,11 @@ export function resolveScopedSettingsTargets( return scope.members.flatMap((member) => { const environment = byId.get(member.environmentId); if (!environment?.serverConfig) return []; - const resolved = resolveProjectSettings(environment.serverConfig.settings, member.id); + const projectFile = projectFiles?.get(member.physicalProjectKey); + const resolved = + projectFile === undefined + ? resolveProjectSettings(environment.serverConfig.settings, member.id) + : resolveProjectSettings(environment.serverConfig.settings, member.id, null, projectFile); return [ { environmentId: member.environmentId, @@ -143,7 +152,13 @@ export function scopedSettingsSource( const scoped = keys.filter(isProjectScopedSettingKey); if (scoped.length === 0 || targets.length === 0) return "environment"; const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key]))); - return sources.size > 1 ? "mixed" : sources.has("project") ? "project" : "environment"; + return sources.size > 1 + ? "mixed" + : sources.has("project") + ? "project" + : sources.has("t3.json") + ? "t3.json" + : "environment"; } interface ScopedServerWrite { @@ -234,6 +249,15 @@ export function planScopedSettingsPatch( }; continue; } + // A picker's "Inherit" sends null; for keys whose override + // cannot store null that means remove the override. + if ( + value === null && + !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) + ) { + delete next[key]; + continue; + } const base = effective[key as keyof ServerSettings]; next[key] = isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value; diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index c805e37f1bbd..6691b93a7242 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -407,11 +407,13 @@ export function SettingsRow({ ? { state: "mixed", summary: "Mixed across selected environments" } : source === "project" ? { state: "overridden", summary: "Overridden for this project" } - : source === "environment" && scopedKeys.length > 0 - ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` } - : customized - ? { state: "environment", summary: "Set on the environment" } - : { state: "default", summary: "Built-in default" }; + : source === "t3.json" + ? { state: "inherited", summary: "Inherited from the repository's t3.json" } + : source === "environment" && scopedKeys.length > 0 + ? { state: "inherited", summary: `Inherited from ${inheritedFrom}` } + : customized + ? { state: "environment", summary: "Set on the environment" } + : { state: "default", summary: "Built-in default" }; const renderedInheritance = context && serverScoped && settingKeys.length > 0 ? ( ({ vi.mock("@t3tools/shared/projectSettings", () => ({ // Environment settings pass through; the tests set project fields on the // project record, which the hook still honors until the server folds them. - resolveProjectSettings: (settings: Record) => ({ - settings, + // With a file argument the env mode resolves like the real chain. + resolveProjectSettings: ( + settings: Record, + _projectId: unknown, + _project: unknown, + projectFile?: { defaultThreadEnvMode?: "local" | "worktree" } | null, + ) => ({ + settings: + projectFile === undefined + ? settings + : { + ...settings, + defaultThreadEnvMode: + settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local", + }, sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" }, overrides: {}, }), })); -vi.mock("@t3tools/shared/threadEnvMode", () => ({ - resolveDefaultThreadEnvMode: (input: { - readonly projectFile: "local" | "worktree" | null; - readonly globalDefault: "local" | "worktree"; - }) => input.projectFile ?? input.globalDefault, -})); vi.mock("@tanstack/react-router", () => ({ useParams: () => null, useRouter: () => testState.router, @@ -137,7 +144,7 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ resolveNewThreadModelSelectionOverride: () => null, })); vi.mock("../lib/t3ProjectFileDefaults", () => ({ - readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead, + readT3ProjectFile: () => testState.projectFileRead, })); vi.mock("../lib/utils", () => ({ newDraftId: () => "draft-delayed", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 65ac2307ce89..327f18080008 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -23,14 +23,13 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; import { hasExplicitComposerModelSelection, resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; -import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; +import { readT3ProjectFile } from "../lib/t3ProjectFileDefaults"; import { environmentServerConfigsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; @@ -138,10 +137,6 @@ export function useNewThreadHandler() { ); const projectDefaultModelSelection = projectSettings.settings.defaultModelSelection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; - const projectThreadEnvMode = - projectSettings.sources.defaultThreadEnvMode === "project" - ? projectSettings.settings.defaultThreadEnvMode - : undefined; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ projectDefaultSelection: projectDefaultModelSelection ?? null, @@ -154,17 +149,17 @@ export function useNewThreadHandler() { // skipped entirely when a higher-priority source decides, and its // query atom caches per project after the first call. const resolveDefaultEnvMode = async (): Promise => { - const consultProjectFile = project !== undefined && projectThreadEnvMode == null; - return resolveDefaultThreadEnvMode({ - projectSetting: projectThreadEnvMode, - projectFile: consultProjectFile - ? await readT3ProjectFileDefaultThreadEnvMode( - project.environmentId, - project.workspaceRoot, - ) - : null, - globalDefault: projectSettings.settings.defaultThreadEnvMode, - }); + const consultProjectFile = + project !== undefined && projectSettings.settings.defaultThreadEnvMode === null; + const projectFile = consultProjectFile + ? await readT3ProjectFile(project.environmentId, project.workspaceRoot) + : null; + return resolveProjectSettings( + targetServerSettings, + project?.id ?? null, + project, + projectFile, + ).settings.defaultThreadEnvMode; }; const logicalProjectKey = project ? deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings) diff --git a/apps/web/src/lib/selectionActions.test.ts b/apps/web/src/lib/selectionActions.test.ts index 56f51863b690..d677b2858771 100644 --- a/apps/web/src/lib/selectionActions.test.ts +++ b/apps/web/src/lib/selectionActions.test.ts @@ -24,7 +24,12 @@ function createSelectionSurface({ interactiveActions = false } = {}) { }); const element = Object.assign(new EventTarget(), { ownerDocument: document, - contains: (target: unknown): boolean => target === element, + contains: (target: unknown): boolean => target === element || target === buttonIcon, + closest: () => null, + }); + const button = new EventTarget(); + const buttonIcon = Object.assign(new EventTarget(), { + closest: (selector: string) => (selector === "button, [role=button]" ? button : null), }); const onSelection = vi.fn(); const onDismiss = vi.fn(); @@ -58,7 +63,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { const press = event("pointerdown", { button, isPrimary }); Object.defineProperty(press, "target", { value: target }); document.dispatchEvent(press); - if (target === element && !consumed) element.dispatchEvent(press); + if (element.contains(target) && !consumed) element.dispatchEvent(press); }; const focus = (target: EventTarget) => { document.activeElement = target; @@ -92,6 +97,7 @@ function createSelectionSurface({ interactiveActions = false } = {}) { view, document, element, + buttonIcon, actionElement, field, onSelection, @@ -177,6 +183,31 @@ describe("selection action gestures", () => { expect(surface.onSelection).not.toHaveBeenCalled(); }); + it.each([1, 2])( + "dismisses selection actions when a button is pressed after %i clicks", + (detail) => { + const surface = createSelectionSurface(); + surface.down(); + surface.up({ detail }); + surface.flush(); + surface.onSelection.mockClear(); + surface.onDismiss.mockClear(); + + surface.down({ target: surface.buttonIcon }); + surface.up(); + surface.change(); + surface.flush(1000); + expect(surface.onDismiss).toHaveBeenCalledWith("interaction"); + expect(surface.onSelection).not.toHaveBeenCalled(); + + surface.down(); + surface.change(); + surface.up(); + surface.flush(); + expect(surface.onSelection).toHaveBeenCalledOnce(); + }, + ); + it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => { const surface = createSelectionSurface(); surface.down({ isPrimary: false }); diff --git a/apps/web/src/lib/selectionActions.ts b/apps/web/src/lib/selectionActions.ts index 91e6c2e6a10b..5954a770aa65 100644 --- a/apps/web/src/lib/selectionActions.ts +++ b/apps/web/src/lib/selectionActions.ts @@ -101,6 +101,10 @@ export function observeSelectionActions({ }; const onSelectionStart = (event: PointerEvent) => { if (!event.isPrimary) return; + const target = event.target as Element | null; + // Buttons can preserve an existing text selection while opening a menu. + // Their release must not reopen selection actions beside the button. + if (target?.closest("button, [role=button]")) return; // A surface may consume a press for a link or terminal mouse reporting. gestureActive = event.button === 0 && !event.defaultPrevented; dismissed = !gestureActive; diff --git a/apps/web/src/lib/t3ProjectFileDefaults.ts b/apps/web/src/lib/t3ProjectFileDefaults.ts index b6a1b7484b9e..543519ed488a 100644 --- a/apps/web/src/lib/t3ProjectFileDefaults.ts +++ b/apps/web/src/lib/t3ProjectFileDefaults.ts @@ -1,4 +1,4 @@ -import { T3_PROJECT_FILE_NAME, type EnvironmentId, type ThreadEnvMode } from "@t3tools/contracts"; +import { T3_PROJECT_FILE_NAME, type EnvironmentId, type T3ProjectFile } from "@t3tools/contracts"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; @@ -9,19 +9,19 @@ import { import { appAtomRegistry } from "~/rpc/atomRegistry"; /** - * Read `defaultThreadEnvMode` from the project's checked-in `t3.json`. + * Read and decode the project's checked-in `t3.json`. * - * Imperative counterpart to `useT3ProjectFileScripts` for the new-thread - * path, which resolves defaults at call time rather than render time. The - * file query atom caches per (environment, cwd), so repeat calls don't - * re-fetch. Optimistic in-app writes overlay the query result, matching what + * Imperative counterpart to `useT3ProjectFileState` for the new-thread path, + * which resolves defaults at call time rather than render time. The file + * query atom caches per (environment, cwd), so repeat calls don't re-fetch. + * Optimistic in-app writes overlay the query result, matching what * `useProjectFileQuery` renders. Missing, truncated, or invalid files * resolve to null. */ -export async function readT3ProjectFileDefaultThreadEnvMode( +export async function readT3ProjectFile( environmentId: EnvironmentId, workspaceRoot: string, -): Promise { +): Promise { const result = await executeAtomQuery( appAtomRegistry, getProjectFileQueryAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME), @@ -34,5 +34,5 @@ export async function readT3ProjectFileDefaultThreadEnvMode( result._tag === "Success" ? result.value : null, ); if (data === null || data.truncated) return null; - return parseT3ProjectFile(data.contents)?.defaultThreadEnvMode ?? null; + return parseT3ProjectFile(data.contents); } diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 8c6287010d8f..d748853000c5 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -52,6 +52,32 @@ describe("formatShortTimestamp", () => { }); }); +describe("resolveWeekStartsOn", () => { + it.each([ + ["en-US", 0], + ["en-GB", 1], + ["pl-PL", 1], + ["ar-EG", 6], + ])("starts the %s week on weekday %i", async (locale, weekday) => { + const { resolveWeekStartsOn } = await import("./timestampFormat"); + expect(resolveWeekStartsOn(locale)).toBe(weekday); + }); + + it("leaves the default to the caller for a malformed locale", async () => { + const { resolveWeekStartsOn } = await import("./timestampFormat"); + expect(resolveWeekStartsOn("not a locale")).toBeUndefined(); + }); + + it("follows the locale the desktop host reports", async () => { + vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => "en-GB" } }); + vi.resetModules(); + const { weekStartsOn } = await import("./timestampFormat"); + expect(weekStartsOn).toBe(1); + vi.unstubAllGlobals(); + vi.resetModules(); + }); +}); + describe("formatChatTimestampTooltip", () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 9dd463bb50fa..5106a00bdf2f 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -53,6 +53,35 @@ function readHostSystemLocale(): string | null { const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); +const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; +type WeekdayIndex = (typeof WEEKDAY_INDEXES)[number]; + +type LocaleWithWeekInfo = Intl.Locale & { + readonly weekInfo?: { readonly firstDay: number }; + getWeekInfo?: () => { readonly firstDay: number }; +}; + +/** + * First weekday of a locale as a `Date#getDay` index (0 is Sunday), or + * `undefined` when the runtime has no week data, so callers keep their own + * default. Without a locale it reads the runtime's. + */ +export function resolveWeekStartsOn(locale: string | undefined): WeekdayIndex | undefined { + try { + const resolved: LocaleWithWeekInfo = new Intl.Locale( + locale ?? Intl.DateTimeFormat().resolvedOptions().locale, + ); + // Week info counts Monday as 1 and Sunday as 7. + const firstDay = resolved.getWeekInfo?.().firstDay ?? resolved.weekInfo?.firstDay; + return firstDay === undefined ? undefined : WEEKDAY_INDEXES[firstDay % 7]; + } catch { + return undefined; + } +} + +/** Week start for calendars, from the same locale timestamps are shown in. */ +export const weekStartsOn = resolveWeekStartsOn(timestampLocale); + const timestampFormatterCache = new Map(); function getTimestampFormatter( diff --git a/docs/internals/consistency-action-names.md b/docs/internals/consistency-action-names.md new file mode 100644 index 000000000000..7277c1bbf74f --- /dev/null +++ b/docs/internals/consistency-action-names.md @@ -0,0 +1,39 @@ +# Action names agree across presentations + +Proposed invariant: a command's visible label, tooltip, menu wording and accessible +name describe the same action on the same object. An icon-only presentation must +retain that meaning. This applies to shared web/Electron controls and corresponding +React Native iOS/Android actions, wherever those actions are available. + +Separate strings can drift while each presentation still looks plausible. A message +copy action called “Copy link” promises a different clipboard payload to someone +using a screen reader. Use wording that identifies the actual target, including +when a message contains links or structured context. Additional clipboard formats +do not turn copying a message into copying a link. + +This proposal draws on Apple's [Writing](https://developer.apple.com/design/human-interface-guidelines/writing) +and [Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility) +guidance. It is a product constraint, not a claim of Apple certification or complete +application compliance. + +## Boundaries + +Native menus and assistive technologies may use different wording or omit a tooltip. +Labels need semantic agreement, not identical strings across platforms. A contextual +“Copy” is sufficient when its target is unambiguous; do not globally expand it. +Temporary “Copied” feedback may replace an action label without implying a different +target. Clipboard transport, success timing and error reporting are separate concerns. + +## Observable cases + +- User and assistant message copy controls identify the message, in both tooltip + and accessible name. Collapsing a long message does not change the action's meaning. +- Copying a message with structured context still identifies the message; it does + not promise to copy only a context link. +- Code and plan copy actions retain their own targets. A contextual plan menu can + say “Copy to clipboard”; a code control must not be announced as copying a message. +- An icon, labeled button or native menu for an equivalent command conveys the same + operation. Completed feedback never names a different object. + +Verify visible wording alongside the runtime accessible name and resulting action. +Source inspection and screenshots alone do not establish accessibility-tree behavior. diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 0d5d7e66b1fb..b5ad7c6514d1 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -49,9 +49,18 @@ checkouts and removal. Actions belong to a project: editing them creates the pro on each selected environment, and reset returns to the environment's shared list. A project's `t3.json` actions can be imported there. -For workspace mode, a project's `t3.json` preference applies when the project has no override. +Settings a repository can also declare in `t3.json`, such as the workspace for new threads, +resolve in one order: a project override, then the environment setting, then `t3.json`, then the +built-in default. Leave a setting on **Inherit** to let the next tier decide. Browser access changes apply when an agent session next starts. +New worktrees initialize git submodules recursively. If that step is slow because the repository +declares many nested submodules, set **Submodules** in **Settings → General** (with the project +selected to override it there) to **Top level only** to stop at the ones the repository declares +itself, or **Skip** to leave them for a setup script. It resolves in the same order as the +workspace default: a `"worktreeSubmodules"` value in the `t3.json` of the branch being checked out +applies when the project and environment are both on **Inherit**. + ## Storage cleanup Open **Settings → Storage** to enable automatic cleanup on one machine or all connected diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 602515843651..2d40b92e526b 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -82,6 +82,31 @@ export const ForwardCompatibleNullable = (value: Value ); }; +/** + * A nullable setting whose null is "unset" and never crosses the wire: it + * decodes from a missing or unknown key and encodes back to a missing key. + * For a field that older clients decode as a required literal, so a null + * on the wire would fail their whole settings snapshot. + */ +export const OmittedWhenNull = (value: Value) => { + const decodeValue = Schema.decodeUnknownOption(value as never); + return Schema.optionalKey(Schema.Unknown).pipe( + Schema.decodeTo( + Schema.NullOr(value), + SchemaTransformation.transformOptional({ + decode: (raw) => + Option.some( + Option.isSome(raw) && Option.isSome(decodeValue(raw.value)) + ? (raw.value as Value["Encoded"]) + : null, + ), + encode: (raw) => + Option.isSome(raw) && raw.value !== null ? Option.some(raw.value) : Option.none(), + }), + ), + ); +}; + export const ForwardCompatibleArray = (element: Element) => { const decodeElement = Schema.decodeUnknownOption(element as never); return Schema.Array(Schema.Unknown).pipe( diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 70888f2988e6..b5a3290f6314 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -58,6 +58,13 @@ export const ExecutionEnvironmentPlatform = Schema.Struct({ */ export const ThreadEnvMode = Schema.Literals(["local", "worktree"]); export type ThreadEnvMode = typeof ThreadEnvMode.Type; + +/** + * How a new worktree populates git submodules: every nested level, only the + * ones this repository declares, or not at all. + */ +export const WorktreeSubmodules = Schema.Literals(["recursive", "top-level", "none"]); +export type WorktreeSubmodules = typeof WorktreeSubmodules.Type; export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; /** How a server can replace itself with another version when asked over RPC. diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d89e1fb2957c..7c7bade4502e 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -812,6 +812,37 @@ it.effect("decodes thread pull request links with snapshot and stack", () => }), ); +// A stored event that fails to decode stops the event store read, and with it +// server startup, so rows written before `turnId` existed must still load. +it.effect("decodes a legacy message-sent event persisted without turnId", () => + Effect.gen(function* () { + const event = yield* decodeOrchestrationEvent({ + sequence: 539, + eventId: "event-message-legacy-1", + aggregateKind: "thread", + aggregateId: "thread-1", + type: "thread.message-sent", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: "cmd-message-legacy-1", + causationEventId: null, + correlationId: "cmd-message-legacy-1", + metadata: {}, + payload: { + threadId: "thread-1", + messageId: "message-1", + role: "user", + text: "written before turn ids were recorded", + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + assert.strictEqual(event.type, "thread.message-sent"); + if (event.type !== "thread.message-sent") return; + assert.strictEqual(event.payload.turnId, null); + }), +); + it.effect("decodes thread archived and unarchived events", () => Effect.gen(function* () { const archived = yield* decodeOrchestrationEvent({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9a9ed05d4e6d..3e323e4964d5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1893,7 +1893,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), context: Schema.optional(OrchestrationMessageContext), - turnId: Schema.NullOr(TurnId), + // Events persisted before the field existed carry no key at all. + turnId: Schema.NullOr(TurnId).pipe(Schema.withDecodingDefault(Effect.succeed(null))), streaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ff54b6c0aa53..692edfb18a65 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -805,6 +805,27 @@ describe("provider enabled defaults", () => { }); describe("ServerSettings worktree defaults", () => { + it("defaults the thread env mode to inherit and keeps stored values", () => { + expect(decodeServerSettings({}).defaultThreadEnvMode).toBeNull(); + expect(decodeServerSettings({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe( + "worktree", + ); + expect( + decodeServerSettings({ defaultThreadEnvMode: "remote" }).defaultThreadEnvMode, + ).toBeNull(); + expect( + decodeServerSettingsPatch({ defaultThreadEnvMode: null }).defaultThreadEnvMode, + ).toBeNull(); + }); + + it("keeps an inherited thread env mode off the wire for older clients", () => { + const encode = Schema.encodeSync(ServerSettings); + expect("defaultThreadEnvMode" in encode(decodeServerSettings({}))).toBe(false); + expect( + encode(decodeServerSettings({ defaultThreadEnvMode: "worktree" })).defaultThreadEnvMode, + ).toBe("worktree"); + }); + it("defaults start-from-origin on for legacy configs", () => { expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); }); @@ -814,6 +835,15 @@ describe("ServerSettings worktree defaults", () => { decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin, ).toBe(false); }); + + it("defaults worktree submodules to inherit and tolerates unknown modes", () => { + expect(decodeServerSettings({}).worktreeSubmodules).toBeNull(); + expect(decodeServerSettings({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe( + "top-level", + ); + expect(decodeServerSettings({ worktreeSubmodules: "shallow" }).worktreeSubmodules).toBeNull(); + expect(decodeServerSettingsPatch({ worktreeSubmodules: null }).worktreeSubmodules).toBeNull(); + }); }); describe("ServerSettings.sourceControlWritingStyle", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 73a2a2517d05..504a9423f2a3 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,12 +5,14 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { ForwardCompatibleNullable, + ForwardCompatibleOptional, + OmittedWhenNull, ProjectId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; -import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; +import { EnvironmentMachineKind, ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, @@ -1000,6 +1002,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultRuntimeMode", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", + "worktreeSubmodules", "defaultAutoPull", "defaultProjectScripts", "enableAgentBrowserAccess", @@ -1026,6 +1029,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultRuntimeMode: Schema.optionalKey(RuntimeMode), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), @@ -1041,6 +1045,26 @@ export const ProjectSettingsOverrides = Schema.Struct({ } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; +/** + * Whether `null` is a stored override value for this key rather than "unset". + * Clients writing a project override treat null for every other key as a + * request to remove the override, so a picker's "Inherit" item and the row's + * reset do the same thing. + */ +export function isNullableProjectSettingsOverride(key: ProjectScopedServerSettingKey): boolean { + return NULLABLE_PROJECT_SETTINGS_OVERRIDES.has(key); +} +const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet = new Set< + { + [K in ProjectScopedServerSettingKey]: null extends ProjectSettingsOverrides[K] ? K : never; + }[ProjectScopedServerSettingKey] +>([ + "defaultModelSelection", + "sourceControlWriterModelSelection", + "pullRequestMergeMethod", + "sidebarAutoSettleAfterDays", +]); + export const StorageCleanupSettings = Schema.Struct({ worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -1176,12 +1200,25 @@ export const ServerSettings = Schema.Struct({ environmentIcon: ForwardCompatibleNullable(EnvironmentMachineKind).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), - defaultThreadEnvMode: ThreadEnvMode.pipe( - Schema.withDecodingDefault(Effect.succeed("local" as const satisfies ThreadEnvMode)), - ), + /** + * Null means inherit: the repository's t3.json, then "local". The old + * default "local" was never persisted (defaults are stripped on write), so + * it now decodes as inherit, which resolves the same way because the old + * chain also let t3.json outrank the environment. Null stays off the wire + * so older clients, which require a literal here, keep decoding. + */ + defaultThreadEnvMode: OmittedWhenNull(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + /** + * Null defers to the repository's t3.json, then to recursive. A value + * picked on a newer server decodes as null here rather than failing the + * whole settings snapshot for an older client. + */ + worktreeSubmodules: ForwardCompatibleNullable(WorktreeSubmodules).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -1471,8 +1508,9 @@ export const ServerSettingsPatch = Schema.Struct({ providerHealthRefreshInterval: Schema.optionalKey(Schema.DurationFromMillis), backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), environmentIcon: Schema.optionalKey(Schema.NullOr(EnvironmentMachineKind)), - defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), + defaultThreadEnvMode: Schema.optionalKey(Schema.NullOr(ThreadEnvMode)), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeSubmodules: Schema.optionalKey(Schema.NullOr(WorktreeSubmodules)), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/contracts/src/t3ProjectFile.test.ts b/packages/contracts/src/t3ProjectFile.test.ts index 4352449c7ade..64128a4990b0 100644 --- a/packages/contracts/src/t3ProjectFile.test.ts +++ b/packages/contracts/src/t3ProjectFile.test.ts @@ -60,4 +60,10 @@ describe("T3ProjectFile", () => { expect(decode({ defaultThreadEnvMode: "local" }).defaultThreadEnvMode).toBe("local"); expect(() => decode({ defaultThreadEnvMode: "remote" })).toThrow(); }); + + it("decodes worktreeSubmodules and rejects unknown modes", () => { + expect(decode({ worktreeSubmodules: "none" }).worktreeSubmodules).toBe("none"); + expect(decode({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe("top-level"); + expect(() => decode({ worktreeSubmodules: "shallow" })).toThrow(); + }); }); diff --git a/packages/contracts/src/t3ProjectFile.ts b/packages/contracts/src/t3ProjectFile.ts index 6bd4168054d5..536ac77880aa 100644 --- a/packages/contracts/src/t3ProjectFile.ts +++ b/packages/contracts/src/t3ProjectFile.ts @@ -1,8 +1,9 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ThreadEnvMode } from "./environment.ts"; +import { ThreadEnvMode, WorktreeSubmodules } from "./environment.ts"; import { ProjectScriptIcon } from "./orchestration.ts"; +import type { ProjectScopedServerSettingKey, ServerSettings } from "./settings.ts"; /** File name of the checked-in T3 project file, resolved at the workspace root. */ export const T3_PROJECT_FILE_NAME = "t3.json"; @@ -86,6 +87,12 @@ export const T3ProjectFile = Schema.Struct({ 'Where new threads start for this repository: "worktree" for a fresh git worktree, "local" for the current checkout. A per-project setting in T3 Code overrides this; when neither is set, the global default applies.', }), ), + worktreeSubmodules: Schema.optionalKey( + WorktreeSubmodules.annotate({ + description: + 'How new worktrees populate git submodules: "recursive" (the default) initializes nested submodules too, "top-level" initializes only those declared by this repository, and "none" leaves every submodule empty for a setup script to handle. A project or environment setting in T3 Code overrides this.', + }), + ), scripts: Schema.optionalKey( Schema.Array(T3ProjectFileScript) .annotate({ @@ -99,3 +106,38 @@ export const T3ProjectFile = Schema.Struct({ "Checked-in project configuration for T3 Code (t3.json at the repository root). See https://t3.codes for documentation.", }); export type T3ProjectFile = typeof T3ProjectFile.Type; + +/** + * Settings a repository can also declare in t3.json. A key here must be + * nullable on `ServerSettings` (null means inherit) so both the project + * override and the environment value can defer to the file; `field` names + * the t3.json field carrying the same value and `builtIn` is what applies + * when every tier is unset. `resolveProjectSettings` walks project override, + * environment value, file, built-in, so listing a key here is the whole + * change for a new file-backed setting. + */ +export const PROJECT_FILE_BACKED_SETTINGS = { + defaultThreadEnvMode: { field: "defaultThreadEnvMode", builtIn: "local" }, + worktreeSubmodules: { field: "worktreeSubmodules", builtIn: "recursive" }, +} as const satisfies { + readonly [K in ProjectScopedServerSettingKey]?: { + readonly field: { + readonly [F in keyof T3ProjectFile]: T3ProjectFile[F] extends + | Exclude + | undefined + ? F + : never; + }[keyof T3ProjectFile]; + readonly builtIn: Exclude; + }; +}; +export type ProjectFileBackedSettingKey = keyof typeof PROJECT_FILE_BACKED_SETTINGS; + +/** + * `ServerSettings` with every file-backed key resolved to a concrete value. + * What `resolveProjectSettings(...).settings` produces once a t3.json (or + * its absence) has been accounted for. + */ +export type ResolvedServerSettings = Omit & { + readonly [K in ProjectFileBackedSettingKey]: Exclude; +}; diff --git a/packages/shared/package.json b/packages/shared/package.json index 1cd162593160..9e929325a5d4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -127,10 +127,6 @@ "types": "./src/projectSettings.ts", "import": "./src/projectSettings.ts" }, - "./threadEnvMode": { - "types": "./src/threadEnvMode.ts", - "import": "./src/threadEnvMode.ts" - }, "./t3ProjectFile": { "types": "./src/t3ProjectFile.ts", "import": "./src/t3ProjectFile.ts" diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index e08be45807cb..f59156530aa5 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -9,6 +9,7 @@ import { createModelSelection } from "./model.ts"; import { clearProjectSettingsOverrides, hasProjectSettingsOverrides, + resolveProjectFileBackedSetting, resolveProjectSettings, resolveWorktreeCleanup, withProjectSettingsOverrides, @@ -30,6 +31,24 @@ describe("resolveProjectSettings", () => { ); }); + it("ignores an override left undefined by a forward-compatible decode", () => { + const resolved = resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + defaultRuntimeMode: "full-access", + projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: undefined } as never }, + }, + projectId, + ); + expect(resolved.settings.defaultRuntimeMode).toBe("full-access"); + expect(resolved.sources.defaultRuntimeMode).toBe("environment"); + expect( + hasProjectSettingsOverrides({ + projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: undefined } as never }, + }), + ).toBe(false); + }); + it("treats a null project like an absent one before the shell snapshot arrives", () => { // The mobile new-task flow resolves settings while its selected project is // still null; reading the aggregate's legacy fields off null crashed launch. @@ -118,6 +137,81 @@ describe("resolveProjectSettings", () => { }); }); +describe("resolveProjectSettings with a t3.json", () => { + it("walks project override, environment value, file, then built-in for file-backed keys", () => { + const file = { defaultThreadEnvMode: "worktree" as const }; + const fromOverride = resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } }, + }, + projectId, + null, + file, + ); + expect(fromOverride.settings.defaultThreadEnvMode).toBe("local"); + expect(fromOverride.sources.defaultThreadEnvMode).toBe("project"); + + const fromEnvironment = resolveProjectSettings( + { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" }, + projectId, + null, + file, + ); + expect(fromEnvironment.settings.defaultThreadEnvMode).toBe("local"); + expect(fromEnvironment.sources.defaultThreadEnvMode).toBe("environment"); + + const fromFile = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file); + expect(fromFile.settings.defaultThreadEnvMode).toBe("worktree"); + expect(fromFile.sources.defaultThreadEnvMode).toBe("t3.json"); + + const builtIn = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, null); + expect(builtIn.settings.defaultThreadEnvMode).toBe("local"); + expect(builtIn.sources.defaultThreadEnvMode).toBe("environment"); + // A stored null override defers like an unset one and is not reported + // as the project's value. + const nullOverride = resolveProjectSettings( + { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: null } as never }, + }, + projectId, + null, + file, + ); + expect(nullOverride.settings.defaultThreadEnvMode).toBe("worktree"); + expect(nullOverride.sources.defaultThreadEnvMode).toBe("t3.json"); + // A file that does not mention the key leaves the source alone too. + expect( + resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, {}).sources + .defaultThreadEnvMode, + ).toBe("environment"); + }); + + it("resolves one key from the settings tier, then the file, then the built-in", () => { + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", "none", { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "none", source: "environment" }); + expect( + resolveProjectFileBackedSetting("worktreeSubmodules", null, { + worktreeSubmodules: "top-level", + }), + ).toEqual({ value: "top-level", source: "t3.json" }); + expect(resolveProjectFileBackedSetting("worktreeSubmodules", null, null)).toEqual({ + value: "recursive", + source: "environment", + }); + }); + + it("leaves settings untouched when no file is passed", () => { + expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe( + DEFAULT_SERVER_SETTINGS, + ); + }); +}); + 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 fd137545666f..f99dacbbb69d 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -1,24 +1,35 @@ import { type ModelSelection, + PROJECT_FILE_BACKED_SETTINGS, PROJECT_SCOPED_SERVER_SETTING_KEYS, + type ProjectFileBackedSettingKey, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, + type ResolvedServerSettings, type ServerSettings, + type T3ProjectFile, type ThreadEnvMode, type WorktreeCleanupRules, } from "@t3tools/contracts"; import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; -export type ProjectSettingSource = "environment" | "project"; +/** + * Where a project-scoped value came from. The order is the priority order: + * a project override, then the environment value, then the repository's + * t3.json for keys in `PROJECT_FILE_BACKED_SETTINGS`, then the built-in + * default (reported as "environment", since that is what the environment + * value is when nothing set it). + */ +export type ProjectSettingSource = "environment" | "project" | "t3.json"; export type ProjectSettingSources = Readonly< Record >; -export interface ResolvedProjectSettings { +export interface ResolvedProjectSettings { /** Environment settings with the project's overrides applied. */ - readonly settings: ServerSettings; + readonly settings: Settings; /** Where each scopable key's effective value came from. */ readonly sources: ProjectSettingSources; /** The project's raw override entry; `{}` when it has none. */ @@ -36,7 +47,9 @@ export function hasProjectSettingsOverrides( settings: Pick, ): boolean { for (const entry of Object.values(settings.projectSettingsOverrides)) { - if (Object.keys(entry).length > 0) return true; + // A forward-compatible decode can leave an unknown value as a present + // undefined; that is not an override. + if (Object.values(entry).some((value) => value !== undefined)) return true; } return false; } @@ -63,6 +76,75 @@ export function resolveProjectSettings( // Nullable, not just optional: the mobile new-task flow passes its selected // project straight through, and that is null until the shell snapshot lands. project?: LegacyProjectSettingsFields | null, +): ResolvedProjectSettings; +/** + * With the checkout's decoded t3.json (or null for a missing or invalid + * one), every file-backed key resolves to a concrete value: the file fills + * keys whose project and environment tiers are both unset, and the built-in + * default fills what is left. + */ +export function resolveProjectSettings( + settings: ServerSettings, + projectId: ProjectId | null, + project: LegacyProjectSettingsFields | null | undefined, + projectFile: T3ProjectFile | null, +): ResolvedProjectSettings; +export function resolveProjectSettings( + settings: ServerSettings, + projectId: ProjectId | null, + project?: LegacyProjectSettingsFields | null, + projectFile?: T3ProjectFile | null, +): ResolvedProjectSettings { + const resolved = resolveProjectOverrides(settings, projectId, project); + return projectFile === undefined ? resolved : applyProjectFile(resolved, projectFile); +} + +function applyProjectFile( + resolved: ResolvedProjectSettings, + projectFile: T3ProjectFile | null, +): ResolvedProjectSettings { + let effective: Record | null = null; + let sources: Record | null = null; + for (const key of Object.keys(PROJECT_FILE_BACKED_SETTINGS) as ProjectFileBackedSettingKey[]) { + if (resolved.settings[key] !== null) continue; + const { value, source } = resolveProjectFileBackedSetting(key, null, projectFile); + effective ??= { ...resolved.settings }; + sources ??= { ...resolved.sources }; + effective[key] = value; + // A project override of null defers like an unset one, so the value did + // not come from the project either way. + sources[key] = source; + } + return effective === null || sources === null + ? resolved + : { ...resolved, settings: effective as ServerSettings, sources }; +} + +/** + * The file and built-in tiers for one key, given the project-over-environment + * value (`null` when neither is set). For callers that hold the settings tier + * but only see the file later, such as the git driver reading the t3.json of + * the checkout it just created. Same chain as `resolveProjectSettings`. + */ +export function resolveProjectFileBackedSetting( + key: K, + setting: ServerSettings[K], + projectFile: T3ProjectFile | null, +): { value: ResolvedServerSettings[K]; source: ProjectSettingSource } { + if (setting !== null) { + return { value: setting as ResolvedServerSettings[K], source: "environment" }; + } + const { field, builtIn } = PROJECT_FILE_BACKED_SETTINGS[key]; + const fromFile = projectFile?.[field] as ResolvedServerSettings[K] | undefined; + return fromFile === undefined + ? { value: builtIn as ResolvedServerSettings[K], source: "environment" } + : { value: fromFile, source: "t3.json" }; +} + +function resolveProjectOverrides( + settings: ServerSettings, + projectId: ProjectId | null, + project?: LegacyProjectSettingsFields | null, ): ResolvedProjectSettings { const stored = projectId === null ? undefined : settings.projectSettingsOverrides[projectId]; const overrides: ProjectSettingsOverrides = @@ -87,6 +169,9 @@ export function resolveProjectSettings( for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) { if (!Object.hasOwn(overrides, key)) continue; const value = overrides[key]; + // A forward-compatible decode leaves an unknown value as a present + // undefined; that is not an override. + if (value === undefined) continue; // A model on a disabled provider falls back to the environment, like the // environment-level guards do for these keys. if ( diff --git a/packages/shared/src/t3ProjectFile.test.ts b/packages/shared/src/t3ProjectFile.test.ts index 924a01c4414b..42045cc4284f 100644 --- a/packages/shared/src/t3ProjectFile.test.ts +++ b/packages/shared/src/t3ProjectFile.test.ts @@ -36,6 +36,7 @@ describe("buildT3ProjectFileJsonSchema", () => { "defaultThreadEnvMode", "iconPath", "scripts", + "worktreeSubmodules", ]); expect(schema.required).toBeUndefined(); expect(schema.properties.iconPath?.description).toContain("Workspace-relative path"); diff --git a/packages/shared/src/threadEnvMode.test.ts b/packages/shared/src/threadEnvMode.test.ts deleted file mode 100644 index 4cf22c248868..000000000000 --- a/packages/shared/src/threadEnvMode.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { isDefaultThreadEnvModeSettled, resolveDefaultThreadEnvMode } from "./threadEnvMode.ts"; - -describe("resolveDefaultThreadEnvMode", () => { - it("prefers the project setting over t3.json over the global default", () => { - expect( - resolveDefaultThreadEnvMode({ - projectSetting: "local", - projectFile: "worktree", - globalDefault: "worktree", - }), - ).toBe("local"); - expect( - resolveDefaultThreadEnvMode({ - projectSetting: null, - projectFile: "local", - globalDefault: "worktree", - }), - ).toBe("local"); - expect( - resolveDefaultThreadEnvMode({ - projectSetting: undefined, - projectFile: null, - globalDefault: "worktree", - }), - ).toBe("worktree"); - }); -}); - -describe("isDefaultThreadEnvModeSettled", () => { - it("settles on an explicit pick or project setting even while the file loads", () => { - expect( - isDefaultThreadEnvModeSettled({ - explicitMode: "local", - projectSetting: null, - projectFilePending: true, - }), - ).toBe(true); - expect( - isDefaultThreadEnvModeSettled({ - explicitMode: undefined, - projectSetting: "worktree", - projectFilePending: true, - }), - ).toBe(true); - }); - - it("stays unsettled only while a consulted file read is pending", () => { - expect( - isDefaultThreadEnvModeSettled({ - explicitMode: undefined, - projectSetting: null, - projectFilePending: true, - }), - ).toBe(false); - expect( - isDefaultThreadEnvModeSettled({ - explicitMode: undefined, - projectSetting: null, - projectFilePending: false, - }), - ).toBe(true); - }); -}); diff --git a/packages/shared/src/threadEnvMode.ts b/packages/shared/src/threadEnvMode.ts deleted file mode 100644 index 4c01c0f27b91..000000000000 --- a/packages/shared/src/threadEnvMode.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ThreadEnvMode } from "@t3tools/contracts"; - -/** - * Canonical priority order for a project's default thread env mode: - * per-project setting > checked-in t3.json > global server setting. - * - * An explicit composer pick outranks all of these; callers apply it before - * consulting the defaults. Web resolves the sources imperatively at draft - * creation, mobile reactively — both must route through this function so the - * platforms cannot disagree on the order. - */ -export function resolveDefaultThreadEnvMode(sources: { - readonly projectSetting: ThreadEnvMode | null | undefined; - readonly projectFile: ThreadEnvMode | null | undefined; - readonly globalDefault: ThreadEnvMode; -}): ThreadEnvMode { - return sources.projectSetting ?? sources.projectFile ?? sources.globalDefault; -} - -/** - * True once the resolved default can no longer change: an explicit pick or a - * source that outranks t3.json decided, or the file read settled. While - * false, nothing may persist the provisional default (for example into a - * draft's workspace selection) — it could differ from the final value. - */ -export function isDefaultThreadEnvModeSettled(sources: { - readonly explicitMode: ThreadEnvMode | undefined; - readonly projectSetting: ThreadEnvMode | null | undefined; - readonly projectFilePending: boolean; -}): boolean { - return ( - sources.explicitMode !== undefined || - sources.projectSetting != null || - !sources.projectFilePending - ); -} diff --git a/patches/react-native-screens@4.26.2.patch b/patches/react-native-screens@4.26.2.patch index ee017ce1993f..9212cfc7b0ff 100644 --- a/patches/react-native-screens@4.26.2.patch +++ b/patches/react-native-screens@4.26.2.patch @@ -294,7 +294,7 @@ index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab6 break; } case RNSScreenStackHeaderSubviewTypeCenter: -@@ -648,10 +693,470 @@ + (void)updateViewController:(UIViewController *)vc +@@ -648,10 +693,479 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -586,6 +586,15 @@ index 1c844846a5c66e31cfa530ca462774d2fb6bbea1..a60b96b44e85d3f4b29dc0d190b95ab6 + ]]; + } + ++ // A bare UISearchTextField keeps UITextField defaults (sentence ++ // capitalization, autocorrect), unlike UISearchBar. Search queries are ++ // identifiers, so match the autoCapitalize "none" the JS fallbacks use. ++ if (resolvedSearchTextField != nil) { ++ resolvedSearchTextField.autocapitalizationType = UITextAutocapitalizationTypeNone; ++ resolvedSearchTextField.autocorrectionType = UITextAutocorrectionTypeNo; ++ resolvedSearchTextField.spellCheckingType = UITextSpellCheckingTypeNo; ++ } ++ + NSString *searchTextChangeId = mailSearchToolbarConfig[@"searchTextChangeId"]; + NSString *searchTextChangeActionIdentifier = @"org.react-native-screens.mail-search-toolbar.text-change"; + if (resolvedSearchTextField != nil) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b4d8281b7df..dd50a292a464 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,7 +110,7 @@ patchedDependencies: react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-reanimated@4.5.5: bae9878a5bdba94e11c890e5ee164542feb622624fc81c13890063094608216d - react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d + react-native-screens@4.26.2: 595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0 uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 importers: @@ -277,7 +277,7 @@ importers: version: 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97) + version: 7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -457,7 +457,7 @@ importers: version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: ~4.26.0 - version: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14519,7 +14519,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(ad1eff2c3e588b799b6541240bb21d97)': + '@react-navigation/native-stack@7.17.6(patch_hash=e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552)(f640afa6cd4b9a1dc5422cbafdc82f53)': dependencies: '@react-navigation/elements': 2.9.26(c10301b6e0c42fc6434d2b643197a81e) '@react-navigation/native': 7.3.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14527,7 +14527,7 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -20375,7 +20375,7 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.26.2(patch_hash=8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.26.2(patch_hash=595114f3f9ce8b9f0d99d1aafe5c12c997b7d75ca9add31d46cbedbfe347b8e0)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3)