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

@@ -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 };
}