diff --git a/apps/twig/src/main/services/workspace/schemas.ts b/apps/twig/src/main/services/workspace/schemas.ts index d787c4f2a..2441c97b7 100644 --- a/apps/twig/src/main/services/workspace/schemas.ts +++ b/apps/twig/src/main/services/workspace/schemas.ts @@ -5,6 +5,17 @@ import { z } from "zod"; export const workspaceModeSchema = z .enum(["worktree", "local", "cloud", "root"]) .transform((val) => (val === "root" ? "local" : val)); + +export const workspaceNameSchema = z + .string() + .min(1, "Workspace name is required") + .max(50, "Workspace name must be 50 characters or less") + .regex(/^[a-zA-Z]/, "Workspace name must begin with a letter") + .regex( + /^[a-zA-Z0-9-]+$/, + "Workspace name can only contain letters, numbers, and dashes", + ); + export const worktreeInfoSchema = z.object({ worktreePath: z.string(), worktreeName: z.string(), @@ -62,6 +73,17 @@ export const createWorkspaceInput = z.object({ mode: workspaceModeSchema, branch: z.string().optional(), useExistingBranch: z.boolean().optional(), + customName: workspaceNameSchema.optional(), +}); + +export const checkNameAvailableInput = z.object({ + name: workspaceNameSchema, + mainRepoPath: z.string().min(2, "Repository path is required"), +}); + +export const checkNameAvailableOutput = z.object({ + available: z.boolean(), + reason: z.string().optional(), }); export const deleteWorkspaceInput = z.object({ @@ -214,3 +236,5 @@ export type IsLocalBackgroundedInput = z.infer; export type GetLocalWorktreePathInput = z.infer< typeof getLocalWorktreePathInput >; +export type CheckNameAvailableInput = z.infer; +export type CheckNameAvailableOutput = z.infer; diff --git a/apps/twig/src/main/services/workspace/service.ts b/apps/twig/src/main/services/workspace/service.ts index cfba52005..e2155c6f6 100644 --- a/apps/twig/src/main/services/workspace/service.ts +++ b/apps/twig/src/main/services/workspace/service.ts @@ -313,9 +313,10 @@ export class WorkspaceService extends TypedEventEmitter mode, branch, useExistingBranch, + customName, } = options; log.info( - `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, useExistingBranch: ${useExistingBranch})`, + `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, useExistingBranch: ${useExistingBranch}${customName ? `, customName: ${customName}` : ""})`, ); if (mode === "cloud") { @@ -510,6 +511,7 @@ export class WorkspaceService extends TypedEventEmitter // Standard mode: create new twig/ branch worktree = await worktreeManager.createWorktree({ baseBranch: branch ?? undefined, + customName, }); log.info( `Created worktree: ${worktree.worktreeName} at ${worktree.worktreePath}`, @@ -1107,6 +1109,18 @@ export class WorkspaceService extends TypedEventEmitter return result; } + async checkNameAvailable( + name: string, + mainRepoPath: string, + ): Promise<{ available: boolean; reason?: string }> { + const worktreeBasePath = getWorktreeLocation(); + const worktreeManager = new WorktreeManager({ + mainRepoPath, + worktreeBasePath, + }); + return worktreeManager.isNameAvailable(name); + } + private async cleanupWorktree( taskId: string, mainRepoPath: string, diff --git a/apps/twig/src/main/trpc/routers/workspace.ts b/apps/twig/src/main/trpc/routers/workspace.ts index a62b63bf0..32464d2e7 100644 --- a/apps/twig/src/main/trpc/routers/workspace.ts +++ b/apps/twig/src/main/trpc/routers/workspace.ts @@ -1,6 +1,8 @@ import { container } from "../../di/container.js"; import { MAIN_TOKENS } from "../../di/tokens.js"; import { + checkNameAvailableInput, + checkNameAvailableOutput, createWorkspaceInput, createWorkspaceOutput, deleteWorkspaceInput, @@ -99,6 +101,13 @@ export const workspaceRouter = router({ .output(getWorktreeTasksOutput) .query(({ input }) => getService().getWorktreeTasks(input.worktreePath)), + checkNameAvailable: publicProcedure + .input(checkNameAvailableInput) + .output(checkNameAvailableOutput) + .query(({ input }) => + getService().checkNameAvailable(input.name, input.mainRepoPath), + ), + onTerminalCreated: subscribe(WorkspaceServiceEvent.TerminalCreated), onError: subscribe(WorkspaceServiceEvent.Error), onWarning: subscribe(WorkspaceServiceEvent.Warning), diff --git a/apps/twig/src/renderer/features/settings/components/SettingsView.tsx b/apps/twig/src/renderer/features/settings/components/SettingsView.tsx index 271d0bbfa..95c608557 100644 --- a/apps/twig/src/renderer/features/settings/components/SettingsView.tsx +++ b/apps/twig/src/renderer/features/settings/components/SettingsView.tsx @@ -80,11 +80,13 @@ export function SettingsView() { autoConvertLongText, sendMessagesWith, allowBypassPermissions, + customWorkspaceNames, setCursorGlow, setDesktopNotifications, setAutoConvertLongText, setSendMessagesWith, setAllowBypassPermissions, + setCustomWorkspaceNames, } = useSettingsStore(); const terminalLayoutMode = useTerminalSettingsStore( (state) => state.terminalLayoutMode, @@ -339,6 +341,18 @@ export function SettingsView() { setShowBypassWarning(false); }, [setAllowBypassPermissions]); + const handleCustomWorkspaceNamesChange = useCallback( + (checked: boolean) => { + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "custom_workspace_names", + new_value: checked, + old_value: customWorkspaceNames, + }); + setCustomWorkspaceNames(checked); + }, + [customWorkspaceNames, setCustomWorkspaceNames], + ); + const terminalFontSelection = TERMINAL_FONT_PRESETS.some( (preset) => preset.value === terminalFontFamily, ) @@ -681,7 +695,7 @@ export function SettingsView() { Workspace storage - + Workspace location @@ -697,6 +711,23 @@ export function SettingsView() { task. Workspaces are organized by repository name. + + + + + Custom workspace names + + + Name workspaces manually instead of using auto-generated + names + + + + diff --git a/apps/twig/src/renderer/features/settings/stores/settingsStore.ts b/apps/twig/src/renderer/features/settings/stores/settingsStore.ts index 5ec4c951d..d83771e41 100644 --- a/apps/twig/src/renderer/features/settings/stores/settingsStore.ts +++ b/apps/twig/src/renderer/features/settings/stores/settingsStore.ts @@ -16,6 +16,7 @@ interface SettingsStore { autoConvertLongText: boolean; sendMessagesWith: SendMessagesWith; allowBypassPermissions: boolean; + customWorkspaceNames: boolean; setDefaultRunMode: (mode: DefaultRunMode) => void; setLastUsedRunMode: (mode: "local" | "cloud") => void; @@ -26,6 +27,7 @@ interface SettingsStore { setAutoConvertLongText: (enabled: boolean) => void; setSendMessagesWith: (mode: SendMessagesWith) => void; setAllowBypassPermissions: (enabled: boolean) => void; + setCustomWorkspaceNames: (enabled: boolean) => void; } export const useSettingsStore = create()( @@ -40,6 +42,7 @@ export const useSettingsStore = create()( autoConvertLongText: true, sendMessagesWith: "enter", allowBypassPermissions: false, + customWorkspaceNames: false, setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), setLastUsedRunMode: (mode) => set({ lastUsedRunMode: mode }), @@ -54,6 +57,8 @@ export const useSettingsStore = create()( setSendMessagesWith: (mode) => set({ sendMessagesWith: mode }), setAllowBypassPermissions: (enabled) => set({ allowBypassPermissions: enabled }), + setCustomWorkspaceNames: (enabled) => + set({ customWorkspaceNames: enabled }), }), { name: "settings-storage", diff --git a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx index 54ecc7878..7fe1cc679 100644 --- a/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx @@ -13,14 +13,18 @@ import { useTaskCreation } from "../hooks/useTaskCreation"; import { SuggestedTasks } from "./SuggestedTasks"; import { TaskInputEditor } from "./TaskInputEditor"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; +import { WorkspaceNameInput } from "./WorkspaceNameInput"; const DOT_FILL = "var(--gray-6)"; export function TaskInput() { const { view } = useNavigationStore(); const { lastUsedDirectory } = useTaskDirectoryStore(); - const { lastUsedLocalWorkspaceMode, allowBypassPermissions } = - useSettingsStore(); + const { + lastUsedLocalWorkspaceMode, + allowBypassPermissions, + customWorkspaceNames, + } = useSettingsStore(); const editorRef = useRef(null); const containerRef = useRef(null); @@ -35,6 +39,11 @@ export function TaskInput() { ); const [editorIsEmpty, setEditorIsEmpty] = useState(true); const [executionMode, setExecutionMode] = useState("default"); + const [workspaceName, setWorkspaceName] = useState(""); + const [isWorkspaceNameValid, setIsWorkspaceNameValid] = useState(false); + + const showWorkspaceNameInput = + customWorkspaceNames && workspaceMode === "worktree"; // Reset to default mode if bypass was disabled while in bypass mode useEffect(() => { @@ -74,6 +83,8 @@ export function TaskInput() { branch: null, editorIsEmpty, executionMode: executionMode === "default" ? undefined : executionMode, + workspaceName: showWorkspaceNameInput ? workspaceName : undefined, + isWorkspaceNameValid: showWorkspaceNameInput ? isWorkspaceNameValid : true, }); return ( @@ -147,6 +158,16 @@ export function TaskInput() { onChange={setWorkspaceMode} size="1" /> + {showWorkspaceNameInput && ( + + )} void; + onValidChange: (isValid: boolean) => void; + directoryPath: string; + size?: Responsive<"1" | "2">; + placeholder?: string; +} + +type ValidationState = { + isValid: boolean; + error?: string; + isChecking: boolean; +}; + +function validateNameFormat(name: string): { valid: boolean; error?: string } { + if (!name) return { valid: false, error: "Workspace name is required" }; + if (name.length > MAX_LENGTH) { + return { + valid: false, + error: `Name must be ${MAX_LENGTH} characters or less`, + }; + } + if (!STARTS_WITH_LETTER.test(name)) { + return { valid: false, error: "Name must begin with a letter" }; + } + if (!VALID_CHARACTERS.test(name)) { + return { + valid: false, + error: "Name can only contain letters, numbers, and dashes", + }; + } + return { valid: true }; +} + +export function WorkspaceNameInput({ + value, + onChange, + onValidChange, + directoryPath, + size = "1", + placeholder = "my-workspace", +}: WorkspaceNameInputProps) { + const [validation, setValidation] = useState({ + isValid: false, + isChecking: false, + }); + const debounceRef = useRef | null>(null); + const lastCheckedRef = useRef(""); + + useEffect(() => { + onValidChange(validation.isValid && !validation.isChecking); + }, [validation.isValid, validation.isChecking, onValidChange]); + + const checkUniqueness = useCallback( + async (name: string) => { + if (!directoryPath || !name) return; + const formatValidation = validateNameFormat(name); + if (!formatValidation.valid) { + setValidation({ + isValid: false, + error: formatValidation.error, + isChecking: false, + }); + return; + } + if (lastCheckedRef.current === name) { + return; + } + setValidation((prev) => ({ ...prev, isChecking: true })); + try { + const result = await trpcVanilla.workspace.checkNameAvailable.query({ + name, + mainRepoPath: directoryPath, + }); + if (lastCheckedRef.current !== name) { + lastCheckedRef.current = name; + setValidation({ + isValid: result.available, + error: result.available ? undefined : result.reason, + isChecking: false, + }); + } + } catch (_error) { + setValidation({ + isValid: false, + error: "Failed to validate name", + isChecking: false, + }); + } + }, + [directoryPath], + ); + + const handleChange = useCallback( + (newValue: string) => { + onChange(newValue); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + const formatValidation = validateNameFormat(newValue); + if (!formatValidation.valid) { + setValidation({ + isValid: false, + error: formatValidation.error, + isChecking: false, + }); + return; + } + setValidation((prev) => ({ + ...prev, + isChecking: true, + error: undefined, + })); + debounceRef.current = setTimeout(() => { + checkUniqueness(newValue); + }, 200); + }, + [onChange, checkUniqueness], + ); + + useEffect(() => { + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, []); + + // recheck when directory changes + useEffect(() => { + if (value && directoryPath) { + lastCheckedRef.current = ""; + checkUniqueness(value); + } + }, [directoryPath, value, checkUniqueness]); + + const showError = !validation.isChecking && validation.error && value; + + return ( + + handleChange(e.target.value)} + color={showError ? "red" : undefined} + /> + {showError && ( + + {validation.error} + + )} + + ); +} diff --git a/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 11f51b6da..edf756b67 100644 --- a/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -26,6 +26,8 @@ interface UseTaskCreationOptions { branch?: string | null; editorIsEmpty: boolean; executionMode?: ExecutionMode; + workspaceName?: string; + isWorkspaceNameValid?: boolean; } interface UseTaskCreationReturn { @@ -80,6 +82,7 @@ function prepareTaskInput( workspaceMode: WorkspaceMode; branch?: string | null; executionMode?: ExecutionMode; + workspaceName?: string; }, ): TaskCreationInput { return { @@ -91,6 +94,7 @@ function prepareTaskInput( workspaceMode: options.workspaceMode, branch: options.branch, executionMode: options.executionMode, + workspaceName: options.workspaceName, }; } @@ -115,6 +119,8 @@ export function useTaskCreation({ branch, editorIsEmpty, executionMode, + workspaceName, + isWorkspaceNameValid = true, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); const { navigateToTask } = useNavigationStore(); @@ -129,7 +135,8 @@ export function useTaskCreation({ isOnline && (isCloudMode ? !!selectedRepository : !!selectedDirectory) && !isCreatingTask && - !editorIsEmpty; + !editorIsEmpty && + isWorkspaceNameValid; const handleSubmit = useCallback(async () => { const editor = editorRef.current; @@ -149,6 +156,7 @@ export function useTaskCreation({ workspaceMode, branch, executionMode, + workspaceName, }); const taskService = get(RENDERER_TOKENS.TaskService); @@ -191,6 +199,7 @@ export function useTaskCreation({ workspaceMode, branch, executionMode, + workspaceName, invalidateTasks, navigateToTask, ]); diff --git a/apps/twig/src/renderer/sagas/task/task-creation.ts b/apps/twig/src/renderer/sagas/task/task-creation.ts index d1d08d5ff..7d5fd3744 100644 --- a/apps/twig/src/renderer/sagas/task/task-creation.ts +++ b/apps/twig/src/renderer/sagas/task/task-creation.ts @@ -36,6 +36,7 @@ export interface TaskCreationInput { branch?: string | null; githubIntegrationId?: number; executionMode?: ExecutionMode; + workspaceName?: string; } export interface TaskCreationOutput { @@ -131,6 +132,7 @@ export class TaskCreationSaga extends Saga< folderPath: repoPath, mode: workspaceMode, branch: branch ?? undefined, + customName: input.workspaceName, }); }, rollback: async () => { diff --git a/apps/twig/src/shared/types.ts b/apps/twig/src/shared/types.ts index d1ef08b7b..d942ff2bc 100644 --- a/apps/twig/src/shared/types.ts +++ b/apps/twig/src/shared/types.ts @@ -97,6 +97,7 @@ export interface CreateWorkspaceOptions { mode: WorkspaceMode; branch?: string; useExistingBranch?: boolean; + customName?: string; } export interface ScriptExecutionResult { diff --git a/packages/agent/src/worktree-manager.ts b/packages/agent/src/worktree-manager.ts index 080621222..1215d98a2 100644 --- a/packages/agent/src/worktree-manager.ts +++ b/packages/agent/src/worktree-manager.ts @@ -142,6 +142,24 @@ export class WorktreeManager { } } + async isNameAvailable( + name: string, + ): Promise<{ available: boolean; reason?: string }> { + if (await this.worktreeExists(name)) { + return { + available: false, + reason: `A workspace with the name "${name}" already exists`, + }; + } + if (await this.gitManager.branchExists(name)) { + return { + available: false, + reason: `A branch with the name "${name}" already exists`, + }; + } + return { available: true }; + } + async ensureArrayDirIgnored(): Promise { // Use .git/info/exclude instead of .gitignore to avoid modifying tracked files const excludePath = path.join(this.mainRepoPath, ".git", "info", "exclude"); @@ -198,6 +216,7 @@ export class WorktreeManager { async createWorktree(options?: { baseBranch?: string; + customName?: string; }): Promise { const totalStart = Date.now(); @@ -213,8 +232,10 @@ export class WorktreeManager { setupPromises.push(fs.mkdir(folderPath, { recursive: true })); } - // Generate unique worktree name (in parallel with above) - const worktreeNamePromise = this.generateUniqueWorktreeName(); + // Use custom name if provided, otherwise generate unique worktree name (in parallel with above) + const worktreeNamePromise = options?.customName + ? Promise.resolve(options.customName) + : this.generateUniqueWorktreeName(); setupPromises.push(worktreeNamePromise); // Get default branch in parallel if not provided @@ -229,6 +250,21 @@ export class WorktreeManager { const worktreeName = await worktreeNamePromise; const baseBranch = await baseBranchPromise; + + // Validate uniqueness for custom names + if (options?.customName) { + if (await this.worktreeExists(worktreeName)) { + throw new Error( + `A workspace with the name "${worktreeName}" already exists`, + ); + } + if (await this.gitManager.branchExists(worktreeName)) { + throw new Error( + `A branch with the name "${worktreeName}" already exists`, + ); + } + } + const worktreePath = this.getWorktreePath(worktreeName); const branchName = worktreeName;