import type { useRouter } from "next/navigation"; import type { Task, Branch, LocalRepository, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { AppState } from "@/lib/state/store"; import type { StepType, TaskRemoteRepoRow, TaskRepoRow, } from "@/components/task-create-dialog-types"; import type { UsePRInfoByURLResult } from "@/hooks/domains/github/use-pr-info-by-url"; import { parseGitHubAnyUrl } from "@/hooks/domains/github/use-pr-info-by-url"; import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { linkToTask } from "@/lib/links"; import { INTENT_PLAN } from "@/lib/state/layout-manager"; import { createTask } from "@/lib/api"; import type { FileAttachment } from "@/components/task/chat/file-attachment"; import type { MessageAttachment } from "@/lib/services/session-launch-service"; type CreateTaskParams = Parameters[0]; export type { CreateTaskParams }; /** Converts FileAttachment array to MessageAttachment array for the launch request. */ export function toMessageAttachments( attachments: FileAttachment[], ): MessageAttachment[] | undefined { if (attachments.length === 0) return undefined; return attachments.map((att) => att.isImage ? { type: "image" as const, data: att.data, mime_type: att.mimeType } : { type: "resource" as const, data: att.data, mime_type: att.mimeType, name: att.fileName, }, ); } export function autoSelectBranch(branchList: Branch[], setBranch: (value: string) => void): void { const lastUsedBranch = getLocalStorage(STORAGE_KEYS.LAST_BRANCH, null); if ( lastUsedBranch && branchList.some((b) => { const displayName = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name; return displayName === lastUsedBranch; }) ) { setBranch(lastUsedBranch); return; } const preferredBranch = selectPreferredBranch(branchList); if (preferredBranch) setBranch(preferredBranch); } export function computePassthroughProfile( agentProfileId: string, agentProfiles: AgentProfileOption[], ) { if (!agentProfileId) return false; return ( agentProfiles.find((p: AgentProfileOption) => p.id === agentProfileId)?.cli_passthrough === true ); } export function computeEffectiveStepId( selectedWorkflowId: string | null, workflowId: string | null, fetchedSteps: StepType[] | null, defaultStepId: string | null, ) { return selectedWorkflowId && selectedWorkflowId !== workflowId && fetchedSteps ? (fetchedSteps[0]?.id ?? null) : defaultStepId; } export function computeIsTaskStarted( isEditMode: boolean, editingTask?: { state?: Task["state"] } | null, ) { if (!isEditMode || !editingTask?.state) return false; return editingTask.state !== "TODO" && editingTask.state !== "CREATED"; } export type ActivatePlanModeArgs = { sessionId: string; taskId: string; setActiveDocument: AppState["setActiveDocument"]; setPlanMode: AppState["setPlanMode"]; router: ReturnType; }; export function activatePlanMode({ sessionId, taskId, setActiveDocument, setPlanMode, router, }: ActivatePlanModeArgs) { setActiveDocument(sessionId, { type: "plan", taskId }); setPlanMode(sessionId, true); useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" }); router.push(linkToTask(taskId, INTENT_PLAN)); } export type BuildCreatePayloadArgs = { workspaceId: string; effectiveWorkflowId: string; trimmedTitle: string; trimmedDescription: string; repositoriesPayload: CreateTaskParams["repositories"]; agentProfileId: string; executorId: string; executorProfileId: string; withAgent: boolean; planMode?: boolean; attachments?: MessageAttachment[]; parentId?: string; workspacePath?: string; }; export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams { return { workspace_id: args.workspaceId, workflow_id: args.effectiveWorkflowId, title: args.trimmedTitle, description: args.trimmedDescription, repositories: args.repositoriesPayload, state: args.withAgent ? "IN_PROGRESS" : "CREATED", start_agent: args.withAgent ? true : undefined, prepare_session: args.withAgent ? undefined : true, agent_profile_id: args.agentProfileId || undefined, executor_id: args.executorId || undefined, executor_profile_id: args.executorProfileId || undefined, plan_mode: args.planMode || undefined, attachments: args.attachments, parent_id: args.parentId || undefined, workspace_path: args.workspacePath || undefined, }; } export function validateCreateInputs(inputs: { trimmedTitle: string; workspaceId: string | null; effectiveWorkflowId: string | null; /** Unified repos list. The form is valid if any row has a repo set OR URL mode is filled. */ repositories: TaskRepoRow[]; /** Remote URL rows. The form is valid when at least one has a non-empty URL. */ remoteRepos?: TaskRemoteRepoRow[]; agentProfileId: string; noRepository?: boolean; }): boolean { const hasRemoteRepo = (inputs.remoteRepos ?? []).some((r) => r.url.trim() !== ""); const hasRepo = inputs.noRepository || inputs.repositories.some((r) => r.repositoryId || r.localPath) || hasRemoteRepo; return Boolean( inputs.trimmedTitle && inputs.workspaceId && inputs.effectiveWorkflowId && inputs.agentProfileId && hasRepo, ); } /** * Detects two remote-repo rows that resolve to the same GitHub `owner/repo`. * * Both plain repo URLs and PR URLs are parsed via `parseGitHubAnyUrl`, so two * different PRs of the same repo (`/pull/1116` and `/pull/1117`) or the same * PR URL pasted twice are caught — they all collapse to the same backend * repository, which would otherwise surface as an opaque UUID-laden error. * * Rows with an empty URL, or a URL that can't be parsed to `owner/repo` * (garbage), are skipped — only parseable rows participate in the comparison, * which is case-insensitive on `owner/repo`. * * Returns the human-readable label (`owner/repo`, preserving the first row's * casing) of the first duplicate found, or `null` when every parseable row is * a distinct repo. */ export function findDuplicateRemoteRepo(remoteRepos: TaskRemoteRepoRow[]): string | null { const seen = new Map(); for (const row of remoteRepos) { const parsed = parseGitHubAnyUrl(row.url ?? ""); if (!parsed) continue; const label = `${parsed.owner}/${parsed.repo}`; const key = label.toLowerCase(); const existing = seen.get(key); if (existing) return existing; seen.set(key, label); } return null; } /** * Builds the repositories payload for task creation from the unified list. * * - URL mode produces a single entry with `github_url`. * - Otherwise each row maps to either a workspace `repository_id` or a * discovered `local_path`. Empty rows are dropped silently so a user * can leave an unfinished chip without blocking submit; duplicate * detection happens on the backend. */ export function buildRepositoriesPayload(opts: { /** True when the form is in GitHub Remote (URL) mode. */ useRemote: boolean; /** Remote-URL rows; non-empty `url` rows are mapped 1:1 to payload entries. */ remoteRepos: TaskRemoteRepoRow[]; /** * Per-URL PR-info cache. Consulted for each remote row whose URL is a PR * URL: if the row's branch equals the PR head (auto-fill or user-confirmed * default), the payload anchors `base_branch` to the PR's actual target * from the API so origin can resolve it even when the head only lives on * a fork. Optional — non-Remote call sites can omit it. */ prInfoByUrl?: Pick; repositories: TaskRepoRow[]; /** Used to look up `default_branch` for `localPath` rows. */ discoveredRepositories: LocalRepository[]; /** Workspace repositories — used to look up `default_branch` for `repositoryId` rows. */ workspaceRepositories?: Repository[]; /** * For the local executor (no worktree), the chip's branch field represents * the working branch on disk, not the parent integration branch. Send it as * `checkout_branch` so the session's `base_branch` stays anchored to the * repo's `default_branch` (which is what git log / cumulative diff use as * the merge-base reference). Without this, "new branch on local executor" * collapses the merge-base recomputation to HEAD and the changes panel * goes empty after a refresh. */ isLocalExecutor?: boolean; /** * Optional fresh-branch metadata. The UI gates this to single-row + local * executor; when present we apply it to every row (which is at most one). */ freshBranch?: { confirmDiscard: boolean; consentedDirtyFiles: string[] }; }): NonNullable { if (opts.useRemote) { return buildRemoteRepoPayload(opts); } const fresh = opts.freshBranch ? { fresh_branch: true, confirm_discard: opts.freshBranch.confirmDiscard, consented_dirty_files: opts.freshBranch.consentedDirtyFiles, } : {}; // Fresh-branch flow inverts the chip's semantics: instead of "the working // branch I'm on", row.branch becomes "the base I want to fork from". The // backend then creates a new branch and rewrites repos[i].BaseBranch to it. // Splitting here would force `base_branch=default_branch, checkout_branch= // ` and the backend would fork from the wrong base. Skip the // split entirely when fresh-branch is active. const isLocalExecutor = !!opts.isLocalExecutor && !opts.freshBranch; return opts.repositories .filter((row) => row.repositoryId || row.localPath) .map((row) => { const defaultBranch = resolveRowDefaultBranch(row, opts); const branches = splitLocalExecutorBranches({ rowBranch: row.branch, defaultBranch, isLocalExecutor, }); if (row.repositoryId) { return { repository_id: row.repositoryId, base_branch: branches.base_branch, checkout_branch: branches.checkout_branch, ...fresh, }; } return { repository_id: "", base_branch: branches.base_branch, checkout_branch: branches.checkout_branch, local_path: row.localPath, default_branch: defaultBranch || undefined, ...fresh, }; }); } /** * Builds the `repos: [{ github_url, branch }]` payload from the remote-URL * rows. Rows with an empty URL are dropped silently — they're partially * filled rows the user hasn't completed yet. * * Per-row PR-info inference: if a row's URL is a PR URL and the row's * branch equals the PR's head branch (auto-selected by the chip or * user-confirmed via "leave default"), the payload anchors `base_branch` * to the PR's actual target from the GitHub API and surfaces the PR head * as `checkout_branch`. This keeps fork PRs resolvable on `origin` (their * head doesn't live there, but the base does). When the user overrides * the branch to something other than the PR head, we treat their pick as * the base and drop `checkout_branch`. */ function buildRemoteRepoPayload(opts: { remoteRepos: TaskRemoteRepoRow[]; prInfoByUrl?: Pick; }): NonNullable { const nonEmpty = opts.remoteRepos.filter((r) => r.url.trim() !== ""); if (nonEmpty.length === 0) return []; return nonEmpty.map((row) => { const url = row.url.trim(); // The cache is keyed on the trimmed URL (ensure() also trims), so we // must look it up with the trimmed value too. Passing `row.url` directly // would miss the cache when the user has stray whitespace around their // URL and silently lose the PR base-branch anchoring. const prInfo = opts.prInfoByUrl?.info(url); if (prInfo) { const isPrAutoSelection = !!prInfo.prHeadBranch && row.branch === prInfo.prHeadBranch; const baseBranch = isPrAutoSelection ? prInfo.prBaseBranch || undefined : row.branch || undefined; return { repository_id: "", base_branch: baseBranch, checkout_branch: isPrAutoSelection ? prInfo.prHeadBranch || undefined : undefined, github_url: url, }; } return { repository_id: "", base_branch: row.branch || undefined, checkout_branch: undefined, github_url: url, }; }); } function resolveRowDefaultBranch( row: TaskRepoRow, opts: { discoveredRepositories: LocalRepository[]; workspaceRepositories?: Repository[]; }, ): string | undefined { if (row.repositoryId) { return opts.workspaceRepositories?.find((r) => r.id === row.repositoryId)?.default_branch; } return opts.discoveredRepositories.find((d) => d.path === row.localPath)?.default_branch; } /** * For the local executor: return `base_branch=defaultBranch`, * `checkout_branch=rowBranch` so the session anchors merge-base to the repo's * integration branch while the preparer still checks out the user's working * branch. When `rowBranch === defaultBranch` we omit checkout_branch — the * preparer treats matching values as "use current state" and skips git ops. * * For non-local executors (worktree-based): keep the historical shape where * `base_branch=rowBranch` (the worktree creates a new branch off of it). */ function splitLocalExecutorBranches(args: { rowBranch?: string; defaultBranch?: string; isLocalExecutor: boolean; }): { base_branch: string | undefined; checkout_branch: string | undefined } { // Without a known default_branch we can't anchor base_branch to the // integration ref — and using rowBranch as a stand-in reproduces the // exact bug this PR fixes (changes panel collapses to HEAD on refresh). // Fall through to the legacy non-split shape: a workspace repo with an // unset default_branch is no worse off than before, and the backend's // resolveRepoInput probe will populate it on the next CreateRepository // call. Wait for that probe rather than synthesizing a guess here. if (!args.isLocalExecutor || !args.defaultBranch) { return { base_branch: args.rowBranch || undefined, checkout_branch: undefined }; } const base = args.defaultBranch; const checkout = args.rowBranch && args.rowBranch !== args.defaultBranch ? args.rowBranch : undefined; return { base_branch: base, checkout_branch: checkout }; }