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:
gsxdsm
2026-02-19 21:55:12 -08:00
committed by GitHub
parent ee52333636
commit 7df2182818
80 changed files with 4729 additions and 1107 deletions

View File

@@ -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,

View File

@@ -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,

View File

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