diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index bc7cd07..69f5b82 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -973,7 +973,7 @@ export const SessionView = memo(() => { .filter(p => !defaultTerminalPanel || p.id !== defaultTerminalPanel.id) .map(panel => { const isActive = panel.id === currentActivePanel.id; - const shouldKeepAlive = ['terminal', 'browser'].includes(panel.type); + const shouldKeepAlive = ['terminal'].includes(panel.type); if (!isActive && !shouldKeepAlive) return null; return (
{ .filter(p => !defaultTerminalPanel || p.id !== defaultTerminalPanel.id) .map(panel => { const isActive = panel.id === currentActivePanel.id; - const shouldKeepAlive = ['terminal', 'browser'].includes(panel.type); + const shouldKeepAlive = ['terminal'].includes(panel.type); if (!isActive && !shouldKeepAlive) return null; return (
= memo(({ const resourceChipRef = useRef(null); const resourcePopoverRef = useRef(null); const [expandedSections, setExpandedSections] = useState>(new Set(['pane-app'])); - const { snapshot, startActive, stopActive, refresh } = useResourceMonitor(); + const { snapshot, isLoading: resourceLoading, startActive, stopActive, refresh } = useResourceMonitor(); const [popoverStyle, setPopoverStyle] = useState({}); const getPanelActivityStatus = usePanelStore(s => s.getPanelActivityStatus); @@ -150,12 +150,16 @@ export const PanelTabBar: React.FC = memo(({ // Resource monitor handlers const toggleResourcePopover = useCallback(() => { - setShowResourcePopover(prev => { - if (!prev) startActive(); - else stopActive(); - return !prev; - }); - }, [startActive, stopActive]); + if (showResourcePopover) { + setShowResourcePopover(false); + stopActive(); + return; + } + + setShowResourcePopover(true); + void refresh(); + startActive(); + }, [showResourcePopover, refresh, startActive, stopActive]); // Popover positioning useEffect(() => { @@ -230,7 +234,7 @@ export const PanelTabBar: React.FC = memo(({ }); }, []); - const handleRefresh = useCallback(() => { refresh(); }, [refresh]); + const handleRefresh = useCallback(() => { void refresh(); }, [refresh]); const electronTotalCpu = useMemo(() => snapshot?.electronProcesses.reduce((sum, p) => sum + p.cpuPercent, 0) ?? 0 @@ -910,7 +914,7 @@ export const PanelTabBar: React.FC = memo(({
{/* Resource monitor popover */} - {showResourcePopover && snapshot && createPortal( + {showResourcePopover && createPortal(
= memo(({
- {/* Summary */} -
- - CPU {snapshot.cpuReady ? `${snapshot.totalCpuPercent.toFixed(1)}%` : '—'} - - - Memory {formatMemory(snapshot.totalMemoryMB)} - -
+ {!snapshot ? ( +
+ {resourceLoading ? 'Loading resource usage...' : 'No resource snapshot yet.'} +
+ ) : ( + <> + {/* Summary */} +
+ + CPU {snapshot.cpuReady ? `${snapshot.totalCpuPercent.toFixed(1)}%` : '—'} + + + Memory {formatMemory(snapshot.totalMemoryMB)} + +
- {/* Scrollable content */} -
- {/* Pane App section */} -
- - {expandedSections.has('pane-app') && snapshot.electronProcesses.map(p => ( -
- {p.label} -
- {snapshot.cpuReady ? `${p.cpuPercent.toFixed(1)}%` : '—'} - {formatMemory(p.memoryMB)} -
+ {/* Scrollable content */} +
+ {/* Pane App section */} +
+ + {expandedSections.has('pane-app') && snapshot.electronProcesses.map(p => ( +
+ {p.label} +
+ {snapshot.cpuReady ? `${p.cpuPercent.toFixed(1)}%` : '—'} + {formatMemory(p.memoryMB)} +
+
+ ))}
- ))} -
- {/* Per-session sections */} - {snapshot.sessions.map(sess => ( -
- - {expandedSections.has(sess.sessionId) && sess.children.map(child => ( -
- {child.name} -
- {snapshot.cpuReady ? `${child.cpuPercent.toFixed(1)}%` : '—'} - {formatMemory(child.memoryMB)} -
+ > + + {expandedSections.has(sess.sessionId) && sess.children.map(child => ( +
+ {child.name} +
+ {snapshot.cpuReady ? `${child.cpuPercent.toFixed(1)}%` : '—'} + {formatMemory(child.memoryMB)} +
+
+ ))}
))}
- ))} -
+ + )}
, document.body )} diff --git a/frontend/src/components/panels/diff/CombinedDiffView.tsx b/frontend/src/components/panels/diff/CombinedDiffView.tsx index d992035..ac1b9e0 100644 --- a/frontend/src/components/panels/diff/CombinedDiffView.tsx +++ b/frontend/src/components/panels/diff/CombinedDiffView.tsx @@ -73,17 +73,29 @@ const CombinedDiffView = memo(forwardRef(initialSelected); const [lastSessionId, setLastSessionId] = useState(sessionId); const [combinedDiff, setCombinedDiff] = useState(null); - const [loading, setLoading] = useState(false); + const [executionsLoading, setExecutionsLoading] = useState(false); + const [diffLoading, setDiffLoading] = useState(false); + const [commitDiffLoading, setCommitDiffLoading] = useState(false); const [error, setError] = useState(null); const [viewingCommitHash, setViewingCommitHash] = useState(null); const [showCommitDialog, setShowCommitDialog] = useState(false); const [mainBranch, setMainBranch] = useState('main'); const [historySource, setHistorySource] = useState<'remote' | 'local' | 'branch'>(isMainRepo ? 'remote' : 'branch'); - const [forceRefresh, setForceRefresh] = useState(0); + const [executionRefreshNonce, setExecutionRefreshNonce] = useState(0); // Diff cache: keyed by sessionId + sorted selection const diffCacheRef = useRef>(new Map()); + const executionsRequestIdRef = useRef(0); + const combinedDiffRequestIdRef = useRef(0); + const commitDiffRequestIdRef = useRef(0); + const executionsRef = useRef(executions); + const selectedExecutionsRef = useRef(selectedExecutions); + const viewingCommitHashRef = useRef(viewingCommitHash); + const mountedRef = useRef(true); + executionsRef.current = executions; + selectedExecutionsRef.current = selectedExecutions; + viewingCommitHashRef.current = viewingCommitHash; // Resizable sidebar state const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -100,18 +112,13 @@ const CombinedDiffView = memo(forwardRef(null); - // Expose refresh() to parent (DiffPanel) via ref - // Keeps current diff visible while new data loads (no flash), - // but resets selection since execution IDs are positional and - // may point to different commits after history changes. - useImperativeHandle(ref, () => ({ - refresh: () => { - diffCacheRef.current.clear(); - setSelectedExecutions([]); - setViewingCommitHash(null); - setForceRefresh(prev => prev + 1); - } - })); + const isAnyLoading = executionsLoading || diffLoading || commitDiffLoading; + const showInitialSkeleton = executionsLoading && executions.length === 0; + const showDiffSkeleton = (diffLoading || commitDiffLoading) && combinedDiff === null; + + useEffect(() => { + return () => { mountedRef.current = false; }; + }, []); // Save sidebar width to localStorage useEffect(() => { @@ -182,13 +189,51 @@ const CombinedDiffView = memo(forwardRef { + const allCommitIds = data + .filter((exec: ExecutionDiff) => exec.id !== 0) + .map((exec: ExecutionDiff) => exec.id); + + if (allCommitIds.length > 0) { + return [allCommitIds[allCommitIds.length - 1], allCommitIds[0]]; + } + + return data.map((exec: ExecutionDiff) => exec.id); + }, []); + + const getSelectedHashes = useCallback((data: ExecutionDiff[], selection: number[]) => { + const executionById = new Map(data.map(exec => [exec.id, exec])); + return selection + .map(id => executionById.get(id)?.after_commit_hash) + .filter((hash): hash is string => Boolean(hash)); + }, []); + + const reconcileSelection = useCallback((data: ExecutionDiff[], selectedHashes: string[]) => { + if (selectedHashes.length === 0) { + return getDefaultSelection(data); + } + + const executionByHash = new Map(data.map(exec => [exec.after_commit_hash, exec])); + const reconciled = selectedHashes + .map(hash => executionByHash.get(hash)?.id) + .filter((id): id is number => typeof id === 'number'); + + return reconciled.length > 0 ? reconciled : getDefaultSelection(data); + }, [getDefaultSelection]); + // Shared logic to process loaded executions - const processExecutions = useCallback((data: ExecutionDiff[], autoSelect: boolean) => { + const processExecutions = useCallback((data: ExecutionDiff[]) => { setError(null); setExecutions(data); @@ -210,20 +255,64 @@ const CombinedDiffView = memo(forwardRef 0) { - const allCommitIds = data - .filter((exec: ExecutionDiff) => exec.id !== 0) - .map((exec: ExecutionDiff) => exec.id); + const refreshExecutions = useCallback(async ({ preserveSelection }: { preserveSelection: boolean }) => { + if (!isVisible) return; - if (allCommitIds.length > 0) { - setSelectedExecutions([allCommitIds[allCommitIds.length - 1], allCommitIds[0]]); - } else { - setSelectedExecutions(data.map((exec: ExecutionDiff) => exec.id)); + const requestId = ++executionsRequestIdRef.current; + const selectedHashes = getSelectedHashes(executionsRef.current, selectedExecutionsRef.current); + const shouldAutoSelect = selectedExecutionsRef.current.length === 0 && !viewingCommitHashRef.current; + + try { + setExecutionsLoading(true); + const response = await API.sessions.getExecutions(sessionId); + + if (!mountedRef.current || requestId !== executionsRequestIdRef.current) return; + + if (!response.success) { + throw new Error(response.error || 'Failed to load executions'); + } + + const data: ExecutionDiff[] = response.data || []; + processExecutions(data); + + if (!viewingCommitHashRef.current) { + if (data.length > 0) { + if (preserveSelection) { + setSelectedExecutions(reconcileSelection(data, selectedHashes)); + } else if (shouldAutoSelect) { + setSelectedExecutions(getDefaultSelection(data)); + } + } else { + setSelectedExecutions([]); + setCombinedDiff(null); + } + } + } catch (err) { + if (mountedRef.current && requestId === executionsRequestIdRef.current) { + setError(err instanceof Error ? err.message : 'Failed to load executions'); + } + } finally { + if (mountedRef.current && requestId === executionsRequestIdRef.current) { + setExecutionsLoading(false); } } - }, [isMainRepo]); + }, [getDefaultSelection, getSelectedHashes, isVisible, processExecutions, reconcileSelection, sessionId]); + + const triggerSoftRefresh = useCallback(() => { + diffCacheRef.current.clear(); + commitDiffRequestIdRef.current += 1; + combinedDiffRequestIdRef.current += 1; + setViewingCommitHash(null); + setExecutionRefreshNonce(prev => prev + 1); + }, []); + + // Expose refresh() to parent (DiffPanel) via ref. + // Same-session refresh keeps current diff visible while refreshed data loads. + useImperativeHandle(ref, () => ({ + refresh: triggerSoftRefresh + }), [triggerSoftRefresh]); // Listen for commit-click events dispatched from GitHistoryGraph via SessionView. // Also check the module-level pendingViewCommit on mount — the event may have @@ -231,6 +320,7 @@ const CombinedDiffView = memo(forwardRef { // Consume any pending hash written before this component mounted if (pendingViewCommit && pendingViewCommit.sessionId === sessionId) { + combinedDiffRequestIdRef.current += 1; setViewingCommitHash(pendingViewCommit.commitHash); setSelectedExecutions([]); pendingViewCommit = null; @@ -239,6 +329,7 @@ const CombinedDiffView = memo(forwardRef { const { sessionId: eventSessionId, commitHash } = (event as CustomEvent<{ sessionId: string; commitHash: string }>).detail; if (eventSessionId !== sessionId) return; + combinedDiffRequestIdRef.current += 1; setViewingCommitHash(commitHash); setSelectedExecutions([]); pendingViewCommit = null; // consumed @@ -250,22 +341,23 @@ const CombinedDiffView = memo(forwardRef { if (!viewingCommitHash) return; + const requestId = ++commitDiffRequestIdRef.current; let cancelled = false; const load = async () => { - setLoading(true); + setCommitDiffLoading(true); setError(null); try { const response = await API.sessions.getCommitDiffByHash(sessionId, viewingCommitHash); - if (cancelled) return; + if (cancelled || requestId !== commitDiffRequestIdRef.current) return; if (!response.success) throw new Error(response.error); setCombinedDiff(response.data); } catch (err) { - if (!cancelled) { + if (!cancelled && requestId === commitDiffRequestIdRef.current) { setError(err instanceof Error ? err.message : 'Failed to load commit diff'); setCombinedDiff(null); } } finally { - if (!cancelled) setLoading(false); + if (!cancelled && requestId === commitDiffRequestIdRef.current) setCommitDiffLoading(false); } }; load(); @@ -275,50 +367,24 @@ const CombinedDiffView = memo(forwardRef { if (!isVisible) return; - let cancelled = false; const timeoutId = setTimeout(() => { - const loadExecutions = async () => { - try { - setLoading(true); - const response = await API.sessions.getExecutions(sessionId); - - if (cancelled) return; - - if (!response.success) { - throw new Error(response.error || 'Failed to load executions'); - } - const data: ExecutionDiff[] = response.data || []; - processExecutions(data, selectedExecutions.length === 0 && !viewingCommitHashRef.current); - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to load executions'); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - }; - - loadExecutions(); + void refreshExecutions({ preserveSelection: selectedExecutionsRef.current.length > 0 }); }, 100); return () => { - cancelled = true; clearTimeout(timeoutId); }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedExecutions read inside closure to check if auto-select needed; must not re-trigger on selection change - }, [sessionId, isMainRepo, forceRefresh, isVisible, processExecutions]); + }, [executionRefreshNonce, isVisible, refreshExecutions]); // Keep refs to avoid stale closures in event handlers const executionsLengthRef = useRef(executions.length); executionsLengthRef.current = executions.length; - const viewingCommitHashRef = useRef(viewingCommitHash); - viewingCommitHashRef.current = viewingCommitHash; // Load combined diff when selection changes (with caching) useEffect(() => { + if (viewingCommitHash) return; + const requestId = ++combinedDiffRequestIdRef.current; let cancelled = false; const timeoutId = setTimeout(() => { @@ -333,13 +399,13 @@ const CombinedDiffView = memo(forwardRef { + commitDiffRequestIdRef.current += 1; setViewingCommitHash(null); // exit hash mode setSelectedExecutions(newSelection); }; const handleManualRefresh = () => { - diffCacheRef.current.clear(); - setForceRefresh(prev => prev + 1); - setCombinedDiff(null); - setSelectedExecutions([]); + triggerSoftRefresh(); }; const handleCommit = useCallback(async (message: string) => { @@ -413,13 +476,8 @@ const CombinedDiffView = memo(forwardRef { if (!window.confirm(`Are you sure you want to revert commit ${commitHash.substring(0, 7)}? This will create a new commit that undoes the changes.`)) { @@ -436,16 +494,12 @@ const CombinedDiffView = memo(forwardRef executions.some(exec => exec.history_limit_reached), @@ -472,25 +526,12 @@ const CombinedDiffView = memo(forwardRef { @@ -508,7 +549,7 @@ const CombinedDiffView = memo(forwardRef {/* Header skeleton */} @@ -536,7 +577,7 @@ const CombinedDiffView = memo(forwardRef

Error

@@ -573,9 +614,9 @@ const CombinedDiffView = memo(forwardRef - +
@@ -621,18 +662,13 @@ const CombinedDiffView = memo(forwardRefPlease wait while the operation completes...

- ) : loading && combinedDiff === null ? ( + ) : showDiffSkeleton ? (
- ) : error ? ( -
-

Error loading diff

-

{error}

-
) : combinedDiff ? ( + ) : error ? ( +
+

Error loading diff

+

{error}

+
) : executions.length === 0 ? (
diff --git a/frontend/src/components/panels/diff/DiffPanel.tsx b/frontend/src/components/panels/diff/DiffPanel.tsx index 1e7041b..6584426 100644 --- a/frontend/src/components/panels/diff/DiffPanel.tsx +++ b/frontend/src/components/panels/diff/DiffPanel.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; import CombinedDiffView from './CombinedDiffView'; import type { CombinedDiffViewHandle } from './CombinedDiffView'; import type { ToolPanel, DiffPanelState } from '../../../../../shared/types/panels'; +import type { GitStatus } from '../../../types/session'; import { AlertCircle } from 'lucide-react'; interface DiffPanelProps { @@ -11,6 +12,23 @@ interface DiffPanelProps { isMainRepo?: boolean; } +function buildGitStatusFingerprint(gitStatus?: GitStatus): string { + return [ + gitStatus?.state ?? 'unknown', + gitStatus?.ahead ?? 0, + gitStatus?.behind ?? 0, + gitStatus?.hasUncommittedChanges ? 1 : 0, + gitStatus?.hasUntrackedFiles ? 1 : 0, + gitStatus?.filesChanged ?? 0, + gitStatus?.additions ?? 0, + gitStatus?.deletions ?? 0, + gitStatus?.commitAdditions ?? 0, + gitStatus?.commitDeletions ?? 0, + gitStatus?.commitFilesChanged ?? 0, + gitStatus?.totalCommits ?? 0, + ].join(':'); +} + export const DiffPanel: React.FC = ({ panel, isActive, @@ -22,7 +40,8 @@ export const DiffPanel: React.FC = ({ const lastRefreshRef = useRef(Date.now()); const combinedDiffRef = useRef(null); // Track diff-relevant git state to avoid spurious refreshes on no-op status events - const lastGitFingerprintRef = useRef(''); + const lastGitFingerprintRef = useRef(null); + const wasActiveRef = useRef(isActive); // Listen for file change events from other panels useEffect(() => { @@ -55,12 +74,18 @@ export const DiffPanel: React.FC = ({ // Listen for git-status-updated events (detects new commits from Claude, etc.) // Only mark stale when diff-relevant state actually changes, not on no-op refreshes useEffect(() => { + lastGitFingerprintRef.current = null; + const handleGitStatusUpdated = (event: Event) => { - const { sessionId: eventSessionId, gitStatus } = (event as CustomEvent).detail || {}; + const { sessionId: eventSessionId, gitStatus } = (event as CustomEvent<{ sessionId: string; gitStatus?: GitStatus }>).detail || {}; if (eventSessionId !== sessionId) return; // Fingerprint the diff-relevant fields — ignore no-op status refreshes - const fingerprint = `${gitStatus?.state}-${gitStatus?.ahead}-${gitStatus?.behind}-${gitStatus?.uncommittedChanges}`; + const fingerprint = buildGitStatusFingerprint(gitStatus); + if (lastGitFingerprintRef.current === null) { + lastGitFingerprintRef.current = fingerprint; + return; + } if (fingerprint === lastGitFingerprintRef.current) return; lastGitFingerprintRef.current = fingerprint; @@ -73,7 +98,10 @@ export const DiffPanel: React.FC = ({ // Auto-refresh when becoming active and stale useEffect(() => { - if (isActive && isStale) { + const becameActive = isActive && !wasActiveRef.current; + wasActiveRef.current = isActive; + + if (becameActive && isStale) { setIsStale(false); combinedDiffRef.current?.refresh(); diff --git a/frontend/src/hooks/useResourceMonitor.ts b/frontend/src/hooks/useResourceMonitor.ts index 28cb26c..47df003 100644 --- a/frontend/src/hooks/useResourceMonitor.ts +++ b/frontend/src/hooks/useResourceMonitor.ts @@ -9,7 +9,7 @@ interface IPCResponse { export function useResourceMonitor() { const [snapshot, setSnapshot] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); // Listen for push updates from main process useEffect(() => { @@ -20,14 +20,6 @@ export function useResourceMonitor() { }; window.addEventListener('resource-monitor:update', handler); - // Also fetch initial snapshot - window.electronAPI?.resourceMonitor?.getSnapshot?.().then((response: IPCResponse) => { - if (response?.success && response.data) { - setSnapshot(response.data); - } - setIsLoading(false); - }); - return () => { window.removeEventListener('resource-monitor:update', handler); // Stop active polling if component unmounts while popover is open @@ -44,9 +36,14 @@ export function useResourceMonitor() { }, []); const refresh = useCallback(async () => { - const response: IPCResponse | undefined = await window.electronAPI?.resourceMonitor?.getSnapshot?.(); - if (response?.success && response.data) { - setSnapshot(response.data); + setIsLoading(true); + try { + const response: IPCResponse | undefined = await window.electronAPI?.resourceMonitor?.getSnapshot?.(); + if (response?.success && response.data) { + setSnapshot(response.data); + } + } finally { + setIsLoading(false); } }, []); diff --git a/main/src/index.ts b/main/src/index.ts index 0bac00f..258da44 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -9,7 +9,7 @@ if (process.platform === 'linux') { } // Force integrated GPU for better battery life on dual-GPU systems -app.commandLine.appendSwitch('force_discrete_gpu', '0'); +app.commandLine.appendSwitch('force_low_power_gpu'); // Set Windows AUMID to match electron-builder's appId so Windows resolves // the installed Start Menu shortcut for notification icon and display name. @@ -873,11 +873,9 @@ async function createWindow() { mainWindow.on('restore', () => { // Don't assume restore = focused. The OS will fire 'focus' if/when the user - // actually focuses the window. Keep git/resource hooks as-is. - if (gitStatusManager) { - gitStatusManager.handleVisibilityChange(false); // false = visible/restored - } - resourceMonitorService.handleVisibilityChange(false); + // actually focuses the window; that is what restarts git/resource work. + const focused = mainWindow?.isFocused() ?? false; + mainWindow?.webContents.send('window:focus-changed', focused); }); // Hand the renderer its per-window ptyHost data port once the preload @@ -1081,7 +1079,6 @@ async function initializeServices() { // Start resource monitoring resourceMonitorService.initialize(app); - resourceMonitorService.startIdlePolling(); // Restore spotlight state from previous session try { diff --git a/main/src/services/resourceMonitorService.ts b/main/src/services/resourceMonitorService.ts index 89c097d..8642199 100644 --- a/main/src/services/resourceMonitorService.ts +++ b/main/src/services/resourceMonitorService.ts @@ -374,14 +374,13 @@ export class ResourceMonitorService extends EventEmitter { this.pollInProgress = false; } }; - void poll(); this.activeTimer = setInterval(() => void poll(), 5_000); } stopActivePolling(): void { if (this.isActivePolling) { this.isActivePolling = false; - this.startIdlePolling(); + this.stopAllPolling(); } } @@ -391,12 +390,8 @@ export class ResourceMonitorService extends EventEmitter { this.stopAllPolling(); this.previousCpuSamples.clear(); this.needsCpuWarmup = true; - } else { - if (this.isActivePolling) { - this.startActivePolling(); - } else { - this.startIdlePolling(); - } + } else if (this.isActivePolling) { + this.startActivePolling(); } }