diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index 1f28701f2bd..130b2da0f5b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -44,6 +44,10 @@ import { openExternalWorktree, } from "../utils/workspace-creation"; import { initializeWorkspaceWorktree } from "../utils/workspace-init"; +import { + throwWorkspaceCreateDomainError, + validateExistingBranchCreate, +} from "./utils/create-domain-errors"; function getPrWorkspaceName(prInfo: PullRequestInfo): string { return prInfo.title || `PR #${prInfo.number}`; @@ -269,6 +273,9 @@ export const createCreateProcedures = () => { compareBaseBranch: z.string().optional(), sourceWorkspaceId: z.string().optional(), useExistingBranch: z.boolean().optional(), + intent: z + .enum(["create_from_existing_branch", "create_new_branch"]) + .optional(), applyPrefix: z.boolean().optional().default(true), }) .refine( @@ -319,6 +326,22 @@ export const createCreateProcedures = () => { } let existingBranchName: string | undefined; + let existingBranches: string[]; + try { + const { local, remote } = await listBranches(project.mainRepoPath); + existingBranches = [...local, ...remote]; + } catch (error) { + console.error("[workspaces/create] Failed to list branches", { + projectId: project.id, + error: error instanceof Error ? error.message : String(error), + }); + throwWorkspaceCreateDomainError( + "GIT_OPERATION_FAILED", + "Unable to list branches. Please try again.", + "INTERNAL_SERVER_ERROR", + ); + } + if (input.useExistingBranch) { existingBranchName = input.branchName?.trim(); if (!existingBranchName) { @@ -327,20 +350,34 @@ export const createCreateProcedures = () => { ); } - const existingWorktreePath = await getBranchWorktreePath({ - mainRepoPath: project.mainRepoPath, - branch: existingBranchName, - }); - if (existingWorktreePath) { - throw new Error( - `Branch "${existingBranchName}" is already checked out in another worktree at: ${existingWorktreePath}`, + let existingWorktreePath: string | null; + try { + existingWorktreePath = await getBranchWorktreePath({ + mainRepoPath: project.mainRepoPath, + branch: existingBranchName, + }); + } catch (error) { + console.error( + "[workspaces/create] Failed to resolve existing branch worktree path", + { + projectId: project.id, + branch: existingBranchName, + error: error instanceof Error ? error.message : String(error), + }, + ); + throwWorkspaceCreateDomainError( + "GIT_OPERATION_FAILED", + "Failed to verify existing worktrees for the selected branch.", + "INTERNAL_SERVER_ERROR", ); } + validateExistingBranchCreate({ + branchName: existingBranchName, + existingBranches, + existingWorktreePath, + }); } - const { local, remote } = await listBranches(project.mainRepoPath); - const existingBranches = [...local, ...remote]; - // Resolve branch prefix using shared utility let branchPrefix: string | undefined; if (input.applyPrefix) { @@ -360,11 +397,6 @@ export const createCreateProcedures = () => { let branch: string; if (existingBranchName) { - if (!existingBranches.includes(existingBranchName)) { - throw new Error( - `Branch "${existingBranchName}" does not exist. Please select an existing branch.`, - ); - } branch = existingBranchName; } else if (input.branchName?.trim()) { branch = sanitizeBranchNameWithMaxLength( @@ -502,6 +534,11 @@ export const createCreateProcedures = () => { branch: branch, base_branch: compareBaseBranch, use_existing_branch: input.useExistingBranch ?? false, + intent: + input.intent ?? + (input.useExistingBranch + ? "create_from_existing_branch" + : "create_new_branch"), }); await setBranchBaseConfig({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/utils/create-domain-errors.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/utils/create-domain-errors.test.ts new file mode 100644 index 00000000000..ceb9fae82e7 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/utils/create-domain-errors.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { TRPCError } from "@trpc/server"; +import { + throwWorkspaceCreateDomainError, + validateExistingBranchCreate, +} from "./create-domain-errors"; + +describe("create-domain-errors", () => { + test("throws BRANCH_NOT_FOUND when branch is missing", () => { + expect(() => + validateExistingBranchCreate({ + branchName: "feature/missing", + existingBranches: ["main", "develop"], + existingWorktreePath: null, + }), + ).toThrow('BRANCH_NOT_FOUND: Branch "feature/missing" no longer exists.'); + }); + + test("throws WORKTREE_ALREADY_EXISTS_FOR_BRANCH when worktree exists", () => { + expect(() => + validateExistingBranchCreate({ + branchName: "feature/existing", + existingBranches: ["main", "feature/existing"], + existingWorktreePath: "/tmp/worktrees/feature-existing", + }), + ).toThrow( + 'WORKTREE_ALREADY_EXISTS_FOR_BRANCH: Branch "feature/existing" is already checked out in another worktree.', + ); + }); + + test("passes when branch exists and no worktree exists", () => { + expect(() => + validateExistingBranchCreate({ + branchName: "feature/new", + existingBranches: ["main", "feature/new"], + existingWorktreePath: null, + }), + ).not.toThrow(); + }); + + test("throws with provided tRPC code", () => { + try { + throwWorkspaceCreateDomainError( + "GIT_OPERATION_FAILED", + "Unable to list branches.", + "INTERNAL_SERVER_ERROR", + ); + throw new Error("Expected TRPCError"); + } catch (error) { + expect(error).toBeInstanceOf(TRPCError); + expect((error as TRPCError).code).toBe("INTERNAL_SERVER_ERROR"); + expect((error as TRPCError).message).toBe( + "GIT_OPERATION_FAILED: Unable to list branches.", + ); + } + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/utils/create-domain-errors.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/utils/create-domain-errors.ts new file mode 100644 index 00000000000..ec878b3ac83 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/utils/create-domain-errors.ts @@ -0,0 +1,42 @@ +import { TRPCError } from "@trpc/server"; + +export type WorkspaceCreateDomainErrorCode = + | "WORKTREE_ALREADY_EXISTS_FOR_BRANCH" + | "BRANCH_NOT_FOUND" + | "GIT_OPERATION_FAILED"; + +export function throwWorkspaceCreateDomainError( + code: WorkspaceCreateDomainErrorCode, + message: string, + trpcCode: TRPCError["code"] = "BAD_REQUEST", +): never { + console.warn("[workspaces/create] Domain error", { code, message }); + throw new TRPCError({ + code: trpcCode, + message: `${code}: ${message}`, + }); +} + +export function validateExistingBranchCreate({ + branchName, + existingBranches, + existingWorktreePath, +}: { + branchName: string; + existingBranches: string[]; + existingWorktreePath: string | null; +}): void { + if (!existingBranches.includes(branchName)) { + throwWorkspaceCreateDomainError( + "BRANCH_NOT_FOUND", + `Branch "${branchName}" no longer exists.`, + ); + } + + if (existingWorktreePath) { + throwWorkspaceCreateDomainError( + "WORKTREE_ALREADY_EXISTS_FOR_BRANCH", + `Branch "${branchName}" is already checked out in another worktree.`, + ); + } +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/default-worktree-base-dir.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/default-worktree-base-dir.test.ts new file mode 100644 index 00000000000..b6699561867 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/default-worktree-base-dir.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; +import path from "node:path"; +import { getDefaultWorktreeBaseDir } from "./default-worktree-base-dir"; + +describe("getDefaultWorktreeBaseDir", () => { + test("uses stable ~/.superset/worktrees default base dir", () => { + expect(getDefaultWorktreeBaseDir("/Users/tester")).toBe( + path.join("/Users/tester", ".superset", "worktrees"), + ); + }); + + test("does not include workspace-name-scoped suffix", () => { + const baseDir = getDefaultWorktreeBaseDir("/Users/tester"); + expect(baseDir.includes(".superset-open-workspace")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/default-worktree-base-dir.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/default-worktree-base-dir.ts new file mode 100644 index 00000000000..b9140dd6fe7 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/default-worktree-base-dir.ts @@ -0,0 +1,10 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + PROJECT_SUPERSET_DIR_NAME, + WORKTREES_DIR_NAME, +} from "shared/constants"; + +export function getDefaultWorktreeBaseDir(homeDirectory = homedir()): string { + return join(homeDirectory, PROJECT_SUPERSET_DIR_NAME, WORKTREES_DIR_NAME); +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts index 723f1ab7384..23b7f69eedc 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/resolve-worktree-path.ts @@ -1,8 +1,7 @@ -import { homedir } from "node:os"; import { join } from "node:path"; import { type SelectProject, settings } from "@superset/local-db"; import { localDb } from "main/lib/local-db"; -import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants"; +import { getDefaultWorktreeBaseDir } from "./default-worktree-base-dir"; /** Resolves base dir: project override > global setting > default (~/.superset/worktrees) */ export function resolveWorktreePath( @@ -14,9 +13,7 @@ export function resolveWorktreePath( } const row = localDb.select().from(settings).get(); - const baseDir = - row?.worktreeBaseDir ?? - join(homedir(), SUPERSET_DIR_NAME, WORKTREES_DIR_NAME); + const baseDir = row?.worktreeBaseDir ?? getDefaultWorktreeBaseDir(); return join(baseDir, project.name, branch); } diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 27f6d5807a1..0ef882e29b9 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -28,12 +28,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useNavigate } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; -import { - ArrowUpIcon, - ExternalLinkIcon, - PaperclipIcon, - PlusIcon, -} from "lucide-react"; +import type { WorkspaceCreateDomainErrorCode } from "lib/trpc/routers/workspaces/procedures/utils/create-domain-errors"; +import { ArrowUpIcon, ExternalLinkIcon, PaperclipIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GoArrowUpRight, @@ -73,6 +69,7 @@ import { GitHubIssueLinkCommand } from "./components/GitHubIssueLinkCommand"; import { LinkedGitHubIssuePill } from "./components/LinkedGitHubIssuePill"; import { LinkedPRPill } from "./components/LinkedPRPill"; import { PRLinkCommand } from "./components/PRLinkCommand"; +import { resolveBranchRowState } from "./utils/branchRowState/branchRowState"; import type { OpenableWorktreeAction } from "./utils/resolveOpenableWorktrees"; import { resolveOpenableWorktrees } from "./utils/resolveOpenableWorktrees"; @@ -107,6 +104,50 @@ interface PromptGroupProps { onNewProject: () => void; } +function parseWorkspaceCreateDomainError(error: unknown): { + code: WorkspaceCreateDomainErrorCode | null; + message: string; + rawMessage: string; +} { + const fallbackMessage = "Failed to create workspace"; + const rawMessage = error instanceof Error ? error.message : String(error); + const match = /^([A-Z_]+):\s*(.+)$/.exec(rawMessage); + if (!match) { + return { + code: null, + message: rawMessage || fallbackMessage, + rawMessage, + }; + } + + const [, rawCode, domainMessage] = match; + const code = rawCode as WorkspaceCreateDomainErrorCode; + const mappedMessageByCode: Record = { + WORKTREE_ALREADY_EXISTS_FOR_BRANCH: + domainMessage || + "This branch already has a workspace/worktree. Open it instead.", + BRANCH_NOT_FOUND: + domainMessage || + "The selected branch no longer exists. Refresh branches and try again.", + GIT_OPERATION_FAILED: + domainMessage || "Git operation failed. Please try again.", + }; + + if (!(code in mappedMessageByCode)) { + return { + code: null, + message: rawMessage || fallbackMessage, + rawMessage, + }; + } + + return { + code, + message: mappedMessageByCode[code], + rawMessage, + }; +} + export function PromptGroup(props: PromptGroupProps) { return ; } @@ -284,6 +325,7 @@ function CompareBaseBranchPickerInline({ externalWorktreeBranches, modKey, onSelectCompareBaseBranch, + onCreateFromExistingBranch, onOpenWorktree, onOpenActiveWorkspace, }: { @@ -298,12 +340,17 @@ function CompareBaseBranchPickerInline({ externalWorktreeBranches: Set; modKey: string; onSelectCompareBaseBranch: (branchName: string) => void; + onCreateFromExistingBranch: (branchName: string) => void; onOpenWorktree: (action: OpenableWorktreeAction) => void; onOpenActiveWorkspace: (workspaceId: string) => void; }) { const [open, setOpen] = useState(false); const [branchSearch, setBranchSearch] = useState(""); const [filterMode, setFilterMode] = useState<"all" | "worktrees">("all"); + const lastPointerSelectionRef = useRef<{ + branchName: string; + timestamp: number; + } | null>(null); const filteredBranches = useMemo(() => { if (!branches.length) return []; @@ -330,6 +377,7 @@ function CompareBaseBranchPickerInline({ open={open} onOpenChange={(v) => { setOpen(v); + lastPointerSelectionRef.current = null; if (!v) { setBranchSearch(""); setFilterMode("all"); @@ -358,7 +406,29 @@ function CompareBaseBranchPickerInline({ align="start" onWheel={(event) => event.stopPropagation()} > - + { + if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter") { + return; + } + + const selectedItem = event.currentTarget.querySelector( + '[data-selected="true"][data-branch-name]', + ); + const selectedBranchName = selectedItem?.dataset.branchName; + const canCreateFromBranch = + selectedItem?.dataset.canCreate === "true"; + if (!selectedBranchName || !canCreateFromBranch) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onCreateFromExistingBranch(selectedBranchName); + setOpen(false); + }} + >
{(["all", "worktrees"] as const).map((value) => { const count = @@ -396,7 +466,12 @@ function CompareBaseBranchPickerInline({ branch.name, ); const isExternal = externalWorktreeBranches.has(branch.name); - const hasExistingWorkspace = !!(activeWorkspaceId || openAction); + const branchHasWorktree = worktreeBranches.has(branch.name); + const { showOpen, showCreate } = resolveBranchRowState({ + hasActiveWorkspace: !!activeWorkspaceId, + hasOpenableWorktree: !!openAction, + hasBranchWorktree: branchHasWorktree, + }); // Determine icon based on state - all same color let icon: React.ReactNode; @@ -422,13 +497,31 @@ function CompareBaseBranchPickerInline({ { + lastPointerSelectionRef.current = { + branchName: branch.name, + timestamp: Date.now(), + }; + }} onSelect={() => { - if (activeWorkspaceId) { + const lastPointerSelection = + lastPointerSelectionRef.current; + lastPointerSelectionRef.current = null; + const wasPointerActivated = + lastPointerSelection?.branchName === branch.name && + Date.now() - lastPointerSelection.timestamp < 300; + + if ( + wasPointerActivated || + (!activeWorkspaceId && !openAction) + ) { + onSelectCompareBaseBranch(branch.name); + } else if (activeWorkspaceId) { onOpenActiveWorkspace(activeWorkspaceId); } else if (openAction) { onOpenWorktree(openAction); - } else { - onSelectCompareBaseBranch(branch.name); } setOpen(false); }} @@ -464,14 +557,13 @@ function CompareBaseBranchPickerInline({ )} {/* Show checkmark for selected base branch when not hovering */} - {!hasExistingWorkspace && - effectiveCompareBaseBranch === branch.name && ( - - )} + {effectiveCompareBaseBranch === branch.name && ( + + )} {/* Action buttons - show on hover/select */} - {hasExistingWorkspace && ( + {showOpen && ( )} - + {showCreate && ( + + )} @@ -726,6 +808,133 @@ function PromptGroupInner({ [], ); + const prepareLaunchFiles = useCallback( + async ( + detachedFiles: Array<{ + url: string; + mediaType: string; + filename?: string; + }>, + ): Promise => { + let convertedFiles: ConvertedFile[] = []; + if (detachedFiles.length > 0) { + convertedFiles = await Promise.all( + detachedFiles.map(async (file) => ({ + data: await convertBlobUrlToDataUrl(file.url), + mediaType: file.mediaType, + filename: file.filename, + })), + ); + } + + const githubIssues = linkedIssues.filter( + (issue): issue is typeof issue & { number: number } => + issue.source === "github" && typeof issue.number === "number", + ); + if (githubIssues.length === 0 || !projectId) { + return convertedFiles; + } + + try { + const fetchWithTimeout = ( + promise: Promise, + timeoutMs: number, + ): Promise => { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Request timeout")), timeoutMs), + ), + ]); + }; + + const issueContents = await Promise.all( + githubIssues.map(async (issue) => { + try { + const content = await fetchWithTimeout( + utils.client.projects.getIssueContent.query({ + projectId, + issueNumber: issue.number, + }), + 10000, + ); + + const sanitizeText = (str: string) => + str.replace(/[&<>"']/g, (char) => { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return entities[char] || char; + }); + + const sanitizeUrl = (url: string) => { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return "#invalid-url"; + } + return url; + } catch { + return "#invalid-url"; + } + }; + + const MAX_BODY_LENGTH = 50000; + const truncatedBody = + content.body.length > MAX_BODY_LENGTH + ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` + : content.body; + + const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} + +**URL:** ${sanitizeUrl(content.url)} +**State:** ${content.state} +**Author:** ${sanitizeText(content.author || "Unknown")} +**Created:** ${content.createdAt ? new Date(content.createdAt).toLocaleString() : "Unknown"} +**Updated:** ${content.updatedAt ? new Date(content.updatedAt).toLocaleString() : "Unknown"} + +--- + +${sanitizeText(truncatedBody)}`; + + const base64 = btoa( + encodeURIComponent(markdown).replace( + /%([0-9A-F]{2})/g, + (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), + ), + ); + + return { + data: `data:text/markdown;base64,${base64}`, + mediaType: "text/markdown", + filename: `github-issue-${content.number}.md`, + }; + } catch (err) { + console.warn( + `Failed to fetch GitHub issue #${issue.number}:`, + err, + ); + return null; + } + }), + ); + + const validIssueFiles = issueContents.filter( + (file) => file !== null, + ) as ConvertedFile[]; + return [...convertedFiles, ...validIssueFiles]; + } catch (err) { + console.warn("Failed to fetch GitHub issue contents:", err); + return convertedFiles; + } + }, + [convertBlobUrlToDataUrl, linkedIssues, projectId, utils], + ); + const handleCreate = useCallback(async () => { if (!projectId) { toast.error("Select a project first"); @@ -806,137 +1015,14 @@ function PromptGroupInner({ } let convertedFiles: ConvertedFile[] = []; - if (detachedFiles.length > 0) { - try { - convertedFiles = await Promise.all( - detachedFiles.map(async (file) => ({ - data: await convertBlobUrlToDataUrl(file.url), - mediaType: file.mediaType, - filename: file.filename, - })), - ); - } catch (err) { - clearPendingWorkspace(pendingWorkspaceId); - toast.error( - err instanceof Error - ? err.message - : "Failed to process attachments", - ); - return; - } - } - - // Fetch and attach GitHub issue content - const githubIssues = linkedIssues.filter( - (issue): issue is typeof issue & { number: number } => - issue.source === "github" && typeof issue.number === "number", - ); - if (githubIssues.length > 0 && projectId) { - try { - // Helper to add timeout to promises - const fetchWithTimeout = ( - promise: Promise, - timeoutMs: number, - ): Promise => { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout( - () => reject(new Error("Request timeout")), - timeoutMs, - ), - ), - ]); - }; - - const issueContents = await Promise.all( - githubIssues.map(async (issue) => { - try { - const content = await fetchWithTimeout( - utils.client.projects.getIssueContent.query({ - projectId, - issueNumber: issue.number, - }), - 10000, // 10 second timeout per issue - ); - - // Sanitize user-generated content to prevent injection - const sanitizeText = (str: string) => - str.replace(/[&<>"']/g, (char) => { - const entities: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }; - return entities[char] || char; - }); - - const sanitizeUrl = (url: string) => { - try { - const parsed = new URL(url); - // Only allow http/https protocols - if (!["http:", "https:"].includes(parsed.protocol)) { - return "#invalid-url"; - } - return url; - } catch { - return "#invalid-url"; - } - }; - - // Limit body size to prevent memory issues - const MAX_BODY_LENGTH = 50000; // 50KB - const truncatedBody = - content.body.length > MAX_BODY_LENGTH - ? `${content.body.slice(0, MAX_BODY_LENGTH)}\n\n[... content truncated due to length ...]` - : content.body; - - const markdown = `# GitHub Issue #${content.number}: ${sanitizeText(content.title)} - -**URL:** ${sanitizeUrl(content.url)} -**State:** ${content.state} -**Author:** ${sanitizeText(content.author || "Unknown")} -**Created:** ${content.createdAt ? new Date(content.createdAt).toLocaleString() : "Unknown"} -**Updated:** ${content.updatedAt ? new Date(content.updatedAt).toLocaleString() : "Unknown"} - ---- - -${sanitizeText(truncatedBody)}`; - - // Convert markdown to base64 data URL - const base64 = btoa( - encodeURIComponent(markdown).replace( - /%([0-9A-F]{2})/g, - (_, p1) => String.fromCharCode(Number.parseInt(p1, 16)), - ), - ); - - return { - data: `data:text/markdown;base64,${base64}`, - mediaType: "text/markdown", - filename: `github-issue-${content.number}.md`, - }; - } catch (err) { - console.warn( - `Failed to fetch GitHub issue #${issue.number}:`, - err, - ); - return null; - } - }), - ); - - // Add successfully fetched issues to convertedFiles - const validIssueFiles = issueContents.filter( - (file) => file !== null, - ) as ConvertedFile[]; - convertedFiles = [...convertedFiles, ...validIssueFiles]; - } catch (err) { - console.warn("Failed to fetch GitHub issue contents:", err); - // Don't block workspace creation if issue fetching fails - } + try { + convertedFiles = await prepareLaunchFiles(detachedFiles); + } catch (err) { + clearPendingWorkspace(pendingWorkspaceId); + toast.error( + err instanceof Error ? err.message : "Failed to process attachments", + ); + return; } let launchRequest: AgentLaunchRequest | null = null; @@ -998,6 +1084,7 @@ ${sanitizeText(truncatedBody)}`; ) : aiBranchName) || undefined, compareBaseBranch: compareBaseBranch || undefined, + intent: "create_new_branch", }, { agentLaunchRequest: launchRequest ?? undefined, @@ -1031,19 +1118,17 @@ ${sanitizeText(truncatedBody)}`; buildLaunchRequest, closeAndResetDraft, clearPendingWorkspace, - convertBlobUrlToDataUrl, createFromPr, createWorkspace, generateBranchNameMutation, - linkedIssues, linkedPR, + prepareLaunchFiles, projectId, runAsyncAction, runSetupScript, setPendingWorkspace, setPendingWorkspaceStatus, trimmedPrompt, - utils, workspaceName, workspaceNameEdited, ]); @@ -1052,6 +1137,100 @@ ${sanitizeText(truncatedBody)}`; void handleCreate(); }, [handleCreate]); + const handleCreateFromExistingBranch = useCallback( + async (selectedBranchName: string) => { + if (!projectId) { + toast.error("Select a project first"); + return; + } + + const detachedFiles = attachments.takeFiles(); + try { + let convertedFiles: ConvertedFile[] = []; + try { + convertedFiles = await prepareLaunchFiles(detachedFiles); + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : "Failed to process attachments", + ); + return; + } + + let launchRequest: AgentLaunchRequest | null = null; + try { + launchRequest = buildLaunchRequest( + trimmedPrompt, + convertedFiles.length > 0 ? convertedFiles : undefined, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to prepare agent launch", + ); + return; + } + + void runAsyncAction( + createWorkspace.mutateAsyncWithPendingSetup( + { + projectId, + branchName: selectedBranchName, + useExistingBranch: true, + name: + workspaceNameEdited && workspaceName.trim() + ? workspaceName.trim() + : undefined, + prompt: trimmedPrompt || undefined, + compareBaseBranch: compareBaseBranch || undefined, + intent: "create_from_existing_branch", + }, + { + agentLaunchRequest: launchRequest ?? undefined, + resolveInitialCommands: runSetupScript + ? (commands) => commands + : () => null, + }, + ), + { + loading: "Creating workspace from existing branch...", + success: "Workspace created", + error: (err) => { + const parsed = parseWorkspaceCreateDomainError(err); + console.warn("[PromptGroup] Create from existing branch failed", { + branch: selectedBranchName, + code: parsed.code ?? "UNKNOWN", + error: parsed.rawMessage, + }); + return parsed.message; + }, + }, + ); + } finally { + for (const file of detachedFiles) { + if (file.url?.startsWith("blob:")) { + URL.revokeObjectURL(file.url); + } + } + } + }, + [ + attachments, + compareBaseBranch, + createWorkspace, + buildLaunchRequest, + prepareLaunchFiles, + projectId, + runAsyncAction, + runSetupScript, + trimmedPrompt, + workspaceName, + workspaceNameEdited, + ], + ); + useEffect(() => { if (!isNewWorkspaceModalOpen) return; const handler = (e: KeyboardEvent) => { @@ -1397,6 +1576,7 @@ ${sanitizeText(truncatedBody)}`; externalWorktreeBranches={externalWorktreeBranches} modKey={modKey} onSelectCompareBaseBranch={handleCompareBaseBranchSelect} + onCreateFromExistingBranch={handleCreateFromExistingBranch} onOpenWorktree={handleOpenWorktree} onOpenActiveWorkspace={handleOpenActiveWorkspace} /> diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/utils/branchRowState/branchRowState.test.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/utils/branchRowState/branchRowState.test.ts new file mode 100644 index 00000000000..f27f9e45605 --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/utils/branchRowState/branchRowState.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { resolveBranchRowState } from "./branchRowState"; + +describe("resolveBranchRowState", () => { + test("shows Create and hides Open when branch has no worktree", () => { + expect( + resolveBranchRowState({ + hasActiveWorkspace: false, + hasOpenableWorktree: false, + hasBranchWorktree: false, + }), + ).toEqual({ + showOpen: false, + showCreate: true, + }); + }); + + test("shows Open and hides Create when branch already has worktree", () => { + expect( + resolveBranchRowState({ + hasActiveWorkspace: true, + hasOpenableWorktree: false, + hasBranchWorktree: true, + }), + ).toEqual({ + showOpen: true, + showCreate: false, + }); + }); + + test("shows Open for openable worktree even without active workspace", () => { + expect( + resolveBranchRowState({ + hasActiveWorkspace: false, + hasOpenableWorktree: true, + hasBranchWorktree: true, + }), + ).toEqual({ + showOpen: true, + showCreate: false, + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/utils/branchRowState/branchRowState.ts b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/utils/branchRowState/branchRowState.ts new file mode 100644 index 00000000000..fd6a497e3f4 --- /dev/null +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/utils/branchRowState/branchRowState.ts @@ -0,0 +1,20 @@ +export interface BranchRowStateInput { + hasActiveWorkspace: boolean; + hasOpenableWorktree: boolean; + hasBranchWorktree: boolean; +} + +export interface BranchRowStateOutput { + showOpen: boolean; + showCreate: boolean; +} + +export function resolveBranchRowState({ + hasActiveWorkspace, + hasOpenableWorktree, + hasBranchWorktree, +}: BranchRowStateInput): BranchRowStateOutput { + const showOpen = hasActiveWorkspace || hasOpenableWorktree; + const showCreate = !hasBranchWorktree; + return { showOpen, showCreate }; +}