diff --git a/apps/web/client/src/server/api/routers/project/branch.ts b/apps/web/client/src/server/api/routers/project/branch.ts index af0184af7f..506e3ffcea 100644 --- a/apps/web/client/src/server/api/routers/project/branch.ts +++ b/apps/web/client/src/server/api/routers/project/branch.ts @@ -86,6 +86,10 @@ export const branchRouter = createTRPCRouter({ .input( z.object({ branchId: z.uuid(), + positionOverride: z.object({ + x: z.number(), + y: z.number(), + }).optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -178,7 +182,7 @@ export const branchRouter = createTRPCRouter({ id: uuidv4(), branchId: newBranchId, canvasId: canvas.id, - position: { + position: input.positionOverride ?? { x: baseX + frameWidth + 100, // Initial offset to the right y: baseY, }, diff --git a/packages/ai/src/agents/tool-lookup.ts b/packages/ai/src/agents/tool-lookup.ts index dc3dc564ad..4d672cf46a 100644 --- a/packages/ai/src/agents/tool-lookup.ts +++ b/packages/ai/src/agents/tool-lookup.ts @@ -1,4 +1,4 @@ -import { BashEditTool, BashReadTool, CheckErrorsTool, FuzzyEditFileTool, GlobTool, GrepTool, ListBranchesTool, ListFilesTool, OnlookInstructionsTool, ReadFileTool, ReadStyleGuideTool, SandboxTool, ScrapeUrlTool, SearchReplaceEditTool, SearchReplaceMultiEditFileTool, TerminalCommandTool, TypecheckTool, WebSearchTool, WriteFileTool } from "../tools"; +import { ArrangeBranchesTool, BashEditTool, BashReadTool, CheckErrorsTool, FuzzyEditFileTool, GlobTool, GrepTool, ListBranchesTool, ListFilesTool, OnlookInstructionsTool, ReadFileTool, ReadStyleGuideTool, SandboxTool, ScrapeUrlTool, SearchReplaceEditTool, SearchReplaceMultiEditFileTool, TerminalCommandTool, TypecheckTool, WebSearchTool, WriteFileTool } from "../tools"; export const allTools = [ ListFilesTool, @@ -7,6 +7,7 @@ export const allTools = [ OnlookInstructionsTool, ReadStyleGuideTool, ListBranchesTool, + ArrangeBranchesTool, ScrapeUrlTool, WebSearchTool, GlobTool, @@ -37,6 +38,7 @@ export const readOnlyRootTools = [ CheckErrorsTool, ] const editOnlyRootTools = [ + ArrangeBranchesTool, SearchReplaceEditTool, SearchReplaceMultiEditFileTool, FuzzyEditFileTool, diff --git a/packages/ai/src/tools/classes/arrange-branches.ts b/packages/ai/src/tools/classes/arrange-branches.ts new file mode 100644 index 0000000000..e79c2065f9 --- /dev/null +++ b/packages/ai/src/tools/classes/arrange-branches.ts @@ -0,0 +1,584 @@ +import type { Branch, Frame, Positionable } from '@onlook/models'; +import { Icons } from '@onlook/ui/icons'; +import { calculateNonOverlappingPosition } from '@onlook/utility/src/position'; +import type { EditorEngine } from '@onlook/web-client/src/components/store/editor/engine'; +import { z } from 'zod'; +import { ClientTool } from '../models/client'; + +interface BranchWithFrames { + branch: Branch; + frames: Frame[]; + primaryFrame: Frame; + relativeOffsets: Array<{ frameId: string; offset: { x: number; y: number } }>; +} + +interface FuzzyMatchResult { + branch: Branch; + score: number; +} + +/** + * Calculate fuzzy match score between search term and branch name/description + */ +function calculateFuzzyScore(searchTerm: string, branch: Branch): number { + const searchLower = searchTerm.toLowerCase(); + const nameLower = branch.name.toLowerCase(); + const descLower = branch.description?.toLowerCase() || ''; + + let score = 0; + + // Exact match gets highest score + if (nameLower === searchLower) { + score += 100; + } + // Starts with gets high score + else if (nameLower.startsWith(searchLower)) { + score += 80; + } + // Contains gets medium score + else if (nameLower.includes(searchLower)) { + score += 60; + } + // Search term contains branch name (reverse match) + else if (searchLower.includes(nameLower)) { + score += 40; + } + + // Description matches get lower weight + if (descLower.includes(searchLower)) { + score += 20; + } + + // Partial word matches + const searchWords = searchLower.split(/\s+/); + const nameWords = nameLower.split(/\s+/); + for (const searchWord of searchWords) { + if (nameWords.some(nameWord => nameWord.includes(searchWord) || searchWord.includes(nameWord))) { + score += 10; + } + } + + return score; +} + +/** + * Find branches by fuzzy matching names/descriptions + */ +function findBranchesByFuzzyMatch( + searchTerms: string[], + allBranches: Branch[], + confidenceThreshold: number = 0.7, +): { matches: Branch[]; ambiguous: Array<{ term: string; candidates: FuzzyMatchResult[] }> } { + const matches: Branch[] = []; + const ambiguous: Array<{ term: string; candidates: FuzzyMatchResult[] }> = []; + + for (const term of searchTerms) { + const scored: FuzzyMatchResult[] = allBranches.map(branch => ({ + branch, + score: calculateFuzzyScore(term, branch), + })); + + // Sort by score descending + scored.sort((a, b) => b.score - a.score); + + // Normalize score to 0-1 range (assuming max score is around 100) + const maxScore = scored[0]?.score || 0; + const normalizedScore = maxScore > 0 ? maxScore / 100 : 0; + + if (normalizedScore >= confidenceThreshold && scored[0]) { + matches.push(scored[0].branch); + } else if (scored.length > 0 && scored[0]!.score > 0) { + // Ambiguous - multiple candidates + ambiguous.push({ + term, + candidates: scored.filter(s => s.score > 0).slice(0, 5), // Top 5 candidates + }); + } + } + + return { matches, ambiguous }; +} + +/** + * Get workspace bounds (10x canvas size centered around middle) + * Uses existing frames to determine reasonable bounds + */ +function getWorkspaceBounds(allFrames: Frame[]): { minX: number; minY: number; maxX: number; maxY: number } { + if (allFrames.length === 0) { + // Default bounds: 10000x10000 centered at origin + const size = 10000; + return { + minX: -size / 2, + minY: -size / 2, + maxX: size / 2, + maxY: size / 2, + }; + } + + // Find bounds of all frames + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const frame of allFrames) { + const frameRight = frame.position.x + frame.dimension.width; + const frameBottom = frame.position.y + frame.dimension.height; + + minX = Math.min(minX, frame.position.x); + minY = Math.min(minY, frame.position.y); + maxX = Math.max(maxX, frameRight); + maxY = Math.max(maxY, frameBottom); + } + + // Calculate center + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // Calculate size (use 10x the current span) + const spanX = maxX - minX || 2000; + const spanY = maxY - minY || 2000; + const sizeX = Math.max(spanX * 10, 10000); + const sizeY = Math.max(spanY * 10, 10000); + + return { + minX: centerX - sizeX / 2, + minY: centerY - sizeY / 2, + maxX: centerX + sizeX / 2, + maxY: centerY + sizeY / 2, + }; +} + +/** + * Constrain position to workspace bounds + */ +function constrainToBounds( + position: { x: number; y: number }, + dimension: { width: number; height: number }, + bounds: { minX: number; minY: number; maxX: number; maxY: number }, +): { x: number; y: number } { + let x = position.x; + let y = position.y; + + // Constrain X + if (x < bounds.minX) { + x = bounds.minX; + } else if (x + dimension.width > bounds.maxX) { + x = bounds.maxX - dimension.width; + } + + // Constrain Y + if (y < bounds.minY) { + y = bounds.minY; + } else if (y + dimension.height > bounds.maxY) { + y = bounds.maxY - dimension.height; + } + + return { x, y }; +} + +/** + * Sort branches by current position (left-to-right, top-to-bottom) + */ +function sortBranchesByPosition(branches: BranchWithFrames[]): BranchWithFrames[] { + return [...branches].sort((a, b) => { + const posA = a.primaryFrame.position; + const posB = b.primaryFrame.position; + + // Primary: Y position (top to bottom) + if (Math.abs(posA.y - posB.y) > 50) { + return posA.y - posB.y; + } + // Secondary: X position (left to right) + return posA.x - posB.x; + }); +} + +export class ArrangeBranchesTool extends ClientTool { + static readonly toolName = 'arrange_branches'; + static readonly description = 'Arrange branches on the canvas based on their characteristics. Supports relative positioning with collision detection. Branches can be identified by selection, IDs, or fuzzy name matching.'; + static readonly parameters = z.object({ + branchIds: z.array(z.string().uuid()).optional().describe('Array of branch UUIDs to arrange'), + branchNames: z.array(z.string()).optional().describe('Array of branch names or descriptions to arrange (supports fuzzy matching)'), + relativeTo: z.string().optional().describe('Branch ID, name, or description to position branches relative to (supports fuzzy matching)'), + direction: z.enum(['horizontal', 'vertical']).optional().default('horizontal').describe('Direction to arrange branches: horizontal (left-to-right) or vertical (top-to-bottom)'), + spacing: z.number().optional().default(100).describe('Minimum spacing between branches in pixels'), + }); + static readonly icon = Icons.Layout; + + async handle( + args: z.infer, + editorEngine: EditorEngine, + ): Promise { + // Step 1: Branch Identification + const selectedFrames = editorEngine.frames.selected; + let targetBranchIds: Set = new Set(); + + // Prefer selected frames + if (selectedFrames.length > 0) { + for (const frameData of selectedFrames) { + targetBranchIds.add(frameData.frame.branchId); + } + } + + // Fall back to branchIds parameter + if (targetBranchIds.size === 0 && args.branchIds && args.branchIds.length > 0) { + targetBranchIds = new Set(args.branchIds); + } + + // Fall back to branchNames with fuzzy matching + if (targetBranchIds.size === 0 && args.branchNames && args.branchNames.length > 0) { + const allBranches = editorEngine.branches.allBranches; + const fuzzyResult = findBranchesByFuzzyMatch(args.branchNames, allBranches); + + if (fuzzyResult.ambiguous.length > 0) { + const ambiguousMessages = fuzzyResult.ambiguous.map( + ({ term, candidates }) => + `Ambiguous match for "${term}": ${candidates.map(c => `"${c.branch.name}" (score: ${c.score.toFixed(2)})`).join(', ')}`, + ); + throw new Error( + `Could not uniquely identify branches. ${ambiguousMessages.join('; ')}. Please provide more specific names or use branch IDs.`, + ); + } + + if (fuzzyResult.matches.length === 0) { + const availableNames = allBranches.map(b => b.name).join(', '); + throw new Error( + `No branches found matching: ${args.branchNames.join(', ')}. Available branches: ${availableNames}`, + ); + } + + targetBranchIds = new Set(fuzzyResult.matches.map(b => b.id)); + } + + // Validate branches exist + const targetBranches: Branch[] = []; + for (const branchId of targetBranchIds) { + const branch = editorEngine.branches.getBranchById(branchId); + if (!branch) { + throw new Error(`Branch with ID ${branchId} not found.`); + } + targetBranches.push(branch); + } + + if (targetBranches.length === 0) { + throw new Error('No branches selected or specified. Please select branches or provide branchIds/branchNames.'); + } + + if (targetBranches.length === 1) { + return 'Only one branch specified. Nothing to arrange.'; + } + + // Step 2: Get all frames for each branch + const branchesWithFrames: BranchWithFrames[] = []; + const allFrames = editorEngine.frames.getAll(); + + for (const branch of targetBranches) { + const branchFrames = editorEngine.frames.getByBranchId(branch.id); + if (branchFrames.length === 0) { + continue; // Skip branches without frames + } + + const frames = branchFrames.map(fd => fd.frame); + const primaryFrame = frames[0]!; + + // Calculate relative offsets for all frames from primary frame + const relativeOffsets = frames.map(frame => ({ + frameId: frame.id, + offset: { + x: frame.position.x - primaryFrame.position.x, + y: frame.position.y - primaryFrame.position.y, + }, + })); + + branchesWithFrames.push({ + branch, + frames, + primaryFrame, + relativeOffsets, + }); + } + + if (branchesWithFrames.length === 0) { + throw new Error('No branches with frames found to arrange.'); + } + + // Step 3: Handle relativeTo branch + let referenceBranch: BranchWithFrames | null = null; + if (args.relativeTo) { + // Try exact ID match first + const refBranch = editorEngine.branches.getBranchById(args.relativeTo); + if (refBranch) { + const refFrames = editorEngine.frames.getByBranchId(refBranch.id); + if (refFrames.length > 0) { + referenceBranch = { + branch: refBranch, + frames: refFrames.map(fd => fd.frame), + primaryFrame: refFrames[0]!.frame, + relativeOffsets: [], + }; + } + } + + // Try fuzzy match + if (!referenceBranch) { + const allBranches = editorEngine.branches.allBranches; + const fuzzyResult = findBranchesByFuzzyMatch([args.relativeTo], allBranches); + if (fuzzyResult.matches.length > 0) { + const refBranch = fuzzyResult.matches[0]; + if (refBranch) { + const refFrames = editorEngine.frames.getByBranchId(refBranch.id); + if (refFrames.length > 0) { + referenceBranch = { + branch: refBranch, + frames: refFrames.map(fd => fd.frame), + primaryFrame: refFrames[0]!.frame, + relativeOffsets: [], + }; + } + } + } + } + + if (!referenceBranch) { + throw new Error(`Reference branch "${args.relativeTo}" not found or has no frames.`); + } + } + + // Step 4: Get workspace bounds + const workspaceBounds = getWorkspaceBounds(allFrames.map(fd => fd.frame)); + + // Step 5: Calculate positions + // Sort branches by current position (default behavior) + const sortedBranches = sortBranchesByPosition(branchesWithFrames); + + // Get all existing frames for collision detection (excluding frames we're moving) + const framesToMoveIds = new Set(branchesWithFrames.flatMap(bwf => bwf.frames.map(f => f.id))); + const existingFramesForCollision: Positionable[] = allFrames + .map(fd => fd.frame) + .filter(frame => !framesToMoveIds.has(frame.id)) + .map(frame => ({ + id: frame.id, + position: frame.position, + dimension: frame.dimension, + })); + + const newPositions: Array<{ branchId: string; framePositions: Array<{ frameId: string; position: { x: number; y: number } }> }> = []; + const spacing = args.spacing ?? 100; + + if (referenceBranch) { + // Position relative to reference branch + const refPos = referenceBranch.primaryFrame.position; + const refDim = referenceBranch.primaryFrame.dimension; + const direction = args.direction ?? 'horizontal'; + + // Track position for sequential arrangement + let currentPos = refPos; + let currentDim = refDim; + + for (const branchWithFrames of sortedBranches) { + // Skip reference branch itself + if (branchWithFrames.branch.id === referenceBranch.branch.id) { + continue; + } + + const primaryDim = branchWithFrames.primaryFrame.dimension; + let proposedPosition: { x: number; y: number }; + + if (direction === 'horizontal') { + proposedPosition = { + x: currentPos.x + currentDim.width + spacing, + y: currentPos.y, + }; + } else { + proposedPosition = { + x: currentPos.x, + y: currentPos.y + currentDim.height + spacing, + }; + } + + // Constrain to bounds + proposedPosition = constrainToBounds(proposedPosition, primaryDim, workspaceBounds); + + // Calculate non-overlapping position + const nonOverlappingPos = calculateNonOverlappingPosition( + { + id: branchWithFrames.primaryFrame.id, + position: proposedPosition, + dimension: primaryDim, + }, + [...existingFramesForCollision, ...newPositions.flatMap(np => { + // Include already-positioned frames in collision detection + const positionedBranch = branchesWithFrames.find(bwf => bwf.branch.id === np.branchId); + if (!positionedBranch) return []; + return np.framePositions.map(fp => ({ + id: fp.frameId, + position: fp.position, + dimension: positionedBranch.frames.find(f => f.id === fp.frameId)!.dimension, + })); + })], + spacing, + ); + + // Constrain again after collision detection + const finalPosition = constrainToBounds(nonOverlappingPos, primaryDim, workspaceBounds); + + // Calculate positions for all frames in branch + const framePositions = branchWithFrames.frames.map(frame => ({ + frameId: frame.id, + position: { + x: finalPosition.x + branchWithFrames.relativeOffsets.find(ro => ro.frameId === frame.id)!.offset.x, + y: finalPosition.y + branchWithFrames.relativeOffsets.find(ro => ro.frameId === frame.id)!.offset.y, + }, + })); + + newPositions.push({ + branchId: branchWithFrames.branch.id, + framePositions, + }); + + // Add positioned frames to collision context + existingFramesForCollision.push(...framePositions.map(fp => ({ + id: fp.frameId, + position: fp.position, + dimension: branchWithFrames.frames.find(f => f.id === fp.frameId)!.dimension, + }))); + + // Update current position for next iteration + currentPos = finalPosition; + currentDim = primaryDim; + } + } else { + // Arrange branches relative to each other + const direction = args.direction ?? 'horizontal'; + + // Use first branch as anchor + const anchorBranch = sortedBranches[0]!; + let anchorPos = anchorBranch.primaryFrame.position; + let currentOffset = 0; + + // Position anchor branch (may need to adjust if it overlaps) + const anchorProposedPos = calculateNonOverlappingPosition( + { + id: anchorBranch.primaryFrame.id, + position: anchorPos, + dimension: anchorBranch.primaryFrame.dimension, + }, + existingFramesForCollision, + spacing, + ); + const anchorFinalPos = constrainToBounds(anchorProposedPos, anchorBranch.primaryFrame.dimension, workspaceBounds); + + const anchorFramePositions = anchorBranch.frames.map(frame => ({ + frameId: frame.id, + position: { + x: anchorFinalPos.x + anchorBranch.relativeOffsets.find(ro => ro.frameId === frame.id)!.offset.x, + y: anchorFinalPos.y + anchorBranch.relativeOffsets.find(ro => ro.frameId === frame.id)!.offset.y, + }, + })); + + newPositions.push({ + branchId: anchorBranch.branch.id, + framePositions: anchorFramePositions, + }); + + // Add anchor frames to collision context + existingFramesForCollision.push(...anchorFramePositions.map(fp => ({ + id: fp.frameId, + position: fp.position, + dimension: anchorBranch.frames.find(f => f.id === fp.frameId)!.dimension, + }))); + + // Track previous branch position for relative positioning + let previousBranchPos = anchorFinalPos; + let previousBranchDim = anchorBranch.primaryFrame.dimension; + + // Position remaining branches + for (let i = 1; i < sortedBranches.length; i++) { + const branchWithFrames = sortedBranches[i]!; + const primaryDim = branchWithFrames.primaryFrame.dimension; + let proposedPosition: { x: number; y: number }; + + if (direction === 'horizontal') { + proposedPosition = { + x: previousBranchPos.x + previousBranchDim.width + spacing, + y: previousBranchPos.y, + }; + } else { + proposedPosition = { + x: previousBranchPos.x, + y: previousBranchPos.y + previousBranchDim.height + spacing, + }; + } + + // Constrain to bounds + proposedPosition = constrainToBounds(proposedPosition, primaryDim, workspaceBounds); + + // Calculate non-overlapping position + const nonOverlappingPos = calculateNonOverlappingPosition( + { + id: branchWithFrames.primaryFrame.id, + position: proposedPosition, + dimension: primaryDim, + }, + existingFramesForCollision, + spacing, + ); + + // Constrain again + const finalPosition = constrainToBounds(nonOverlappingPos, primaryDim, workspaceBounds); + + // Calculate positions for all frames in branch + const framePositions = branchWithFrames.frames.map(frame => ({ + frameId: frame.id, + position: { + x: finalPosition.x + branchWithFrames.relativeOffsets.find(ro => ro.frameId === frame.id)!.offset.x, + y: finalPosition.y + branchWithFrames.relativeOffsets.find(ro => ro.frameId === frame.id)!.offset.y, + }, + })); + + newPositions.push({ + branchId: branchWithFrames.branch.id, + framePositions, + }); + + // Add positioned frames to collision context + existingFramesForCollision.push(...framePositions.map(fp => ({ + id: fp.frameId, + position: fp.position, + dimension: branchWithFrames.frames.find(f => f.id === fp.frameId)!.dimension, + }))); + + // Update previous branch position for next iteration + previousBranchPos = finalPosition; + previousBranchDim = primaryDim; + } + } + + // Step 6: Update frame positions + const updatePromises: Promise[] = []; + for (const { framePositions } of newPositions) { + for (const { frameId, position } of framePositions) { + updatePromises.push( + editorEngine.frames.updateAndSaveToStorage(frameId, { position }), + ); + } + } + + await Promise.all(updatePromises); + + // Format response + const branchNames = newPositions.map(np => { + const branch = branchesWithFrames.find(bwf => bwf.branch.id === np.branchId); + return branch?.branch.name || np.branchId; + }); + + return `Successfully arranged ${newPositions.length} branch${newPositions.length > 1 ? 'es' : ''}: ${branchNames.join(', ')}. All frames moved together maintaining relative positions, with no overlaps.`; + } + + static getLabel(input?: z.infer): string { + const count = input?.branchIds?.length || input?.branchNames?.length || 0; + return `Arranging ${count > 0 ? count : 'selected'} branch${count !== 1 ? 'es' : ''}`; + } +} + diff --git a/packages/ai/src/tools/classes/create-multiple-branches.ts b/packages/ai/src/tools/classes/create-multiple-branches.ts new file mode 100644 index 0000000000..e24c4d9d5f --- /dev/null +++ b/packages/ai/src/tools/classes/create-multiple-branches.ts @@ -0,0 +1,196 @@ +import { api } from '@onlook/web-client/src/trpc/client'; +import { CodeFileSystem } from '@onlook/file-system'; +import { Icons } from '@onlook/ui/icons'; +import type { EditorEngine } from '@onlook/web-client/src/components/store/editor/engine'; +import { ErrorManager } from '@onlook/web-client/src/components/store/editor/error'; +import { HistoryManager } from '@onlook/web-client/src/components/store/editor/history'; +import { SandboxManager } from '@onlook/web-client/src/components/store/editor/sandbox'; +import { z } from 'zod'; +import { ClientTool } from '../models/client'; + +interface BranchCreationResult { + branchId: string; + branchName: string; + ready: boolean; + error?: string; +} + +export class CreateMultipleBranchesTool extends ClientTool { + static readonly toolName = 'create_multiple_branches'; + static readonly description = 'Create multiple duplicate branches of a selected branch, positioned side-by-side underneath the source branch. Useful for creating multiple variations or versions of a branch to compare different implementations. All branches will be created and their sandboxes will be initialized before returning.'; + static readonly parameters = z.object({ + branchId: z.string().uuid().optional().describe('The branch ID to fork. If not provided, uses the currently active branch.'), + count: z.number().int().min(1).max(5).describe('Number of branches to create (1-5). Maximum of 5 branches to limit resource usage.'), + }); + static readonly icon = Icons.Branch; + + async handle( + args: z.infer, + editorEngine: EditorEngine, + ): Promise { + // Determine source branch + let sourceBranchId: string; + if (args.branchId) { + sourceBranchId = args.branchId; + } else { + const activeBranch = editorEngine.branches.activeBranch; + if (!activeBranch) { + throw new Error('No branch is currently selected. Please select a branch first or provide a branchId parameter.'); + } + sourceBranchId = activeBranch.id; + } + + // Verify source branch exists + const sourceBranch = editorEngine.branches.getBranchById(sourceBranchId); + if (!sourceBranch) { + throw new Error(`Branch with ID ${sourceBranchId} not found.`); + } + + // Get source branch frame for positioning reference + const sourceFrames = editorEngine.frames.getAll().filter( + frameData => frameData.frame.branchId === sourceBranchId + ); + + if (sourceFrames.length === 0) { + throw new Error(`Source branch "${sourceBranch.name}" has no frames. Cannot determine positioning.`); + } + + const sourceFrame = sourceFrames[0]!.frame; + const frameWidth = sourceFrame.dimension.width; + const frameHeight = sourceFrame.dimension.height; + const sourceX = sourceFrame.position.x; + const sourceY = sourceFrame.position.y; + + // Calculate positions for branches in horizontal row underneath source + const SPACING = 100; + const baseY = sourceY + frameHeight + SPACING; + const positions: Array<{ x: number; y: number }> = []; + + for (let i = 0; i < args.count; i++) { + positions.push({ + x: sourceX + (i * (frameWidth + SPACING)), + y: baseY, + }); + } + + // Store original active branch ID to preserve it + const originalActiveBranchId = editorEngine.branches.activeBranch.id; + + // Create branches sequentially + const results: BranchCreationResult[] = []; + const errors: string[] = []; + + for (let i = 0; i < args.count; i++) { + try { + const result = await api.branch.fork.mutate({ + branchId: sourceBranchId, + positionOverride: positions[i], + }); + + // Add the new branch to the local branch map + // Replicate the logic from BranchManager.createBranchData (which is private) + const codeEditorApi = new CodeFileSystem(editorEngine.projectId, result.branch.id); + const errorManager = new ErrorManager(result.branch); + const sandboxManager = new SandboxManager(result.branch, editorEngine, errorManager, codeEditorApi); + const historyManager = new HistoryManager(editorEngine); + + const branchData = { + branch: result.branch, + sandbox: sandboxManager, + history: historyManager, + error: errorManager, + codeEditor: codeEditorApi, + }; + + // Access the private branchMap to add the branch data + (editorEngine.branches as any).branchMap.set(result.branch.id, branchData); + + await branchData.codeEditor.initialize(); + await branchData.sandbox.init(); + + // Add the created frames to the frame manager + if (result.frames && result.frames.length > 0) { + editorEngine.frames.applyFrames(result.frames); + } + + results.push({ + branchId: result.branch.id, + branchName: result.branch.name, + ready: false, // Will be updated after sandbox check + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + errors.push(`Failed to create branch ${i + 1}: ${errorMessage}`); + results.push({ + branchId: '', + branchName: `Failed branch ${i + 1}`, + ready: false, + error: errorMessage, + }); + } + } + + // Wait for all sandboxes to be ready + const MAX_WAIT_TIME = 60000; // 60 seconds per sandbox + const POLL_INTERVAL = 500; // Check every 500ms + + for (let i = 0; i < results.length; i++) { + const result = results[i]!; + if (result.error) { + continue; // Skip failed branches + } + + let ready = false; + const branchStartTime = Date.now(); + + while (!ready && (Date.now() - branchStartTime) < MAX_WAIT_TIME) { + const branchData = editorEngine.branches.getBranchDataById(result.branchId); + if (branchData?.sandbox?.session.provider) { + ready = true; + result.ready = true; + break; + } + // Wait before next check + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL)); + } + + if (!ready) { + result.error = `Sandbox initialization timeout after ${MAX_WAIT_TIME / 1000}s`; + } + } + + // Ensure original active branch is still selected + if (editorEngine.branches.activeBranch.id !== originalActiveBranchId) { + await editorEngine.branches.switchToBranch(originalActiveBranchId); + } + + // Format response + const successfulBranches = results.filter(r => r.ready); + const failedBranches = results.filter(r => !r.ready || r.error); + + let response = `Created ${successfulBranches.length} of ${args.count} branches from "${sourceBranch.name}":\n\n`; + + successfulBranches.forEach((result, index) => { + response += `${index + 1}. "${result.branchName}" (ID: ${result.branchId}) - Ready\n`; + }); + + if (failedBranches.length > 0) { + response += `\nFailed branches:\n`; + failedBranches.forEach((result, index) => { + response += `${index + 1}. "${result.branchName}" - ${result.error || 'Not ready'}\n`; + }); + } + + if (errors.length > 0) { + response += `\nErrors encountered:\n${errors.join('\n')}`; + } + + return response; + } + + static getLabel(input?: z.infer): string { + const count = input?.count ?? 1; + return `Creating ${count} branch${count > 1 ? 'es' : ''}`; + } +} + diff --git a/packages/ai/src/tools/classes/index.ts b/packages/ai/src/tools/classes/index.ts index f564d45ecf..93e0d924c3 100644 --- a/packages/ai/src/tools/classes/index.ts +++ b/packages/ai/src/tools/classes/index.ts @@ -1,6 +1,8 @@ +export { ArrangeBranchesTool } from './arrange-branches'; export { BashEditTool } from './bash-edit'; export { BashReadTool } from './bash-read'; export { CheckErrorsTool } from './check-errors'; +export { CreateMultipleBranchesTool } from './create-multiple-branches'; export { FuzzyEditFileTool } from './fuzzy-edit-file'; export { GlobTool } from './glob'; export { GrepTool } from './grep'; diff --git a/packages/ai/src/tools/toolset.ts b/packages/ai/src/tools/toolset.ts index b81bdb59b4..d60824b848 100644 --- a/packages/ai/src/tools/toolset.ts +++ b/packages/ai/src/tools/toolset.ts @@ -1,9 +1,11 @@ import { ChatType } from '@onlook/models'; import { type InferUITools, type ToolSet } from 'ai'; import { + ArrangeBranchesTool, BashEditTool, BashReadTool, CheckErrorsTool, + CreateMultipleBranchesTool, FuzzyEditFileTool, GlobTool, GrepTool, @@ -47,6 +49,7 @@ const readOnlyToolClasses = [ CheckErrorsTool, ]; const editOnlyToolClasses = [ + ArrangeBranchesTool, SearchReplaceEditTool, SearchReplaceMultiEditFileTool, FuzzyEditFileTool, @@ -55,6 +58,7 @@ const editOnlyToolClasses = [ SandboxTool, TerminalCommandTool, UploadImageTool, + CreateMultipleBranchesTool, ]; const allToolClasses = [...readOnlyToolClasses, ...editOnlyToolClasses]; diff --git a/packages/ai/test/tools/create-multiple-branches.test.ts b/packages/ai/test/tools/create-multiple-branches.test.ts new file mode 100644 index 0000000000..499b372ee7 --- /dev/null +++ b/packages/ai/test/tools/create-multiple-branches.test.ts @@ -0,0 +1,48 @@ +import { CreateMultipleBranchesTool } from '@onlook/ai/src/tools/classes/create-multiple-branches'; +import { describe, expect, test } from 'bun:test'; + +describe('CreateMultipleBranchesTool', () => { + test('should have correct tool name and description', () => { + expect(CreateMultipleBranchesTool.toolName).toBe('create_multiple_branches'); + expect(CreateMultipleBranchesTool.description).toContain('multiple duplicate branches'); + }); + + test('should validate count parameter (1-5)', () => { + const schema = CreateMultipleBranchesTool.parameters; + + // Valid counts + expect(() => schema.parse({ count: 1 })).not.toThrow(); + expect(() => schema.parse({ count: 5 })).not.toThrow(); + expect(() => schema.parse({ count: 3 })).not.toThrow(); + + // Invalid counts + expect(() => schema.parse({ count: 0 })).toThrow(); + expect(() => schema.parse({ count: 6 })).toThrow(); + expect(() => schema.parse({ count: -1 })).toThrow(); + }); + + test('should accept optional branchId', () => { + const schema = CreateMultipleBranchesTool.parameters; + + // With branchId + expect(() => schema.parse({ + count: 2, + branchId: '123e4567-e89b-12d3-a456-426614174000' + })).not.toThrow(); + + // Without branchId (should use active branch) + expect(() => schema.parse({ count: 2 })).not.toThrow(); + }); + + test('should generate correct label', () => { + expect(CreateMultipleBranchesTool.getLabel({ count: 1 })).toBe('Creating 1 branch'); + expect(CreateMultipleBranchesTool.getLabel({ count: 3 })).toBe('Creating 3 branches'); + expect(CreateMultipleBranchesTool.getLabel({ count: 5 })).toBe('Creating 5 branches'); + expect(CreateMultipleBranchesTool.getLabel()).toBe('Creating 1 branch'); // Default + }); + + test('should have Branch icon', () => { + expect(CreateMultipleBranchesTool.icon).toBeDefined(); + }); +}); + diff --git a/packages/models/src/project/index.ts b/packages/models/src/project/index.ts index 1a36948638..de5ccdc296 100644 --- a/packages/models/src/project/index.ts +++ b/packages/models/src/project/index.ts @@ -3,6 +3,7 @@ export * from './canvas'; export * from './command'; export * from './create'; export * from './frame'; +export * from './positionable'; export * from './project'; export * from './rect'; export * from './role'; diff --git a/packages/models/src/project/positionable.ts b/packages/models/src/project/positionable.ts new file mode 100644 index 0000000000..b78eccff85 --- /dev/null +++ b/packages/models/src/project/positionable.ts @@ -0,0 +1,12 @@ +import type { RectDimension, RectPosition } from './rect'; + +/** + * Minimal abstraction for any canvas object that can be positioned. + * This allows tools to work with frames now and extend to other object types (images, rectangles, assets) later. + */ +export interface Positionable { + id: string; + position: RectPosition; + dimension: RectDimension; +} + diff --git a/packages/utility/src/frame.ts b/packages/utility/src/frame.ts index 89dfd845ea..4259cb5dc1 100644 --- a/packages/utility/src/frame.ts +++ b/packages/utility/src/frame.ts @@ -1,120 +1,29 @@ import type { Frame } from '@onlook/models'; - +import { calculateNonOverlappingPosition as calculateNonOverlappingPositionGeneric } from './position'; + +/** + * Calculate a non-overlapping position for a frame given existing frames. + * This is a Frame-specific wrapper around the generic position utility. + * + * @deprecated Consider using the generic version from './position' for new code + */ export function calculateNonOverlappingPosition( proposedFrame: Frame, existingFrames: Frame[], ): { x: number; y: number } { const SPACING = 100; - - if (existingFrames.length === 0) { - return proposedFrame.position; - } - - // Check if original position is free - if (!hasOverlap(proposedFrame.position, proposedFrame, existingFrames, SPACING)) { - return proposedFrame.position; - } - - // Use Bottom-Left Fill algorithm: find the bottommost, then leftmost valid position - return findBottomLeftPosition(proposedFrame, existingFrames, SPACING); -} - -function hasOverlap( - position: { x: number; y: number }, - proposedFrame: Frame, - existingFrames: Frame[], - spacing: number, -): boolean { - const proposed = { - left: position.x, - top: position.y, - right: position.x + proposedFrame.dimension.width, - bottom: position.y + proposedFrame.dimension.height, - }; - - return existingFrames.some((existingFrame) => { - if (existingFrame.id === proposedFrame.id) return false; - - const existing = { - left: existingFrame.position.x - spacing, - top: existingFrame.position.y - spacing, - right: existingFrame.position.x + existingFrame.dimension.width + spacing, - bottom: existingFrame.position.y + existingFrame.dimension.height + spacing, - }; - - return ( - proposed.left < existing.right && - proposed.right > existing.left && - proposed.top < existing.bottom && - proposed.bottom > existing.top - ); - }); -} - -function findBottomLeftPosition( - proposedFrame: Frame, - existingFrames: Frame[], - spacing: number, -): { x: number; y: number } { - // Get all potential anchor points (corners of existing frames) - const anchorPoints = getAnchorPoints(existingFrames, spacing); - - // Add the original position as a candidate - anchorPoints.push(proposedFrame.position); - - // Filter valid positions and sort by bottom-left preference - const validPositions = anchorPoints - .filter((point) => !hasOverlap(point, proposedFrame, existingFrames, spacing)) - .sort((a, b) => { - // Primary: prefer lower Y (bottom) - if (Math.abs(a.y - b.y) > 10) { - return a.y - b.y; - } - // Secondary: prefer lower X (left) - return a.x - b.x; - }); - - if (validPositions.length > 0) { - return validPositions[0]!; - } - - // Fallback: extend to the right of the rightmost frame - let rightmostX = proposedFrame.position.x; - let rightmostY = proposedFrame.position.y; - - for (const frame of existingFrames) { - const frameRight = frame.position.x + frame.dimension.width; - if (frameRight > rightmostX) { - rightmostX = frameRight; - rightmostY = frame.position.y; - } - } - - return { - x: rightmostX + spacing, - y: rightmostY, - }; -} - -function getAnchorPoints(existingFrames: Frame[], spacing: number): { x: number; y: number }[] { - const points: { x: number; y: number }[] = []; - - for (const frame of existingFrames) { - const { position, dimension } = frame; - - // Priority positions: right and below (common UI patterns) - points.push( - // Right edge, same Y - { x: position.x + dimension.width + spacing, y: position.y }, - // Bottom edge, same X - { x: position.x, y: position.y + dimension.height + spacing }, - // Bottom-right corner - { - x: position.x + dimension.width + spacing, - y: position.y + dimension.height + spacing, - }, - ); - } - - return points; + + return calculateNonOverlappingPositionGeneric( + { + id: proposedFrame.id, + position: proposedFrame.position, + dimension: proposedFrame.dimension, + }, + existingFrames.map(frame => ({ + id: frame.id, + position: frame.position, + dimension: frame.dimension, + })), + SPACING, + ); } diff --git a/packages/utility/src/index.ts b/packages/utility/src/index.ts index 92597f14ba..aca1654c13 100644 --- a/packages/utility/src/index.ts +++ b/packages/utility/src/index.ts @@ -16,6 +16,8 @@ export * from './math'; export * from './name'; export * from './null'; export * from './path'; +// Note: position.ts exports are not re-exported here to avoid conflict with frame.ts +// Import directly from './position' if you need the generic version export * from './screenshot'; export * from './string'; export * from './tailwind'; diff --git a/packages/utility/src/position.ts b/packages/utility/src/position.ts new file mode 100644 index 0000000000..cbd25d4381 --- /dev/null +++ b/packages/utility/src/position.ts @@ -0,0 +1,129 @@ +import type { Positionable, RectPosition } from '@onlook/models'; + +/** + * Calculate a non-overlapping position for a positionable object given existing objects. + * Uses Bottom-Left Fill algorithm: finds the bottommost, then leftmost valid position. + * + * @param proposed - The object to position (with proposed position) + * @param existing - Array of all existing objects to avoid collisions with + * @param spacing - Minimum spacing between objects (default: 100) + * @returns A non-overlapping position + */ +export function calculateNonOverlappingPosition( + proposed: Positionable, + existing: Positionable[], + spacing: number = 100, +): RectPosition { + if (existing.length === 0) { + return proposed.position; + } + + // Check if original position is free + if (!hasOverlap(proposed.position, proposed, existing, spacing)) { + return proposed.position; + } + + // Use Bottom-Left Fill algorithm: find the bottommost, then leftmost valid position + return findBottomLeftPosition(proposed, existing, spacing); +} + +function hasOverlap( + position: RectPosition, + proposed: Positionable, + existing: Positionable[], + spacing: number, +): boolean { + const proposedRect = { + left: position.x, + top: position.y, + right: position.x + proposed.dimension.width, + bottom: position.y + proposed.dimension.height, + }; + + return existing.some((existingItem) => { + if (existingItem.id === proposed.id) return false; + + const existingRect = { + left: existingItem.position.x - spacing, + top: existingItem.position.y - spacing, + right: existingItem.position.x + existingItem.dimension.width + spacing, + bottom: existingItem.position.y + existingItem.dimension.height + spacing, + }; + + return ( + proposedRect.left < existingRect.right && + proposedRect.right > existingRect.left && + proposedRect.top < existingRect.bottom && + proposedRect.bottom > existingRect.top + ); + }); +} + +function findBottomLeftPosition( + proposed: Positionable, + existing: Positionable[], + spacing: number, +): RectPosition { + // Get all potential anchor points (corners of existing objects) + const anchorPoints = getAnchorPoints(existing, spacing); + + // Add the original position as a candidate + anchorPoints.push(proposed.position); + + // Filter valid positions and sort by bottom-left preference + const validPositions = anchorPoints + .filter((point) => !hasOverlap(point, proposed, existing, spacing)) + .sort((a, b) => { + // Primary: prefer higher Y (bottommost positions) + if (Math.abs(a.y - b.y) > 10) { + return b.y - a.y; + } + // Secondary: prefer lower X (leftmost positions) + return a.x - b.x; + }); + + if (validPositions.length > 0) { + return validPositions[0]!; + } + + // Fallback: extend to the right of the rightmost object + let rightmostX = proposed.position.x; + let rightmostY = proposed.position.y; + + for (const item of existing) { + const itemRight = item.position.x + item.dimension.width; + if (itemRight > rightmostX) { + rightmostX = itemRight; + rightmostY = item.position.y; + } + } + + return { + x: rightmostX + spacing, + y: rightmostY, + }; +} + +function getAnchorPoints(existing: Positionable[], spacing: number): RectPosition[] { + const points: RectPosition[] = []; + + for (const item of existing) { + const { position, dimension } = item; + + // Priority positions: right and below (common UI patterns) + points.push( + // Right edge, same Y + { x: position.x + dimension.width + spacing, y: position.y }, + // Bottom edge, same X + { x: position.x, y: position.y + dimension.height + spacing }, + // Bottom-right corner + { + x: position.x + dimension.width + spacing, + y: position.y + dimension.height + spacing, + }, + ); + } + + return points; +} +