Comprehensive set of mobile and all improvements phase 1

This commit is contained in:
gsxdsm
2026-02-17 17:33:11 -08:00
parent 7fcf3c1e1f
commit cb44f8a717
36 changed files with 2037 additions and 304 deletions

View File

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

View File

@@ -692,6 +692,10 @@ export class HttpApiClient implements ElectronAPI {
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
private reconnectTimer: NodeJS.Timeout | null = null;
private isConnecting = false;
/** Consecutive reconnect failure count for exponential backoff */
private reconnectAttempts = 0;
/** Visibility change handler reference for cleanup */
private visibilityHandler: (() => void) | null = null;
constructor() {
this.serverUrl = getServerUrl();
@@ -709,6 +713,27 @@ export class HttpApiClient implements ElectronAPI {
this.connectWebSocket();
});
}
// OPTIMIZATION: Reconnect WebSocket immediately when tab becomes visible
// This eliminates the reconnection delay after tab discard/background
this.visibilityHandler = () => {
if (document.visibilityState === 'visible') {
// If WebSocket is disconnected, reconnect immediately
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
logger.info('Tab became visible - attempting immediate WebSocket reconnect');
// Clear any pending reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.reconnectAttempts = 0; // Reset backoff on visibility change
this.connectWebSocket();
}
}
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.visibilityHandler);
}
}
/**
@@ -832,6 +857,7 @@ export class HttpApiClient implements ElectronAPI {
this.ws.onopen = () => {
logger.info('WebSocket connected');
this.isConnecting = false;
this.reconnectAttempts = 0; // Reset backoff on successful connection
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
@@ -863,12 +889,27 @@ export class HttpApiClient implements ElectronAPI {
logger.info('WebSocket disconnected');
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
// OPTIMIZATION: Exponential backoff instead of fixed 5-second delay
// First attempt: immediate (0ms), then 500ms → 1s → 2s → 5s max
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
const backoffDelays = [0, 500, 1000, 2000, 5000];
const delayMs =
backoffDelays[Math.min(this.reconnectAttempts, backoffDelays.length - 1)] ?? 5000;
this.reconnectAttempts++;
if (delayMs === 0) {
// Immediate reconnect on first attempt
this.connectWebSocket();
}, 5000);
} else {
logger.info(
`WebSocket reconnecting in ${delayMs}ms (attempt ${this.reconnectAttempts})`
);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connectWebSocket();
}, delayMs);
}
}
};
@@ -2147,8 +2188,8 @@ export class HttpApiClient implements ElectronAPI {
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
discardChanges: (worktreePath: string) =>
this.post('/api/worktree/discard-changes', { worktreePath }),
discardChanges: (worktreePath: string, files?: string[]) =>
this.post('/api/worktree/discard-changes', { worktreePath, files }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';

View File

@@ -10,7 +10,7 @@
* blank screens, reloads, and battery drain on flaky mobile connections.
*/
import { QueryClient } from '@tanstack/react-query';
import { QueryClient, keepPreviousData } from '@tanstack/react-query';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { isConnectionError, handleServerOffline } from './http-api-client';
@@ -63,10 +63,10 @@ export const STALE_TIMES = {
* and component unmounts, preventing blank screens on re-mount.
*/
export const GC_TIMES = {
/** Default garbage collection time */
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 5 * 60 * 1000, // 15 min on mobile, 5 min desktop
/** Default garbage collection time - must exceed persist maxAge for cache to survive tab discard */
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 10 * 60 * 1000, // 15 min on mobile, 10 min desktop
/** Extended for expensive queries */
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 10 * 60 * 1000, // 30 min on mobile, 10 min desktop
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 15 * 60 * 1000, // 30 min on mobile, 15 min desktop
} as const;
/**
@@ -143,13 +143,14 @@ export const queryClient = new QueryClient({
// invalidation handles real-time updates; polling handles the rest.
refetchOnWindowFocus: !isMobileDevice,
refetchOnReconnect: true,
// On mobile, only refetch on mount if data is stale (not always).
// On mobile, only refetch on mount if data is stale (true = refetch only when stale).
// On desktop, always refetch on mount for freshest data ('always' = refetch even if fresh).
// This prevents unnecessary network requests when navigating between
// routes, which was causing blank screen flickers on mobile.
refetchOnMount: isMobileDevice ? true : true,
refetchOnMount: isMobileDevice ? true : 'always',
// 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,
placeholderData: isMobileDevice ? keepPreviousData : undefined,
},
mutations: {
onError: handleMutationError,

View File

@@ -0,0 +1,133 @@
/**
* React Query Cache Persistence
*
* Persists the React Query cache to IndexedDB so that after a tab discard
* or page reload, the user sees cached data instantly while fresh data
* loads in the background.
*
* Uses @tanstack/react-query-persist-client with idb-keyval for IndexedDB storage.
* Cached data is treated as stale on restore and silently refetched.
*/
import { get, set, del } from 'idb-keyval';
import type { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('QueryPersist');
const IDB_KEY = 'automaker-react-query-cache';
/**
* Maximum age of persisted cache before it's discarded (24 hours).
* After this time, the cache is considered too old and will be removed.
*/
export const PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Throttle time for persisting cache to IndexedDB.
* Prevents excessive writes during rapid query updates.
*/
export const PERSIST_THROTTLE_MS = 2000;
/**
* Query key prefixes that should NOT be persisted.
* Auth-related and volatile data should always be fetched fresh.
*/
const EXCLUDED_QUERY_KEY_PREFIXES = ['auth', 'health', 'wsToken', 'sandbox'];
/**
* Check if a query key should be excluded from persistence
*/
function shouldExcludeQuery(queryKey: readonly unknown[]): boolean {
if (queryKey.length === 0) return false;
const firstKey = String(queryKey[0]);
return EXCLUDED_QUERY_KEY_PREFIXES.some((prefix) => firstKey.startsWith(prefix));
}
/**
* Check whether there is a recent enough React Query cache in IndexedDB
* to consider the app "warm" (i.e., safe to skip blocking on the server
* health check and show the UI immediately).
*
* Returns true only if:
* 1. The cache exists and is recent (within maxAgeMs)
* 2. The cache buster matches the current build hash
*
* If the buster doesn't match, PersistQueryClientProvider will wipe the
* cache on restore — so we must NOT skip the server wait in that case,
* otherwise the board renders with empty queries and no data.
*
* This is a read-only probe — it does not restore the cache (that is
* handled by PersistQueryClientProvider automatically).
*/
export async function hasWarmIDBCache(
currentBuster: string,
maxAgeMs = PERSIST_MAX_AGE_MS
): Promise<boolean> {
try {
const client = await get<PersistedClient>(IDB_KEY);
if (!client) return false;
// PersistedClient stores a `timestamp` (ms) when it was last persisted
const age = Date.now() - (client.timestamp ?? 0);
if (age >= maxAgeMs) return false;
// If the buster doesn't match, PersistQueryClientProvider will wipe the cache.
// Treat this as a cold start — we need fresh data from the server.
if (currentBuster && client.buster !== currentBuster) return false;
return true;
} catch {
return false;
}
}
/**
* Create an IndexedDB-based persister for React Query.
*
* This persister:
* - Stores the full query cache in IndexedDB under a single key
* - Filters out auth/health queries that shouldn't be persisted
* - Handles errors gracefully (cache persistence is best-effort)
*/
export function createIDBPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
try {
// Filter out excluded queries before persisting
const filteredClient: PersistedClient = {
...client,
clientState: {
...client.clientState,
queries: client.clientState.queries.filter(
(query) => !shouldExcludeQuery(query.queryKey)
),
// Don't persist mutations (they should be re-triggered, not replayed)
mutations: [],
},
};
await set(IDB_KEY, filteredClient);
} catch (error) {
logger.warn('Failed to persist query cache to IndexedDB:', error);
}
},
restoreClient: async () => {
try {
const client = await get<PersistedClient>(IDB_KEY);
if (client) {
logger.info('Restored React Query cache from IndexedDB');
}
return client ?? undefined;
} catch (error) {
logger.warn('Failed to restore query cache from IndexedDB:', error);
return undefined;
}
},
removeClient: async () => {
try {
await del(IDB_KEY);
} catch (error) {
logger.warn('Failed to remove query cache from IndexedDB:', error);
}
},
};
}