fix(terminal): improve worktree name input UX (#1012)

* fix(terminal): improve worktree name input to not strip trailing characters while typing

- Allow trailing hyphens/underscores during input (only trim on submit)
- Add preview name that shows the final sanitized value for branch preview
- Remove invalid characters instead of replacing with hyphens
- Collapse consecutive underscores in addition to hyphens
- Final sanitization happens on submit to match backend WORKTREE_NAME_REGEX

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): address PR review findings for worktree name validation

- Fix submit button disabled check to use sanitized name instead of raw input
- Simplify trailing trim logic (apply once after all transformations)
- Apply lowercase in handleNameChange to reduce input/preview gap
- Internationalize 'name' fallback using existing translation key

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(terminal): improve header responsiveness for multiple terminals

- Hide text labels (Claude, Open in IDE) when ≥4 terminals, show icon only
- Add dynamic max-width to worktree name badge with truncation
- Add tooltips to all icon-only elements for accessibility
- Maintain full functionality while reducing header width requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Test User <test@example.com>
This commit is contained in:
Andy
2026-01-13 18:57:16 +01:00
committed by AndyMik90
parent 4dbb7ee4ee
commit 54e9f22878
2 changed files with 61 additions and 24 deletions
@@ -32,20 +32,27 @@ const PROJECT_DEFAULT_BRANCH = '__project_default__';
* - Converts to lowercase
* - Replaces spaces and invalid characters with hyphens
* - Collapses consecutive hyphens
* - Trims leading/trailing hyphens
* - Trims leading hyphens (but allows trailing during input)
* - Ensures name ends with alphanumeric (matching backend WORKTREE_NAME_REGEX)
*
* @param trimTrailing - If true, trims trailing hyphens/underscores (for final validation)
*/
function sanitizeWorktreeName(value: string, maxLength?: number): string {
function sanitizeWorktreeName(value: string, maxLength?: number, trimTrailing = false): string {
let sanitized = value
.toLowerCase()
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/[^a-z0-9_-]/g, '-') // Replace invalid chars (including dots) with hyphens
.replace(/[^a-z0-9_-]/g, '') // Remove invalid chars (only allow letters, numbers, hyphens, underscores)
.replace(/-{2,}/g, '-') // Collapse consecutive hyphens
.replace(/^[-_]+|[-_]+$/g, ''); // Trim leading and trailing hyphens/underscores
.replace(/_{2,}/g, '_') // Collapse consecutive underscores
.replace(/^[-_]+/, ''); // Trim leading hyphens/underscores only
if (maxLength) {
sanitized = sanitized.slice(0, maxLength);
// Trim trailing hyphens/underscores again after slicing
}
// Only trim trailing hyphens/underscores when explicitly requested (final validation)
// Applied once at the end after all other transformations including maxLength slice
if (trimTrailing) {
sanitized = sanitized.replace(/[-_]+$/, '');
}
@@ -93,6 +100,12 @@ export function CreateWorktreeDialog({
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
// Sanitized name for validation (without display fallback)
const sanitizedName = useMemo(() => sanitizeWorktreeName(name, undefined, true), [name]);
// Preview name with fallback for display (using i18n)
const previewName = sanitizedName || t('terminal:worktree.namePlaceholder');
// Fetch branches when dialog opens
useEffect(() => {
if (!open || !projectPath) return;
@@ -138,8 +151,11 @@ export function CreateWorktreeDialog({
}, [open, projectPath, project?.settings?.mainBranch]);
const handleNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const sanitized = sanitizeWorktreeName(e.target.value);
setName(sanitized);
// Apply lowercase and convert spaces to hyphens as user types
// This reduces the visual gap between input and preview
// Full sanitization (removing invalid chars) happens on submit
const rawValue = e.target.value.toLowerCase().replace(/\s+/g, '-');
setName(rawValue);
setError(null);
}, []);
@@ -153,21 +169,25 @@ export function CreateWorktreeDialog({
if (!name) {
const task = backlogTasks.find(t => t.id === taskId);
if (task) {
const autoName = sanitizeWorktreeName(task.title, 40);
// Trim trailing when auto-filling from task title (complete value)
const autoName = sanitizeWorktreeName(task.title, 40, true);
setName(autoName);
}
}
}, [backlogTasks, name]);
const handleCreate = async () => {
if (!name.trim()) {
// Final sanitization: trim trailing hyphens/underscores for submission
const finalName = sanitizeWorktreeName(name, undefined, true);
if (!finalName) {
setError(t('terminal:worktree.nameRequired'));
return;
}
// Validate name format - allow letters, numbers, dashes, and underscores
// Must start and end with letter or number (matching backend WORKTREE_NAME_REGEX)
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$/.test(finalName) && !/^[a-z0-9]$/.test(finalName)) {
setError(t('terminal:worktree.nameInvalid'));
return;
}
@@ -178,7 +198,7 @@ export function CreateWorktreeDialog({
try {
const result = await window.electronAPI.createTerminalWorktree({
terminalId,
name: name.trim(),
name: finalName,
taskId: selectedTaskId,
createGitBranch,
projectPath,
@@ -297,7 +317,7 @@ export function CreateWorktreeDialog({
{t('terminal:worktree.createBranch')}
</Label>
<p className="text-xs text-muted-foreground">
{t('terminal:worktree.branchHelp', { branch: `terminal/${name || 'name'}` })}
{t('terminal:worktree.branchHelp', { branch: `terminal/${previewName}` })}
</p>
</div>
<Switch
@@ -338,7 +358,7 @@ export function CreateWorktreeDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
{t('common:buttons.cancel')}
</Button>
<Button onClick={handleCreate} disabled={isCreating || !name.trim()}>
<Button onClick={handleCreate} disabled={isCreating || !sanitizedName}>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -98,9 +98,12 @@ export function TerminalHeader({
/>
</div>
{isClaudeMode && (
<span className="flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
<span
className="flex items-center gap-1 text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded"
title="Claude"
>
<Sparkles className="h-2.5 w-2.5" />
Claude
{terminalCount < 4 && <span>Claude</span>}
</span>
)}
{isClaudeMode && (
@@ -115,9 +118,15 @@ export function TerminalHeader({
)}
{/* Worktree selector or badge - placed next to task selector */}
{worktreeConfig ? (
<span className="flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded">
<FolderGit className="h-2.5 w-2.5" />
{worktreeConfig.name}
<span
className={cn(
'flex items-center gap-1 text-[10px] font-medium text-amber-500 bg-amber-500/10 px-1.5 py-0.5 rounded',
terminalCount >= 6 ? 'max-w-20' : terminalCount >= 4 ? 'max-w-28' : 'max-w-40'
)}
title={worktreeConfig.name}
>
<FolderGit className="h-2.5 w-2.5 flex-shrink-0" />
<span className="truncate">{worktreeConfig.name}</span>
</span>
) : (
projectPath && onCreateWorktree && onSelectWorktree && (
@@ -136,29 +145,37 @@ export function TerminalHeader({
{worktreeConfig && onOpenInIDE && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1 hover:bg-muted"
size={terminalCount >= 4 ? 'icon' : 'sm'}
className={cn(
'h-6 hover:bg-muted',
terminalCount >= 4 ? 'w-6' : 'px-2 text-xs gap-1'
)}
onClick={(e) => {
e.stopPropagation();
onOpenInIDE();
}}
title={t('terminal:worktree.openInIDE')}
>
<ExternalLink className="h-3 w-3" />
{t('terminal:worktree.openInIDE')}
{terminalCount < 4 && t('terminal:worktree.openInIDE')}
</Button>
)}
{!isClaudeMode && status !== 'exited' && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1 hover:bg-primary/10 hover:text-primary"
size={terminalCount >= 4 ? 'icon' : 'sm'}
className={cn(
'h-6 hover:bg-primary/10 hover:text-primary',
terminalCount >= 4 ? 'w-6' : 'px-2 text-xs gap-1'
)}
onClick={(e) => {
e.stopPropagation();
onInvokeClaude();
}}
title="Claude"
>
<Sparkles className="h-3 w-3" />
Claude
{terminalCount < 4 && <span>Claude</span>}
</Button>
)}
{/* Expand/collapse button */}