Skip to content

Commit e86f0cf

Browse files
7418claude
andcommitted
perf: optimize polling with caching and event-driven approach (P1-2)
- CLI binary detection cached with 60s TTL - Settings poll replaced with visibility/focus events - Health check uses adaptive backoff (30s → 60s when stable) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a5805cb commit e86f0cf

File tree

3 files changed

+76
-11
lines changed

3 files changed

+76
-11
lines changed

src/components/layout/AppShell.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,20 @@ export function AppShell({ children }: { children: React.ReactNode }) {
150150
}
151151
}, []);
152152

153-
// Poll periodically so the indicator stays in sync when settings change
153+
// Re-fetch when window gains focus / becomes visible instead of polling every 5s
154154
useEffect(() => {
155155
fetchSkipPermissions();
156-
const id = setInterval(fetchSkipPermissions, 5000);
157-
return () => clearInterval(id);
156+
const handleVisibility = () => {
157+
if (document.visibilityState === "visible") {
158+
fetchSkipPermissions();
159+
}
160+
};
161+
document.addEventListener("visibilitychange", handleVisibility);
162+
window.addEventListener("focus", fetchSkipPermissions);
163+
return () => {
164+
document.removeEventListener("visibilitychange", handleVisibility);
165+
window.removeEventListener("focus", fetchSkipPermissions);
166+
};
158167
}, [fetchSkipPermissions]);
159168

160169
// --- Update check state ---

src/components/layout/ConnectionStatus.tsx

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState, useCallback } from "react";
3+
import { useEffect, useState, useCallback, useRef } from "react";
44
import {
55
Dialog,
66
DialogContent,
@@ -17,26 +17,67 @@ interface ClaudeStatus {
1717
version: string | null;
1818
}
1919

20+
const BASE_INTERVAL = 30_000; // 30s
21+
const BACKED_OFF_INTERVAL = 60_000; // 60s after 3 consecutive stable results
22+
const STABLE_THRESHOLD = 3;
23+
2024
export function ConnectionStatus() {
2125
const [status, setStatus] = useState<ClaudeStatus | null>(null);
2226
const [dialogOpen, setDialogOpen] = useState(false);
27+
const stableCountRef = useRef(0);
28+
const lastConnectedRef = useRef<boolean | null>(null);
29+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30+
31+
// Use a ref-based approach to avoid circular deps between check and schedule
32+
const checkRef = useRef<() => void>(() => {});
33+
34+
const schedule = useCallback(() => {
35+
if (timerRef.current) clearTimeout(timerRef.current);
36+
const interval = stableCountRef.current >= STABLE_THRESHOLD
37+
? BACKED_OFF_INTERVAL
38+
: BASE_INTERVAL;
39+
timerRef.current = setTimeout(() => checkRef.current(), interval);
40+
}, []);
2341

2442
const checkStatus = useCallback(async () => {
2543
try {
2644
const res = await fetch("/api/claude-status");
2745
if (res.ok) {
2846
const data: ClaudeStatus = await res.json();
47+
if (lastConnectedRef.current === data.connected) {
48+
stableCountRef.current++;
49+
} else {
50+
stableCountRef.current = 0;
51+
}
52+
lastConnectedRef.current = data.connected;
2953
setStatus(data);
3054
}
3155
} catch {
56+
if (lastConnectedRef.current === false) {
57+
stableCountRef.current++;
58+
} else {
59+
stableCountRef.current = 0;
60+
}
61+
lastConnectedRef.current = false;
3262
setStatus({ connected: false, version: null });
3363
}
34-
}, []);
64+
schedule();
65+
}, [schedule]);
3566

3667
useEffect(() => {
68+
checkRef.current = checkStatus;
69+
}, [checkStatus]);
70+
71+
useEffect(() => {
72+
checkStatus(); // eslint-disable-line react-hooks/set-state-in-effect -- setState is called asynchronously after fetch
73+
return () => {
74+
if (timerRef.current) clearTimeout(timerRef.current);
75+
};
76+
}, [checkStatus]);
77+
78+
const handleManualRefresh = useCallback(() => {
79+
stableCountRef.current = 0;
3780
checkStatus();
38-
const interval = setInterval(checkStatus, 30000);
39-
return () => clearInterval(interval);
4081
}, [checkStatus]);
4182

4283
const connected = status?.connected ?? false;
@@ -127,9 +168,7 @@ export function ConnectionStatus() {
127168
<DialogFooter>
128169
<Button
129170
variant="outline"
130-
onClick={() => {
131-
checkStatus();
132-
}}
171+
onClick={handleManualRefresh}
133172
>
134173
Refresh
135174
</Button>

src/lib/platform.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,28 @@ export function getExpandedPath(): string {
9595
return parts.join(path.delimiter);
9696
}
9797

98+
// TTL cache for findClaudeBinary to avoid repeated filesystem probes
99+
let _cachedBinaryPath: string | undefined | null = null; // null = not cached
100+
let _cachedBinaryTimestamp = 0;
101+
const BINARY_CACHE_TTL = 60_000; // 60 seconds
102+
98103
/**
99104
* Find and validate the Claude CLI binary.
100-
* Tests each candidate with --version before returning.
105+
* Results are cached for 60s to avoid redundant filesystem probes on every poll.
101106
*/
102107
export function findClaudeBinary(): string | undefined {
108+
const now = Date.now();
109+
if (_cachedBinaryPath !== null && now - _cachedBinaryTimestamp < BINARY_CACHE_TTL) {
110+
return _cachedBinaryPath;
111+
}
112+
113+
const found = _findClaudeBinaryUncached();
114+
_cachedBinaryPath = found;
115+
_cachedBinaryTimestamp = now;
116+
return found;
117+
}
118+
119+
function _findClaudeBinaryUncached(): string | undefined {
103120
// Try known candidate paths first
104121
for (const p of getClaudeCandidatePaths()) {
105122
try {

0 commit comments

Comments
 (0)