Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { SettingsRow } from "./components/SettingsRow";
import { AuthSettingsWriteScope } from "@t3tools/contracts";
import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { AppText as Text } from "../../components/AppText";
import { AppText as Text, AppTextInput } from "../../components/AppText";
import {
type ResponseStreamingMode,
type ServerSettings,
type ServerSettingsPatch,
type ThreadEnvMode,
type WorktreeSubmodules,
type WorktreeBaseRef,
PROJECT_SCOPED_SERVER_SETTING_KEYS,
type ProjectScopedServerSettingKey,
} from "@t3tools/contracts";
Expand All @@ -32,6 +33,7 @@ import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import {
mobileSettingsAreMixed,
planMobileScopedSettingsClear,
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
Expand All @@ -49,7 +51,12 @@ const PAGE_TITLES: Record<SettingsPage, string> = {
};

const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
"new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
"new-threads": [
"defaultThreadEnvMode",
"defaultWorktreeBaseRef",
"worktreeSubmodules",
"defaultRuntimeMode",
],
"source-control": [
"defaultAutoPull",
"removeAgentCreditsOnMerge",
Expand Down Expand Up @@ -163,9 +170,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
const uniform = <K extends keyof ServerSettings>(key: K) =>
uniformMobileSetting(displayTargets, key);
// `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 isMixed = (key: keyof ServerSettings) => mobileSettingsAreMixed(displayTargets, key);
const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
label: "environment settings update",
reportFailure: true,
Expand Down Expand Up @@ -298,6 +303,48 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
/>
))}
</SettingsSection>
<SettingsSection title="Worktree base ref">
<SettingsChoiceRow
label="Repository default"
description="Start from the repository's default branch."
selected={
!isMixed("defaultWorktreeBaseRef") &&
uniform("defaultWorktreeBaseRef") === null
}
separated={false}
disabled={disabledFor("defaultWorktreeBaseRef")}
onPress={() => write({ defaultWorktreeBaseRef: null })}
/>
<SettingsChoiceRow
label="Last used"
description="Reuse your last base in this project on this device."
selected={
!isMixed("defaultWorktreeBaseRef") &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium settings/SettingsServerControlsRouteScreen.tsx:299

When multiple selected targets use defaultWorktreeBaseRef: { mode: "last-used" }, the Last used option is not selected and WorktreeBaseRefField receives null despite the values being uniform. mobileSettingsAreMixed compares these objects structurally, but uniformMobileSetting still relies on reference equality and returns null; update uniformMobileSetting to use the same value comparison.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx around line 299:

When multiple selected targets use `defaultWorktreeBaseRef: { mode: "last-used" }`, the Last used option is not selected and `WorktreeBaseRefField` receives `null` despite the values being uniform. `mobileSettingsAreMixed` compares these objects structurally, but `uniformMobileSetting` still relies on reference equality and returns `null`; update `uniformMobileSetting` to use the same value comparison.

uniform("defaultWorktreeBaseRef") !== null &&
typeof uniform("defaultWorktreeBaseRef") === "object"
}
separated
disabled={disabledFor("defaultWorktreeBaseRef")}
onPress={() => write({ defaultWorktreeBaseRef: { mode: "last-used" } })}
/>
<View className="gap-3 p-4">
<Text className="text-sm text-foreground-muted">
Or enter a specific branch, tag, or commit.
</Text>
<WorktreeBaseRefField
key={JSON.stringify(
targets.map((target) => [
target.environment.environmentId,
target.projectId,
]),
)}
value={uniform("defaultWorktreeBaseRef")}
mixed={isMixed("defaultWorktreeBaseRef")}
disabled={disabledFor("defaultWorktreeBaseRef")}
onCommit={(value) => write({ defaultWorktreeBaseRef: value })}
/>
</View>
</SettingsSection>
<SettingsSection
title="Worktree submodules"
trailing={
Expand Down Expand Up @@ -505,3 +552,31 @@ function MixedValuesLabel(props: { readonly projectSelected: boolean }) {
</Text>
);
}

