Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ServerSettings,
type ServerSettingsPatch,
type ThreadEnvMode,
type WorktreeSubmodules,
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type ProjectScopedServerSettingKey,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -43,17 +44,43 @@ const PAGE_TITLES: Record<SettingsPage, string> = {
};

const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
"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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -219,23 +250,53 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
<SettingsSection
title="Default workspace"
trailing={
pendingWrites === 0 && uniform("defaultThreadEnvMode") === null ? (
pendingWrites === 0 && isMixed("defaultThreadEnvMode") ? (
<MixedValuesLabel projectSelected={projectSelected} />
) : null
}
>
{WORKSPACE_CHOICES.map((choice, index) => (
{WORKSPACE_CHOICES.filter(
(choice) => choice.mode !== null || !projectSelected,
).map((choice, index) => (
<ChoiceRow
key={choice.mode}
key={choice.mode ?? "inherit"}
label={choice.label}
description={choice.description}
selected={uniform("defaultThreadEnvMode") === choice.mode}
selected={
!isMixed("defaultThreadEnvMode") &&
uniform("defaultThreadEnvMode") === choice.mode
}
separated={index > 0}
disabled={disabledFor("defaultThreadEnvMode")}
onPress={() => write({ defaultThreadEnvMode: choice.mode })}
/>
))}
</SettingsSection>
<SettingsSection
title="Worktree submodules"
trailing={
pendingWrites === 0 && isMixed("worktreeSubmodules") ? (
<MixedValuesLabel projectSelected={projectSelected} />
) : null
}
>
{SUBMODULE_CHOICES.filter(
(choice) => choice.mode !== null || !projectSelected,
).map((choice, index) => (
<ChoiceRow
key={choice.mode ?? "inherit"}
label={choice.label}
description={choice.description}
selected={
!isMixed("worktreeSubmodules") &&
uniform("worktreeSubmodules") === choice.mode
}
separated={index > 0}
disabled={disabledFor("worktreeSubmodules")}
onPress={() => write({ worktreeSubmodules: choice.mode })}
/>
))}
</SettingsSection>
<SettingsSection
title="Default permissions"
trailing={
Expand Down
33 changes: 33 additions & 0 deletions apps/mobile/src/features/settings/settings-scoped-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ describe("mobile project settings scope", () => {
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,
Expand Down
15 changes: 14 additions & 1 deletion apps/mobile/src/features/settings/settings-scoped-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isNullableProjectSettingsOverride,
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type EnvironmentId,
type ProjectId,
Expand Down Expand Up @@ -69,7 +70,19 @@ export function planMobileScopedSettingsPatch(
continue;
const current =
target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};
const next = { ...current, ...patch };
const next: Record<string, unknown> = { ...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);
Expand Down
49 changes: 21 additions & 28 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
19 changes: 14 additions & 5 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]!,
);
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type VcsRemoveWorktreeInput,
type VcsStatusInput,
type VcsStatusResult,
type WorktreeSubmodules,
} from "@t3tools/contracts";
import {
makeGitVcsDriverCore,
Expand Down Expand Up @@ -128,6 +129,10 @@ export interface CreateWorktreeProgress {
total: number;
}) => Effect.Effect<void, never>;
readonly onSubmodulesStarted?: () => Effect.Effect<void, never>;
/** Fires when `.gitmodules` exists but the resolved submodule mode is `"none"`. */
readonly onSubmodulesDisabled?: (input: {
source: "settings" | "t3.json";
}) => Effect.Effect<void, never>;
readonly onSubmoduleLine?: (line: string) => Effect.Effect<void, never>;
readonly onSubmodulesFinished?: (input: {
ok: boolean;
Expand All @@ -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 {
Expand Down
Loading
Loading