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
143 changes: 130 additions & 13 deletions apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { AppText as Text, AppTextInput } from "../../components/AppText";
import {
type ResponseStreamingMode,
type ServerSettings,
Expand All @@ -9,7 +9,7 @@ import {
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type ProjectScopedServerSettingKey,
} from "@t3tools/contracts";
import { useRef, useState, type ComponentProps } from "react";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import { Alert, Platform, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

Expand All @@ -26,6 +26,7 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import {
planMobileProjectOverridePatch,
planMobileScopedSettingsClear,
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
Expand Down Expand Up @@ -148,7 +149,45 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
};
const clearProjectOverrides = () => {
if (writeInFlight.current) return;
const writes = planMobileScopedSettingsClear(targets, PAGE_PROJECT_KEYS[props.page]);
const keys =
props.page === "source-control"
? ([...PAGE_PROJECT_KEYS[props.page], "defaultThreadBaseBranch"] as const)
: PAGE_PROJECT_KEYS[props.page];
const writes = planMobileScopedSettingsClear(targets, keys);
if (writes.length === 0) return;
writeInFlight.current = true;
setPendingTargets(targets);
setPendingWrites((count) => count + 1);
void Promise.allSettled(
writes.map((entry) =>
updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }),
),
).finally(() => {
writeInFlight.current = false;
setPendingTargets(null);
setPendingWrites((count) => count - 1);
});
};
const writeProjectOverrides = (patch: { readonly defaultThreadBaseBranch?: string }) => {
if (writeInFlight.current || !projectSelected || !hasConnectedSelection) return;
const writes = planMobileProjectOverridePatch(targets, patch);
if (writes.length === 0) return;
writeInFlight.current = true;
setPendingTargets(targets);
setPendingWrites((count) => count + 1);
void Promise.allSettled(
writes.map((entry) =>
updateSettings({ environmentId: entry.environmentId, input: { patch: entry.patch } }),
),
).finally(() => {
writeInFlight.current = false;
setPendingTargets(null);
setPendingWrites((count) => count - 1);
});
};
const clearDefaultThreadBaseBranch = () => {
if (writeInFlight.current || !projectSelected) return;
const writes = planMobileScopedSettingsClear(targets, ["defaultThreadBaseBranch"]);
if (writes.length === 0) return;
writeInFlight.current = true;
setPendingTargets(targets);
Expand Down Expand Up @@ -205,8 +244,13 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
{projectSelected ? (
<SettingsProjectOverridesSection
projectLabel={selectedProject?.label ?? "Unavailable project"}
hasOverrides={targets.some((target) =>
PAGE_PROJECT_KEYS[props.page].some((key) => target.sources[key] === "project"),
hasOverrides={targets.some(
(target) =>
PAGE_PROJECT_KEYS[props.page].some(
(key) => target.sources[key] === "project",
) ||
(props.page === "source-control" &&
Object.hasOwn(target.overrides, "defaultThreadBaseBranch")),
)}
supportsOverrides={supportsProjectOverrides}
pending={pendingWrites > 0}
Expand Down Expand Up @@ -271,14 +315,35 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
/>
</SettingsSection>
<SettingsSection title="Worktrees">
<FanoutSwitchRow
icon="arrow.triangle.branch"
label="Start from origin"
subtitle="Base new worktrees on the remote branch."
value={uniform("newWorktreesStartFromOrigin")}
disabled={disabledFor("newWorktreesStartFromOrigin")}
onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })}
/>
{projectSelected ? (
<DefaultBaseBranchField
key={selectedProjectKey}
value={
targets.every(
(target) =>
target.overrides.defaultThreadBaseBranch ===
reference.overrides.defaultThreadBaseBranch,
)
? (reference.overrides.defaultThreadBaseBranch ?? "")
: null
}
disabled={disabled}
onSave={(value) =>
writeProjectOverrides({ defaultThreadBaseBranch: value })
}
onClear={clearDefaultThreadBaseBranch}
/>
) : null}
<View className={projectSelected ? "border-t border-border-subtle" : undefined}>
<FanoutSwitchRow
icon="arrow.triangle.branch"
label="Start from origin"
subtitle="Base new worktrees on the remote branch."
value={uniform("newWorktreesStartFromOrigin")}
disabled={disabledFor("newWorktreesStartFromOrigin")}
onValueChange={(value) => write({ newWorktreesStartFromOrigin: value })}
/>
</View>
</SettingsSection>
</>
) : null}
Expand Down Expand Up @@ -374,6 +439,58 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
);
}

