mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
feat: Mobile improvements and Add selective file staging and improve branch switching
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
127
apps/ui/src/hooks/use-mobile-visibility.ts
Normal file
127
apps/ui/src/hooks/use-mobile-visibility.ts
Normal 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
64
apps/ui/src/hooks/use-virtual-keyboard-resize.ts
Normal file
64
apps/ui/src/hooks/use-virtual-keyboard-resize.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user