mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Comprehensive set of mobile and all improvements phase 1
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
133
apps/ui/src/lib/query-persist.ts
Normal file
133
apps/ui/src/lib/query-persist.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user