function DefaultBaseBranchField(props: {
readonly value: string | null;
readonly disabled: boolean;
readonly onSave: (value: string) => void;
readonly onClear: () => void;
}) {
const [draft, setDraft] = useState(props.value ?? "");
useEffect(() => setDraft(props.value ?? ""), [props.value]);
const current = props.value ?? "";
const trimmed = draft.trim();
const changed = trimmed !== current;
const commit = () => {
if (props.disabled || !changed) return;
if (trimmed.length === 0) {
if (props.value !== null) props.onClear();
} else props.onSave(trimmed);
};
return (
<View className="gap-2 p-4">
<Text className="text-lg text-foreground">Default base branch</Text>
<Text className="text-sm leading-normal text-foreground-muted">
New worktree threads use this branch. Clear it to use the repository default.
</Text>
<View className="flex-row items-center gap-3">
<AppTextInput
accessibilityLabel="Default base branch"
autoCapitalize="none"
autoCorrect={false}
className="min-h-11 min-w-0 flex-1 rounded-xl border-continuous bg-card px-3 text-base text-foreground"
editable={!props.disabled}
placeholder={props.value === null ? "Mixed" : "Repository default"}
returnKeyType="done"
value={draft}
onChangeText={setDraft}
onSubmitEditing={commit}
/>
{changed ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Save default base branch"
disabled={props.disabled}
onPress={commit}
className="rounded-full bg-subtle-strong px-4 py-2 active:opacity-70 disabled:opacity-40"
>
<Text className="text-sm font-t3-medium text-foreground">Save</Text>
</Pressable>
) : null}
</View>
</View>
);
}

function ChoiceRow(props: {
readonly label: string;
readonly description: string;
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 @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test";

import type { SettingsTarget } from "./settings-environment-filter";
import {
planMobileProjectOverridePatch,
planMobileScopedSettingsClear,
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
Expand Down Expand Up @@ -91,4 +92,36 @@ describe("mobile project settings scope", () => {
planMobileScopedSettingsPatch(targets, true, { enableProviderUpdateChecks: false }),
).toEqual([]);
});

it("saves and clears the project-only base branch without dropping sibling overrides", () => {
const settings: ServerSettings = {
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: {
[firstProject]: { defaultAutoPull: true, defaultThreadBaseBranch: "dev" },
},
};
const targets = resolveMobileSettingsTargets(
[environment(firstId, settings)],
[{ environmentId: firstId, id: firstProject }],
);

expect(planMobileProjectOverridePatch(targets, { defaultThreadBaseBranch: "release" })).toEqual(
[
{
environmentId: firstId,
patch: {
projectSettingsOverrides: {
[firstProject]: { defaultAutoPull: true, defaultThreadBaseBranch: "release" },
},
},
},
],
);
expect(planMobileScopedSettingsClear(targets, ["defaultThreadBaseBranch"])).toEqual([
{
environmentId: firstId,
patch: { projectSettingsOverrides: { [firstProject]: { defaultAutoPull: true } } },
},
]);
});
});
40 changes: 32 additions & 8 deletions apps/mobile/src/features/settings/settings-scoped-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import {
type EnvironmentId,
type ProjectId,
type ProjectScopedServerSettingKey,
type ProjectSettingsOverrides,
type ServerSettings,
type ServerSettingsPatch,
} from "@t3tools/contracts";
import {
clearProjectSettingsOverrides,
resolveProjectSettings,
type ResolvedProjectSettings,
} from "@t3tools/shared/projectSettings";

import type { SettingsTarget } from "./settings-environment-filter";
Expand All @@ -17,7 +18,8 @@ export interface ScopedMobileSettingsTarget {
readonly environment: SettingsTarget;
readonly projectId: ProjectId | null;
readonly settings: ServerSettings;
readonly sources: ReturnType<typeof resolveProjectSettings>["sources"];
readonly sources: ResolvedProjectSettings["sources"];
readonly overrides: ProjectSettingsOverrides;
}

