Skip to content
Open
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
12 changes: 10 additions & 2 deletions apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import {
useScheduledTaskDestinationLabel,
useScheduledTaskEditor,
} from "./scheduled-task-editor";
import { moveDraftToThread, unbindDraftFromThread } from "./scheduledTaskDraft";
import {
moveDraftToThread,
scheduledTaskWorktreeBaseRef,
unbindDraftFromThread,
} from "./scheduledTaskDraft";

function MissingTaskDraft() {
return (
Expand Down Expand Up @@ -145,7 +149,11 @@ export function ScheduledTaskBranchPickerRouteScreen() {
title="Base branch"
project={project}
branches={visibleBranches}
selectedBranchName={editor.draft.baseRef}
selectedBranchName={
editor.draft.baseRef.trim() || debouncedQuery.trim() || branches.data === null
? editor.draft.baseRef
: scheduledTaskWorktreeBaseRef("", branches.refs)
}
query={query}
onQueryChange={setQuery}
loading={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { usePreparedConnection } from "../../state/session";
import { buildModelOptions } from "../../lib/modelOptions";
import { useProjects, useEnvironmentServerConfig } from "../../state/entities";
import { useEnvironmentQuery } from "../../state/query";
import { usePaginatedBranches } from "../../state/queries";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { resolveNewTaskBranchLabel } from "../threads/new-task-context-presentation";
Expand All @@ -78,6 +79,7 @@ import {
parseIntervalMinutes,
scheduledTaskDefaultModel,
scheduleFromDraft,
scheduledTaskWorktreeBaseRef,
unbindDraftFromThread,
type ScheduledTaskDraft as Draft,
} from "./scheduledTaskDraft";
Expand Down Expand Up @@ -667,6 +669,21 @@ function TaskForm({
project.id === draft.task?.projectId),
)
.sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null));
const automaticBaseRef = draft.workspace === "worktree" && !draft.baseRef.trim();
const automaticBaseRefCwd = automaticBaseRef
? (projects.find((project) => project.id === draft.projectId)?.workspaceRoot ?? null)
: null;
const automaticBaseRefs = usePaginatedBranches({
environmentId,
cwd: automaticBaseRefCwd,
query: "",
});
const worktreeBaseRef = scheduledTaskWorktreeBaseRef(draft.baseRef, automaticBaseRefs.refs);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment thread
binjamshid marked this conversation as resolved.
Resolved
// Until the project's refs load, the automatic base would save as `main`. A failed lookup falls back to it.
const automaticBaseRefLoading =
automaticBaseRefCwd !== null &&
automaticBaseRefs.data === null &&
automaticBaseRefs.error === null;
const config = useEnvironmentServerConfig(environmentId);
const destinationLabel = useScheduledTaskDestinationLabel(environmentId, draft.threadId);
const modelOptions = buildModelOptions(config, null, undefined, draft.projectId);
Expand Down Expand Up @@ -708,7 +725,8 @@ function TaskForm({
saving ||
dictationPending ||
taskMissing ||
environmentUnavailable
environmentUnavailable ||
automaticBaseRefLoading
)
return;
// Preserve signatures edited on other clients by sending the task's current value.
Expand Down Expand Up @@ -782,7 +800,7 @@ function TaskForm({
? { type: "existing_worktree", worktreePath: draft.checkoutPath.trim() }
: {
type: "worktree",
baseRef: draft.baseRef.trim() || "main",
baseRef: worktreeBaseRef,
startFromOrigin: draft.startFromOrigin,
},
// Tasks have no mode picker; a mode above the administrator's ceiling is refused.
Expand Down Expand Up @@ -995,7 +1013,7 @@ function TaskForm({
<PickerRow
label="Base branch"
value={resolveNewTaskBranchLabel({
branchName: draft.baseRef,
branchName: automaticBaseRefLoading ? null : worktreeBaseRef,
startFromOrigin: draft.startFromOrigin,
workspaceMode: "worktree",
})}
Expand Down Expand Up @@ -1309,6 +1327,7 @@ function TaskForm({
dictationPending ||
taskMissing ||
environmentUnavailable ||
automaticBaseRefLoading ||
// The server refuses an enabled webhook task under this policy.
(draft.enabled && draft.schedule.mode === "webhook" && webhookDenial !== undefined)
}
Expand Down
64 changes: 64 additions & 0 deletions apps/mobile/src/features/settings/scheduledTaskDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import {
ProviderDriverKind,
ProviderInstanceId,
ProjectId,
ScheduledTaskId,
type ScheduledTask,
} from "@t3tools/contracts";
import {
createDraft,
editDraft,
scheduledTaskDefaultModel,
scheduledTaskWorktreeBaseRef,
DEFAULT_SCHEDULE,
scheduleDraftForTask,
scheduleFromDraft,
Expand Down Expand Up @@ -207,3 +212,62 @@ it("rejects dedupe keys without valid request placeholders", () => {
scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "webhook", coalesceKey: "{{body.bad" }),
).toBeNull();
});

describe("scheduled task worktree base", () => {
const ref = (name: string, isDefault = false, isRemote = false) => ({
name,
isDefault,
isRemote,
});
const rootTask = {
id: ScheduledTaskId.make("task"),
title: "Review issues",
prompt: "Review open issues",
enabled: true,
schedule: { type: "interval", everyMs: 60_000 },
projectId: ProjectId.make("project"),
threadId: null,
workspaceStrategy: { type: "root" },
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
createdBy: "user",
creationSource: "mobile",
createdAt: "2026-09-17T00:00:00.000Z",
updatedAt: "2026-09-17T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
lastRunStatus: "never",
lastRunError: null,
runCount: 0,
} satisfies ScheduledTask;

it("leaves the base automatic for new tasks and tasks without a worktree", () => {
expect(createDraft(ProjectId.make("project"), null).baseRef).toBe("");
expect(editDraft(rootTask).baseRef).toBe("");
expect(
editDraft({ ...rootTask, workspaceStrategy: { type: "worktree", baseRef: "release" } })
.baseRef,
).toBe("release");
});

it("starts an automatic base from the project's default branch", () => {
expect(scheduledTaskWorktreeBaseRef("", [ref("feature"), ref("master", true)])).toBe("master");
expect(scheduledTaskWorktreeBaseRef(" ", [ref("origin/develop", true, true)])).toBe(
"origin/develop",
);
});

it("falls back to main or master when the remote records no default", () => {
expect(scheduledTaskWorktreeBaseRef("", [ref("feature"), ref("master")])).toBe("master");
expect(scheduledTaskWorktreeBaseRef("", [ref("origin/master", false, true)])).toBe(
"origin/master",
);
expect(scheduledTaskWorktreeBaseRef("", [ref("master"), ref("main")])).toBe("main");
expect(scheduledTaskWorktreeBaseRef("", [])).toBe("main");
});

it("keeps a base the user picked", () => {
expect(scheduledTaskWorktreeBaseRef(" release ", [ref("master", true)])).toBe("release");
});
});
21 changes: 19 additions & 2 deletions apps/mobile/src/features/settings/scheduledTaskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ScheduledTaskThreadMode,
ScheduledTaskUpsertSchedule,
ScheduledTaskWebhookSignature,
VcsRef,
ThreadId,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -254,6 +255,22 @@ export function hasScheduledTaskDraftChanges(
return draftSignature(initial) !== draftSignature(current);
}

/** An empty base is automatic: the project's default branch, else `main` or `master`. */
export function scheduledTaskWorktreeBaseRef(
baseRef: string,
refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault">>,
): string {
const picked = baseRef.trim();
if (picked) return picked;
const defaultRef = refs.find((ref) => ref.isDefault);
if (defaultRef) return defaultRef.name;
for (const name of ["main", "master"]) {
const match = refs.find((ref) => ref.name === name || ref.name === `origin/${name}`);
if (match) return match.name;
}
return "main";
}

export function moveDraftToThread(
draft: ScheduledTaskDraft,
thread: { readonly id: ThreadId; readonly projectId: ProjectId },
Expand Down Expand Up @@ -301,7 +318,7 @@ export function createDraft(
schedule: DEFAULT_SCHEDULE,
threadMode: "new_thread",
workspace: "worktree",
baseRef: "main",
baseRef: "",
checkoutPath: "",
enabled: true,
startFromOrigin: true,
Expand All @@ -321,7 +338,7 @@ export function editDraft(task: ScheduledTask): ScheduledTaskDraft {
schedule: scheduleDraftForTask(task),
threadMode: scheduledTaskThreadMode(task),
workspace: task.workspaceStrategy.type,
baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main",
baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "",
checkoutPath:
task.workspaceStrategy.type === "existing_worktree"
? task.workspaceStrategy.worktreePath
Expand Down
27 changes: 22 additions & 5 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList";
import { useThreadSearch } from "../../state/queries";
import { formatEnvironmentQueryError, useEnvironmentQuery } from "../../state/query";
import { usePaginatedBranches } from "../../state/queries";
import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
import { threadFamilies } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -106,6 +107,7 @@ import {
scheduleFromDraft,
scheduleLabel,
scheduledTaskDefaultModel,
scheduledTaskWorktreeBaseRef,
WEEKDAY_LABELS,
taskToDraft,
unbindDraftFromThread,
Expand Down Expand Up @@ -179,7 +181,7 @@ const EMPTY_DRAFT: DraftState = {
threadId: "",
threadMode: "new_thread",
workspaceMode: "worktree",
baseRef: "main",
baseRef: "",
startFromOrigin: true,
existingWorktreePath: "",
modelKey: "",
Expand Down Expand Up @@ -1404,6 +1406,19 @@ function ScheduledTaskEditorForm({
: task;
const selectedProjectId = draft.projectId || projects[0]?.id || "";
const selectedProject = projects.find((project) => project.id === selectedProjectId);
const automaticBaseRef = draft.workspaceMode === "worktree" && !draft.baseRef.trim();
const automaticBaseRefs = usePaginatedBranches({
environmentId,
cwd: automaticBaseRef ? (selectedProject?.workspaceRoot ?? null) : null,
query: "",
});
const worktreeBaseRef = scheduledTaskWorktreeBaseRef(draft.baseRef, automaticBaseRefs.refs);
// Until the project's refs load, the automatic base would save as `main`. A failed lookup falls back to it.
const automaticBaseRefLoading =
automaticBaseRef &&
selectedProject !== undefined &&
automaticBaseRefs.data === null &&
automaticBaseRefs.error === null;
const instanceEntries = providerInstanceEntriesFor(
providers,
settings,
Expand Down Expand Up @@ -1444,7 +1459,8 @@ function ScheduledTaskEditorForm({
saving ||
editingTaskMissing ||
!connected ||
tasksQuery.data === null
tasksQuery.data === null ||
automaticBaseRefLoading
)
return;
const selection = activeSelection;
Expand Down Expand Up @@ -1508,7 +1524,7 @@ function ScheduledTaskEditorForm({
? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() }
: {
type: "worktree",
baseRef: draft.baseRef.trim() || "main",
baseRef: worktreeBaseRef,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
startFromOrigin: draft.startFromOrigin,
};
const input: ScheduledTaskUpsertInput = {
Expand Down Expand Up @@ -1573,7 +1589,7 @@ function ScheduledTaskEditorForm({
projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope),
modelKey: "",
baseModelSelection: null,
baseRef: "main",
baseRef: "",
startFromOrigin: true,
existingWorktreePath: "",
}));
Expand Down Expand Up @@ -1724,7 +1740,7 @@ function ScheduledTaskEditorForm({
id="scheduled-task-base-ref"
environmentId={environmentId}
cwd={selectedProject?.workspaceRoot ?? null}
value={draft.baseRef}
value={automaticBaseRefLoading ? "" : worktreeBaseRef}
onValueChange={(baseRef) => setDraft((current) => ({ ...current, baseRef }))}
startFromOrigin={draft.startFromOrigin}
onStartFromOriginChange={(startFromOrigin) =>
Expand Down Expand Up @@ -2133,6 +2149,7 @@ function ScheduledTaskEditorForm({
editingTaskMissing ||
!connected ||
!tasksQuery.data ||
automaticBaseRefLoading ||
(draft.enabled && draft.scheduleMode === "webhook" && webhookDenial !== undefined)
}
onClick={() => void submit()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
defaultScheduledTaskProjectId,
matchesScheduledTaskScope,
scheduleFromDraft,
scheduledTaskWorktreeBaseRef,
scheduleLabel,
taskToDraft,
unbindDraftFromThread,
Expand Down Expand Up @@ -171,6 +172,39 @@ describe("editing scheduled task branch settings", () => {
expect(draft.baseRef).toBe("release");
expect(draft.startFromOrigin).toBe(false);
});

it("leaves the base automatic when a task without a worktree switches to one", () => {
const draft = taskToDraft({ ...legacyTask, workspaceStrategy: { type: "root" } });
expect(draft.baseRef).toBe("");
});
});

describe("scheduled task worktree base", () => {
const ref = (name: string, isDefault = false, isRemote = false) => ({
name,
isDefault,
isRemote,
});

it("starts an automatic base from the project's default branch", () => {
expect(scheduledTaskWorktreeBaseRef("", [ref("feature"), ref("master", true)])).toBe("master");
expect(scheduledTaskWorktreeBaseRef(" ", [ref("origin/develop", true, true)])).toBe(
"origin/develop",
);
});

it("falls back to main or master when the remote records no default", () => {
expect(scheduledTaskWorktreeBaseRef("", [ref("feature"), ref("master")])).toBe("master");
expect(scheduledTaskWorktreeBaseRef("", [ref("origin/master", false, true)])).toBe(
"origin/master",
);
expect(scheduledTaskWorktreeBaseRef("", [ref("master"), ref("main")])).toBe("main");
expect(scheduledTaskWorktreeBaseRef("", [])).toBe("main");
});

it("keeps a base the user picked", () => {
expect(scheduledTaskWorktreeBaseRef(" release ", [ref("master", true)])).toBe("release");
});
});

describe("fixed-time scheduled tasks", () => {
Expand Down
Loading
Loading