From 3aa723993130840aa33ba4b1dbb554f6950daf8b Mon Sep 17 00:00:00 2001 From: Ajraba04 <143622128+Ajraba04@users.noreply.github.com> Date: Sun, 8 Nov 2026 10:13:47 +0400 Subject: [PATCH 1/3] fix(web,mobile): scheduled tasks from Settings start from the project's default branch --- .../settings/ScheduledTaskPickerScreens.tsx | 7 +- .../SettingsScheduledTasksRouteScreen.tsx | 16 ++++- .../settings/scheduledTaskDraft.test.ts | 64 +++++++++++++++++++ .../features/settings/scheduledTaskDraft.ts | 21 +++++- .../settings/ScheduledTasksSettings.tsx | 17 +++-- .../scheduledTasksSettings.logic.test.ts | 34 ++++++++++ .../settings/scheduledTasksSettings.logic.ts | 19 +++++- 7 files changed, 168 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index aec2b1a0eeb3c..9eeab6c277efb 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -11,6 +11,7 @@ import { BranchPickerScreen } from "../threads/NewTaskContextPickerScreens"; import { ThreadSettingsPickerScreen } from "../threads/ThreadSettingsSheet"; import { SettingsScreen } from "./components/SettingsScreen"; import { useScheduledTaskEditor } from "./scheduled-task-editor"; +import { scheduledTaskWorktreeBaseRef } from "./scheduledTaskDraft"; function MissingTaskDraft() { return ( @@ -131,7 +132,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={ diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 6e90e449359ca..eaefa4902e819 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -62,6 +62,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"; @@ -76,6 +77,7 @@ import { parseIntervalMinutes, scheduledTaskDefaultModel, scheduleFromDraft, + scheduledTaskWorktreeBaseRef, type ScheduledTaskDraft as Draft, } from "./scheduledTaskDraft"; import { settingsTargetsForProject } from "./settings-environment-filter.logic"; @@ -662,6 +664,15 @@ 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 automaticBaseRefs = usePaginatedBranches({ + environmentId, + cwd: automaticBaseRef + ? (projects.find((project) => project.id === draft.projectId)?.workspaceRoot ?? null) + : null, + query: "", + }); + const worktreeBaseRef = scheduledTaskWorktreeBaseRef(draft.baseRef, automaticBaseRefs.refs); const config = useEnvironmentServerConfig(environmentId); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); // Existing webhook tasks keep the option so they can still switch to a timer. @@ -775,7 +786,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. @@ -972,7 +983,8 @@ function TaskForm({ { 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"); + }); +}); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3a7d919299896..538a597b6693e 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -8,6 +8,7 @@ import type { ScheduledTaskThreadMode, ScheduledTaskUpsertSchedule, ScheduledTaskWebhookSignature, + VcsRef, } from "@t3tools/contracts"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; @@ -251,6 +252,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>, +): 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 createDraft( projectId: ProjectId | null, modelSelection: ModelSelection | null, @@ -265,7 +282,7 @@ export function createDraft( schedule: DEFAULT_SCHEDULE, threadMode: "new_thread", workspace: "worktree", - baseRef: "main", + baseRef: "", checkoutPath: "", enabled: true, startFromOrigin: true, @@ -284,7 +301,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 diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 55efbec750805..a125adc45f103 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -66,6 +66,7 @@ import { buildThreadRouteParams } from "../../threadRoutes"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { environmentAtomList, environmentIdsKey } from "../../state/environmentAtomList"; 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"; @@ -94,6 +95,7 @@ import { matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, + scheduledTaskWorktreeBaseRef, taskToDraft, type DraftState, type ScheduleMode, @@ -154,7 +156,7 @@ const EMPTY_DRAFT: DraftState = { threadId: "", threadMode: "new_thread", workspaceMode: "worktree", - baseRef: "main", + baseRef: "", startFromOrigin: true, existingWorktreePath: "", modelKey: "", @@ -1306,6 +1308,13 @@ 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); const instanceEntries = providerInstanceEntriesFor( providers, settings, @@ -1410,7 +1419,7 @@ function ScheduledTaskEditorForm({ ? { type: "existing_worktree", worktreePath: draft.existingWorktreePath.trim() } : { type: "worktree", - baseRef: draft.baseRef.trim() || "main", + baseRef: worktreeBaseRef, startFromOrigin: draft.startFromOrigin, }; const input: ScheduledTaskUpsertInput = { @@ -1475,7 +1484,7 @@ function ScheduledTaskEditorForm({ projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), modelKey: "", baseModelSelection: null, - baseRef: "main", + baseRef: "", startFromOrigin: true, existingWorktreePath: "", })); @@ -1613,7 +1622,7 @@ function ScheduledTaskEditorForm({ id="scheduled-task-base-ref" environmentId={environmentId} cwd={selectedProject?.workspaceRoot ?? null} - value={draft.baseRef} + value={automaticBaseRef && automaticBaseRefs.data === null ? "" : worktreeBaseRef} onValueChange={(baseRef) => setDraft((current) => ({ ...current, baseRef }))} startFromOrigin={draft.startFromOrigin} onStartFromOriginChange={(startFromOrigin) => diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index c4c66c1cfbae0..a0498b574a943 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -21,6 +21,7 @@ import { defaultScheduledTaskProjectId, matchesScheduledTaskScope, scheduleFromDraft, + scheduledTaskWorktreeBaseRef, taskToDraft, } from "./scheduledTasksSettings.logic"; @@ -168,6 +169,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("one-time scheduled tasks", () => { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 522554e1cce32..4ae7d26f46fb4 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -12,6 +12,7 @@ import { type RuntimeMode, type ProviderInteractionMode, type ServerSettings, + type VcsRef, } from "@t3tools/contracts"; import { parseMaxDeliveryAge, @@ -236,6 +237,22 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul }; } +/** An empty base is automatic: the project's default branch, else `main` or `master`. */ +export function scheduledTaskWorktreeBaseRef( + baseRef: string, + refs: ReadonlyArray>, +): 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 taskToDraft(task: ScheduledTask): DraftState { const schedule = task.schedule; const filters = webhookFilterDrafts(schedule.type === "webhook" ? schedule.filters : undefined); @@ -259,7 +276,7 @@ export function taskToDraft(task: ScheduledTask): DraftState { threadId: task.threadId ?? "", threadMode: scheduledTaskThreadMode(task), workspaceMode: task.workspaceStrategy.type, - baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "main", + baseRef: task.workspaceStrategy.type === "worktree" ? task.workspaceStrategy.baseRef : "", startFromOrigin: task.workspaceStrategy.type === "worktree" ? (task.workspaceStrategy.startFromOrigin ?? false) From a964ff7ee7cf7d6636d5e9363326875ea9c4d675 Mon Sep 17 00:00:00 2001 From: Ajraba04 <143622128+Ajraba04@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:30:18 +0400 Subject: [PATCH 2/3] fix(web,mobile): scheduled task save waits for the project's refs before saving an automatic base --- .../SettingsScheduledTasksRouteScreen.tsx | 16 ++++++++++++---- .../settings/ScheduledTasksSettings.tsx | 10 +++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 446dbd5779013..7158c1bafb643 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -668,14 +668,20 @@ function TaskForm({ ) .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: automaticBaseRef - ? (projects.find((project) => project.id === draft.projectId)?.workspaceRoot ?? null) - : null, + cwd: automaticBaseRefCwd, 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 = + 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); @@ -716,7 +722,8 @@ function TaskForm({ saving || dictationPending || taskMissing || - environmentUnavailable + environmentUnavailable || + automaticBaseRefLoading ) return; // Preserve signatures edited on other clients by sending the task's current value. @@ -1315,6 +1322,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) } diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index d288d255995a2..87a6b2055fe15 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1430,6 +1430,12 @@ function ScheduledTaskEditorForm({ 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, @@ -1470,7 +1476,8 @@ function ScheduledTaskEditorForm({ saving || editingTaskMissing || !connected || - tasksQuery.data === null + tasksQuery.data === null || + automaticBaseRefLoading ) return; const selection = activeSelection; @@ -2151,6 +2158,7 @@ function ScheduledTaskEditorForm({ editingTaskMissing || !connected || !tasksQuery.data || + automaticBaseRefLoading || (draft.enabled && draft.scheduleMode === "webhook" && webhookDenial !== undefined) } onClick={() => void submit()} From 393d741c2ed33b897afffa74670f221aae32a48f Mon Sep 17 00:00:00 2001 From: binjamshid <143622128+binjamshid@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:54:59 +0400 Subject: [PATCH 3/3] fix(web,mobile): a failed ref lookup shows the fallback base the task will save --- .../features/settings/SettingsScheduledTasksRouteScreen.tsx | 3 +-- apps/web/src/components/settings/ScheduledTasksSettings.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 7158c1bafb643..1971b6cf92528 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -1010,8 +1010,7 @@ function TaskForm({ setDraft((current) => ({ ...current, baseRef }))} startFromOrigin={draft.startFromOrigin} onStartFromOriginChange={(startFromOrigin) =>