mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch * feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count * feat: Add validation for remote names and improve error handling * Address PR comments and mobile layout fixes * ``` refactor: Extract PR target resolution logic into dedicated service ``` * feat: Add app shell UI and improve service imports. Address PR comments * fix: Improve security validation and cache handling in git operations * feat: Add GET /list endpoint and improve parameter handling * chore: Improve validation, accessibility, and error handling across apps * chore: Format vite server port configuration * fix: Add error handling for gh pr list command and improve offline fallbacks * fix: Preserve existing PR creation time and improve remote handling
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
* automatic caching, deduplication, and background refetching.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
@@ -18,6 +19,117 @@ const FEATURES_REFETCH_ON_RECONNECT = false;
|
||||
const FEATURES_POLLING_INTERVAL = 30000;
|
||||
/** Default polling interval for agent output when WebSocket is inactive */
|
||||
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||
const FEATURES_CACHE_PREFIX = 'automaker:features-cache:';
|
||||
|
||||
/**
|
||||
* Bump this version whenever the Feature shape changes so stale localStorage
|
||||
* entries with incompatible schemas are automatically discarded.
|
||||
*/
|
||||
const FEATURES_CACHE_VERSION = 1;
|
||||
|
||||
/** Maximum number of per-project cache entries to keep in localStorage (LRU). */
|
||||
const MAX_FEATURES_CACHE_ENTRIES = 10;
|
||||
|
||||
interface PersistedFeaturesCache {
|
||||
/** Schema version — mismatched versions are treated as stale and discarded. */
|
||||
schemaVersion: number;
|
||||
timestamp: number;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
function readPersistedFeatures(projectPath: string): PersistedFeaturesCache | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${FEATURES_CACHE_PREFIX}${projectPath}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PersistedFeaturesCache;
|
||||
if (!parsed || !Array.isArray(parsed.features) || typeof parsed.timestamp !== 'number') {
|
||||
return null;
|
||||
}
|
||||
// Reject entries written by an older (or newer) schema version
|
||||
if (parsed.schemaVersion !== FEATURES_CACHE_VERSION) {
|
||||
// Remove the stale entry so it doesn't accumulate
|
||||
window.localStorage.removeItem(`${FEATURES_CACHE_PREFIX}${projectPath}`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedFeatures(projectPath: string, features: Feature[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
const payload: PersistedFeaturesCache = {
|
||||
schemaVersion: FEATURES_CACHE_VERSION,
|
||||
timestamp: Date.now(),
|
||||
features,
|
||||
};
|
||||
window.localStorage.setItem(`${FEATURES_CACHE_PREFIX}${projectPath}`, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Best effort cache only.
|
||||
}
|
||||
// Run lightweight eviction after every write to keep localStorage bounded
|
||||
evictStaleFeaturesCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan localStorage for feature-cache entries, sort by timestamp (LRU),
|
||||
* and remove entries beyond MAX_FEATURES_CACHE_ENTRIES so orphaned project
|
||||
* caches don't accumulate indefinitely.
|
||||
*/
|
||||
function evictStaleFeaturesCache(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
// First pass: collect all matching keys without mutating localStorage.
|
||||
// Iterating forward while calling removeItem() shifts indexes and can skip keys.
|
||||
const allKeys: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (key && key.startsWith(FEATURES_CACHE_PREFIX)) {
|
||||
allKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: classify collected keys — remove stale/corrupt, keep valid.
|
||||
const validEntries: Array<{ key: string; timestamp: number }> = [];
|
||||
const keysToRemove: string[] = [];
|
||||
for (const key of allKeys) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) continue;
|
||||
const parsed = JSON.parse(raw) as { timestamp?: number; schemaVersion?: number };
|
||||
// Evict entries with wrong schema version
|
||||
if (parsed.schemaVersion !== FEATURES_CACHE_VERSION) {
|
||||
keysToRemove.push(key);
|
||||
continue;
|
||||
}
|
||||
validEntries.push({
|
||||
key,
|
||||
timestamp: typeof parsed.timestamp === 'number' ? parsed.timestamp : 0,
|
||||
});
|
||||
} catch {
|
||||
// Corrupt entry — mark for removal
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale/corrupt entries
|
||||
for (const key of keysToRemove) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
// Enforce max entries: sort by timestamp (newest first), remove excess oldest
|
||||
if (validEntries.length <= MAX_FEATURES_CACHE_ENTRIES) return;
|
||||
validEntries.sort((a, b) => b.timestamp - a.timestamp);
|
||||
for (let i = MAX_FEATURES_CACHE_ENTRIES; i < validEntries.length; i++) {
|
||||
window.localStorage.removeItem(validEntries[i].key);
|
||||
}
|
||||
} catch {
|
||||
// Best effort — never break the app for cache housekeeping failures.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all features for a project
|
||||
@@ -31,6 +143,14 @@ const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||
* ```
|
||||
*/
|
||||
export function useFeatures(projectPath: string | undefined) {
|
||||
// Memoize the persisted cache read so it only runs when projectPath changes,
|
||||
// not on every render. Both initialData and initialDataUpdatedAt reference
|
||||
// the same memoized value to avoid a redundant second localStorage read.
|
||||
const persisted = useMemo(
|
||||
() => (projectPath ? readPersistedFeatures(projectPath) : null),
|
||||
[projectPath]
|
||||
);
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.features.all(projectPath ?? ''),
|
||||
queryFn: async (): Promise<Feature[]> => {
|
||||
@@ -40,9 +160,13 @@ export function useFeatures(projectPath: string | undefined) {
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.error || 'Failed to fetch features');
|
||||
}
|
||||
return (result.features ?? []) as Feature[];
|
||||
const features = (result.features ?? []) as Feature[];
|
||||
writePersistedFeatures(projectPath, features);
|
||||
return features;
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
initialData: () => persisted?.features,
|
||||
initialDataUpdatedAt: () => persisted?.timestamp,
|
||||
staleTime: STALE_TIMES.FEATURES,
|
||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||
|
||||
@@ -185,6 +185,8 @@ interface BranchesResult {
|
||||
hasAnyRemotes: boolean;
|
||||
isGitRepo: boolean;
|
||||
hasCommits: boolean;
|
||||
/** The name of the remote that the current branch is tracking (e.g. "origin"), if any */
|
||||
trackingRemote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,6 +244,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
|
||||
hasAnyRemotes: result.result?.hasAnyRemotes ?? false,
|
||||
isGitRepo: true,
|
||||
hasCommits: true,
|
||||
trackingRemote: result.result?.trackingRemote,
|
||||
};
|
||||
},
|
||||
enabled: !!worktreePath,
|
||||
|
||||
@@ -431,18 +431,38 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
return;
|
||||
}
|
||||
|
||||
// If projects array changed (by reference, meaning content changed), sync immediately
|
||||
// This is critical - projects list changes must sync right away to prevent loss
|
||||
// when switching between Electron and web modes or closing the app
|
||||
// If projects array changed *meaningfully*, sync immediately.
|
||||
// This is critical — projects list changes must sync right away to prevent loss
|
||||
// when switching between Electron and web modes or closing the app.
|
||||
//
|
||||
// We compare by content (IDs, names, and paths), NOT by reference. The background
|
||||
// reconcile in __root.tsx calls hydrateStoreFromSettings() with server data,
|
||||
// which always creates a new projects array (.map() produces a new reference).
|
||||
// A reference-only check would trigger an immediate sync-back to the server
|
||||
// with identical data, causing a visible re-render flash on mobile.
|
||||
if (newState.projects !== prevState.projects) {
|
||||
logger.info('[PROJECTS_CHANGED] Projects array changed, syncing immediately', {
|
||||
prevCount: prevState.projects?.length ?? 0,
|
||||
newCount: newState.projects?.length ?? 0,
|
||||
prevProjects: prevState.projects?.map((p) => p.name) ?? [],
|
||||
newProjects: newState.projects?.map((p) => p.name) ?? [],
|
||||
});
|
||||
syncNow();
|
||||
return;
|
||||
const prevIds = prevState.projects
|
||||
?.map((p) => JSON.stringify([p.id, p.name, p.path]))
|
||||
.join(',');
|
||||
const newIds = newState.projects
|
||||
?.map((p) => JSON.stringify([p.id, p.name, p.path]))
|
||||
.join(',');
|
||||
if (prevIds !== newIds) {
|
||||
logger.info('[PROJECTS_CHANGED] Projects array changed, syncing immediately', {
|
||||
prevCount: prevState.projects?.length ?? 0,
|
||||
newCount: newState.projects?.length ?? 0,
|
||||
});
|
||||
syncNow();
|
||||
// Don't return here — fall through so the general loop below can still
|
||||
// detect and schedule a debounced sync for other project-field mutations
|
||||
// (e.g. lastOpened) that the id/name/path comparison above doesn't cover.
|
||||
} else {
|
||||
// The projects array reference changed but id/name/path are identical.
|
||||
// This means nested project fields mutated (e.g. lastOpened, remotes).
|
||||
// Schedule a debounced sync so these mutations reach the server.
|
||||
logger.debug('[PROJECTS_NESTED_CHANGE] Projects nested fields changed, scheduling sync');
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any other synced field changed
|
||||
|
||||
Reference in New Issue
Block a user