function WorktreeBaseRefField(props: {
readonly value: WorktreeBaseRef;
readonly mixed: boolean;
readonly disabled: boolean;
readonly onCommit: (value: string | null) => void;
}) {
const [draft, setDraft] = useState<string | null>(null);
return (
<AppTextInput
accessibilityLabel="Worktree base ref"
className="min-h-11 rounded-xl border-continuous bg-card px-3 text-base text-foreground"
value={draft ?? (typeof props.value === "string" ? props.value : "")}
placeholder={props.mixed ? "Mixed" : "Enter a specific ref…"}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
editable={!props.disabled}
onChangeText={setDraft}
onEndEditing={(event) => {
const value = event.nativeEvent.text.trim() || null;
setDraft(null);
if (draft !== null && !props.disabled && (props.mixed || value !== props.value))
props.onCommit(value);
}}
/>
);
}
46 changes: 46 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 @@ -3,11 +3,13 @@ import {
type EnvironmentId,
type ProjectId,
type ServerSettings,
type WorktreeBaseRef,
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import type { SettingsTarget } from "./settings-environment-filter";
import {
mobileSettingsAreMixed,
planMobileScopedSettingsClear,
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
Expand Down Expand Up @@ -66,6 +68,29 @@ function environment(environmentId: EnvironmentId, settings: ServerSettings): Se
}

describe("mobile project settings scope", () => {
it.each<[WorktreeBaseRef, WorktreeBaseRef, boolean]>([
[{ mode: "last-used" }, { mode: "last-used" }, false],
[{ mode: "last-used" }, "last-used", true],
[{ mode: "last-used" }, null, true],
[null, null, false],
["dev", "dev", false],
["dev", "main", true],
])("compares base refs by value: %j and %j", (first, second, mixed) => {
const targets = resolveMobileSettingsTargets(
[
environment(firstId, { ...DEFAULT_SERVER_SETTINGS, defaultWorktreeBaseRef: first }),
environment(secondId, { ...DEFAULT_SERVER_SETTINGS, defaultWorktreeBaseRef: second }),
],
null,
);
expect(mobileSettingsAreMixed(targets, "defaultWorktreeBaseRef")).toBe(mixed);
expect(uniformMobileSetting(targets, "defaultWorktreeBaseRef")).toEqual(mixed ? null : first);
});

it("has no uniform value when no environments are selected", () => {
expect(mobileSettingsAreMixed([], "defaultWorktreeBaseRef")).toBe(true);
});

it("edits each checkout's own override without changing either environment default", () => {
const firstSettings: ServerSettings = {
...DEFAULT_SERVER_SETTINGS,
Expand Down Expand Up @@ -139,6 +164,27 @@ describe("mobile project settings scope", () => {
]);
});

it("keeps an explicit repository default separate from inheriting the environment base", () => {
const settings = {
...DEFAULT_SERVER_SETTINGS,
defaultWorktreeBaseRef: "origin/dev",
projectSettingsOverrides: { [firstProject]: { defaultWorktreeBaseRef: "release" } },
};
const targets = resolveMobileSettingsTargets(
[environment(firstId, settings)],
[{ environmentId: firstId, id: firstProject }],
);
expect(planMobileScopedSettingsPatch(targets, true, { defaultWorktreeBaseRef: null })).toEqual([
{
environmentId: firstId,
patch: { projectSettingsOverrides: { [firstProject]: { defaultWorktreeBaseRef: null } } },
},
]);
expect(planMobileScopedSettingsClear(targets, ["defaultWorktreeBaseRef"])).toEqual([
{ environmentId: firstId, patch: { projectSettingsOverrides: { [firstProject]: 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
Expand Up @@ -11,6 +11,7 @@ import {
clearProjectSettingsOverrides,
resolveProjectSettings,
} from "@t3tools/shared/projectSettings";
import * as Equal from "effect/Equal";

import type { SettingsTarget } from "./settings-environment-filter";

Expand All @@ -21,6 +22,18 @@ export interface ScopedMobileSettingsTarget {
readonly sources: ReturnType<typeof resolveProjectSettings>["sources"];
}

/** Compare decoded setting values across the selected environments. */
export function mobileSettingsAreMixed(
targets: readonly Pick<ScopedMobileSettingsTarget, "settings">[],
key: keyof ServerSettings,
): boolean {
const reference = targets[0];
return (
reference === undefined ||
targets.some((entry) => !Equal.equals(entry.settings[key], reference.settings[key]))
);
}

export function resolveMobileSettingsTargets(
environments: readonly SettingsTarget[],
members: readonly { readonly environmentId: EnvironmentId; readonly id: ProjectId }[] | null,
Expand Down Expand Up @@ -101,7 +114,7 @@ export function uniformMobileSetting<K extends keyof ServerSettings>(
const reference = targets[0];
if (!reference) return null;
const value = reference.settings[key];
return targets.every((target) => target.settings[key] === value) ? value : null;
return targets.every((target) => Equal.equals(target.settings[key], value)) ? value : null;
}

export function planMobileScopedSettingsClear(
Expand Down
13 changes: 12 additions & 1 deletion apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export function NewTaskBranchPickerRouteScreen() {
onLoadMore={flow.loadMoreBranches}
selectionDisabled={switchingBranchName !== null}
onSelect={selectBranch}
lastWorktreeBaseBranch={flow.lastWorktreeBaseBranch}
worktree={
flow.workspaceMode === "worktree"
? {
Expand Down Expand Up @@ -406,6 +407,7 @@ export function BranchPickerScreen(props: {
readonly onLoadMore: () => void;
readonly selectionDisabled?: boolean;
readonly onSelect: (branch: VcsRef) => void;
readonly lastWorktreeBaseBranch?: string | null;
readonly worktree?: {
readonly startFromOrigin: boolean;
readonly onChangeStartFromOrigin: (value: boolean) => void;
Expand Down Expand Up @@ -435,7 +437,14 @@ export function BranchPickerScreen(props: {
const renderBranch = useCallback(
({ item, index }: { readonly item: VcsRef; readonly index: number }) => (
<BranchSelectionRow
badge={branchBadgeLabel({ branch: item, project: props.project })}
badge={
[
branchBadgeLabel({ branch: item, project: props.project }),
props.worktree && item.name === props.lastWorktreeBaseBranch ? "Last used" : null,
]
.filter(Boolean)
.join(" · ") || null
}
branch={item}
disabled={props.selectionDisabled ?? false}
isFirst={index === 0}
Expand All @@ -448,6 +457,8 @@ export function BranchPickerScreen(props: {
props.branches.length,
props.project,
props.onSelect,
props.worktree,
props.lastWorktreeBaseBranch,
selectedBranchName,
props.selectionDisabled,
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveNewTaskBranchWorktreePath,
resolveNewTaskBranchLabel,
resolveNewTaskLocalWorkspaceSelection,
resolveNewTaskBranchAfterWorkspaceModeChange,
} from "./new-task-context-presentation";

describe("resolveNewTaskLocalWorkspaceSelection", () => {
Expand Down Expand Up @@ -54,6 +55,58 @@ describe("resolveNewTaskLocalWorkspaceSelection", () => {
});
});

describe("resolveNewTaskBranchAfterWorkspaceModeChange", () => {
it("applies the configured base after leaving the current checkout", () => {
expect(
resolveNewTaskBranchAfterWorkspaceModeChange({
mode: "worktree",
previousMode: "local",
branch: "feature/current",
branchSelection: "auto",
configuredBaseRef: "develop",
}),
).toBeNull();
});

it("keeps the checkout branch when no base ref is configured", () => {
for (const configuredBaseRef of [null, undefined]) {
expect(
resolveNewTaskBranchAfterWorkspaceModeChange({
mode: "worktree",
previousMode: "local",
branch: "feature/current",
branchSelection: "auto",
configuredBaseRef,
}),
).toBe("feature/current");
}
});

it("keeps an explicit branch choice even when it matches the checkout", () => {
expect(
resolveNewTaskBranchAfterWorkspaceModeChange({
mode: "worktree",
previousMode: "local",
branch: "feature/current",
branchSelection: "manual",
configuredBaseRef: { mode: "last-used" },
}),
).toBe("feature/current");
});

it("keeps a legacy draft branch whose source is unknown", () => {
expect(
resolveNewTaskBranchAfterWorkspaceModeChange({
mode: "worktree",
previousMode: "local",
branch: "feature/current",
branchSelection: undefined,
configuredBaseRef: "develop",
}),
).toBe("feature/current");
});
});

describe("resolveNewTaskBranchWorktreePath", () => {
it("moves Current checkout to the selected existing worktree", () => {
expect(
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/new-task-context-presentation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { WorktreeBaseRef } from "@t3tools/contracts";
import { sanitizeNewRefName } from "@t3tools/shared/git";

type WorkspaceMode = "local" | "worktree";
Expand Down Expand Up @@ -59,6 +60,25 @@ export function resolveNewTaskLocalWorkspaceSelection(input: {
};
}

export function resolveNewTaskBranchAfterWorkspaceModeChange(input: {
readonly mode: WorkspaceMode;
readonly previousMode: WorkspaceMode;
readonly branch: string | null;
readonly branchSelection: "auto" | "manual" | undefined;
readonly configuredBaseRef: WorktreeBaseRef | undefined;
}): string | null {
// Without a configured base, keep the checkout branch exactly as before the setting existed.
if (
input.configuredBaseRef != null &&
input.previousMode === "local" &&
input.mode === "worktree" &&
input.branchSelection === "auto"
) {
return null;
}
return input.branch;
}

export function resolveNewTaskBranchLabel(input: {
readonly branchName: string | null;
readonly startFromOrigin: boolean;
Expand Down
Loading
Loading