Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
454 changes: 454 additions & 0 deletions .library/use-effect-audit-checklist.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/src/components/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function AgentCard({
Configuration
</h4>
<AgentSettingsCard
key={`${agent.id}-${settings?.model ?? ''}-${settings?.permissionMode ?? ''}-${settings?.providerMode ?? ''}-${settings?.opencodeProvider ?? ''}`}
agent={agent}
settings={settings}
onSave={onSaveSettings}
Expand Down
12 changes: 1 addition & 11 deletions apps/web/src/components/AgentSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
VALID_PERMISSION_MODES,
} from '@simple-agent-manager/shared';
import { Alert, Card } from '@simple-agent-manager/ui';
import { useEffect, useState } from 'react';
import { useState } from 'react';

import { ModelSelect } from './ModelSelect';

Expand Down Expand Up @@ -80,16 +80,6 @@ export function AgentSettingsCard({
const formControlClass =
'w-full min-h-11 py-2 px-3 rounded-sm border border-border-default bg-inset text-fg-primary text-sm outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring box-border';

// Sync state when settings prop changes
useEffect(() => {
setModel(settings?.model ?? '');
setPermissionMode(settings?.permissionMode ?? 'default');
setOpencodeProvider(settings?.opencodeProvider ?? '');
setOpencodeBaseUrl(settings?.opencodeBaseUrl ?? '');
setOpencodeProviderName(settings?.opencodeProviderName ?? '');
setProviderMode(settings?.providerMode ?? '');
}, [settings]);

const handleSave = async () => {
try {
setError(null);
Expand Down
12 changes: 4 additions & 8 deletions apps/web/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,8 @@ export function AppShell({ children }: AppShellProps) {
setShowGlobalNav(false);
}, [location.pathname]);

// Clear project name when leaving project context
useEffect(() => {
if (!projectId) {
setProjectNameState(undefined);
}
}, [projectId]);
// Derive display project name — clear when leaving project context
const displayProjectName = projectId ? projectName : undefined;

const handleSignOut = async () => {
try {
Expand Down Expand Up @@ -214,7 +210,7 @@ export function AppShell({ children }: AppShellProps) {
currentPath={location.pathname}
onNavigate={(path) => { navigate(path); setDrawerOpen(false); }}
onSignOut={handleSignOut}
projectName={projectId ? (projectName || 'Project') : undefined}
projectName={projectId ? (displayProjectName || 'Project') : undefined}
infraSection={mobileInfraSection}
projectListSection={mobileProjectListSection}
showGlobalNav={showGlobalNav}
Expand Down Expand Up @@ -257,7 +253,7 @@ export function AppShell({ children }: AppShellProps) {
</kbd>
</button>
<NavSidebar
projectName={projectName}
projectName={displayProjectName}
showGlobalNav={showGlobalNav}
onToggleGlobalNav={handleToggleGlobalNav}
projectListSection={desktopProjectListSection}
Expand Down
71 changes: 39 additions & 32 deletions apps/web/src/components/ChatSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
useImperativeHandle(ref, () => ({
focusInput: () => agentPanelRef.current?.focusInput(),
}));
const wsUrlCacheRef = useRef<{ url: string; resolvedAt: number } | null>(null);
const wsUrlCacheRef = useRef<{ key: string; url: string; resolvedAt: number } | null>(null);

// Resolve transcription API URL once (stable across renders)
const transcribeApiUrl = useMemo(() => getTranscribeApiUrl(), []);
Expand Down Expand Up @@ -114,15 +114,13 @@
[workspaceId, sessionId]
);

useEffect(() => {
wsUrlCacheRef.current = null;
}, [wsHostInfo, workspaceId, sessionId, worktreePath]);

const resolveWsUrl = useCallback(async (): Promise<string | null> => {
if (!wsHostInfo) return null;

// Key the cache by inputs so stale session/worktree URLs cannot be reused
const cacheKey = `${wsHostInfo}|${workspaceId}|${sessionId}|${worktreePath ?? ''}`;
const cached = wsUrlCacheRef.current;
if (cached && Date.now() - cached.resolvedAt < 15_000) {
if (cached && cached.key === cacheKey && Date.now() - cached.resolvedAt < 15_000) {

Check warning on line 123 in apps/web/src/components/ChatSession.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ7-faiJizCD26of2Fju&open=AZ7-faiJizCD26of2Fju&pullRequest=1408
return cached.url;
}

Expand All @@ -138,7 +136,7 @@
const sessionQuery = `&sessionId=${encodeURIComponent(sessionId)}`;
const worktreeQuery = worktreePath ? `&worktree=${encodeURIComponent(worktreePath)}` : '';
const url = `${wsHostInfo}/agent/ws?token=${encodeURIComponent(token)}${sessionQuery}${worktreeQuery}`;
wsUrlCacheRef.current = { url, resolvedAt: Date.now() };
wsUrlCacheRef.current = { key: cacheKey, url, resolvedAt: Date.now() };
return url;
} catch (err) {
reportError({
Expand All @@ -151,10 +149,38 @@
}
}, [wsHostInfo, workspaceId, sessionId, worktreePath]);

// Refs for parent callbacks — latest-ref pattern keeps processMessage stable.
const onActivityRef = useRef(onActivity);
onActivityRef.current = onActivity;
const onUsageChangeRef = useRef(onUsageChange);
onUsageChangeRef.current = onUsageChange;

// Each chat session gets its own message store.
// No client-side persistence — on reconnect, LoadSession replays the full
// conversation from the agent via session/update notifications.
const acpMessages = useAcpMessages();
const acpMessagesRaw = useAcpMessages();

// Wrap processMessage to report activity on each incoming message (UE012).
// processMessage is stable from useAcpMessages, so this callback is also stable.
const processMessageWrapped = useCallback(
(msg: Parameters<typeof acpMessagesRaw.processMessage>[0]) => {
acpMessagesRaw.processMessage(msg);
// Report activity whenever a message arrives (UE012)
onActivityRef.current?.();
},
[acpMessagesRaw.processMessage],
);

const acpMessages = { ...acpMessagesRaw, processMessage: processMessageWrapped };

// UE013: Push token usage to parent at render time when it changes.
// Track the last-reported total to avoid redundant calls.
const lastReportedUsageTotalRef = useRef(0);
if (acpMessages.usage.totalTokens > 0 &&
acpMessages.usage.totalTokens !== lastReportedUsageTotalRef.current) {
lastReportedUsageTotalRef.current = acpMessages.usage.totalTokens;
onUsageChangeRef.current?.(sessionId, acpMessages.usage);
}

// Handle agent auto-selection on first connection using event-driven approach.
// This replaces the useEffect-based logic and prevents infinite loops by design.
Expand Down Expand Up @@ -207,13 +233,12 @@

const { agentType, state, switchAgent } = acpSession;
switchAgentRef.current = switchAgent;
const { clear: clearMessages } = acpMessages;
const { clear: clearMessages } = acpMessagesRaw;

// Clear messages when no agent session exists (idle SessionHost).
// Replay clearing is now handled synchronously by onPrepareForReplay
// (called from useAcpSession when session_state arrives with replayCount > 0)
// which avoids the race where this useEffect fires after replay messages
// have already been appended.
// UE011: Clear messages when no agent session exists (idle SessionHost).
// Replay clearing is handled synchronously by onPrepareForReplay (above), which
// fires when session_state arrives with replayCount > 0, avoiding the race where
// this effect would fire after replay messages have already been appended.
useEffect(() => {
if (state === 'no_session') {
reportError({
Expand All @@ -226,24 +251,6 @@
}
}, [state, clearMessages, workspaceId, sessionId]);

// Report activity
const handleActivity = useCallback(() => {
onActivity?.();
}, [onActivity]);

useEffect(() => {
if (acpMessages.items.length > 0) {
handleActivity();
}
}, [acpMessages.items.length, handleActivity]);

// Report token usage changes to parent for sidebar aggregation
useEffect(() => {
if (acpMessages.usage.totalTokens > 0) {
onUsageChange?.(sessionId, acpMessages.usage);
}
}, [acpMessages.usage, sessionId, onUsageChange]);

// ── Agent settings ──
const [agentSettings, setAgentSettings] = useState<ChatSettingsData | null>(null);
const [agentSettingsLoading, setAgentSettingsLoading] = useState(false);
Expand Down
25 changes: 16 additions & 9 deletions apps/web/src/components/GitDiffView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,12 @@ export const GitDiffView: FC<GitDiffViewProps> = ({
}
}, [workspaceUrl, workspaceId, token, filePath, staged, worktree]);

// Track the identity of the cached full content so stale content is cleared
// when the file/worktree/staged context changes.
const [fullContentKey, setFullContentKey] = useState('');
const currentFullKey = `${filePath}:${worktree ?? ''}:${staged}`;

const fetchFullFile = useCallback(async () => {
if (fullContent !== null) return; // already loaded
setFullLoading(true);
try {
const result = await getGitFile(
Expand All @@ -93,25 +97,28 @@ export const GitDiffView: FC<GitDiffViewProps> = ({
worktree ?? undefined
);
setFullContent(result.content);
setFullContentKey(currentFullKey);
} catch {
// Fallback: just show diff if full file fails
setFullContent(null);
setViewMode('diff');
} finally {
setFullLoading(false);
}
}, [workspaceUrl, workspaceId, token, filePath, fullContent, worktree]);
}, [workspaceUrl, workspaceId, token, filePath, worktree, staged, currentFullKey]);

const handleToggleFull = useCallback(() => {
setViewMode('full');
// Fetch if we don't have content or it's for a different file/worktree/staged
if (fullContent === null || fullContentKey !== currentFullKey) {
fetchFullFile();
}
}, [fullContent, fullContentKey, currentFullKey, fetchFullFile]);

useEffect(() => {
fetchDiff();
}, [fetchDiff]);

useEffect(() => {
if (viewMode === 'full') {
fetchFullFile();
}
}, [viewMode, fetchFullFile]);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
Expand Down Expand Up @@ -183,7 +190,7 @@ export const GitDiffView: FC<GitDiffViewProps> = ({
<ToggleButton
label="Full"
active={viewMode === 'full'}
onClick={() => setViewMode('full')}
onClick={handleToggleFull}
/>
</div>

Expand Down
28 changes: 18 additions & 10 deletions apps/web/src/components/GitHubAppSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,28 +50,36 @@ export function GitHubAppSection() {
}
}, []);

// Load installations and install URL independently — each has its own concern
useEffect(() => {
loadInstallations();
}, [loadInstallations]);

useEffect(() => {
loadInstallUrl();
}, [loadInstallations, loadInstallUrl]);
}, [loadInstallUrl]);

// Show feedback message if redirected from GitHub App installation
// Consume OAuth callback params — runs once when params are present, cleans up URL
useEffect(() => {
const status = searchParams.get('github_app');
if (!status) return;

if (status === 'installed') {
setShowSuccess(true);
} else if (status === 'error') {
const reason = searchParams.get('reason') || 'Unknown error';
setError(`GitHub App installation failed: ${reason}`);
}
if (status) {
// Clean up the URL params without triggering navigation
const newParams = new URLSearchParams(searchParams);
newParams.delete('github_app');
newParams.delete('reason');
setSearchParams(newParams, { replace: true });
}
}, [searchParams, setSearchParams]);

// Clean up the URL params without triggering navigation
setSearchParams((prev) => {
prev.delete('github_app');
prev.delete('reason');
return prev;
}, { replace: true });
// Only re-run when the specific callback param changes, not on every searchParams update
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams.get('github_app')]);

const handleInstallClick = () => {
if (installUrl) {
Expand Down
22 changes: 10 additions & 12 deletions apps/web/src/components/GlobalCommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,9 +531,12 @@ export function GlobalCommandPalette({ onClose }: GlobalCommandPaletteProps) {
return flat;
}, [groups]);

// Clamp selectedIndex so it never exceeds the result list length
const clampedIndex = Math.min(selectedIndex, Math.max(flatResults.length - 1, 0));

// Active descendant ID for ARIA
const activeDescendantId = flatResults[selectedIndex]
? `gcp-option-${resultKey(flatResults[selectedIndex])}`
const activeDescendantId = flatResults[clampedIndex]
? `gcp-option-${resultKey(flatResults[clampedIndex])}`
: undefined;

// Auto-focus on mount
Expand All @@ -546,12 +549,7 @@ export function GlobalCommandPalette({ onClose }: GlobalCommandPaletteProps) {
if (selectedRef.current && typeof selectedRef.current.scrollIntoView === 'function') {
selectedRef.current.scrollIntoView({ block: 'nearest' });
}
}, [selectedIndex]);

// Reset selection when query changes
useEffect(() => {
setSelectedIndex(0);
}, [query]);
}, [clampedIndex]);

// Focus trap — keep Tab within the dialog
useEffect(() => {
Expand Down Expand Up @@ -599,8 +597,8 @@ export function GlobalCommandPalette({ onClose }: GlobalCommandPaletteProps) {
break;
case 'Enter':
e.preventDefault();
if (flatResults[selectedIndex]) {
executeResult(flatResults[selectedIndex]);
if (flatResults[clampedIndex]) {
executeResult(flatResults[clampedIndex]);
}
break;
case 'Escape':
Expand Down Expand Up @@ -654,7 +652,7 @@ export function GlobalCommandPalette({ onClose }: GlobalCommandPaletteProps) {
aria-controls="gcp-listbox"
aria-activedescendant={activeDescendantId}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
placeholder="Search pages, projects, chats, nodes..."
className="w-full bg-transparent border-none text-fg-primary text-sm outline-none font-[inherit] placeholder:text-fg-muted focus:ring-0"
Expand Down Expand Up @@ -689,7 +687,7 @@ export function GlobalCommandPalette({ onClose }: GlobalCommandPaletteProps) {
{group.results.map((result) => {
flatIndex++;
const currentFlatIndex = flatIndex;
const isSelected = currentFlatIndex === selectedIndex;
const isSelected = currentFlatIndex === clampedIndex;

return (
<div
Expand Down
13 changes: 5 additions & 8 deletions apps/web/src/components/OrphanedSessionsBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AgentSession } from '@simple-agent-manager/shared';
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';

interface OrphanedSessionsBannerProps {
orphanedSessions: AgentSession[];
Expand All @@ -14,14 +14,11 @@ export function OrphanedSessionsBanner({
onStopAll,
onDismiss,
}: OrphanedSessionsBannerProps) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
timerRef.current = setTimeout(onDismiss, AUTO_DISMISS_MS);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [onDismiss]);
if (orphanedSessions.length === 0) return;
const timeoutId = setTimeout(onDismiss, AUTO_DISMISS_MS);
return () => clearTimeout(timeoutId);
}, [onDismiss, orphanedSessions.length]);

if (orphanedSessions.length === 0) return null;

Expand Down
Loading
Loading