export function resolveMobileSettingsTargets(
Expand Down Expand Up @@ -79,10 +81,32 @@ export function planMobileScopedSettingsPatch(
patch: { projectSettingsOverrides } as ServerSettingsPatch,
}));
}
export function planMobileProjectOverridePatch(
targets: readonly ScopedMobileSettingsTarget[],
patch: Partial<ProjectSettingsOverrides>,
) {
const writes = new Map<EnvironmentId, Record<string, unknown>>();
for (const target of targets) {
if (
target.projectId === null ||
target.environment.serverConfig.environment.capabilities.projectSettingsOverrides !== true
)
Comment on lines +90 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate default-branch writes on a versioned capability

When an updated mobile or web client connects to a pre-change server, that server still advertises the generic projectSettingsOverrides capability even though its nested settings schema does not know defaultThreadBaseBranch. These writes therefore appear supported and successful, but the old decoder strips the new field, so the value immediately disappears; mixed-version environments can also save it on only some machines without reporting a partial failure. Add a capability specifically covering this field and hide or disable the control unless every write target advertises it.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

continue;
const current =
target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};
const overrides = writes.get(target.environment.environmentId) ?? {};
overrides[target.projectId] = { ...current, ...patch };
writes.set(target.environment.environmentId, overrides);
}
return [...writes].map(([environmentId, projectSettingsOverrides]) => ({
environmentId,
patch: { projectSettingsOverrides } as ServerSettingsPatch,
}));
}

export function planMobileScopedSettingsClear(
targets: readonly ScopedMobileSettingsTarget[],
keys: readonly ProjectScopedServerSettingKey[],
keys: readonly (keyof ProjectSettingsOverrides)[],
) {
const writes = new Map<EnvironmentId, Record<string, unknown>>();
for (const target of targets) {
Expand All @@ -91,12 +115,12 @@ export function planMobileScopedSettingsClear(
target.environment.serverConfig.environment.capabilities.projectSettingsOverrides !== true
)
continue;
const current =
target.environment.serverConfig.settings.projectSettingsOverrides[target.projectId] ?? {};
const next = { ...current };
for (const key of keys) delete next[key];
const overrides = writes.get(target.environment.environmentId) ?? {};
overrides[target.projectId] = clearProjectSettingsOverrides(
target.environment.serverConfig.settings,
target.projectId,
keys,
);
overrides[target.projectId] = Object.keys(next).length === 0 ? null : next;
writes.set(target.environment.environmentId, overrides);
}
return [...writes].map(([environmentId, projectSettingsOverrides]) => ({
Expand Down
60 changes: 60 additions & 0 deletions apps/mobile/src/features/threads/new-task-branch-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { VcsRef } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { resolveAutomaticWorktreeBaseBranch } from "./new-task-branch-default";

const ref = (name: string, options: Partial<VcsRef> = {}): VcsRef => ({
name,
current: false,
isDefault: false,
worktreePath: null,
...options,
});

describe("automatic new-task worktree base branch", () => {
it("prefers the project override over git defaults and current checkout", () => {
const branches = [
ref("main", { isDefault: true }),
ref("current", { current: true }),
ref("dev"),
];

expect(
resolveAutomaticWorktreeBaseBranch({
configuredBranch: "dev",
refs: branches,
localRefs: branches,
}),
).toEqual(branches[2]);
});

it("retains a configured branch missing from refs instead of silently falling back", () => {
expect(
resolveAutomaticWorktreeBaseBranch({
configuredBranch: "release/next",
refs: [ref("main", { isDefault: true })],
localRefs: [ref("main", { isDefault: true })],
}),
).toEqual(ref("release/next"));
});

it("keeps existing git-default then current-checkout fallback when unset", () => {
const current = ref("dev", { current: true });
const defaultBranch = ref("main", { isDefault: true });

expect(
resolveAutomaticWorktreeBaseBranch({
configuredBranch: undefined,
refs: [current, defaultBranch],
localRefs: [current, defaultBranch],
}),
).toEqual(defaultBranch);
expect(
resolveAutomaticWorktreeBaseBranch({
configuredBranch: undefined,
refs: [current],
localRefs: [current],
}),
).toEqual(current);
});
});
23 changes: 23 additions & 0 deletions apps/mobile/src/features/threads/new-task-branch-default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { VcsRef } from "@t3tools/contracts";

export function resolveAutomaticWorktreeBaseBranch(input: {
readonly configuredBranch: string | undefined;
readonly refs: ReadonlyArray<VcsRef>;
readonly localRefs: ReadonlyArray<VcsRef>;
}): VcsRef | null {
if (input.configuredBranch !== undefined) {
return (
input.refs.find((branch) => branch.name === input.configuredBranch) ?? {
name: input.configuredBranch,
current: false,
isDefault: false,
worktreePath: null,
}
);
}
return (
input.refs.find((branch) => branch.isDefault) ??
input.localRefs.find((branch) => branch.current) ??
null
);
}
Loading
Loading