feat: Mobile improvements and Add selective file staging and improve branch switching

This commit is contained in:
gsxdsm
2026-02-17 15:20:28 -08:00
parent de021f96bf
commit 7fcf3c1e1f
42 changed files with 2706 additions and 256 deletions

View File

@@ -6,11 +6,13 @@ import { SplashScreen } from './components/splash-screen';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
import { useMobileVisibility, useMobileOnlineManager } from './hooks/use-mobile-visibility';
import { useAppStore } from './store/app-store';
import { TooltipProvider } from '@/components/ui/tooltip';
import './styles/global.css';
import './styles/theme-imports';
import './styles/font-imports';
import { loadUserFonts, preloadAllFonts } from './styles/font-imports';
const logger = createLogger('App');
@@ -38,6 +40,30 @@ export default function App() {
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
}, [disableSplashScreen]);
// Load user-selected custom fonts on startup, then preload remaining fonts during idle time.
// Uses requestIdleCallback where available for better mobile performance - this ensures
// font loading doesn't compete with critical rendering and input handling.
useEffect(() => {
// Immediately load any fonts the user has configured
loadUserFonts();
// After the app is fully interactive, preload remaining fonts
// so font picker previews work instantly.
// Use requestIdleCallback on mobile for better scheduling - it yields to
// user interactions and critical rendering, unlike setTimeout which may fire
// during a busy frame and cause jank.
const schedulePreload =
typeof requestIdleCallback !== 'undefined'
? () => requestIdleCallback(() => preloadAllFonts(), { timeout: 5000 })
: () => setTimeout(() => preloadAllFonts(), 3000);
const timer = setTimeout(() => {
schedulePreload();
}, 2000); // Wait 2s after mount, then use idle callback for the actual loading
return () => clearTimeout(timer);
}, []);
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
useEffect(() => {
@@ -70,6 +96,12 @@ export default function App() {
// Initialize Provider auth status at startup (for Claude/Codex usage display)
useProviderAuthInit();
// Mobile-specific: Manage React Query focus/online state based on page visibility.
// Prevents the "blank screen + reload" cycle caused by aggressive refetching
// when the mobile PWA is switched away from and back to.
useMobileVisibility();
useMobileOnlineManager();
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem('automaker-splash-shown', 'true');
setShowSplash(false);

View File

@@ -16,7 +16,7 @@ export function SandboxRejectionScreen() {
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="min-h-full bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="flex justify-center">
<div className="rounded-full bg-destructive/10 p-4">

View File

@@ -98,7 +98,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
data-slot="dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-4rem)]',
'bg-card border border-border rounded-xl shadow-2xl',
// Premium shadow
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',

View File

@@ -62,7 +62,12 @@ import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialo
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types';
import type {
PRInfo,
WorktreeInfo,
MergeConflictInfo,
BranchSwitchConflictInfo,
} from './board-view/worktree-panel/types';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
useBoardFeatures,
@@ -1015,6 +1020,56 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler called when branch switch stash reapply causes merge conflicts
const handleBranchSwitchConflict = useCallback(
async (conflictInfo: BranchSwitchConflictInfo) => {
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
// Create the feature
const featureData = {
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: conflictInfo.branchName,
workMode: 'custom' as const,
priority: 1,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
try {
await handleAddFeature(featureData);
} catch (error) {
logger.error('Failed to create branch switch conflict resolution feature:', error);
toast.error('Failed to create feature', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return;
}
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
@@ -1454,6 +1509,7 @@ export function BoardView() {
onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts}
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
onBranchSwitchConflict={handleBranchSwitchConflict}
onBranchDeletedDuringMerge={(branchName) => {
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
hookFeatures.forEach((feature) => {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -10,11 +10,24 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Sparkles } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import {
GitCommit,
Sparkles,
FilePlus,
FileX,
FilePen,
FileText,
File,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import type { FileStatus } from '@/types/electron';
interface WorktreeInfo {
path: string;
@@ -31,6 +44,229 @@ interface CommitWorktreeDialogProps {
onCommitted: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
case '?':
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
case 'D':
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
case 'M':
case 'U':
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
case 'R':
case 'C':
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
default:
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return 'Changed';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-muted text-muted-foreground border-border';
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.old ?? ''}
</span>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
export function CommitWorktreeDialog({
open,
onOpenChange,
@@ -43,8 +279,85 @@ export function CommitWorktreeDialog({
const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
// File selection state
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map<string, ParsedFileDiff>();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
const loadDiffs = async () => {
try {
const api = getElectronAPI();
if (api?.git?.getDiffs) {
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
} catch (err) {
console.warn('Failed to load diffs for commit dialog:', err);
} finally {
setIsLoadingDiffs(false);
}
};
loadDiffs();
}
}, [open, worktree]);
const handleToggleFile = useCallback((filePath: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const handleToggleAll = useCallback(() => {
setSelectedFiles((prev) => {
if (prev.size === files.length) {
return new Set();
}
return new Set(files.map((f) => f.path));
});
}, [files]);
const handleFileClick = useCallback((filePath: string) => {
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
setIsLoading(true);
setError(null);
@@ -55,7 +368,12 @@ export function CommitWorktreeDialog({
setError('Worktree API not available');
return;
}
const result = await api.worktree.commit(worktree.path, message);
// Pass selected files if not all files are selected
const filesToCommit =
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
const result = await api.worktree.commit(worktree.path, message, filesToCommit);
if (result.success && result.result) {
if (result.result.committed) {
@@ -81,8 +399,14 @@ export function CommitWorktreeDialog({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Prevent commit while loading or while AI is generating a message
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
if (
e.key === 'Enter' &&
e.metaKey &&
!isLoading &&
!isGenerating &&
message.trim() &&
selectedFiles.size > 0
) {
handleCommit();
}
};
@@ -94,7 +418,6 @@ export function CommitWorktreeDialog({
setMessage('');
setError(null);
// Only generate AI commit message if enabled
if (!enableAiCommitMessages) {
return;
}
@@ -119,13 +442,11 @@ export function CommitWorktreeDialog({
if (result.success && result.message) {
setMessage(result.message);
} else {
// Don't show error toast, just log it and leave message empty
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
// Don't show error toast for generation failures
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
@@ -145,9 +466,11 @@ export function CommitWorktreeDialog({
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
@@ -156,17 +479,151 @@ export function CommitWorktreeDialog({
<DialogDescription>
Commit changes in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
{worktree.changedFilesCount && (
<span className="ml-1">
({worktree.changedFilesCount} file
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* File Selection */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-sm font-medium flex items-center gap-2">
Files to commit
{isLoadingDiffs ? (
<Spinner size="sm" />
) : (
<span className="text-xs text-muted-foreground font-normal">
({selectedFiles.size}/{files.length} selected)
</span>
)}
</Label>
{files.length > 0 && (
<button
onClick={handleToggleAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
)}
</div>
{isLoadingDiffs ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<Spinner size="sm" className="mr-2" />
<span className="text-sm">Loading changes...</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
{files.map((file) => {
const isChecked = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
const fileDiff = diffsByFile.get(file.path);
const additions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
)
: 0;
const deletions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
)
: 0;
return (
<div key={file.path} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
)}
>
{/* Checkbox */}
<Checkbox
checked={isChecked}
onCheckedChange={() => handleToggleFile(file.path)}
className="flex-shrink-0"
/>
{/* Clickable file row to show diff */}
<button
onClick={() => handleFileClick(file.path)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{getFileIcon(file.status)}
<span className="text-xs font-mono truncate flex-1 text-foreground">
{file.path}
</span>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
getStatusBadgeColor(file.status)
)}
>
{getStatusLabel(file.status)}
</span>
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="text-[10px] text-red-400 flex-shrink-0">
-{deletions}
</span>
)}
</button>
</div>
{/* Expanded diff view */}
{isExpanded && fileDiff && (
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div
key={hunkIndex}
className="border-b border-border-glass last:border-b-0"
>
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
{isExpanded && !fileDiff && (
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - diff preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Commit Message */}
<div className="grid gap-1.5">
<Label htmlFor="commit-message" className="flex items-center gap-2">
Commit Message
{isGenerating && (
@@ -187,7 +644,7 @@ export function CommitWorktreeDialog({
setError(null);
}}
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
className="min-h-[80px] font-mono text-sm"
autoFocus
disabled={isGenerating}
/>
@@ -207,7 +664,10 @@ export function CommitWorktreeDialog({
>
Cancel
</Button>
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
<Button
onClick={handleCommit}
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
@@ -217,6 +677,9 @@ export function CommitWorktreeDialog({
<>
<GitCommit className="w-4 h-4 mr-2" />
Commit
{selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ''}
</>
)}
</Button>

View File

@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />

View File

@@ -93,6 +93,7 @@ export function useBoardActions({
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
getAutoModeState,
getMaxConcurrencyForWorktree,
} = useAppStore();
const autoMode = useAutoMode();
@@ -566,7 +567,11 @@ export function useBoardActions({
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
const featureMaxConcurrency = currentProject
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
: autoMode.maxConcurrency;
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
@@ -647,6 +652,7 @@ export function useBoardActions({
handleRunFeature,
currentProject,
getAutoModeState,
getMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]
);

View File

@@ -191,7 +191,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'max-h-[calc(100dvh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@@ -8,7 +9,7 @@ import {
DropdownMenuTrigger,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
import { GitBranch, GitBranchPlus, Check, Search, Globe } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, BranchInfo } from '../types';
@@ -42,6 +43,43 @@ export function BranchSwitchDropdown({
onSwitchBranch,
onCreateBranch,
}: BranchSwitchDropdownProps) {
// Separate local and remote branches, filtering out bare remotes without a branch
const { localBranches, remoteBranches } = useMemo(() => {
const local: BranchInfo[] = [];
const remote: BranchInfo[] = [];
for (const branch of filteredBranches) {
if (branch.isRemote) {
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
if (!branch.name.includes('/')) continue;
remote.push(branch);
} else {
local.push(branch);
}
}
return { localBranches: local, remoteBranches: remote };
}, [filteredBranches]);
const renderBranchItem = (branch: BranchInfo) => {
const isCurrent = branch.name === worktree.branch;
return (
<DropdownMenuItem
key={branch.name}
onClick={() => onSwitchBranch(worktree, branch.name)}
disabled={isSwitching || isCurrent}
className="text-xs font-mono"
>
{isCurrent ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : branch.isRemote ? (
<Globe className="w-3.5 h-3.5 mr-2 flex-shrink-0 text-muted-foreground" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
);
};
return (
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
@@ -60,7 +98,7 @@ export function BranchSwitchDropdown({
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
@@ -73,13 +111,13 @@ export function BranchSwitchDropdown({
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
onKeyPress={(e) => e.stopPropagation()}
className="h-7 pl-7 text-xs"
className="h-7 pl-7 text-base md:text-xs"
autoFocus
/>
</div>
</div>
<DropdownMenuSeparator />
<div className="max-h-[250px] overflow-y-auto">
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<Spinner size="xs" className="mr-2" />
@@ -90,21 +128,28 @@ export function BranchSwitchDropdown({
{branchFilter ? 'No matching branches' : 'No branches found'}
</DropdownMenuItem>
) : (
filteredBranches.map((branch) => (
<DropdownMenuItem
key={branch.name}
onClick={() => onSwitchBranch(worktree, branch.name)}
disabled={isSwitching || branch.name === worktree.branch}
className="text-xs font-mono"
>
{branch.name === worktree.branch ? (
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
) : (
<span className="w-3.5 mr-2 flex-shrink-0" />
)}
<span className="truncate">{branch.name}</span>
</DropdownMenuItem>
))
<>
{/* Local branches */}
{localBranches.length > 0 && (
<>
<DropdownMenuLabel className="text-[10px] text-muted-foreground uppercase tracking-wider px-2 py-1">
Local
</DropdownMenuLabel>
{localBranches.map(renderBranchItem)}
</>
)}
{/* Remote branches */}
{remoteBranches.length > 0 && (
<>
{localBranches.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-[10px] text-muted-foreground uppercase tracking-wider px-2 py-1">
Remote
</DropdownMenuLabel>
{remoteBranches.map(renderBranchItem)}
</>
)}
</>
)}
</div>
<DropdownMenuSeparator />

View File

@@ -17,7 +17,7 @@ export function useBranches() {
data: branchData,
isLoading: isLoadingBranches,
refetch,
} = useWorktreeBranches(currentWorktreePath);
} = useWorktreeBranches(currentWorktreePath, true);
const branches = branchData?.branches ?? [];
const aheadCount = branchData?.aheadCount ?? 0;

View File

@@ -13,12 +13,23 @@ import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
export function useWorktreeActions() {
interface UseWorktreeActionsOptions {
/** Callback when merge conflicts occur after branch switch stash reapply */
onBranchSwitchConflict?: (info: {
worktreePath: string;
branchName: string;
previousBranch: string;
}) => void;
}
export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
const navigate = useNavigate();
const [isActivating, setIsActivating] = useState(false);
// Use React Query mutations
const switchBranchMutation = useSwitchBranch();
const switchBranchMutation = useSwitchBranch({
onConflict: options?.onBranchSwitchConflict,
});
const pullMutation = usePullWorktree();
const pushMutation = usePushWorktree();
const openInEditorMutation = useOpenInEditor();

View File

@@ -80,6 +80,12 @@ export interface MergeConflictInfo {
targetWorktreePath: string;
}
export interface BranchSwitchConflictInfo {
worktreePath: string;
branchName: string;
previousBranch: string;
}
export interface WorktreePanelProps {
projectPath: string;
onCreateWorktree: () => void;
@@ -90,6 +96,8 @@ export interface WorktreePanelProps {
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void;
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when branch switch stash reapply results in merge conflicts */
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
/** Called when a branch is deleted during merge - features should be reassigned to main */
onBranchDeletedDuringMerge?: (branchName: string) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;

View File

@@ -14,7 +14,12 @@ import type {
TestRunnerOutputEvent,
TestRunnerCompletedEvent,
} from '@/types/electron';
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
import type {
WorktreePanelProps,
WorktreeInfo,
TestSessionInfo,
BranchSwitchConflictInfo,
} from './types';
import {
useWorktrees,
useDevServers,
@@ -50,6 +55,7 @@ export function WorktreePanel({
onAddressPRComments,
onResolveConflicts,
onCreateMergeConflictResolutionFeature,
onBranchSwitchConflict,
onBranchDeletedDuringMerge,
onRemovedWorktrees,
runningFeatureIds = [],
@@ -101,7 +107,9 @@ export function WorktreePanel({
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
} = useWorktreeActions();
} = useWorktreeActions({
onBranchSwitchConflict: onBranchSwitchConflict,
});
const { hasRunningFeatures } = useRunningFeatures({
runningFeatureIds,

View File

@@ -489,7 +489,7 @@ export function DashboardView() {
const hasProjects = projects.length > 0;
return (
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="dashboard-view">
<div className="flex-1 flex flex-col h-full content-bg" data-testid="dashboard-view">
{/* Header with logo */}
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
{/* Electron titlebar drag region */}

View File

@@ -6,7 +6,7 @@ export function LoggedOutView() {
const navigate = useNavigate();
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-full items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">

View File

@@ -348,7 +348,7 @@ export function LoginView() {
// Checking server connectivity
if (state.phase === 'checking_server') {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-full items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Spinner size="xl" className="mx-auto" />
<p className="text-sm text-muted-foreground">
@@ -363,7 +363,7 @@ export function LoginView() {
// Server unreachable after retries
if (state.phase === 'server_error') {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-full items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<ServerCrash className="h-8 w-8 text-destructive" />
@@ -384,7 +384,7 @@ export function LoginView() {
// Checking setup status after auth
if (state.phase === 'checking_setup' || state.phase === 'redirecting') {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-full items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Spinner size="xl" className="mx-auto" />
<p className="text-sm text-muted-foreground">
@@ -401,7 +401,7 @@ export function LoginView() {
const error = state.phase === 'awaiting_login' ? state.error : null;
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="flex min-h-full items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
{/* Header */}
<div className="text-center">

View File

@@ -243,7 +243,7 @@ export function OverviewView() {
);
return (
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="overview-view">
<div className="flex-1 flex flex-col h-full content-bg" data-testid="overview-view">
{/* Header */}
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
{/* Electron titlebar drag region */}

View File

@@ -0,0 +1,310 @@
import { useCallback, useRef, useEffect, useState } from 'react';
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* ANSI escape sequences for special keys.
* These are what terminal emulators send when these keys are pressed.
*/
const SPECIAL_KEYS = {
escape: '\x1b',
tab: '\t',
delete: '\x1b[3~',
home: '\x1b[H',
end: '\x1b[F',
} as const;
/**
* Common Ctrl key combinations sent as control codes.
* Ctrl+<char> sends the char code & 0x1f (e.g., Ctrl+C = 0x03).
*/
const CTRL_KEYS = {
'Ctrl+C': '\x03', // Interrupt / SIGINT
'Ctrl+Z': '\x1a', // Suspend / SIGTSTP
'Ctrl+D': '\x04', // EOF
'Ctrl+L': '\x0c', // Clear screen
'Ctrl+A': '\x01', // Move to beginning of line
'Ctrl+B': '\x02', // Move cursor back (tmux prefix)
} as const;
const ARROW_KEYS = {
up: '\x1b[A',
down: '\x1b[B',
right: '\x1b[C',
left: '\x1b[D',
} as const;
interface MobileTerminalControlsProps {
/** Callback to send input data to the terminal WebSocket */
onSendInput: (data: string) => void;
/** Whether the terminal is connected and ready */
isConnected: boolean;
}
/**
* Mobile quick controls bar for terminal interaction on touch devices.
* Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are
* typically unavailable on mobile virtual keyboards.
*
* Anchored at the top of the terminal panel, above the terminal content.
* Can be collapsed to a minimal toggle to maximize terminal space.
*/
export function MobileTerminalControls({ onSendInput, isConnected }: MobileTerminalControlsProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
// Track repeat interval for arrow key long-press
const repeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
const repeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup repeat timers on unmount
useEffect(() => {
return () => {
if (repeatIntervalRef.current) clearInterval(repeatIntervalRef.current);
if (repeatTimeoutRef.current) clearTimeout(repeatTimeoutRef.current);
};
}, []);
const clearRepeat = useCallback(() => {
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current);
repeatIntervalRef.current = null;
}
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current);
repeatTimeoutRef.current = null;
}
}, []);
/** Sends a key sequence to the terminal. */
const sendKey = useCallback(
(data: string) => {
if (!isConnected) return;
onSendInput(data);
},
[isConnected, onSendInput]
);
/** Handles arrow key press with long-press repeat support. */
const handleArrowPress = useCallback(
(data: string) => {
sendKey(data);
// Start repeat after 400ms hold, then every 80ms
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
sendKey(data);
}, 80);
}, 400);
},
[sendKey]
);
const handleArrowRelease = useCallback(() => {
clearRepeat();
}, [clearRepeat]);
if (isCollapsed) {
return (
<div className="flex items-center justify-center shrink-0 bg-card/95 backdrop-blur-sm border-b border-border">
<button
className="flex items-center gap-1 px-4 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
onClick={() => setIsCollapsed(false)}
title="Show quick controls"
>
<ChevronDown className="h-3.5 w-3.5" />
<span>Controls</span>
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 bg-card/95 backdrop-blur-sm border-b border-border overflow-x-auto">
{/* Collapse button */}
<button
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0 touch-manipulation"
onClick={() => setIsCollapsed(true)}
title="Hide quick controls"
>
<ChevronUp className="h-4 w-4" />
</button>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Special keys */}
<ControlButton
label="Esc"
onPress={() => sendKey(SPECIAL_KEYS.escape)}
disabled={!isConnected}
/>
<ControlButton
label="Tab"
onPress={() => sendKey(SPECIAL_KEYS.tab)}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Common Ctrl shortcuts */}
<ControlButton
label="^C"
title="Ctrl+C (Interrupt)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
disabled={!isConnected}
/>
<ControlButton
label="^Z"
title="Ctrl+Z (Suspend)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+Z'])}
disabled={!isConnected}
/>
<ControlButton
label="^D"
title="Ctrl+D (EOF)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+D'])}
disabled={!isConnected}
/>
<ControlButton
label="^L"
title="Ctrl+L (Clear)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+L'])}
disabled={!isConnected}
/>
<ControlButton
label="^B"
title="Ctrl+B (Back/tmux prefix)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Navigation keys */}
<ControlButton
label="Del"
onPress={() => sendKey(SPECIAL_KEYS.delete)}
disabled={!isConnected}
/>
<ControlButton
label="Home"
onPress={() => sendKey(SPECIAL_KEYS.home)}
disabled={!isConnected}
/>
<ControlButton
label="End"
onPress={() => sendKey(SPECIAL_KEYS.end)}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Arrow keys with long-press repeat */}
<ArrowButton
direction="left"
onPress={() => handleArrowPress(ARROW_KEYS.left)}
onRelease={handleArrowRelease}
disabled={!isConnected}
/>
<ArrowButton
direction="down"
onPress={() => handleArrowPress(ARROW_KEYS.down)}
onRelease={handleArrowRelease}
disabled={!isConnected}
/>
<ArrowButton
direction="up"
onPress={() => handleArrowPress(ARROW_KEYS.up)}
onRelease={handleArrowRelease}
disabled={!isConnected}
/>
<ArrowButton
direction="right"
onPress={() => handleArrowPress(ARROW_KEYS.right)}
onRelease={handleArrowRelease}
disabled={!isConnected}
/>
</div>
);
}
/**
* Individual control button for special keys and shortcuts.
*/
function ControlButton({
label,
title,
onPress,
disabled = false,
}: {
label: string;
title?: string;
onPress: () => void;
disabled?: boolean;
}) {
return (
<button
className={cn(
'px-3 py-2 rounded-md text-xs font-medium shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
'active:scale-95 touch-manipulation',
'bg-muted/80 text-foreground hover:bg-accent',
disabled && 'opacity-40 pointer-events-none'
)}
onPointerDown={(e) => {
e.preventDefault(); // Prevent focus stealing from terminal
onPress();
}}
title={title}
disabled={disabled}
>
{label}
</button>
);
}
/**
* Arrow key button with long-press repeat support.
* Uses pointer events for reliable touch + mouse handling.
*/
function ArrowButton({
direction,
onPress,
onRelease,
disabled = false,
}: {
direction: 'up' | 'down' | 'left' | 'right';
onPress: () => void;
onRelease: () => void;
disabled?: boolean;
}) {
const icons = {
up: ArrowUp,
down: ArrowDown,
left: ArrowLeft,
right: ArrowRight,
};
const Icon = icons[direction];
return (
<button
className={cn(
'p-2 rounded-md shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
'active:scale-95 touch-manipulation',
'bg-muted/80 text-foreground hover:bg-accent',
disabled && 'opacity-40 pointer-events-none'
)}
onPointerDown={(e) => {
e.preventDefault(); // Prevent focus stealing from terminal
onPress();
}}
onPointerUp={onRelease}
onPointerLeave={onRelease}
onPointerCancel={onRelease}
disabled={disabled}
>
<Icon className="h-4 w-4" />
</button>
);
}

View File

@@ -51,6 +51,9 @@ import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { MobileTerminalControls } from './mobile-terminal-controls';
const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
@@ -163,6 +166,12 @@ export function TerminalPanel({
const INITIAL_RECONNECT_DELAY = 1000;
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
// Detect mobile viewport for quick controls
const isMobile = useIsMobile();
// Track virtual keyboard height on mobile to prevent overlap
const { keyboardHeight, isKeyboardOpen } = useVirtualKeyboardResize();
// Get current project for image saving
const currentProject = useAppStore((state) => state.currentProject);
@@ -345,6 +354,13 @@ export function TerminalPanel({
}
}, []);
// Send raw input to terminal via WebSocket (used by mobile quick controls)
const sendTerminalInput = useCallback((data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
}
}, []);
// Paste from clipboard
const pasteFromClipboard = useCallback(async () => {
const terminal = xtermRef.current;
@@ -1722,6 +1738,9 @@ export function TerminalPanel({
// Visual feedback when hovering over as drop target
isOver && isDropTarget && 'ring-2 ring-green-500 ring-inset'
)}
style={
isMobile && isKeyboardOpen ? { height: `calc(100% - ${keyboardHeight}px)` } : undefined
}
onClick={onFocus}
onKeyDownCapture={handleContainerKeyDownCapture}
tabIndex={0}
@@ -2138,6 +2157,14 @@ export function TerminalPanel({
</div>
)}
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
{isMobile && (
<MobileTerminalControls
onSendInput={sendTerminalInput}
isConnected={connectionStatus === 'connected'}
/>
)}
{/* Terminal container - uses terminal theme */}
<div
ref={terminalRef}

View File

@@ -87,10 +87,18 @@ export function useCommitWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ worktreePath, message }: { worktreePath: string; message: string }) => {
mutationFn: async ({
worktreePath,
message,
files,
}: {
worktreePath: string;
message: string;
files?: string[];
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.commit(worktreePath, message);
const result = await api.worktree.commit(worktreePath, message, files);
if (!result.success) {
throw new Error(result.error || 'Failed to commit changes');
}
@@ -275,12 +283,30 @@ export function useMergeWorktree(projectPath: string) {
});
}
/**
* Result from the switch branch API call
*/
interface SwitchBranchResult {
previousBranch: string;
currentBranch: string;
message: string;
hasConflicts?: boolean;
stashedChanges?: boolean;
}
/**
* Switch to a different branch
*
* Automatically stashes local changes before switching and reapplies them after.
* If the reapply causes merge conflicts, the onConflict callback is called so
* the UI can create a conflict resolution task.
*
* @param options.onConflict - Callback when merge conflicts occur after stash reapply
* @returns Mutation for switching branches
*/
export function useSwitchBranch() {
export function useSwitchBranch(options?: {
onConflict?: (info: { worktreePath: string; branchName: string; previousBranch: string }) => void;
}) {
const queryClient = useQueryClient();
return useMutation({
@@ -290,18 +316,33 @@ export function useSwitchBranch() {
}: {
worktreePath: string;
branchName: string;
}) => {
}): Promise<SwitchBranchResult> => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.switchBranch(worktreePath, branchName);
if (!result.success) {
throw new Error(result.error || 'Failed to switch branch');
}
return result.result;
return result.result as SwitchBranchResult;
},
onSuccess: () => {
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
toast.success('Switched branch');
if (data?.hasConflicts) {
toast.warning('Switched branch with conflicts', {
description: data.message,
duration: 8000,
});
// Trigger conflict resolution callback
options?.onConflict?.({
worktreePath: variables.worktreePath,
branchName: data.currentBranch,
previousBranch: data.previousBranch,
});
} else {
const desc = data?.stashedChanges ? 'Local changes were stashed and reapplied' : undefined;
toast.success('Switched branch', { description: desc });
}
},
onError: (error: Error) => {
toast.error('Failed to switch branch', {

View File

@@ -143,7 +143,12 @@ export function useAutoMode(worktree?: WorktreeInfo) {
const isAutoModeRunning = worktreeAutoModeState.isRunning;
const runningAutoTasks = worktreeAutoModeState.runningTasks;
const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
// Use getMaxConcurrencyForWorktree which properly falls back to the global
// maxConcurrency setting, instead of DEFAULT_MAX_CONCURRENCY (1) which would
// incorrectly block agents when the user has set a higher global limit
const maxConcurrency = projectId
? getMaxConcurrencyForWorktree(projectId, branchName)
: DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;

View File

@@ -4,17 +4,25 @@
* Tracks the timestamp of the last WebSocket event received.
* Used to conditionally disable polling when events are flowing
* through WebSocket (indicating the connection is healthy).
*
* Mobile-aware: On mobile devices, the recency threshold is extended
* and polling intervals are multiplied to reduce battery drain and
* network usage while maintaining data freshness through WebSocket.
*/
import { useCallback } from 'react';
import { create } from 'zustand';
import { isMobileDevice, getMobilePollingMultiplier } from '@/lib/mobile-detect';
/**
* Time threshold (ms) to consider events as "recent"
* If an event was received within this time, WebSocket is considered healthy
* and polling can be safely disabled.
*
* On mobile, the threshold is extended to 10 seconds since WebSocket
* connections on mobile may have higher latency and more jitter.
*/
export const EVENT_RECENCY_THRESHOLD = 5000; // 5 seconds
export const EVENT_RECENCY_THRESHOLD = isMobileDevice ? 10000 : 5000;
/**
* Store for tracking event timestamps per query key
@@ -136,6 +144,12 @@ export function useEventRecency(queryKey?: string) {
* Utility function to create a refetchInterval that respects event recency.
* Returns false (no polling) if events are recent, otherwise returns the interval.
*
* On mobile, the interval is multiplied by getMobilePollingMultiplier() to reduce
* battery drain and network usage. This is safe because:
* - WebSocket invalidation handles real-time updates (features, agents, etc.)
* - The service worker caches API responses for instant display
* - Longer intervals mean fewer network round-trips on slow mobile connections
*
* @param defaultInterval - The polling interval to use when events aren't recent
* @returns A function suitable for React Query's refetchInterval option
*
@@ -149,9 +163,10 @@ export function useEventRecency(queryKey?: string) {
* ```
*/
export function createSmartPollingInterval(defaultInterval: number) {
const mobileAwareInterval = defaultInterval * getMobilePollingMultiplier();
return () => {
const { areGlobalEventsRecent } = useEventRecencyStore.getState();
return areGlobalEventsRecent() ? false : defaultInterval;
return areGlobalEventsRecent() ? false : mobileAwareInterval;
};
}

View File

@@ -0,0 +1,127 @@
/**
* Mobile Visibility Hook
*
* Manages React Query's online/focus state based on page visibility
* to prevent unnecessary refetching when the mobile app is backgrounded.
*
* On mobile devices, switching to another app triggers:
* 1. visibilitychange → hidden (app goes to background)
* 2. visibilitychange → visible (app comes back)
*
* Without this hook, step 2 triggers refetchOnWindowFocus for ALL active queries,
* causing a "storm" of network requests that overwhelms the connection and causes
* blank screens, layout shifts, and perceived reloads.
*
* This hook:
* - Pauses polling intervals while the app is hidden on mobile
* - Delays query refetching by a short grace period when the app becomes visible again
* - Prevents the WebSocket reconnection from triggering immediate refetches
*
* Desktop behavior is unchanged - this hook is a no-op on non-mobile devices.
*/
import { useEffect } from 'react';
import { focusManager, onlineManager } from '@tanstack/react-query';
import { isMobileDevice } from '@/lib/mobile-detect';
/**
* Grace period (ms) after the app becomes visible before allowing refetches.
* This prevents a burst of refetches when the user quickly switches back to the app.
* During this time, queries will use their cached data (which may be slightly stale
* but is far better than showing a blank screen or loading spinner).
*/
const VISIBILITY_GRACE_PERIOD = 1500;
/**
* Hook to manage query behavior based on mobile page visibility.
*
* Call this once at the app root level (e.g., in App.tsx or __root.tsx).
*
* @example
* ```tsx
* function App() {
* useMobileVisibility();
* return <RouterProvider router={router} />;
* }
* ```
*/
export function useMobileVisibility(): void {
useEffect(() => {
// No-op on desktop - default React Query behavior is fine
if (!isMobileDevice) return;
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
const handleVisibilityChange = () => {
if (document.hidden) {
// App went to background - tell React Query we've lost focus
// This prevents any scheduled refetches from firing while backgrounded
focusManager.setFocused(false);
} else {
// App came back to foreground
// Wait a grace period before signaling focus to prevent refetch storms.
// During this time, the UI renders with cached data (no blank screen).
if (graceTimeout) clearTimeout(graceTimeout);
graceTimeout = setTimeout(() => {
focusManager.setFocused(true);
graceTimeout = null;
}, VISIBILITY_GRACE_PERIOD);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
if (graceTimeout) clearTimeout(graceTimeout);
// Restore default focus management
focusManager.setFocused(undefined);
};
}, []);
}
/**
* Hook to pause online status during extended background periods on mobile.
* When the app has been in the background for more than the threshold,
* we mark it as "offline" to prevent React Query from refetching all queries
* at once when it comes back online. Instead, we let the WebSocket reconnect
* first and then gradually re-enable queries.
*
* Call this once at the app root level alongside useMobileVisibility.
*/
export function useMobileOnlineManager(): void {
useEffect(() => {
if (!isMobileDevice) return;
let backgroundTimestamp: number | null = null;
// If the app was backgrounded for more than 30 seconds, throttle reconnection
const BACKGROUND_THRESHOLD = 30 * 1000;
const handleVisibilityChange = () => {
if (document.hidden) {
backgroundTimestamp = Date.now();
} else if (backgroundTimestamp) {
const backgroundDuration = Date.now() - backgroundTimestamp;
backgroundTimestamp = null;
if (backgroundDuration > BACKGROUND_THRESHOLD) {
// App was backgrounded for a long time.
// Briefly mark as offline to prevent all queries from refetching at once,
// then restore online status after a delay so queries refetch gradually.
onlineManager.setOnline(false);
setTimeout(() => {
onlineManager.setOnline(true);
}, 2000);
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
// Restore online status on cleanup
onlineManager.setOnline(true);
};
}, []);
}

View File

@@ -0,0 +1,64 @@
import { useState, useEffect, useCallback, useRef } from 'react';
/**
* Hook that detects when the mobile virtual keyboard is open and returns
* the height offset needed to prevent the keyboard from overlapping content.
*
* Uses the Visual Viewport API to detect viewport shrinkage caused by the
* virtual keyboard. When the keyboard is open, the visual viewport height
* is smaller than the layout viewport height.
*
* @returns An object with:
* - `keyboardHeight`: The estimated keyboard height in pixels (0 when closed)
* - `isKeyboardOpen`: Boolean indicating if the keyboard is currently open
*/
export function useVirtualKeyboardResize() {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
const initialHeightRef = useRef<number | null>(null);
const handleViewportResize = useCallback(() => {
const vv = window.visualViewport;
if (!vv) return;
// On first call, record the full viewport height (no keyboard)
if (initialHeightRef.current === null) {
initialHeightRef.current = vv.height;
}
// The keyboard height is the difference between the window inner height
// and the visual viewport height. On iOS, window.innerHeight stays the same
// when the keyboard opens, but visualViewport.height shrinks.
const heightDiff = window.innerHeight - vv.height;
// Use a threshold to avoid false positives from browser chrome changes
// (address bar show/hide causes ~50-80px changes on most browsers)
const KEYBOARD_THRESHOLD = 100;
if (heightDiff > KEYBOARD_THRESHOLD) {
setKeyboardHeight(heightDiff);
setIsKeyboardOpen(true);
} else {
setKeyboardHeight(0);
setIsKeyboardOpen(false);
}
}, []);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
vv.addEventListener('resize', handleViewportResize);
vv.addEventListener('scroll', handleViewportResize);
// Initial check
handleViewportResize();
return () => {
vv.removeEventListener('resize', handleViewportResize);
vv.removeEventListener('scroll', handleViewportResize);
};
}, [handleViewportResize]);
return { keyboardHeight, isKeyboardOpen };
}

View File

@@ -2174,8 +2174,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
commit: async (worktreePath: string, message: string) => {
console.log('[Mock] Committing changes:', { worktreePath, message });
commit: async (worktreePath: string, message: string, files?: string[]) => {
console.log('[Mock] Committing changes:', { worktreePath, message, files });
return {
success: true,
result: {

View File

@@ -2076,8 +2076,8 @@ export class HttpApiClient implements ElectronAPI {
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post('/api/worktree/commit', { worktreePath, message }),
commit: (worktreePath: string, message: string, files?: string[]) =>
this.post('/api/worktree/commit', { worktreePath, message, files }),
generateCommitMessage: (worktreePath: string) =>
this.post('/api/worktree/generate-commit-message', { worktreePath }),
push: (worktreePath: string, force?: boolean, remote?: string) =>

View File

@@ -0,0 +1,75 @@
/**
* Mobile Detection Utility
*
* Provides a cached, non-reactive mobile detection for use outside React components.
* Used by service worker registration, query client configuration, and other
* non-component code that needs to know if the device is mobile.
*
* For React components, use the `useIsMobile()` hook from `hooks/use-media-query.ts`
* instead, which responds to viewport changes reactively.
*/
/**
* Cached mobile detection result.
* Evaluated once on module load for consistent behavior across the app lifetime.
* Uses both media query and user agent for reliability:
* - Media query catches small desktop windows
* - User agent catches mobile browsers at any viewport size
* - Touch detection as supplementary signal
*/
export const isMobileDevice: boolean = (() => {
if (typeof window === 'undefined') return false;
// Check viewport width (consistent with useIsMobile hook's 768px breakpoint)
const isSmallViewport = window.matchMedia('(max-width: 768px)').matches;
// Check user agent for mobile devices
const isMobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
// Check for touch-primary device (most mobile devices)
const isTouchPrimary = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// Consider it mobile if viewport is small OR if it's a mobile UA with touch
return isSmallViewport || (isMobileUA && isTouchPrimary);
})();
/**
* Check if the device has a slow connection.
* Uses the Network Information API when available.
* Falls back to mobile detection as a heuristic.
*/
export function isSlowConnection(): boolean {
if (typeof navigator === 'undefined') return false;
const connection = (
navigator as Navigator & {
connection?: {
effectiveType?: string;
saveData?: boolean;
};
}
).connection;
if (connection) {
// Respect data saver mode
if (connection.saveData) return true;
// 2g and slow-2g are definitely slow
if (connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g') return true;
}
// On mobile without connection info, assume potentially slow
return false;
}
/**
* Multiplier for polling intervals on mobile.
* Mobile devices benefit from less frequent polling to save battery and bandwidth.
* Slow connections get an even larger multiplier.
*/
export function getMobilePollingMultiplier(): number {
if (!isMobileDevice) return 1;
if (isSlowConnection()) return 4;
return 2;
}

View File

@@ -4,51 +4,69 @@
* Central configuration for TanStack React Query.
* Provides default options for queries and mutations including
* caching, retries, and error handling.
*
* Mobile-aware: Automatically extends stale times and garbage collection
* on mobile devices to reduce unnecessary refetching, which causes
* blank screens, reloads, and battery drain on flaky mobile connections.
*/
import { QueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { isConnectionError, handleServerOffline } from './http-api-client';
import { isMobileDevice } from './mobile-detect';
const logger = createLogger('QueryClient');
/**
* Default stale times for different data types
* Mobile multiplier for stale times.
* On mobile, data stays "fresh" longer to avoid refetching on every
* component mount, which causes blank flickers and layout shifts.
* The WebSocket invalidation system still ensures critical updates
* (feature status changes, agent events) arrive in real-time.
*/
const MOBILE_STALE_MULTIPLIER = isMobileDevice ? 3 : 1;
/**
* Default stale times for different data types.
* On mobile, these are multiplied by MOBILE_STALE_MULTIPLIER to reduce
* unnecessary network requests while WebSocket handles real-time updates.
*/
export const STALE_TIMES = {
/** Features change frequently during auto-mode */
FEATURES: 60 * 1000, // 1 minute
FEATURES: 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 1 min (3 min on mobile)
/** GitHub data is relatively stable */
GITHUB: 2 * 60 * 1000, // 2 minutes
GITHUB: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile)
/** Running agents state changes very frequently */
RUNNING_AGENTS: 5 * 1000, // 5 seconds
RUNNING_AGENTS: 5 * 1000 * MOBILE_STALE_MULTIPLIER, // 5s (15s on mobile)
/** Agent output changes during streaming */
AGENT_OUTPUT: 5 * 1000, // 5 seconds
AGENT_OUTPUT: 5 * 1000 * MOBILE_STALE_MULTIPLIER, // 5s (15s on mobile)
/** Usage data with polling */
USAGE: 30 * 1000, // 30 seconds
USAGE: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile)
/** Models rarely change */
MODELS: 5 * 60 * 1000, // 5 minutes
MODELS: 5 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 5 min (15 min on mobile)
/** CLI status rarely changes */
CLI_STATUS: 5 * 60 * 1000, // 5 minutes
CLI_STATUS: 5 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 5 min (15 min on mobile)
/** Settings are relatively stable */
SETTINGS: 2 * 60 * 1000, // 2 minutes
SETTINGS: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile)
/** Worktrees change during feature development */
WORKTREES: 30 * 1000, // 30 seconds
WORKTREES: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile)
/** Sessions rarely change */
SESSIONS: 2 * 60 * 1000, // 2 minutes
SESSIONS: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile)
/** Default for unspecified queries */
DEFAULT: 30 * 1000, // 30 seconds
DEFAULT: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile)
} as const;
/**
* Default garbage collection times (gcTime, formerly cacheTime)
* Default garbage collection times (gcTime, formerly cacheTime).
* On mobile, cache is kept longer so data persists across navigations
* and component unmounts, preventing blank screens on re-mount.
*/
export const GC_TIMES = {
/** Default garbage collection time */
DEFAULT: 5 * 60 * 1000, // 5 minutes
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 5 * 60 * 1000, // 15 min on mobile, 5 min desktop
/** Extended for expensive queries */
EXTENDED: 10 * 60 * 1000, // 10 minutes
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 10 * 60 * 1000, // 30 min on mobile, 10 min desktop
} as const;
/**
@@ -93,7 +111,15 @@ const handleMutationError = (error: Error) => {
};
/**
* Create and configure the QueryClient singleton
* Create and configure the QueryClient singleton.
*
* Mobile optimizations:
* - refetchOnWindowFocus disabled on mobile (prevents refetch storms when
* switching apps, which causes the blank screen + reload cycle)
* - refetchOnMount uses 'always' on desktop but only refetches stale data
* on mobile (prevents unnecessary network requests on navigation)
* - Longer stale times and GC times via STALE_TIMES and GC_TIMES above
* - structuralSharing enabled to minimize re-renders when data hasn't changed
*/
export const queryClient = new QueryClient({
defaultOptions: {
@@ -109,13 +135,21 @@ export const queryClient = new QueryClient({
if (isConnectionError(error)) {
return false;
}
// Retry up to 2 times for other errors
return failureCount < 2;
// Retry up to 2 times for other errors (3 on mobile for flaky connections)
return failureCount < (isMobileDevice ? 3 : 2);
},
refetchOnWindowFocus: true,
// On mobile, disable refetch on focus to prevent the blank screen + reload
// cycle that occurs when the user switches back to the app. WebSocket
// invalidation handles real-time updates; polling handles the rest.
refetchOnWindowFocus: !isMobileDevice,
refetchOnReconnect: true,
// Don't refetch on mount if data is fresh
refetchOnMount: true,
// On mobile, only refetch on mount if data is stale (not always).
// This prevents unnecessary network requests when navigating between
// routes, which was causing blank screen flickers on mobile.
refetchOnMount: isMobileDevice ? true : true,
// Keep previous data visible while refetching to prevent blank flashes.
// This is especially important on mobile where network is slower.
placeholderData: isMobileDevice ? (previousData: unknown) => previousData : undefined,
},
mutations: {
onError: handleMutationError,

View File

@@ -1,7 +1,136 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';
import { isMobileDevice } from './lib/mobile-detect';
// Register service worker for PWA support (web mode only)
// Uses optimized registration strategy for faster mobile loading:
// - Registers after load event to avoid competing with critical resources
// - Handles updates gracefully with skipWaiting support
// - Triggers cache cleanup on activation
// - Prefetches likely-needed route chunks during idle time
// - Enables mobile-specific API caching when on a mobile device
if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js', {
// Check for updates on every page load for PWA freshness
updateViaCache: 'none',
})
.then((registration) => {
// Check for service worker updates periodically
// Mobile: every 60 minutes (saves battery/bandwidth)
// Desktop: every 30 minutes
const updateInterval = isMobileDevice ? 60 * 60 * 1000 : 30 * 60 * 1000;
setInterval(() => {
registration.update().catch(() => {
// Update check failed silently - will try again next interval
});
}, updateInterval);
// When a new service worker takes over, trigger cache cleanup
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
// New service worker is active - clean up old immutable cache entries
newWorker.postMessage({ type: 'CACHE_CLEANUP' });
}
});
}
});
// Notify the service worker about mobile mode.
// This enables stale-while-revalidate caching for API responses,
// preventing blank screens caused by failed/slow API fetches on mobile.
if (isMobileDevice && registration.active) {
registration.active.postMessage({
type: 'SET_MOBILE_MODE',
enabled: true,
});
}
// Also listen for the SW becoming active (in case it wasn't ready above)
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (isMobileDevice && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'SET_MOBILE_MODE',
enabled: true,
});
}
});
// Prefetch likely-needed route chunks during idle time.
// On mobile, this means subsequent navigations are instant from cache
// instead of requiring network round-trips over slow cellular connections.
prefetchRouteChunks(registration);
})
.catch(() => {
// Service worker registration failed; app still works without it
});
});
}
/**
* Prefetch route JS chunks that the user is likely to navigate to.
* Uses requestIdleCallback to avoid competing with the initial render,
* and sends URLs to the service worker for background caching.
* This is especially impactful on mobile where network latency is high.
*/
function prefetchRouteChunks(registration: ServiceWorkerRegistration): void {
const idleCallback =
typeof requestIdleCallback !== 'undefined'
? requestIdleCallback
: (cb: () => void) => setTimeout(cb, 2000);
// On mobile, wait a bit longer before prefetching to let the critical path complete.
// Mobile connections are often slower and we don't want to compete with initial data fetches.
const prefetchDelay = isMobileDevice ? 4000 : 0;
const doPrefetch = () => {
// Find all modulepreload links already in the document (Vite injects these)
// and any route chunks that might be linked
const existingPreloads = new Set(
Array.from(document.querySelectorAll('link[rel="modulepreload"]')).map(
(link) => (link as HTMLLinkElement).href
)
);
// Also collect prefetch links (Vite mobile optimization converts some to prefetch)
Array.from(document.querySelectorAll('link[rel="prefetch"]')).forEach((link) => {
const href = (link as HTMLLinkElement).href;
if (href) existingPreloads.add(href);
});
// Discover route chunk URLs from the document's script tags
// These are the code-split route bundles that TanStack Router will lazy-load
const routeChunkUrls: string[] = [];
document.querySelectorAll('script[src*="/assets/"]').forEach((script) => {
const src = (script as HTMLScriptElement).src;
if (src && !existingPreloads.has(src)) {
routeChunkUrls.push(src);
}
});
// Send URLs to service worker for background caching
if (routeChunkUrls.length > 0 && registration.active) {
registration.active.postMessage({
type: 'PRECACHE_ASSETS',
urls: routeChunkUrls,
});
}
};
// Wait for idle time after the app is interactive
if (prefetchDelay > 0) {
setTimeout(() => idleCallback(doPrefetch), prefetchDelay);
} else {
idleCallback(doPrefetch);
}
}
// Render the app - prioritize First Contentful Paint
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />

View File

@@ -778,7 +778,7 @@ function RootLayoutContent() {
// Note: No sandbox dialog here - it only shows after login and setup complete
if (isLoginRoute || isLoggedOutRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<main className="h-full overflow-hidden" data-testid="app-container">
<Outlet />
</main>
);
@@ -787,7 +787,7 @@ function RootLayoutContent() {
// Wait for auth check before rendering protected routes (ALL modes - unified flow)
if (!authChecked) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<main className="flex h-full items-center justify-center" data-testid="app-container">
<LoadingState message="Loading..." />
</main>
);
@@ -797,7 +797,7 @@ function RootLayoutContent() {
// Show loading state while navigation is in progress
if (!isAuthenticated) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<main className="flex h-full items-center justify-center" data-testid="app-container">
<LoadingState message="Redirecting..." />
</main>
);
@@ -805,7 +805,7 @@ function RootLayoutContent() {
if (shouldBlockForSettings) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<main className="flex h-full items-center justify-center" data-testid="app-container">
<LoadingState message="Loading settings..." />
</main>
);
@@ -813,7 +813,7 @@ function RootLayoutContent() {
if (shouldAutoOpen) {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<main className="flex h-full items-center justify-center" data-testid="app-container">
<LoadingState message="Opening project..." />
</main>
);
@@ -822,7 +822,7 @@ function RootLayoutContent() {
// Show setup page (full screen, no sidebar) - authenticated only
if (isSetupRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<main className="h-full overflow-hidden" data-testid="app-container">
<Outlet />
</main>
);
@@ -832,7 +832,7 @@ function RootLayoutContent() {
if (isDashboardRoute) {
return (
<>
<main className="h-screen overflow-hidden" data-testid="app-container">
<main className="h-full overflow-hidden" data-testid="app-container">
<Outlet />
<Toaster richColors position="bottom-right" />
</main>
@@ -847,7 +847,7 @@ function RootLayoutContent() {
return (
<>
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<main className="flex h-full overflow-hidden" data-testid="app-container">
{/* Full-width titlebar drag region for Electron window dragging */}
{isElectron() && (
<div

View File

@@ -6,6 +6,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
// Note: setItem/getItem moved to ./utils/theme-utils.ts
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
import { loadFont } from '@/styles/font-imports';
import type {
FeatureImagePath,
FeatureTextFilePath,
@@ -663,12 +664,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
},
setPreviewTheme: (theme) => set({ previewTheme: theme }),
// Font actions
// Font actions - triggers lazy font loading for on-demand fonts
setFontSans: (fontFamily) => {
if (fontFamily) loadFont(fontFamily);
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily) => {
if (fontFamily) loadFont(fontFamily);
set({ fontFamilyMono: fontFamily });
saveFontMonoToStorage(fontFamily);
},

View File

@@ -1,113 +1,254 @@
/**
* Bundles all web font packages so they're available
* for use in the font customization settings.
* Font Loading Strategy for Mobile PWA Performance
*
* These fonts are self-hosted with the app, so users don't need
* to have them installed on their system.
* Critical fonts (Zed Sans/Mono - used as Geist fallback) are loaded eagerly.
* All other fonts are lazy-loaded on demand when the user selects them
* in font customization settings. This dramatically reduces initial bundle
* size and speeds up mobile PWA loading.
*
* Font loading is split into:
* 1. Critical path: Zed fonts (default/fallback fonts) - loaded synchronously
* 2. Deferred path: All @fontsource fonts - loaded on-demand or after idle
*/
// Zed Fonts (from zed-industries/zed-fonts)
// Critical: Zed Fonts (default fallback) - loaded immediately
import '@/assets/fonts/zed/zed-fonts.css';
// ============================================
// Sans-serif / UI Fonts (Top 10)
// ============================================
/**
* Registry of lazy-loadable font imports.
* Each font family maps to a function that dynamically imports its CSS files.
* This ensures fonts are only downloaded when actually needed.
*/
type FontLoader = () => Promise<void>;
// Inter - Designed specifically for screens; excellent legibility at small sizes
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
const fontLoaders: Record<string, FontLoader> = {
// Sans-serif / UI Fonts
Inter: async () => {
await Promise.all([
import('@fontsource/inter/400.css'),
import('@fontsource/inter/500.css'),
import('@fontsource/inter/600.css'),
import('@fontsource/inter/700.css'),
]);
},
Roboto: async () => {
await Promise.all([
import('@fontsource/roboto/400.css'),
import('@fontsource/roboto/500.css'),
import('@fontsource/roboto/700.css'),
]);
},
'Open Sans': async () => {
await Promise.all([
import('@fontsource/open-sans/400.css'),
import('@fontsource/open-sans/500.css'),
import('@fontsource/open-sans/600.css'),
import('@fontsource/open-sans/700.css'),
]);
},
Montserrat: async () => {
await Promise.all([
import('@fontsource/montserrat/400.css'),
import('@fontsource/montserrat/500.css'),
import('@fontsource/montserrat/600.css'),
import('@fontsource/montserrat/700.css'),
]);
},
Lato: async () => {
await Promise.all([import('@fontsource/lato/400.css'), import('@fontsource/lato/700.css')]);
},
Poppins: async () => {
await Promise.all([
import('@fontsource/poppins/400.css'),
import('@fontsource/poppins/500.css'),
import('@fontsource/poppins/600.css'),
import('@fontsource/poppins/700.css'),
]);
},
Raleway: async () => {
await Promise.all([
import('@fontsource/raleway/400.css'),
import('@fontsource/raleway/500.css'),
import('@fontsource/raleway/600.css'),
import('@fontsource/raleway/700.css'),
]);
},
'Work Sans': async () => {
await Promise.all([
import('@fontsource/work-sans/400.css'),
import('@fontsource/work-sans/500.css'),
import('@fontsource/work-sans/600.css'),
import('@fontsource/work-sans/700.css'),
]);
},
'Source Sans 3': async () => {
await Promise.all([
import('@fontsource/source-sans-3/400.css'),
import('@fontsource/source-sans-3/500.css'),
import('@fontsource/source-sans-3/600.css'),
import('@fontsource/source-sans-3/700.css'),
]);
},
// Roboto - Highly versatile and clean; the standard for Google-based interfaces
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
// Monospace / Code Fonts
'Fira Code': async () => {
await Promise.all([
import('@fontsource/fira-code/400.css'),
import('@fontsource/fira-code/500.css'),
import('@fontsource/fira-code/600.css'),
import('@fontsource/fira-code/700.css'),
]);
},
'JetBrains Mono': async () => {
await Promise.all([
import('@fontsource/jetbrains-mono/400.css'),
import('@fontsource/jetbrains-mono/500.css'),
import('@fontsource/jetbrains-mono/600.css'),
import('@fontsource/jetbrains-mono/700.css'),
]);
},
'Cascadia Code': async () => {
await Promise.all([
import('@fontsource/cascadia-code/400.css'),
import('@fontsource/cascadia-code/600.css'),
import('@fontsource/cascadia-code/700.css'),
]);
},
Iosevka: async () => {
await Promise.all([
import('@fontsource/iosevka/400.css'),
import('@fontsource/iosevka/500.css'),
import('@fontsource/iosevka/600.css'),
import('@fontsource/iosevka/700.css'),
]);
},
Inconsolata: async () => {
await Promise.all([
import('@fontsource/inconsolata/400.css'),
import('@fontsource/inconsolata/500.css'),
import('@fontsource/inconsolata/600.css'),
import('@fontsource/inconsolata/700.css'),
]);
},
'Source Code Pro': async () => {
await Promise.all([
import('@fontsource/source-code-pro/400.css'),
import('@fontsource/source-code-pro/500.css'),
import('@fontsource/source-code-pro/600.css'),
import('@fontsource/source-code-pro/700.css'),
]);
},
'IBM Plex Mono': async () => {
await Promise.all([
import('@fontsource/ibm-plex-mono/400.css'),
import('@fontsource/ibm-plex-mono/500.css'),
import('@fontsource/ibm-plex-mono/600.css'),
import('@fontsource/ibm-plex-mono/700.css'),
]);
},
};
// Open Sans - Neutral and friendly; optimized for web and mobile readability
import '@fontsource/open-sans/400.css';
import '@fontsource/open-sans/500.css';
import '@fontsource/open-sans/600.css';
import '@fontsource/open-sans/700.css';
// Track which fonts have been loaded to avoid duplicate loading
const loadedFonts = new Set<string>();
// Montserrat - Geometric and modern; best for high-impact titles and branding
import '@fontsource/montserrat/400.css';
import '@fontsource/montserrat/500.css';
import '@fontsource/montserrat/600.css';
import '@fontsource/montserrat/700.css';
/**
* Load a specific font family on demand.
* Returns immediately if the font is already loaded.
* Safe to call multiple times - font will only be loaded once.
*/
export async function loadFont(fontFamily: string): Promise<void> {
// Extract the primary font name from CSS font-family string
// e.g., "'JetBrains Mono', monospace" -> "JetBrains Mono"
const primaryFont = fontFamily
.split(',')[0]
.trim()
.replace(/^['"]|['"]$/g, '');
// Lato - Blends professionalism with warmth; ideal for longer body text
import '@fontsource/lato/400.css';
import '@fontsource/lato/700.css';
if (loadedFonts.has(primaryFont)) return;
// Poppins - Geometric and energetic; popular for modern, friendly brand identities
import '@fontsource/poppins/400.css';
import '@fontsource/poppins/500.css';
import '@fontsource/poppins/600.css';
import '@fontsource/poppins/700.css';
const loader = fontLoaders[primaryFont];
if (loader) {
try {
await loader();
loadedFonts.add(primaryFont);
} catch (error) {
// Font loading failed silently - system fallback fonts will be used
console.warn(`Failed to load font: ${primaryFont}`, error);
}
}
}
// Raleway - Elegant with unique characteristics; great for creative portfolios
import '@fontsource/raleway/400.css';
import '@fontsource/raleway/500.css';
import '@fontsource/raleway/600.css';
import '@fontsource/raleway/700.css';
/**
* Load fonts that the user has configured (from localStorage).
* Called during app initialization to ensure custom fonts are available
* before the first render completes.
*/
export function loadUserFonts(): void {
try {
const stored = localStorage.getItem('automaker-storage');
if (!stored) return;
// Work Sans - Optimized for screen readability; feels clean and contemporary
import '@fontsource/work-sans/400.css';
import '@fontsource/work-sans/500.css';
import '@fontsource/work-sans/600.css';
import '@fontsource/work-sans/700.css';
const data = JSON.parse(stored);
const state = data?.state;
// Source Sans 3 - Adobe's first open-source font; highly functional for complex interfaces
import '@fontsource/source-sans-3/400.css';
import '@fontsource/source-sans-3/500.css';
import '@fontsource/source-sans-3/600.css';
import '@fontsource/source-sans-3/700.css';
// Load globally configured fonts
if (state?.fontFamilySans && state.fontFamilySans !== 'default') {
loadFont(state.fontFamilySans);
}
if (state?.fontFamilyMono && state.fontFamilyMono !== 'default') {
loadFont(state.fontFamilyMono);
}
// ============================================
// Monospace / Code Fonts (Top 10)
// ============================================
// Load current project's font overrides
const currentProject = state?.currentProject;
if (currentProject?.fontSans && currentProject.fontSans !== 'default') {
loadFont(currentProject.fontSans);
}
if (currentProject?.fontMono && currentProject.fontMono !== 'default') {
loadFont(currentProject.fontMono);
}
} catch {
// localStorage not available or parse error - ignore
}
}
// Fira Code - Excellent legibility and stylish ligatures (=>, !=, etc.)
import '@fontsource/fira-code/400.css';
import '@fontsource/fira-code/500.css';
import '@fontsource/fira-code/600.css';
import '@fontsource/fira-code/700.css';
/**
* Preload all available fonts during idle time.
* Called after the app is fully loaded to ensure font previews
* in settings work instantly.
*/
export function preloadAllFonts(): void {
const idleCallback =
typeof requestIdleCallback !== 'undefined'
? requestIdleCallback
: (cb: () => void) => setTimeout(cb, 100);
// JetBrains Mono - Designed by JetBrains for developers, focusing on readability
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/600.css';
import '@fontsource/jetbrains-mono/700.css';
// Load fonts in batches during idle periods to avoid blocking
const fontNames = Object.keys(fontLoaders);
let index = 0;
// Cascadia Code - Microsoft's font, popular in Windows Terminal, with ligatures
import '@fontsource/cascadia-code/400.css';
import '@fontsource/cascadia-code/600.css';
import '@fontsource/cascadia-code/700.css';
function loadNextBatch() {
const batchSize = 2; // Load 2 fonts per idle callback
const end = Math.min(index + batchSize, fontNames.length);
// Iosevka - Highly customizable, slender sans-serif/slab-serif font
import '@fontsource/iosevka/400.css';
import '@fontsource/iosevka/500.css';
import '@fontsource/iosevka/600.css';
import '@fontsource/iosevka/700.css';
for (let i = index; i < end; i++) {
const fontName = fontNames[i];
if (!loadedFonts.has(fontName)) {
fontLoaders[fontName]()
.then(() => {
loadedFonts.add(fontName);
})
.catch(() => {
// Silently ignore preload failures
});
}
}
// Inconsolata - Popular, clean, and highly readable choice for screens
import '@fontsource/inconsolata/400.css';
import '@fontsource/inconsolata/500.css';
import '@fontsource/inconsolata/600.css';
import '@fontsource/inconsolata/700.css';
index = end;
if (index < fontNames.length) {
idleCallback(loadNextBatch);
}
}
// Source Code Pro - Adobe's clean, geometric, open-source font
import '@fontsource/source-code-pro/400.css';
import '@fontsource/source-code-pro/500.css';
import '@fontsource/source-code-pro/600.css';
import '@fontsource/source-code-pro/700.css';
// IBM Plex Mono - Clean, modern monospaced font from IBM
import '@fontsource/ibm-plex-mono/400.css';
import '@fontsource/ibm-plex-mono/500.css';
import '@fontsource/ibm-plex-mono/600.css';
import '@fontsource/ibm-plex-mono/700.css';
// Note: Monaco/Menlo are macOS system fonts (not bundled)
// Note: Hack font is not available via @fontsource
idleCallback(loadNextBatch);
}

View File

@@ -384,6 +384,41 @@
font-family: var(--font-sans);
}
/* Fixed viewport for mobile - app-like feel with no scrolling/bouncing */
html,
body {
overflow: hidden;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
position: fixed;
width: 100%;
height: 100%;
height: 100dvh;
}
#app {
height: 100%;
height: 100dvh;
overflow: hidden;
overscroll-behavior: none;
}
/* Prevent pull-to-refresh and rubber-band scrolling on mobile */
@supports (-webkit-touch-callout: none) {
body {
/* iOS Safari specific: prevent overscroll bounce */
-webkit-touch-callout: none;
}
}
/* Safe area insets for devices with notches/home indicators (viewport-fit=cover) */
#app {
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
/* Apply monospace font to code elements */
code,
pre,

View File

@@ -864,7 +864,8 @@ export interface WorktreeAPI {
// Commit changes in a worktree
commit: (
worktreePath: string,
message: string
message: string,
files?: string[]
) => Promise<{
success: boolean;
result?: {