diff --git a/.library/use-effect-audit-checklist.md b/.library/use-effect-audit-checklist.md new file mode 100644 index 000000000..31d3b2042 --- /dev/null +++ b/.library/use-effect-audit-checklist.md @@ -0,0 +1,454 @@ +# useEffect Audit Checklist + +Generated: 2026-06-25 + +## Scope + +- Inventory source: parser-backed scan of tracked `*.ts`, `*.tsx`, `*.js`, and `*.jsx` files. +- `rg` was unavailable in the workspace, so the initial broad search used `git grep`; a scanner then excluded comments and strings to keep only actual call expressions. +- Actual hook calls found: 350 `useEffect` call expressions across 189 files. +- Textual mentions in markdown, task files, comments, and string fixtures were excluded from the primary checklist. +- Area counts: `apps/web/src` 302, `apps/web/tests` 4, `packages/acp-client` 25, `packages/terminal` 14, `packages/ui` 5. +- Local review process: 10 local subagent slices, 35 entries each, run in waves of about five reviewers. +- Repository React catalog is pinned to React 19.1.0; React docs checked on 2026-06-25 are React 19.2. + +## React Docs Basis + +- [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect): remove Effects used only for deriving render data, resetting state from props, or event-specific logic. +- [Synchronizing with Effects](https://react.dev/learn/synchronizing-with-effects): keep Effects for synchronizing with external systems such as browser APIs, network connections, timers, DOM subscriptions, and analytics on display. +- [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore): prefer a custom hook around it for browser/external-store subscriptions when the current value must be read consistently during render. +- [useLayoutEffect](https://react.dev/reference/react/useLayoutEffect): reserve for layout measurement/mutation that must happen before paint; otherwise prefer `useEffect`. +- [React 19.2 useEffectEvent guidance](https://react.dev/blog/2025/10/01/react-19-2): useful for event-like logic fired from Effects, but this repo should treat it as a future option until React and `eslint-plugin-react-hooks` are upgraded. +- [set-state-in-effect lint](https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect): synchronous `setState` in Effects is a refactor target when the value can be calculated during render or initialized directly. + +## Priority Summary + +- High: 30 +- Medium: 106 +- Low: 113 +- Keep: 101 + +## Main Themes + +- Replace prop-to-state mirroring in forms and panels with keyed child components, draft reducers, or explicit reset actions. +- Remove Effects that only derive UI state, selection, filtered lists, or URL/search mirrors; derive during render, update in event handlers, or key the subtree. +- Keep true external synchronization, but move repeated network, timer, WebSocket, dropdown, Escape-key, focus, and scroll patterns into custom hooks with cancellation/keying. +- Use `useSyncExternalStore` for media-query/browser-store subscriptions where render needs a consistent snapshot. +- Consider `useLayoutEffect` only for initial positioning, autosizing, or DOM measurement where post-paint flicker is visible. +- Treat `useEffectEvent` as future cleanup after the React 19.2 upgrade, not as an immediate recommendation while the repo is pinned to 19.1.0. + +## High-Priority Targets + +- UE010 `apps/web/src/components/ChatSession.tsx:117` - **Current:** Clears a cached WebSocket URL after workspace/session/worktree inputs change. **Recommendation:** Remove the Effect by keying the cache with those inputs inside `resolveWsUrl`, so stale session/worktree URLs cannot be reused before the Effect runs. **Priority:** high +- UE035 `apps/web/src/components/GitDiffView.tsx:109` - **Current:** Fetches full file content when `viewMode` becomes `full`, but caches only by `fullContent` presence. **Recommendation:** Load full content from the Full toggle handler or a keyed query, and reset/cache by file/worktree/staged identity to avoid showing stale full content. **Priority:** high +- UE042 `apps/web/src/components/GlobalCommandPalette.tsx:552` - **Current:** Resets `selectedIndex` from an Effect when `query` changes. **Recommendation:** Remove the Effect and reset selection in the query `onChange` handler, with render-time clamping against `flatResults.length` if needed. **Priority:** high +- UE051 `apps/web/src/components/OrphanedSessionsBanner.tsx:19` - **Current:** Starts an auto-dismiss timer even when the banner renders `null`. **Recommendation:** Keep the timer Effect, but guard on `orphanedSessions.length > 0` and use a local timeout id cleanup instead of a ref-only pattern. **Priority:** high +- UE053 `apps/web/src/components/ProjectAgentCard.tsx:82` - **Current:** Mirrors `defaultValue` props into local form state. **Recommendation:** Remove the prop-sync Effect by making the form controlled, or remount/reset the draft with a deliberate key/defaults version when saved defaults change. **Priority:** high +- UE054 `apps/web/src/components/ProjectAgentsSection.tsx:49` - **Current:** Mirrors `initialAgentDefaults` into local `defaults` state. **Recommendation:** Make defaults controlled by the parent or key/reset the section on project/defaults changes; avoid Effect-driven prop-to-state copying. **Priority:** high +- UE059 `apps/web/src/components/RepoSelector.tsx:71` - **Current:** Stores filtered repository suggestions in state via an Effect. **Recommendation:** Remove the Effect and derive `filteredRepos` with `useMemo` or inline render-time filtering from `value` and `repositories`. **Priority:** high +- UE066 `apps/web/src/components/ScalingSettings.tsx:136` - **Current:** Mirrors the `project` prop into editable provider/location/scaling draft state. **Recommendation:** Remove the sync Effect by using a keyed form component or reducer initialized from a deliberate project/defaults version, resetting drafts only on explicit project reload/change. **Priority:** high +- UE080 `apps/web/src/components/agent-profiles/ProfileFormDialog.tsx:267` - **Current:** Resets many form fields from `isOpen` and `profile` in an Effect. **Recommendation:** Split the form body into a keyed child like `key={profile?.id ?? 'new'}` or use a reducer initialized from `profileToFormState`; avoid bulk prop-to-state resets. **Priority:** high +- UE087 `apps/web/src/components/chat/ChatFilePanel.tsx:227` - **Current:** Mode-driven Effect triggers file/git/diff loads and suppresses exhaustive deps while event handlers also load data. **Recommendation:** Remove the mode Effect; make explicit transition handlers own their fetches, and use a mount-only or keyed initial loader for `initialMode`/`initialPath`. **Priority:** high +- UE096 `apps/web/src/components/library/FilePreviewModal.tsx:57` - **Current:** Fetches markdown preview content with an abort controller and timeout. **Recommendation:** Keep the network Effect, but fix timeout handling so timeout aborts set an error instead of leaving loading true, and clear old `mdContent` at fetch start. **Priority:** high +- UE105 `apps/web/src/components/onboarding/OnboardingContext.tsx:66` - **Current:** Fetches setup status and mixes completion, dismissal, URL forcing, localStorage, and overlay decisions in one Effect. **Recommendation:** Keep the fetch Effect, but split it into `useOnboardingStatus(userId)` plus a reducer/state machine for dismissal and overlay visibility; pass abort signals through API calls where possible. **Priority:** high +- UE113 `apps/web/src/components/project-message-view/index.tsx:214` - **Current:** Mutates a `Set` after render to mark optimistic messages for animation, then deletes entries with a timeout. **Recommendation:** Remove the Effect; derive animation from optimistic message IDs on mount or set animation state in the submit handler. Current mutation does not trigger a render. **Priority:** high +- UE121 `apps/web/src/components/project-message-view/useSessionLifecycle.ts:245` - **Current:** Fetches workspace/node details with retries after a session workspace appears. **Recommendation:** Move to a keyed `useWorkspaceDetails(workspaceId)` hook that clears stale workspace/node immediately on key changes and cancels retries/fetches. **Priority:** high +- UE146 `apps/web/src/components/triggers/TriggerForm.tsx:188` - **Current:** Copies `editTrigger`/defaults into many local state fields when opened. **Recommendation:** Remove the props-to-state Effect; render a keyed form body or reducer initialized from `editTrigger`/mode, and reset from the explicit create/edit/open action. **Priority:** high +- UE163 `apps/web/src/hooks/useAgentChat.ts:60` - **Current:** Loads conversation history on `apiBase` change with a boolean cancellation flag. **Recommendation:** Keep as a network query, but use `AbortController` and guard every state update; current cancelled early-return branches can still set loading after cleanup. **Priority:** high +- UE169 `apps/web/src/hooks/useBootLogStream.ts:145` - **Current:** Separately clears logs when status leaves `creating`, but ignores `workspaceId` changes while still creating. **Recommendation:** Remove the standalone reset Effect; clear logs when starting/stopping a workspace stream or key log state by `workspaceId`. **Priority:** high +- UE204 `apps/web/src/hooks/useTrialDraft.ts:93` - **Current:** Cleanup clears a pending debounce timer despite the comment saying it flushes. **Recommendation:** Keep cleanup for the timer, but actually flush the pending draft on unmount/trial change using a pending value ref before clearing. **Priority:** high +- UE208 `apps/web/src/pages/AccountMap.tsx:33` - **Current:** Effect calls `reorganize()` from a derived filter-count flag that is mutated inside `useMemo`. **Recommendation:** Remove the effect and make layout a pure derivation of filtered nodes/edges, or trigger layout-version updates directly in filter event handlers. **Priority:** high +- UE222 `apps/web/src/pages/CreateWorkspace.tsx:127` - **Current:** One mount Effect launches credentials/trial/catalog, installations, projects, and nodes requests and mutates several independent state groups. **Recommendation:** Split into focused hooks (`useWorkspacePrerequisites`, `useGitHubInstallations`, `useAvailableNodes`, provider catalog hook) or a route loader, with cancellation and explicit `locationState` inputs. **Priority:** high +- UE223 `apps/web/src/pages/CreateWorkspace.tsx:276` - **Current:** Mount-only Effect with disabled deps loads project details from navigation state. **Recommendation:** Make `selectedProjectId` the source of truth and fetch details through a dependency-correct hook/effect, or hydrate via route/navigation loader; remove the eslint disable. **Priority:** high +- UE270 `apps/web/src/pages/project-chat/useProjectChatState.ts:241` - **Current:** Task mode is synchronized from workspace profile until a ref says the user changed it. **Recommendation:** Remove this pure state-sync Effect; model task mode as `explicitTaskMode ?? defaultTaskModeFor(selectedWorkspaceProfile)` or update workspace profile and task mode together in one reducer/handler. **Priority:** high +- UE276 `apps/web/src/pages/project-chat/useProjectChatState.ts:349` - **Current:** Provisioning status polling mixes interval setup, task fetches, workspace URL fetches, navigation, and session reloads; it also reads `provisioning.workspaceUrl` without depending on it. **Recommendation:** Keep polling as an external sync, but extract `useProvisioningPoller` with refs or complete dependencies, separate transition callbacks for ready/terminal states, and consider WebSocket task updates over interval polling. **Priority:** high +- UE284 `apps/web/src/pages/workspace/index.tsx:95` - **Current:** One-time effect copies `sessionIdParam` into `viewMode` and suppresses exhaustive deps. **Recommendation:** Remove it by deriving initial/current view mode from the URL, or initialize state lazily from `sessionIdParam` without a mount effect. **Priority:** high +- UE289 `apps/web/src/pages/workspace/index.tsx:282` - **Current:** Lazily fetches the command-palette file index from an effect when the palette opens. **Recommendation:** Move the fetch to the palette-open event or a keyed lazy query, and key/cache it by workspace URL, token, and active worktree with stale-response guarding. **Priority:** high +- UE298 `apps/web/src/pages/workspace/useWorkspaceCore.ts:242` - **Current:** Derives `wsUrl` from workspace URL/token in an effect and guards updates with `wsUrlSetRef`. **Recommendation:** Replace this derived-state effect with `useMemo` or a `useTerminalConnectionUrl` state machine keyed by workspace URL, token, and terminal mode. **Priority:** high +- UE304 `apps/web/tests/unit/components/project-message-view-recovery.test.ts:151` - **Current:** Test-only mirror hook schedules ACP recovery retries with timers, refs, and suppressed deps. **Recommendation:** Replace the mirror with tests for the production recovery hook, or delete it if ACP recovery is no longer a live path. **Priority:** high +- UE332 `packages/terminal/src/MultiTerminal.tsx:228` - **Current:** One large Effect owns WebSocket connect/reconnect, heartbeat, message routing, session reconciliation, and terminal disposal. **Recommendation:** Keep an external connection Effect, but split it into a `useMultiTerminalTransport` hook/state machine with separate message handlers and terminal lifecycle cleanup. **Priority:** high +- UE335 `packages/terminal/src/MultiTerminal.tsx:680` - **Current:** Notifies `onSessionsChange` from an Effect derived from `sessionsArray`, which is recreated every render. **Recommendation:** Remove the Effect by lifting session snapshots to the state owner or calling the callback from session mutations; at minimum memoize snapshots/dependencies. **Priority:** high +- UE336 `packages/terminal/src/Terminal.tsx:67` - **Current:** Initializes xterm, input handling, and resize observers in an Effect whose dependencies include connection/session-derived callbacks. **Recommendation:** Split into a mount/ref-callback `useXTerm` initializer with latest callback refs plus separate resize/socket effects so connection changes do not dispose/recreate xterm. **Priority:** high + +## Full Checklist + +### Chunk 01 (UE001-UE035) + +- [x] UE001 `apps/web/src/components/AgentSettingsCard.tsx:84` - **Current:** Copies `settings` props into editable form state after render. **Recommendation:** Replace with a keyed draft form/reducer initialized from `settings`, resetting on agent/settings identity changes instead of syncing props via Effect. **Priority:** medium +- [x] UE002 `apps/web/src/components/AgentsSection.tsx:70` - **Current:** Fetches agents, credentials, and settings when the section is displayed. **Recommendation:** Keep as external network synchronization; optionally extract to a `useAgentsSectionData` hook with cancellation/cache. **Priority:** keep +- [x] UE003 `apps/web/src/components/ApiTokens.tsx:57` - **Current:** Fetches API tokens on mount. **Recommendation:** Keep as external network synchronization; a small data hook would centralize loading/error/refetch behavior. **Priority:** keep +- [x] UE004 `apps/web/src/components/AppShell.tsx:136` - **Current:** Closes navigation UI after `location.pathname` changes. **Recommendation:** Mostly valid route synchronization; close in navigation handlers where possible and keep this only as the browser/back-navigation fallback, ideally in a route-change UI hook. **Priority:** low +- [x] UE005 `apps/web/src/components/AppShell.tsx:142` - **Current:** Clears `projectName` state when no `projectId` is present. **Recommendation:** Remove the Effect and derive `displayProjectName = projectId ? projectName : undefined` at render/pass-through sites. **Priority:** medium +- [x] UE006 `apps/web/src/components/AuthProvider.tsx:74` - **Current:** Sends the authenticated user id to the analytics module. **Recommendation:** Keep; analytics is an external system and `useEffect` is the right hook. **Priority:** keep +- [x] UE007 `apps/web/src/components/BranchSelector.tsx:65` - **Current:** Resets highlight state when filtered branch count changes. **Recommendation:** Remove the Effect by resetting in input/open/select handlers and clamping the active highlight during render. **Priority:** low +- [x] UE008 `apps/web/src/components/BranchSelector.tsx:70` - **Current:** Subscribes to document `mousedown` while the dropdown is open. **Recommendation:** Keep as DOM subscription; extract to `useClickOutside` if reused. **Priority:** keep +- [x] UE009 `apps/web/src/components/BranchSelector.tsx:87` - **Current:** Scrolls the highlighted branch item into view. **Recommendation:** Keep as DOM synchronization; `useEffect` is preferable unless pre-paint layout correction becomes necessary. **Priority:** keep +- [x] UE010 `apps/web/src/components/ChatSession.tsx:117` - **Current:** Clears a cached WebSocket URL after workspace/session/worktree inputs change. **Recommendation:** Remove the Effect by keying the cache with those inputs inside `resolveWsUrl`, so stale session/worktree URLs cannot be reused before the Effect runs. **Priority:** high +- [x] UE011 `apps/web/src/components/ChatSession.tsx:217` - **Current:** Clears ACP messages when session state becomes `no_session`. **Recommendation:** Move this into the ACP session/message reducer lifecycle so the `no_session` event clears messages synchronously. **Priority:** medium +- [x] UE012 `apps/web/src/components/ChatSession.tsx:234` - **Current:** Reports activity to the parent when message count changes. **Recommendation:** Remove the Effect and call `onActivity` from the ACP message/event handler that actually receives activity; future `useEffectEvent` can help after React 19.2. **Priority:** medium +- [x] UE013 `apps/web/src/components/ChatSession.tsx:241` - **Current:** Pushes token usage changes up to the parent from an Effect. **Recommendation:** Prefer lifting usage state or emitting `onUsageChange` from ACP message processing when usage is computed, not by observing rendered state. **Priority:** medium +- [x] UE014 `apps/web/src/components/ChatSession.tsx:263` - **Current:** Fetches agent settings when the active agent type is known. **Recommendation:** Keep as external network synchronization; extract to `useAgentSettings(activeAgentType)` with cancellation/cache if refactoring. **Priority:** keep +- [x] UE015 `apps/web/src/components/CollapsibleSection.tsx:41` - **Current:** Persists collapsed state to `localStorage` after state changes. **Recommendation:** Prefer a `usePersistentState` hook or write in the toggle setter so storage updates are tied to the user action and mount does not rewrite storage. **Priority:** low +- [x] UE016 `apps/web/src/components/CommandPalette.tsx:239` - **Current:** Focuses the search input on mount. **Recommendation:** Prefer `autoFocus` if it works reliably through the portal; otherwise keep this focus Effect. **Priority:** low +- [x] UE017 `apps/web/src/components/CommandPalette.tsx:244` - **Current:** Scrolls the selected result into view. **Recommendation:** Keep as DOM synchronization; no `useLayoutEffect` unless visible scroll flicker appears. **Priority:** keep +- [x] UE018 `apps/web/src/components/CommandPalette.tsx:251` - **Current:** Resets selected index when `query` changes. **Recommendation:** Remove the Effect by setting `selectedIndex` to `0` in the query input handler and clamping against `flatResults.length` during render/navigation. **Priority:** low +- [x] UE019 `apps/web/src/components/ConfirmDialog.tsx:54` - **Current:** Registers an Escape-key document listener. **Recommendation:** Keep as DOM subscription, but subscribe only while open and consider `useEscapeKey`; future `useEffectEvent` can avoid callback resubscribe after React 19.2. **Priority:** low +- [x] UE020 `apps/web/src/components/ConfirmDialog.tsx:69` - **Current:** Focuses the dialog when it opens. **Recommendation:** Keep as DOM focus synchronization, ideally as part of a reusable dialog/focus-management hook. **Priority:** keep +- [x] UE021 `apps/web/src/components/ConnectionsOverview.tsx:49` - **Current:** Fetches credential resolution status for the displayed scope. **Recommendation:** Keep as external network synchronization; a `useResolutionStatus(projectId)` hook would make reload/cancellation reusable. **Priority:** keep +- [x] UE022 `apps/web/src/components/DeploymentSettings.tsx:61` - **Current:** Fetches deployment GCP config for the project. **Recommendation:** Keep as external network synchronization; extract to a keyed data hook if this surface grows. **Priority:** keep +- [x] UE023 `apps/web/src/components/DeploymentSettings.tsx:67` - **Current:** Consumes OAuth callback URL params, mutates URL search params, then fetches projects. **Recommendation:** Keep URL/API synchronization but extract an idempotent callback-consumption hook and create fresh `URLSearchParams` instead of mutating `prev`. **Priority:** medium +- [x] UE024 `apps/web/src/components/EnvironmentSecretsSection.tsx:46` - **Current:** Fetches environment secrets for a project/environment. **Recommendation:** Keep as external network synchronization; a `useDeploymentSecrets` hook would centralize refetch after save/delete. **Priority:** keep +- [x] UE025 `apps/web/src/components/FileBrowserPanel.tsx:61` - **Current:** Fetches a directory listing for the current workspace/path. **Recommendation:** Keep as external workspace API synchronization, but add request cancellation/keying to prevent stale listings after fast path/worktree changes. **Priority:** low +- [x] UE026 `apps/web/src/components/FileBrowserPanel.tsx:65` - **Current:** Registers an Escape-key window listener. **Recommendation:** Keep as DOM subscription; extract to `useEscapeKey(onClose)` and consider future `useEffectEvent` after React 19.2. **Priority:** keep +- [x] UE027 `apps/web/src/components/FileViewerPanel.tsx:98` - **Current:** Fetches file content unless the file is an image. **Recommendation:** Keep as external workspace API synchronization, with cancellation/keying if file/worktree can change while mounted. **Priority:** keep +- [x] UE028 `apps/web/src/components/FileViewerPanel.tsx:102` - **Current:** Registers an Escape-key window listener. **Recommendation:** Keep as DOM subscription; extract to shared `useEscapeKey` with future `useEffectEvent` after React 19.2. **Priority:** keep +- [x] UE029 `apps/web/src/components/FileViewerPanel.tsx:110` - **Current:** Persists markdown render mode to `localStorage` after state changes. **Recommendation:** Move persistence into a `usePersistentState` hook or markdown-mode setter so the browser write is tied to the user action. **Priority:** low +- [x] UE030 `apps/web/src/components/GcpCredentialForm.tsx:60` - **Current:** Reads OAuth callback params from `window.location`, cleans history, and fetches an OAuth handle on mount. **Recommendation:** Keep as URL/API synchronization, but use router search params or a dedicated idempotent `useGcpOAuthCallback` hook. **Priority:** medium +- [x] UE031 `apps/web/src/components/GcpCredentialForm.tsx:113` - **Current:** Fetches projects by reacting to internal `phase` plus `oauthHandle` state. **Recommendation:** Remove this Effect by chaining `fetchProjects(handle)` directly after `getGcpOAuthResult` or modeling the OAuth flow in a reducer command. **Priority:** medium +- [x] UE032 `apps/web/src/components/GitChangesPanel.tsx:83` - **Current:** Fetches git status for the workspace/worktree. **Recommendation:** Keep as external workspace API synchronization; add cancellation/keying if this panel can switch contexts while mounted. **Priority:** keep +- [x] UE033 `apps/web/src/components/GitChangesPanel.tsx:87` - **Current:** Registers an Escape-key window listener. **Recommendation:** Keep as DOM subscription; extract to shared `useEscapeKey` with future `useEffectEvent` after React 19.2. **Priority:** keep +- [x] UE034 `apps/web/src/components/GitDiffView.tsx:105` - **Current:** Fetches the diff for file/staged/worktree inputs. **Recommendation:** Keep as external workspace API synchronization; key or cancel requests to avoid stale diffs on fast file changes. **Priority:** keep +- [x] UE035 `apps/web/src/components/GitDiffView.tsx:109` - **Current:** Fetches full file content when `viewMode` becomes `full`, but caches only by `fullContent` presence. **Recommendation:** Load full content from the Full toggle handler or a keyed query, and reset/cache by file/worktree/staged identity to avoid showing stale full content. **Priority:** high + +### Chunk 02 (UE036-UE070) + +- [x] UE036 `apps/web/src/components/GitDiffView.tsx:115` - **Current:** Window Escape-key subscription for a portaled diff overlay. **Recommendation:** Keep as an external DOM subscription; optionally consolidate with other overlays in a `useEscapeKey` hook, with `useEffectEvent` only after a React/hooks-plugin upgrade. **Priority:** keep +- [x] UE037 `apps/web/src/components/GitHubAppSection.tsx:53` - **Current:** Mount-time network fetch for installations and install URL. **Recommendation:** Keep the fetch-on-display behavior, but move it into a `useGitHubAppSettings` data hook or route loader and add stale-result guarding. **Priority:** low +- [x] UE038 `apps/web/src/components/GitHubAppSection.tsx:59` - **Current:** URL query params drive React state and URL cleanup from an Effect. **Recommendation:** Split the concerns: derive the flash status from router/search state or a route-level handler, and keep only a minimal replace-navigation step for removing consumed params. **Priority:** medium +- [x] UE039 `apps/web/src/components/GlobalCommandPalette.tsx:200` - **Current:** Fetches command-palette projects, nodes, and chats when the palette mounts. **Recommendation:** Keep as a network synchronization Effect; a `useCommandPaletteData` hook would make the cancellation/loading behavior reusable. **Priority:** keep +- [x] UE040 `apps/web/src/components/GlobalCommandPalette.tsx:540` - **Current:** Imperatively focuses the input after mount. **Recommendation:** Prefer `autoFocus` or a callback ref on the input; keep an Effect only if portal timing proves unreliable. **Priority:** low +- [x] UE041 `apps/web/src/components/GlobalCommandPalette.tsx:545` - **Current:** Scrolls the selected option into view after keyboard selection changes. **Recommendation:** Keep as DOM synchronization; extract to `useScrollActiveDescendantIntoView` if reused, and only switch to `useLayoutEffect` if visible scroll flicker is observed. **Priority:** keep +- [x] UE042 `apps/web/src/components/GlobalCommandPalette.tsx:552` - **Current:** Resets `selectedIndex` from an Effect when `query` changes. **Recommendation:** Remove the Effect and reset selection in the query `onChange` handler, with render-time clamping against `flatResults.length` if needed. **Priority:** high +- [x] UE043 `apps/web/src/components/GlobalCommandPalette.tsx:557` - **Current:** Window keydown listener traps Tab focus in the dialog. **Recommendation:** Prefer dialog/input `onKeyDown` or a shared modal focus-trap hook; a DOM subscription is still an appropriate Effect if focus can leave the input. **Priority:** low +- [x] UE044 `apps/web/src/components/KeyboardShortcutsHelp.tsx:27` - **Current:** Window Escape-key listener closes the shortcuts overlay. **Recommendation:** Keep as an external DOM subscription; factor into the same `useEscapeKey` overlay hook used elsewhere. **Priority:** keep +- [x] UE045 `apps/web/src/components/MarkdownRenderer.tsx:62` - **Current:** Runs Mermaid rendering, sanitizes SVG, mutates the diagram container, and cleans Mermaid temp DOM. **Recommendation:** Keep as third-party DOM synchronization; consider a `useMermaidDiagram` hook that resets errors on `code` changes and owns cleanup. **Priority:** keep +- [x] UE046 `apps/web/src/components/MobileNavDrawer.tsx:67` - **Current:** Document Escape-key listener closes the mobile drawer. **Recommendation:** Keep as a DOM subscription; share via `useEscapeKey` and use a stable callback/ref pattern until `useEffectEvent` is available. **Priority:** keep +- [x] UE047 `apps/web/src/components/NotificationCenter.tsx:68` - **Current:** Measures the bell button after open and stores portal panel coordinates. **Recommendation:** Use `useLayoutEffect` or compute position before opening so the first painted portal has coordinates; consider a shared floating-position hook that also handles resize/scroll. **Priority:** medium +- [x] UE048 `apps/web/src/components/NotificationCenter.tsx:80` - **Current:** Document outside-click and Escape listeners while the panel is open. **Recommendation:** Keep as external DOM synchronization, but extract to a reusable `useDismissableLayer`/dropdown hook. **Priority:** low +- [x] UE049 `apps/web/src/components/OnboardingChecklist.tsx:28` - **Current:** Reads per-user dismissal state from `localStorage` after `userId` is known. **Recommendation:** Keep browser-storage access out of render; wrap it in a keyed `useLocalStorageState` hook, using `useSyncExternalStore` only if cross-tab storage updates should live-sync. **Priority:** low +- [x] UE050 `apps/web/src/components/OnboardingChecklist.tsx:76` - **Current:** Mount-time network check for onboarding completion. **Recommendation:** Keep as fetch-on-display, but move to `useOnboardingStatus` and guard against stale/unmounted updates. **Priority:** low +- [x] UE051 `apps/web/src/components/OrphanedSessionsBanner.tsx:19` - **Current:** Starts an auto-dismiss timer even when the banner renders `null`. **Recommendation:** Keep the timer Effect, but guard on `orphanedSessions.length > 0` and use a local timeout id cleanup instead of a ref-only pattern. **Priority:** high +- [x] UE052 `apps/web/src/components/PageViewTracker.tsx:21` - **Current:** Sends analytics page-view and previous-page duration events on pathname changes. **Recommendation:** Keep as analytics-on-display synchronization; optionally move into `usePageViewTracking`. **Priority:** keep +- [x] UE053 `apps/web/src/components/ProjectAgentCard.tsx:82` - **Current:** Mirrors `defaultValue` props into local form state. **Recommendation:** Remove the prop-sync Effect by making the form controlled, or remount/reset the draft with a deliberate key/defaults version when saved defaults change. **Priority:** high +- [x] UE054 `apps/web/src/components/ProjectAgentsSection.tsx:49` - **Current:** Mirrors `initialAgentDefaults` into local `defaults` state. **Recommendation:** Make defaults controlled by the parent or key/reset the section on project/defaults changes; avoid Effect-driven prop-to-state copying. **Priority:** high +- [x] UE055 `apps/web/src/components/ProjectAgentsSection.tsx:71` - **Current:** Fetches agents and credentials for the project. **Recommendation:** Keep as network synchronization, preferably inside `useProjectAgentsData(projectId)` with stale-result protection. **Priority:** low +- [x] UE056 `apps/web/src/components/RecentChatsDropdown.tsx:25` - **Current:** Measures the trigger after open and stores portal panel coordinates. **Recommendation:** Use a floating-position hook or `useLayoutEffect` so positioning is ready before paint and can respond to resize/scroll. **Priority:** medium +- [x] UE057 `apps/web/src/components/RecentChatsDropdown.tsx:39` - **Current:** Document outside-click and Escape listeners while open. **Recommendation:** Keep as DOM synchronization; share the logic with NotificationCenter via a dismissable-layer hook. **Priority:** low +- [x] UE058 `apps/web/src/components/RepoSelector.tsx:44` - **Current:** Fetches GitHub repositories when `installationId` changes. **Recommendation:** Keep as network synchronization, but move into a repository-options hook and ignore/abort stale responses across installation changes. **Priority:** medium +- [x] UE059 `apps/web/src/components/RepoSelector.tsx:71` - **Current:** Stores filtered repository suggestions in state via an Effect. **Recommendation:** Remove the Effect and derive `filteredRepos` with `useMemo` or inline render-time filtering from `value` and `repositories`. **Priority:** high +- [x] UE060 `apps/web/src/components/RepoSelector.tsx:92` - **Current:** Always registers a document mousedown listener to close the dropdown. **Recommendation:** Keep the DOM subscription but only attach it while `showDropdown` is true, preferably through a shared outside-click hook. **Priority:** low +- [x] UE061 `apps/web/src/components/RepoSelector.tsx:109` - **Current:** Cleans up a debounce timer created by input events. **Recommendation:** Keep timer cleanup as an external-system Effect, or hide it inside a `useDebouncedCallback` hook. **Priority:** keep +- [x] UE062 `apps/web/src/components/RepositoryAccessCombobox.tsx:55` - **Current:** Watches `open` state to lazy-load repository options. **Recommendation:** Move first-open loading into an `openMenu`/focus/keyboard event handler or a lazy query hook with an explicit trigger instead of deriving the fetch from state. **Priority:** medium +- [x] UE063 `apps/web/src/components/RepositoryAccessCombobox.tsx:62` - **Current:** Document outside-click listener while the combobox is open. **Recommendation:** Keep as external DOM synchronization; extract to the same dismissable-layer hook used by dropdowns. **Priority:** low +- [x] UE064 `apps/web/src/components/RepositoryAccessSettings.tsx:98` - **Current:** Loads project repository access when the GitHub-backed project section displays. **Recommendation:** Keep as network synchronization, but wrap in a `useProjectRepositories(projectId, enabled)` hook with stale-result handling. **Priority:** low +- [x] UE065 `apps/web/src/components/ScalingSettings.tsx:122` - **Current:** Fetches configured credential providers on mount. **Recommendation:** Keep as network synchronization; a `useConfiguredProviders` hook should own loading/error/stale handling. **Priority:** low +- [x] UE066 `apps/web/src/components/ScalingSettings.tsx:136` - **Current:** Mirrors the `project` prop into editable provider/location/scaling draft state. **Recommendation:** Remove the sync Effect by using a keyed form component or reducer initialized from a deliberate project/defaults version, resetting drafts only on explicit project reload/change. **Priority:** high +- [x] UE067 `apps/web/src/components/UserMenu.tsx:46` - **Current:** Registers outside-click, resize, and scroll listeners while the user menu is open. **Recommendation:** Keep as external DOM synchronization; combine with the positioning layout effect in a shared floating-menu hook. **Priority:** keep +- [x] UE068 `apps/web/src/components/WorkspaceSidebar.tsx:100` - **Current:** Runs an interval to update a relative-time label. **Recommendation:** Keep as timer synchronization; if more consumers appear, replace per-consumer intervals with a shared `useNow` store built on `useSyncExternalStore`. **Priority:** keep +- [x] UE069 `apps/web/src/components/WorkspaceTabStrip.tsx:66` - **Current:** Focuses and selects the rename input after entering edit mode. **Recommendation:** Prefer a callback ref on the conditionally rendered input that focuses/selects when the node mounts, removing the standalone Effect. **Priority:** low +- [x] UE070 `apps/web/src/components/WorkspaceTabStrip.tsx:139` - **Current:** Cleans up a long-press timeout on unmount. **Recommendation:** Keep timer cleanup, but consider a `useLongPress` hook that clears existing timers on start/end/cancel and owns unmount cleanup. **Priority:** keep + +### Chunk 03 (UE071-UE105) + +- [x] UE071 `apps/web/src/components/WorktreeSelector.tsx:63` - **Current:** Resets transient UI state after `open` becomes false. **Recommendation:** Remove the Effect; route all closes through a `closePopover()` handler that clears create/error state before setting `open(false)`. **Priority:** low +- [x] UE072 `apps/web/src/components/WorktreeSelector.tsx:73` - **Current:** Subscribes to document `mousedown` while the desktop popover is open. **Recommendation:** Keep as an Effect because it syncs with a DOM subscription; consider a shared `useDismissableLayer` hook for outside-click menus. **Priority:** keep +- [x] UE073 `apps/web/src/components/account-map/AccountMapCanvas.tsx:87` - **Current:** Copies `initialNodes` props into React Flow local node state. **Recommendation:** Prefer key-remounting the React Flow inner tree on data/layout version changes, or make graph state fully controlled by the parent; avoid prop-to-state sync Effects. **Priority:** medium +- [x] UE074 `apps/web/src/components/account-map/AccountMapCanvas.tsx:90` - **Current:** Copies `initialEdges` props into React Flow local edge state. **Recommendation:** Pair this with the UE073 refactor: reset nodes and edges together via controlled graph state or a keyed inner component instead of two sync Effects. **Priority:** medium +- [x] UE075 `apps/web/src/components/account-map/hooks/useAccountMapData.ts:54` - **Current:** Fetches account-map data when `activeOnly` changes. **Recommendation:** Keep as a data-fetching Effect in this custom hook, but add cancellation/stale-response protection or move to a project-wide data-fetching layer if one is introduced. **Priority:** keep +- [x] UE076 `apps/web/src/components/admin/ErrorTrends.tsx:41` - **Current:** Fetches error trends for the selected range. **Recommendation:** Keep the network synchronization, ideally extracted to `useAdminErrorTrends(range)` with stale-response handling and a returned `refresh`. **Priority:** keep +- [x] UE077 `apps/web/src/components/admin/LogStream.tsx:44` - **Current:** Mutates scroll position after entries change. **Recommendation:** Keep as DOM synchronization; depend on `entries.length` rather than the whole array and consider a shared `useAutoScrollToBottom` hook. **Priority:** keep +- [x] UE078 `apps/web/src/components/admin/ObservabilityFilters.tsx:55` - **Current:** Mirrors external `search` into local debounced input state. **Recommendation:** Remove the sync Effect by making search fully controlled and debouncing the parent query/fetch, or centralize this in a `useDebouncedControllableInput` hook. **Priority:** medium +- [x] UE079 `apps/web/src/components/admin/ObservabilityFilters.tsx:70` - **Current:** Clears a pending debounce timer on unmount. **Recommendation:** Keep timer cleanup, but hide it inside a reusable `useDebouncedCallback` hook shared with other filter inputs. **Priority:** keep +- [x] UE080 `apps/web/src/components/agent-profiles/ProfileFormDialog.tsx:267` - **Current:** Resets many form fields from `isOpen` and `profile` in an Effect. **Recommendation:** Split the form body into a keyed child like `key={profile?.id ?? 'new'}` or use a reducer initialized from `profileToFormState`; avoid bulk prop-to-state resets. **Priority:** high +- [x] UE081 `apps/web/src/components/agent-profiles/ProfileFormDialog.tsx:302` - **Current:** Loads provider catalog and project defaults when the dialog opens. **Recommendation:** Keep as network synchronization, but extract to `useProfileFormRuntime(projectId, isOpen)` with abort/stale-response handling and caching. **Priority:** keep +- [x] UE082 `apps/web/src/components/chat/BootLogPanel.tsx:22` - **Current:** Focuses the drawer panel on mount. **Recommendation:** Keep as focus synchronization, preferably via a shared drawer/dialog focus hook used by all portal panels. **Priority:** keep +- [x] UE083 `apps/web/src/components/chat/BootLogPanel.tsx:27` - **Current:** Subscribes to `window` keydown for Escape close. **Recommendation:** Keep as a DOM subscription; extract to a shared `useEscapeKey` or dialog-layer hook. **Priority:** keep +- [x] UE084 `apps/web/src/components/chat/BootLogPanel.tsx:36` - **Current:** Auto-scrolls the log container when log count changes. **Recommendation:** Keep as DOM synchronization and share the behavior with other log panels through `useAutoScrollToBottom`. **Priority:** keep +- [x] UE085 `apps/web/src/components/chat/ChatFilePanel.tsx:107` - **Current:** Focuses the file panel on mount. **Recommendation:** Keep as focus synchronization, but move it into shared portal drawer focus management. **Priority:** keep +- [x] UE086 `apps/web/src/components/chat/ChatFilePanel.tsx:115` - **Current:** Registers global Escape and Cmd/Ctrl+P shortcuts, with a ref to avoid stale search activation. **Recommendation:** Keep as a DOM subscription; extract to a shortcut hook, and consider `useEffectEvent` only after React and `eslint-plugin-react-hooks` are upgraded beyond 19.1-era support. **Priority:** keep +- [x] UE087 `apps/web/src/components/chat/ChatFilePanel.tsx:227` - **Current:** Mode-driven Effect triggers file/git/diff loads and suppresses exhaustive deps while event handlers also load data. **Recommendation:** Remove the mode Effect; make explicit transition handlers own their fetches, and use a mount-only or keyed initial loader for `initialMode`/`initialPath`. **Priority:** high +- [x] UE088 `apps/web/src/components/chat/ChatTimelineDrawer.tsx:41` - **Current:** Subscribes to `window` keydown for Escape close. **Recommendation:** Keep as a DOM subscription and consolidate with other drawer Escape handling in a shared hook. **Priority:** keep +- [x] UE089 `apps/web/src/components/chat/ChatTimelineDrawer.tsx:50` - **Current:** Focuses the timeline drawer on mount. **Recommendation:** Keep as focus synchronization, ideally handled by a shared drawer/dialog focus utility. **Priority:** keep +- [x] UE090 `apps/web/src/components/chat/TruncatedSummary.tsx:52` - **Current:** Measures truncation and subscribes via `ResizeObserver`. **Recommendation:** Keep the external measurement subscription; extract to `useIsTruncated(ref, summary)` and use `useLayoutEffect` only if first-paint flicker is visible. **Priority:** keep +- [x] UE091 `apps/web/src/components/deployments/DeploymentCustomDomainsPanel.tsx:129` - **Current:** Loads routes and custom domains for the environment. **Recommendation:** Keep as network synchronization; extract to a `useDeploymentCustomDomains(projectId, environmentId)` hook with refresh and stale-response protection. **Priority:** keep +- [x] UE092 `apps/web/src/components/deployments/DeploymentEnvironmentConfigPanel.tsx:45` - **Current:** Fetches environment config when the panel opens. **Recommendation:** Keep as conditional network synchronization, preferably behind a data hook with an `enabled: open` style contract and cancellation. **Priority:** keep +- [x] UE093 `apps/web/src/components/library/CreateDirectoryDialog.tsx:25` - **Current:** Focuses the folder-name input on mount. **Recommendation:** Replace with `autoFocus` on the input if reliable here, or use the shared modal focus hook. **Priority:** low +- [x] UE094 `apps/web/src/components/library/CreateDirectoryDialog.tsx:39` - **Current:** Subscribes to document keydown for Escape. **Recommendation:** Keep as a DOM subscription but move into shared dialog keyboard handling. **Priority:** keep +- [x] UE095 `apps/web/src/components/library/FileActionsMenu.tsx:29` - **Current:** Subscribes to document `mousedown` while the menu is open. **Recommendation:** Keep as an Effect; replace with a shared `useDismissableLayer` hook that handles outside pointer events consistently. **Priority:** keep +- [x] UE096 `apps/web/src/components/library/FilePreviewModal.tsx:57` - **Current:** Fetches markdown preview content with an abort controller and timeout. **Recommendation:** Keep the network Effect, but fix timeout handling so timeout aborts set an error instead of leaving loading true, and clear old `mdContent` at fetch start. **Priority:** high +- [x] UE097 `apps/web/src/components/library/FilePreviewModal.tsx:90` - **Current:** Subscribes to document keydown for Escape close. **Recommendation:** Keep as a DOM subscription and consolidate with modal keyboard handling. **Priority:** keep +- [x] UE098 `apps/web/src/components/library/FilePreviewModal.tsx:134` - **Current:** Subscribes to document keydown for a manual Tab focus trap. **Recommendation:** Keep the focus-management subscription, but move Escape and Tab handling into a shared modal/focus-trap hook or library. **Priority:** low +- [x] UE099 `apps/web/src/components/library/FilePreviewModal.tsx:141` - **Current:** Saves previous focus, focuses the dialog, and restores focus on unmount. **Recommendation:** Keep as focus synchronization, but centralize it with the modal focus trap so every dialog restores focus consistently. **Priority:** keep +- [x] UE100 `apps/web/src/components/library/FilePreviewModal.tsx:150` - **Current:** Starts a timer while a PDF iframe is loading. **Recommendation:** Keep the timer Effect, but key/reset `pdfLoading` and `pdfError` when `file.id` or `previewUrl` changes so reused modals do not inherit stale PDF state. **Priority:** medium +- [x] UE101 `apps/web/src/components/library/TagEditor.tsx:21` - **Current:** Focuses the tag input on mount. **Recommendation:** Prefer `autoFocus` on the input, falling back to shared focus management only if portal/timing issues require it. **Priority:** low +- [x] UE102 `apps/web/src/components/node/LogFilters.tsx:35` - **Current:** Mirrors external `search` into local debounced input state. **Recommendation:** Remove the sync Effect by making the input controlled and debouncing log fetching upstream, or share the same debounced controllable-input hook as observability filters. **Priority:** medium +- [x] UE103 `apps/web/src/components/node/LogFilters.tsx:46` - **Current:** Clears a pending debounce timer on unmount. **Recommendation:** Keep timer cleanup but encapsulate it in the shared debounce hook. **Priority:** keep +- [x] UE104 `apps/web/src/components/node/LogsSection.tsx:42` - **Current:** Auto-scrolls the log list when new entries append and the user is near the bottom. **Recommendation:** Keep as DOM synchronization; extract with UE077/UE084 into a reusable auto-scroll hook. **Priority:** keep +- [x] UE105 `apps/web/src/components/onboarding/OnboardingContext.tsx:66` - **Current:** Fetches setup status and mixes completion, dismissal, URL forcing, localStorage, and overlay decisions in one Effect. **Recommendation:** Keep the fetch Effect, but split it into `useOnboardingStatus(userId)` plus a reducer/state machine for dismissal and overlay visibility; pass abort signals through API calls where possible. **Priority:** high + +### Chunk 04 (UE106-UE140) + +- [x] UE106 `apps/web/src/components/onboarding/choose-path/ChoosePathWizard.tsx:48` - **Current:** Focuses the wizard content after overlay/step changes. **Recommendation:** Keep as DOM focus synchronization; a small focus-management hook would be fine, but this is a valid Effect. **Priority:** keep +- [x] UE107 `apps/web/src/components/onboarding/choose-path/ChoosePathWizard.tsx:88` - **Current:** Fetches existing setup state on mount to seed onboarding tags. **Recommendation:** Keep as network synchronization; consider extracting `useExistingSetupTags` and passing real abort signals if the API helpers support them. **Priority:** keep +- [x] UE108 `apps/web/src/components/project-chat/ProjectChatComposer.tsx:98` - **Current:** Focuses the textarea once on mount. **Recommendation:** Prefer declarative `autoFocus` on the textarea, or make focus a parent-controlled open/activation behavior. **Priority:** low +- [x] UE109 `apps/web/src/components/project-chat/ProjectChatComposer.tsx:102` - **Current:** Mutates textarea height from `scrollHeight` after value changes. **Recommendation:** Keep DOM sizing logic but move it into `useAutosizeTextarea`; use `useLayoutEffect` if post-paint resize flicker is visible. **Priority:** medium +- [x] UE110 `apps/web/src/components/project-message-view/SessionHeader.tsx:241` - **Current:** Lazily fetches trigger detail when the header expands. **Recommendation:** Move the fetch into the expand/open handler or a keyed lazy query; clear/cache by `projectId:taskId` so stale trigger detail cannot survive session changes. **Priority:** medium +- [x] UE111 `apps/web/src/components/project-message-view/index.tsx:112` - **Current:** Runs a one-second interval for elapsed prompt time. **Recommendation:** Keep as timer synchronization; optionally extract `useElapsedTime(startedAt)` and initialize the first label synchronously. **Priority:** keep +- [x] UE112 `apps/web/src/components/project-message-view/index.tsx:199` - **Current:** Closes the plan modal when agent activity becomes idle. **Recommendation:** Remove the Effect by keying modal open state to the active run/prompt, or close it in the lifecycle event that marks the agent idle. **Priority:** medium +- [x] UE113 `apps/web/src/components/project-message-view/index.tsx:214` - **Current:** Mutates a `Set` after render to mark optimistic messages for animation, then deletes entries with a timeout. **Recommendation:** Remove the Effect; derive animation from optimistic message IDs on mount or set animation state in the submit handler. Current mutation does not trigger a render. **Priority:** high +- [x] UE114 `apps/web/src/components/project-message-view/useConnectionRecovery.ts:63` - **Current:** Resets recovery state when `sessionId` changes. **Recommendation:** Prefer remounting/keying session-scoped state by `sessionId`, or use a reducer with an explicit session-change action instead of prop-to-state reset. **Priority:** medium +- [x] UE115 `apps/web/src/components/project-message-view/useConnectionRecovery.ts:74` - **Current:** Updates idle cleanup countdown with an interval. **Recommendation:** Keep as timer synchronization; extract a reusable `useCountdown(targetMs, enabled)` hook if reused. **Priority:** keep +- [x] UE116 `apps/web/src/components/project-message-view/useConnectionRecovery.ts:96` - **Current:** Schedules automatic idle-session resume with a timeout and REST call. **Recommendation:** Keep the Effect, but move resume logic into a stable callback with full deps and cancellation guards; future `useEffectEvent` can simplify this after React/hooks upgrade. **Priority:** medium +- [x] UE117 `apps/web/src/components/project-message-view/useConnectionRecovery.ts:137` - **Current:** Debounces connection banner visibility with a timeout. **Recommendation:** Keep as timer-based UI synchronization; a `useDebouncedConnectionBanner(connectionState)` hook would make the pattern clearer. **Priority:** keep +- [x] UE118 `apps/web/src/components/project-message-view/usePublicPortsToggle.ts:14` - **Current:** Copies `workspace.portsPublicEnabled` into local state and clears errors on workspace changes. **Recommendation:** Replace prop mirroring with derived value plus an optimistic override keyed by `workspace.id`, or remount the toggle per workspace. **Priority:** medium +- [x] UE119 `apps/web/src/components/project-message-view/useSessionLifecycle.ts:215` - **Current:** Cleans timers/aborts and resets virtual-scroll UI state on session change. **Recommendation:** Keep cleanup for timers/abort controllers, but key/reset visual state through a session-scoped component or reducer. **Priority:** low +- [x] UE120 `apps/web/src/components/project-message-view/useSessionLifecycle.ts:242` - **Current:** Loads session data when the hook mounts or IDs change. **Recommendation:** Keep as network synchronization, but pass an `AbortController`/request key through `getChatSession` to prevent stale responses. **Priority:** medium +- [x] UE121 `apps/web/src/components/project-message-view/useSessionLifecycle.ts:245` - **Current:** Fetches workspace/node details with retries after a session workspace appears. **Recommendation:** Move to a keyed `useWorkspaceDetails(workspaceId)` hook that clears stale workspace/node immediately on key changes and cancels retries/fetches. **Priority:** high +- [x] UE122 `apps/web/src/components/project-message-view/useSessionLifecycle.ts:299` - **Current:** Polls active sessions as a REST fallback to WebSocket updates. **Recommendation:** Keep as external timer/network synchronization; extract `useSessionPollingFallback` and consider disabling when a healthier shared session store exists. **Priority:** keep +- [x] UE123 `apps/web/src/components/project-message-view/useSessionTimeline.ts:69` - **Current:** Clears timeline messages/events when project or session changes. **Recommendation:** Remove the reset Effect by storing timeline data with a `{projectId, sessionId}` key and returning empty data when the key does not match. **Priority:** medium +- [x] UE124 `apps/web/src/components/project-message-view/useSessionTimeline.ts:75` - **Current:** Fetches timeline data when the drawer becomes enabled. **Recommendation:** Keep lazy network loading, but make it a keyed query with abort/cancel guards, or trigger fetch from the drawer-open handler. **Priority:** medium +- [x] UE125 `apps/web/src/components/project-settings/ProjectRuntimeConfigSection.tsx:46` - **Current:** Loads project runtime config on mount/project change. **Recommendation:** Keep as network synchronization; a `useProjectRuntimeConfig(projectId)` hook would centralize loading, reload, and cancellation. **Priority:** keep +- [x] UE126 `apps/web/src/components/project/TaskDelegateDialog.tsx:34` - **Current:** Clears selected workspace after the dialog closes. **Recommendation:** Remove the Effect by clearing selection in local close/delegate handlers, or key the dialog contents by task/open instance. **Priority:** low +- [x] UE127 `apps/web/src/components/project/TaskForm.tsx:50` - **Current:** Fetches agent profiles for the task form. **Recommendation:** Keep as network synchronization; prefer a shared `useAgentProfiles(projectId)` cache used by all task/profile selectors. **Priority:** keep +- [x] UE128 `apps/web/src/components/runtime/RuntimeAssetsSection.tsx:85` - **Current:** Loads runtime assets for a profile/skill entity. **Recommendation:** Keep the fetch Effect, but key `config/loading/loadedRef` by `projectId:entityId` and clear or abort stale loads on entity changes. **Priority:** medium +- [x] UE129 `apps/web/src/components/settings-credentials/ConfigurationSection.tsx:96` - **Current:** Mirrors `cfg` props into edit-form state. **Recommendation:** Remove the Effect by creating draft state when entering edit mode, or render a keyed `ConfigurationEditForm` initialized from `cfg`. **Priority:** medium +- [x] UE130 `apps/web/src/components/shared-file-viewer/ImageViewer.tsx:56` - **Current:** Resets image loading/error/dimensions/zoom state when `src` changes. **Recommendation:** Split image load state into a keyed child (`key={src}`) and let `` drive status instead of resetting via Effect. **Priority:** medium +- [x] UE131 `apps/web/src/components/skills/SkillFormDialog.tsx:42` - **Current:** Copies `skill` props into many form fields whenever the dialog opens. **Recommendation:** Use a keyed form body or reducer initialized from a pure `skillToDraft` helper; reset draft state from the open/edit event, not an Effect. **Priority:** medium +- [x] UE132 `apps/web/src/components/task-hierarchy/HierarchyModal.tsx:161` - **Current:** Initializes collapse state for new tree nodes after render and suppresses exhaustive deps. **Recommendation:** Store only user overrides and derive default expansion from `tree`, `focusTaskId`, and depth during render. **Priority:** medium +- [x] UE133 `apps/web/src/components/task-hierarchy/HierarchyModal.tsx:194` - **Current:** Scrolls the focused hierarchy node into view after modal open with a timeout. **Recommendation:** Keep DOM scroll synchronization, but replace the magic timeout with a mounted-content callback/rAF or a small `useScrollFocusedNodeOnOpen` hook. **Priority:** low +- [x] UE134 `apps/web/src/components/task-hierarchy/HierarchyModal.tsx:207` - **Current:** Resets the auto-scroll guard when the modal closes. **Recommendation:** Remove the separate Effect by resetting in the close handler or in the scroll Effect cleanup/key tracking. **Priority:** low +- [x] UE135 `apps/web/src/components/task/TaskSubmitForm.tsx:95` - **Current:** Loads agent profiles for task submission and supports reload after profile edits. **Recommendation:** Keep as network synchronization; replace with shared `useAgentProfiles(projectId)` exposing `reload`. **Priority:** keep +- [x] UE136 `apps/web/src/components/task/TaskSubmitForm.tsx:99` - **Current:** Loads skills for task submission. **Recommendation:** Keep as network synchronization; use a shared `useSkills(projectId)` hook with cancellation/cache if other surfaces need the same data. **Priority:** keep +- [x] UE137 `apps/web/src/components/task/TaskSubmitForm.tsx:105` - **Current:** Fetches project defaults just to compute provider/location labels. **Recommendation:** Prefer `ProjectContext` or parent props and derive `projectProvider/projectLocation` during render instead of issuing a separate Effect fetch. **Priority:** medium +- [x] UE138 `apps/web/src/components/trial/LoginSheet.tsx:52` - **Current:** Adds document keydown handling for Escape/Tab and focuses the primary CTA while open. **Recommendation:** Keep as DOM subscription/focus synchronization, ideally via shared Dialog/focus-trap primitive; future `useEffectEvent` can avoid listener churn after upgrade. **Priority:** keep +- [x] UE139 `apps/web/src/components/trial/LoginSheet.tsx:85` - **Current:** Locks `document.body` scrolling while the sheet is open. **Recommendation:** Keep as DOM synchronization, but extract `useBodyScrollLock(isOpen)` with nested-lock safety shared with modal primitives. **Priority:** low +- [x] UE140 `apps/web/src/components/triggers/SchedulePicker.tsx:247` - **Current:** Emits the initial human-readable cron description to parent state on mount only. **Recommendation:** Remove the Effect; derive the description from `cronExpression` in the parent or drop `onDescriptionChange` since the stored value is unused. **Priority:** medium + +### Chunk 05 (UE141-UE175) + +- [x] UE141 `apps/web/src/components/triggers/TriggerDropdown.tsx:116` - **Current:** On `open`, measures popover position and fetches triggers. **Recommendation:** Split this: fetch triggers with a cancellable open/project query, and let a positioning hook own measurement, preferably pre-paint for initial placement. **Priority:** medium +- [x] UE142 `apps/web/src/components/triggers/TriggerDropdown.tsx:123` - **Current:** Subscribes to window resize/scroll while the popover is open. **Recommendation:** Keep as browser synchronization; extract to `usePopoverPosition`/Floating UI style helper and reserve `useLayoutEffect` for the initial measurement if there is visible jump. **Priority:** low +- [x] UE143 `apps/web/src/components/triggers/TriggerDropdown.tsx:134` - **Current:** Subscribes to document mouse/key events for outside click and Escape dismissal. **Recommendation:** Keep as an Effect because it syncs with DOM events; only extract to a reusable dismissable-layer hook if shared elsewhere. **Priority:** keep +- [x] UE144 `apps/web/src/components/triggers/TriggerForm.tsx:170` - **Current:** Fetches agent profiles when the panel is open. **Recommendation:** Move this behind an enabled `useAgentProfiles(projectId, { enabled: open })` style hook or add request cancellation so close/project switches cannot apply stale profiles. **Priority:** medium +- [x] UE145 `apps/web/src/components/triggers/TriggerForm.tsx:176` - **Current:** Captures the active element on open and restores focus on close. **Recommendation:** Keep as DOM focus synchronization, preferably hidden behind a small `useReturnFocus(open)` or dialog/focus-management primitive. **Priority:** keep +- [x] UE146 `apps/web/src/components/triggers/TriggerForm.tsx:188` - **Current:** Copies `editTrigger`/defaults into many local state fields when opened. **Recommendation:** Remove the props-to-state Effect; render a keyed form body or reducer initialized from `editTrigger`/mode, and reset from the explicit create/edit/open action. **Priority:** high +- [x] UE147 `apps/web/src/components/ui/SplitButton.tsx:67` - **Current:** Adds document/window listeners while the split-button menu is open. **Recommendation:** Keep as external DOM synchronization; optionally extract a reusable dropdown interaction hook alongside the existing layout positioning. **Priority:** keep +- [x] UE148 `apps/web/src/contexts/ThemeContext.tsx:98` - **Current:** Derives resolved theme, updates React state, mutates ``, and persists to `localStorage`. **Recommendation:** Derive `resolvedTheme` from `theme` plus a media-query external store; keep only DOM sync/persistence, with user preference persistence moved into `setTheme` if possible. **Priority:** medium +- [x] UE149 `apps/web/src/contexts/ThemeContext.tsx:110` - **Current:** Manually subscribes to `prefers-color-scheme` while theme is `system`. **Recommendation:** Replace with a `useSyncExternalStore` media-query hook and a single theme-attribute sync path. **Priority:** medium +- [x] UE150 `apps/web/src/hooks/useActiveTasks.ts:45` - **Current:** Fetches active tasks immediately and starts a polling interval. **Recommendation:** Keep the network/timer synchronization but move it to a shared abortable polling-resource hook that prevents overlapping fetches and stale updates. **Priority:** medium +- [x] UE151 `apps/web/src/hooks/useAdminAnalytics.ts:63` - **Current:** Maintains a `mountedRef` solely via an Effect. **Recommendation:** Remove the lifecycle-ref Effect and use per-request cancellation, `AbortController`, or request IDs inside the async fetch path. **Priority:** medium +- [x] UE152 `apps/web/src/hooks/useAdminAnalytics.ts:111` - **Current:** Fetches all analytics on mount and when `eventPeriod` changes. **Recommendation:** Keep as a network query Effect, but key/cancel requests by period so slower old responses cannot overwrite newer data. **Priority:** medium +- [x] UE153 `apps/web/src/hooks/useAdminAnalytics.ts:116` - **Current:** Auto-refreshes analytics with `setInterval`. **Recommendation:** Keep as timer synchronization; share a polling helper that uses the latest period and avoids overlapping refreshes. **Priority:** low +- [x] UE154 `apps/web/src/hooks/useAdminErrors.ts:53` - **Current:** Maintains a `mountedRef` solely via an Effect. **Recommendation:** Remove this Effect and guard async work with request IDs or abortable fetches scoped to the query. **Priority:** medium +- [x] UE155 `apps/web/src/hooks/useAdminErrors.ts:102` - **Current:** Resets cursor and refetches whenever the filter-derived callback changes. **Recommendation:** Keep query synchronization, but model filter/cursor together in a reducer and cancel/key requests; debounce or explicitly apply search if live search is not intended. **Priority:** medium +- [x] UE156 `apps/web/src/hooks/useAdminHealth.ts:31` - **Current:** Maintains a `mountedRef` solely via an Effect. **Recommendation:** Remove the lifecycle Effect and use abort/request-id guards in the health fetch instead. **Priority:** medium +- [x] UE157 `apps/web/src/hooks/useAdminHealth.ts:62` - **Current:** Fetches health on mount. **Recommendation:** Keep as network synchronization, ideally folded into the same polling-resource hook as the refresh interval. **Priority:** low +- [x] UE158 `apps/web/src/hooks/useAdminHealth.ts:67` - **Current:** Polls health with `setInterval`. **Recommendation:** Keep as timer synchronization; centralize polling behavior to avoid duplicate interval patterns and overlapping fetches. **Priority:** low +- [x] UE159 `apps/web/src/hooks/useAdminLogQuery.ts:50` - **Current:** Maintains a `mountedRef` solely via an Effect. **Recommendation:** Remove it and guard log queries with abort/request IDs tied to the active query. **Priority:** medium +- [x] UE160 `apps/web/src/hooks/useAdminLogQuery.ts:101` - **Current:** Resets cursor/query ID and fetches on mount plus every filter change despite the "user triggers query" comment. **Recommendation:** Split initial display fetch from event-driven querying; move filter/search reset+fetch into explicit handlers or committed query state so timing matches the UI. **Priority:** medium +- [x] UE161 `apps/web/src/hooks/useAdminLogStream.ts:179` - **Current:** Runs a keep-alive interval that pings the current WebSocket when open. **Recommendation:** Keep as timer/WebSocket synchronization, but consider owning the ping timer inside the socket lifecycle so it starts/stops with the connection. **Priority:** low +- [x] UE162 `apps/web/src/hooks/useAdminLogStream.ts:189` - **Current:** Opens the admin log WebSocket on mount and cleans it up on unmount. **Recommendation:** Keep as an external connection Effect; encapsulate socket/reconnect/ping as a resource hook, with `useEffectEvent` only as a future callback-ref replacement after React/hooks plugin upgrade. **Priority:** low +- [x] UE163 `apps/web/src/hooks/useAgentChat.ts:60` - **Current:** Loads conversation history on `apiBase` change with a boolean cancellation flag. **Recommendation:** Keep as a network query, but use `AbortController` and guard every state update; current cancelled early-return branches can still set loading after cleanup. **Priority:** high +- [x] UE164 `apps/web/src/hooks/useAgentProfiles.ts:36` - **Current:** Fetches profiles when `projectId` is available. **Recommendation:** Keep as a project-keyed query, but remove unused `hasLoadedRef`, clear state when `projectId` disappears, and guard stale responses. **Priority:** medium +- [x] UE165 `apps/web/src/hooks/useAllChatSessions.ts:54` - **Current:** Fetches all chat sessions on mount and flips a shared cancellation ref on unmount. **Recommendation:** Keep the initial network Effect, but replace the shared cancellation ref with per-request abort/request IDs used by both initial load and manual refresh. **Priority:** low +- [x] UE166 `apps/web/src/hooks/useAvailableCommands.ts:49` - **Current:** Fetches cached slash commands on project/refresh-key changes. **Recommendation:** Keep as network synchronization; replace the ad hoc `{ cancelled }` signal with an abort/request-id helper shared with `refetch`. **Priority:** low +- [x] UE167 `apps/web/src/hooks/useBootLogStream.ts:54` - **Current:** Unmount-only cleanup flips `mountedRef` and closes the boot-log socket. **Recommendation:** Fold this into the socket lifecycle Effect or a `useBootLogSocket` hook so one owner manages mounted state and socket cleanup. **Priority:** low +- [x] UE168 `apps/web/src/hooks/useBootLogStream.ts:61` - **Current:** Connects to boot-log WebSocket while a workspace is creating, after fetching a terminal token. **Recommendation:** Keep as external connection synchronization; add abort/keying for token fetch and reconnect behavior if premature socket close should be retried. **Priority:** low +- [x] UE169 `apps/web/src/hooks/useBootLogStream.ts:145` - **Current:** Separately clears logs when status leaves `creating`, but ignores `workspaceId` changes while still creating. **Recommendation:** Remove the standalone reset Effect; clear logs when starting/stopping a workspace stream or key log state by `workspaceId`. **Priority:** high +- [x] UE170 `apps/web/src/hooks/useChatWebSocket.ts:239` - **Current:** Runs ping/pong timeout timers while chat WebSocket is enabled. **Recommendation:** Keep as timer/WebSocket synchronization, but consider moving ping ownership into the socket lifecycle so timers are tied to each concrete connection. **Priority:** low +- [x] UE171 `apps/web/src/hooks/useChatWebSocket.ts:266` - **Current:** Manages chat WebSocket connect/disconnect/reconnect for `enabled`, `projectId`, and `sessionId`. **Recommendation:** Keep as an external connection Effect; extract a connection state-machine hook, with `useEffectEvent` only as a future replacement for callback refs after React 19.2 upgrade. **Priority:** low +- [x] UE172 `apps/web/src/hooks/useDebouncedValue.ts:10` - **Current:** Uses a timeout to publish a delayed value. **Recommendation:** Keep; debouncing is timer synchronization and this is already the appropriate custom hook shape. **Priority:** keep +- [x] UE173 `apps/web/src/hooks/useDevcontainerConfigs.ts:64` - **Current:** Fetches devcontainer configs when `projectId`/`enabled` allow it. **Recommendation:** Keep as a network query Effect; reset stale config state when disabled/projectless if callers should not see previous options, and keep request-id guarding. **Priority:** low +- [x] UE174 `apps/web/src/hooks/useGlobalCommandPalette.ts:17` - **Current:** Subscribes to global Cmd/Ctrl+K keydown events. **Recommendation:** Keep as external keyboard synchronization; extract to `useGlobalShortcut` only if more global shortcuts appear. **Priority:** keep +- [x] UE175 `apps/web/src/hooks/useIsMobile.ts:16` - **Current:** Stores `matchMedia` state and updates it from an Effect subscription. **Recommendation:** Replace with a `useSyncExternalStore`-backed `useMediaQuery(QUERY)` using `subscribe`, `getSnapshot`, and an SSR snapshot. **Priority:** medium + +### Chunk 06 (UE176-UE210) + +- [x] UE176 `apps/web/src/hooks/useIsStandalone.ts:19` - **Current:** Subscribes to `matchMedia` and mirrors it into state. **Recommendation:** Prefer a small `useSyncExternalStore` wrapper for the standalone media query plus iOS `navigator.standalone` snapshot. **Priority:** low +- [x] UE177 `apps/web/src/hooks/useKeyboardShortcuts.ts:24` - **Current:** Registers a global `window` keydown listener. **Recommendation:** Keep the effect; this is an external DOM subscription. Future React 19.2 upgrade could replace the latest-handler refs with `useEffectEvent`. **Priority:** keep +- [x] UE178 `apps/web/src/hooks/useLibraryIndex.ts:84` - **Current:** Hydrates local cache and sweeps library files over the network. **Recommendation:** Keep as an acquisition effect, but extract the sweep/cache state machine and remove the lint suppression by making cap/page settings stable explicit inputs. **Priority:** low +- [x] UE179 `apps/web/src/hooks/useNodeLogs.ts:68` - **Current:** Separate mount-state ref effect for guarding async callbacks. **Recommendation:** Remove it and use effect-local cancellation or `AbortController` inside the fetch/WebSocket effects that own the async work. **Priority:** medium +- [x] UE180 `apps/web/src/hooks/useNodeLogs.ts:76` - **Current:** Syncs `paused` into a ref and flushes buffered logs after render. **Recommendation:** Move pause/buffer behavior into a reducer or the `togglePause` updater so unpause flushes in the user event path, not a syncing effect. **Priority:** medium +- [x] UE181 `apps/web/src/hooks/useNodeLogs.ts:190` - **Current:** Fetches initial logs and opens the log WebSocket when node/filter inputs change. **Recommendation:** Keep as the external log-stream lifecycle effect; fold cancellation into this effect and consider a reducer for entries/loading/streaming. **Priority:** keep +- [x] UE182 `apps/web/src/hooks/useNodeLogs.ts:210` - **Current:** Fetches container log targets when the node is running. **Recommendation:** Keep as a network synchronization effect, with request cancellation or a shared data-fetching helper to avoid stale writes. **Priority:** keep +- [x] UE183 `apps/web/src/hooks/useNodeSystemInfo.ts:19` - **Current:** Separate mount-state ref effect. **Recommendation:** Remove it and let the polling effect own a local `cancelled` flag or abort signal for its requests. **Priority:** medium +- [x] UE184 `apps/web/src/hooks/useNodeSystemInfo.ts:26` - **Current:** Polls node system info on an interval. **Recommendation:** Keep as timer/network synchronization; simplify by replacing `mountedRef` with effect-local cleanup and reducer-backed loading/refreshing state. **Priority:** keep +- [x] UE185 `apps/web/src/hooks/useNotifications.ts:115` - **Current:** One large effect does initial fetch, WebSocket lifecycle, ping, reconnect, and message reconciliation. **Recommendation:** Keep the socket effect but split fetching, stream lifecycle, and notification reducer; future React 19.2 could use `useEffectEvent` for message/reconnect callbacks. **Priority:** medium +- [x] UE186 `apps/web/src/hooks/useProjectAgentSession.ts:103` - **Current:** Clears a URL cache ref when key inputs change. **Recommendation:** Remove the effect by storing the cache with an explicit `{key, url, resolvedAt}` and validating the key inside `resolveWsUrl`. **Priority:** low +- [x] UE187 `apps/web/src/hooks/useProjectAgentSession.ts:178` - **Current:** Clears ACP messages when session state becomes `no_session`. **Recommendation:** Move the reset to the owner of the state transition or key/derive displayed messages by session state so render does not briefly depend on stale cleared state. **Priority:** medium +- [x] UE188 `apps/web/src/hooks/useProjectAgentSession.ts:188` - **Current:** Resets an auto-select ref when `workspaceId` changes. **Recommendation:** Remove the effect by storing the workspace id in the auto-select ref and checking/resetting it inside the auto-select logic. **Priority:** low +- [x] UE189 `apps/web/src/hooks/useProjectAgentSession.ts:192` - **Current:** Auto-switches the external ACP agent after connection becomes ready. **Recommendation:** Keep as external session synchronization, but fold the workspace-scoped guard into this effect or pass preferred-agent intent into the ACP/session-start path. **Priority:** low +- [x] UE190 `apps/web/src/hooks/useProjectData.ts:47` - **Current:** Fetches and polls project list data. **Recommendation:** Keep as network/timer synchronization; add effect-local cancellation or migrate to a shared query/polling hook. **Priority:** low +- [x] UE191 `apps/web/src/hooks/useProjectData.ts:83` - **Current:** Fetches project detail when `projectId` changes. **Recommendation:** Keep as network synchronization, but reset/loading state on missing or changed `projectId` and guard stale responses. **Priority:** low +- [x] UE192 `apps/web/src/hooks/useProjectWebSocket.ts:150` - **Current:** Runs a standalone ping interval regardless of socket state. **Recommendation:** Fold ping setup/cleanup into the WebSocket lifecycle so it only exists while a socket is open. **Priority:** low +- [x] UE193 `apps/web/src/hooks/useProjectWebSocket.ts:161` - **Current:** Opens and cleans up the project WebSocket on `projectId`. **Recommendation:** Keep as the external connection lifecycle effect; include ping/reconnect cleanup here and replace broad `mountedRef` checks with lifecycle-local closed state. **Priority:** keep +- [x] UE194 `apps/web/src/hooks/useProviderCatalog.ts:21` - **Current:** Fetches provider catalog on mount. **Recommendation:** Keep as network synchronization, ideally through a cached provider-catalog query hook with cancellation and shared consumers. **Priority:** low +- [x] UE195 `apps/web/src/hooks/useRecentChats.ts:80` - **Current:** Fetches recent chats when the dropdown becomes enabled. **Recommendation:** Keep as enabled network synchronization, but combine with the polling lifecycle or a query hook so cancellation/loading live in one place. **Priority:** low +- [x] UE196 `apps/web/src/hooks/useRecentChats.ts:91` - **Current:** Subscribes to `visibilitychange` and manages polling. **Recommendation:** Keep the timer/subscription effect; consider a reusable `usePageVisibility` built on `useSyncExternalStore` and derive whether polling is active during render. **Priority:** low +- [x] UE197 `apps/web/src/hooks/useScrollLock.ts:14` - **Current:** Mutates `document.body.style.overflow` with reference counting. **Recommendation:** Keep as DOM synchronization; only switch to `useLayoutEffect` if modal opening shows a pre-paint scroll flicker, and preserve/restore the prior overflow value. **Priority:** keep +- [x] UE198 `apps/web/src/hooks/useSkills.ts:26` - **Current:** Fetches skills when `projectId` changes. **Recommendation:** Keep as network synchronization, but clear stale skills/loading on project changes and guard stale responses through a shared async hook. **Priority:** low +- [x] UE199 `apps/web/src/hooks/useTokenRefresh.ts:117` - **Current:** Starts token fetch and owns refresh-timer cleanup when enabled. **Recommendation:** Keep as token/timer lifecycle synchronization, but remove the exhaustive-deps suppression by making the fetch scheduler stable over refs or modeling token state with a reducer. **Priority:** medium +- [x] UE200 `apps/web/src/hooks/useTrialClaim.ts:89` - **Current:** Syncs latest draft/callback/request values into refs for a one-shot effect. **Recommendation:** Remove the effect by using render-time latest refs or a `useLatest` helper; future React 19.2 could replace this with `useEffectEvent`. **Priority:** low +- [x] UE201 `apps/web/src/hooks/useTrialClaim.ts:97` - **Current:** Runs the post-login claim and optional draft submit flow. **Recommendation:** Keep as external route/network workflow synchronization, but structure it as a small async state machine with cancellation; route action/loader would be cleaner long term. **Priority:** low +- [x] UE202 `apps/web/src/hooks/useTrialDraft.ts:77` - **Current:** Syncs `trialId` and debounce into refs after render. **Recommendation:** Remove the effect by keying callbacks on `trialId`/`debounceMs` or updating latest refs during render so storage writes cannot use a stale key before the passive effect runs. **Priority:** medium +- [x] UE203 `apps/web/src/hooks/useTrialDraft.ts:83` - **Current:** Resets draft state from `localStorage` when `trialId` changes. **Recommendation:** Avoid reset-in-effect by keying the draft state by `trialId` or remounting the draft hook owner; use `useSyncExternalStore` only if cross-tab storage updates matter. **Priority:** medium +- [x] UE204 `apps/web/src/hooks/useTrialDraft.ts:93` - **Current:** Cleanup clears a pending debounce timer despite the comment saying it flushes. **Recommendation:** Keep cleanup for the timer, but actually flush the pending draft on unmount/trial change using a pending value ref before clearing. **Priority:** high +- [x] UE205 `apps/web/src/hooks/useTrialEvents.ts:56` - **Current:** Owns the trial SSE stream plus retry/slow/discovery timers. **Recommendation:** Keep as external EventSource/timer synchronization; reset per-trial event state on `trialId` change and consider a reducer for connection/events/timers. **Priority:** medium +- [x] UE206 `apps/web/src/hooks/useWorkspacePorts.ts:26` - **Current:** Separate mount-state ref effect. **Recommendation:** Remove it and let the polling effect own cancellation for its request/interval lifecycle. **Priority:** medium +- [x] UE207 `apps/web/src/hooks/useWorkspacePorts.ts:33` - **Current:** Polls detected workspace ports while the workspace is running. **Recommendation:** Keep as timer/network synchronization, but use captured inputs plus local cancellation/abort checks in all `then/catch/finally` paths. **Priority:** medium +- [x] UE208 `apps/web/src/pages/AccountMap.tsx:33` - **Current:** Effect calls `reorganize()` from a derived filter-count flag that is mutated inside `useMemo`. **Recommendation:** Remove the effect and make layout a pure derivation of filtered nodes/edges, or trigger layout-version updates directly in filter event handlers. **Priority:** high +- [x] UE209 `apps/web/src/pages/AdminAIProxy.tsx:89` - **Current:** Fetches admin AI proxy config on mount. **Recommendation:** Keep as network synchronization, preferably extracted to an admin config resource hook with cancellation; keep save/reset work in event handlers. **Priority:** low +- [x] UE210 `apps/web/src/pages/AdminAnalytics.tsx:54` - **Current:** Tracks loading transitions to set `lastRefreshed`, with redundant empty logic alongside a second refresh effect. **Recommendation:** Remove this effect and have `useAdminAnalytics` return `lastUpdatedAt` from successful fetch completion, or set it in the awaited refresh/fetch path. **Priority:** medium + +### Chunk 07 (UE211-UE245) + +- [x] UE211 `apps/web/src/pages/AdminAnalytics.tsx:66` - **Current:** Tracks `isRefreshing` transitions with a ref to set `lastRefreshed`, duplicating the nearby loading-transition Effect. **Recommendation:** Move `lastRefreshed` ownership into `useAdminAnalytics` and update it when initial load/refresh succeeds, or set it in the refresh completion path; remove this transition Effect. **Priority:** medium +- [x] UE212 `apps/web/src/pages/AdminComputeQuotas.tsx:138` - **Current:** Mount-time admin quota fetch. **Recommendation:** Keep as external network synchronization; if refactoring, extract `useAdminUserQuotas` so initial load and save/remove refreshes share cancellation/loading behavior. **Priority:** keep +- [x] UE213 `apps/web/src/pages/AdminComputeUsage.tsx:107` - **Current:** `UserDetail` fetches node usage on `userId` changes without cancelling stale requests or clearing previous error. **Recommendation:** Keep network sync but wrap in `useAdminUserNodeUsage(userId)` with request cancellation/stale guards and reset `error`/`data` per user. **Priority:** medium +- [x] UE214 `apps/web/src/pages/AdminComputeUsage.tsx:231` - **Current:** Mount-time node usage summary fetch. **Recommendation:** Keep as external network sync; optionally extract `useAdminNodeUsage` for shared refresh/loading state. **Priority:** keep +- [x] UE215 `apps/web/src/pages/AdminCosts.tsx:145` - **Current:** Fetches cost summary whenever `period` changes. **Recommendation:** Keep network sync, but make it period-keyed in a hook with abort/stale-response protection so quick period changes cannot overwrite current data. **Priority:** medium +- [x] UE216 `apps/web/src/pages/AdminPlatformCredentials.tsx:55` - **Current:** Mount-time platform credentials fetch. **Recommendation:** Keep as external network sync; extract `usePlatformCredentials` only if reused, and remove the unused `hasLoadedRef` while there. **Priority:** low +- [x] UE217 `apps/web/src/pages/AdminTrials.tsx:52` - **Current:** Mount-time trial config fetch. **Recommendation:** Keep as external network sync; a `useAdminTrialsConfig` hook would just centralize loading/error/refresh if this grows. **Priority:** keep +- [x] UE218 `apps/web/src/pages/AdminUsers.tsx:40` - **Current:** Refetches users when the status filter changes and uses refs to distinguish first load from refresh. **Recommendation:** Keep filter-keyed network sync, but move to `useAdminUsers(filter)` with abort/stale-response guards so rapid filter changes cannot show the wrong list. **Priority:** medium +- [x] UE219 `apps/web/src/pages/AgentContextPage/MemoryTab.tsx:144` - **Current:** Mirrors `observation` props into local edit state whenever not editing. **Recommendation:** Remove the Effect by splitting an `ObservationEditor` that initializes draft state on mount/open, or reset draft values in explicit Edit/Cancel/Save handlers while rendering non-editing content directly from props. **Priority:** medium +- [x] UE220 `apps/web/src/pages/AgentContextPage/MemoryTab.tsx:320` - **Current:** Lazy-loads entity details when a card becomes expanded. **Recommendation:** Network-on-display is valid, but move this to `useKnowledgeEntityDetail(projectId, entity.id, { enabled: expanded })` or an `ensureDetailLoaded` expand handler with cancellation; `useEffectEvent` can wait for a React 19.2 upgrade if toast/event reads become non-reactive. **Priority:** low +- [x] UE221 `apps/web/src/pages/AgentContextPage/index.tsx:91` - **Current:** Loads knowledge, policy, and action data when project context page displays. **Recommendation:** Keep as external network sync; extract `useAgentContextData(projectId)` if refactoring so refresh and loading/error behavior are isolated from filter/render logic. **Priority:** keep +- [x] UE222 `apps/web/src/pages/CreateWorkspace.tsx:127` - **Current:** One mount Effect launches credentials/trial/catalog, installations, projects, and nodes requests and mutates several independent state groups. **Recommendation:** Split into focused hooks (`useWorkspacePrerequisites`, `useGitHubInstallations`, `useAvailableNodes`, provider catalog hook) or a route loader, with cancellation and explicit `locationState` inputs. **Priority:** high +- [x] UE223 `apps/web/src/pages/CreateWorkspace.tsx:276` - **Current:** Mount-only Effect with disabled deps loads project details from navigation state. **Recommendation:** Make `selectedProjectId` the source of truth and fetch details through a dependency-correct hook/effect, or hydrate via route/navigation loader; remove the eslint disable. **Priority:** high +- [x] UE224 `apps/web/src/pages/DeviceAuth.tsx:25` - **Current:** Mirrors normalized `code` search param into input state. **Recommendation:** Remove prop-to-state sync by using the URL code only as the lazy initial state, or remount/key the form when a new code param should replace user edits. **Priority:** medium +- [x] UE225 `apps/web/src/pages/IdeaDetailPage.tsx:227` - **Current:** Focuses the modal close button after the conversations modal opens. **Recommendation:** Keep as DOM focus synchronization; prefer a reusable dialog/focus hook or `autoFocus` on the initial control, and do not switch to `useLayoutEffect` unless pre-paint focus is required. **Priority:** keep +- [x] UE226 `apps/web/src/pages/IdeaDetailPage.tsx:316` - **Current:** Loads idea details and linked sessions when `projectId` or `taskId` changes. **Recommendation:** Keep as route-param network sync, but extract `useIdeaDetail(projectId, taskId)` with abort/stale guards so navigation between ideas cannot apply an old response. **Priority:** medium +- [x] UE227 `apps/web/src/pages/IdeasPage.tsx:164` - **Current:** Loads draft ideas and session counts for the project page. **Recommendation:** Keep as external network sync; a `useIdeasData(projectId)` hook can own cancellation, refresh, and pagination limits if this page is refactored. **Priority:** keep +- [x] UE228 `apps/web/src/pages/Landing.tsx:24` - **Current:** Imperatively navigates away from the login page when auth finishes. **Recommendation:** Replace with a declarative React Router redirect (``) or auth-route loader while preserving the safe internal return-path check. **Priority:** medium +- [x] UE229 `apps/web/src/pages/Node.tsx:67` - **Current:** Polls node/workspace data every 10s. **Recommendation:** Keep because timer + network polling is external synchronization; consider a `usePollingNode(id, interval)` hook with abort/visibility handling if reused. **Priority:** keep +- [x] UE230 `apps/web/src/pages/Node.tsx:74` - **Current:** Polls node events while the node is running. **Recommendation:** Keep as enabled timer/network sync; a `useNodeEvents(id, { enabled: node?.status === 'running' })` hook would isolate polling, retries, and stale cleanup. **Priority:** keep +- [x] UE231 `apps/web/src/pages/Nodes.tsx:50` - **Current:** One Effect both starts node polling and one-shot loads the provider catalog. **Recommendation:** Split polling into `useNodesPolling` and use the existing provider catalog hook for catalog/default selection so unrelated external sync paths are independent. **Priority:** medium +- [x] UE232 `apps/web/src/pages/Project.tsx:38` - **Current:** Loads the project record for the current route. **Recommendation:** Keep as external network sync; extract `useProject(projectId)` or move to a router loader if adopting route-level data. **Priority:** keep +- [x] UE233 `apps/web/src/pages/Project.tsx:40` - **Current:** Loads GitHub installations on project layout mount. **Recommendation:** Keep as external network sync, but isolate in `useGitHubInstallations` and avoid fetching at the layout level if only some child routes need it. **Priority:** low +- [x] UE234 `apps/web/src/pages/Project.tsx:47` - **Current:** Pushes `project?.name` into AppShell state and clears it on unmount. **Recommendation:** Avoid child-to-parent state synchronization by letting AppShell read project route data/context directly, or lift project loading above the shell so the name is derived during render. **Priority:** medium +- [x] UE235 `apps/web/src/pages/ProjectActivity.tsx:38` - **Current:** Loads the first page of activity events when the activity route displays. **Recommendation:** Keep as external network sync; a `useActivityEvents(projectId)` hook could own initial load and load-more state. **Priority:** keep +- [x] UE236 `apps/web/src/pages/ProjectAgentChat.tsx:54` - **Current:** Measures `scrollHeight` and mutates textarea height after input changes. **Recommendation:** This is layout measurement/mutation, so use `useLayoutEffect` or a dedicated auto-size textarea hook/callback ref to avoid post-paint height jumps. **Priority:** medium +- [x] UE237 `apps/web/src/pages/ProjectAgentChat.tsx:58` - **Current:** Scrolls the message sentinel into view when messages change. **Recommendation:** Keep as browser DOM synchronization in `useEffect`; consider a chat scroll hook that only autoscrolls when the user is already near the bottom or after local sends. **Priority:** low +- [x] UE238 `apps/web/src/pages/ProjectCreate.tsx:35` - **Current:** Mount-time fetch for artifacts-enabled config. **Recommendation:** Keep as external network sync; combine with project-create prerequisites in a single hook if refactoring loading/error behavior. **Priority:** low +- [x] UE239 `apps/web/src/pages/ProjectCreate.tsx:54` - **Current:** Mount-time fetch for GitHub installations on the create form. **Recommendation:** Keep as external network sync, but combine with UE238 in `useProjectCreatePrerequisites` so the form has one data-loading boundary and cancellation path. **Priority:** low +- [x] UE240 `apps/web/src/pages/ProjectDeploymentEnvironmentDetail.tsx:115` - **Current:** Loads deployment environment data by listing project environments and selecting `envId`. **Recommendation:** Keep as external network sync; extract `useDeploymentEnvironment(projectId, envId)` with stale guards and a direct endpoint later if available. **Priority:** keep +- [x] UE241 `apps/web/src/pages/ProjectDeploymentEnvironmentDetail.tsx:156` - **Current:** Polls deployment metrics while the deployment node is running. **Recommendation:** Keep timer/network sync, but key the polling hook on `env.id` and `env.node.status` instead of the whole `env` object to avoid interval churn on unrelated env updates. **Priority:** low +- [x] UE242 `apps/web/src/pages/ProjectDeploymentEnvironmentDetail.tsx:199` - **Current:** Effect auto-fetches logs the first time the Logs tab is displayed. **Recommendation:** Display-triggered network loading is valid; move it into `useDeploymentLogs(env?.id, { enabled: activeTab === 'logs', once: true })` or the tab-selection/deep-link path so tab state changes do not need bespoke Effect glue. **Priority:** low +- [x] UE243 `apps/web/src/pages/ProjectDeployments.tsx:54` - **Current:** Loads deployment environments on page display. **Recommendation:** Keep as external network sync; a `useDeploymentEnvironments(projectId)` hook can centralize initial load, manual refresh, and optimistic create updates. **Priority:** keep +- [x] UE244 `apps/web/src/pages/ProjectLibrary.tsx:211` - **Current:** Uses an Effect to mirror debounced search/count inputs into `announcement` state for the live region. **Recommendation:** Remove derived state by computing the announcement from debounced inputs during render, or use a focused `useDebouncedAnnouncement` hook if the live-region text itself needs timer-based throttling. **Priority:** medium +- [x] UE245 `apps/web/src/pages/ProjectLibrary.tsx:234` - **Current:** Fetches server-side directory entries for project/directory/refresh token changes and updates the directory cache. **Recommendation:** Keep as external network/cache synchronization; extract `useLibraryDirectories(projectId, currentDirectory, dirRefreshToken)` if refactoring library data flow. **Priority:** keep + +### Chunk 08 (UE246-UE280) + +- [x] UE246 `apps/web/src/pages/ProjectLibrary.tsx:285` - **Current:** Over-cap library results are fetched from the server when the index status or filters change. **Recommendation:** Network sync is appropriate; move this into `useServerLibraryFiles(projectId, filters, { enabled: isOverCap })` with abort/latest-request handling so the page only consumes query state. **Priority:** low +- [x] UE247 `apps/web/src/pages/ProjectLibrary.tsx:303` - **Current:** Local search state is mirrored into the URL after debounce. **Recommendation:** Avoid a mirror Effect by either making `q` the source of truth or moving debounced URL writes into the search input handler via a `useDebouncedCallback`, preserving existing `dir` and `preview` params. **Priority:** medium +- [x] UE248 `apps/web/src/pages/ProjectLibrary.tsx:333` - **Current:** Directory changes focus the breadcrumb after render, skipping first mount. **Recommendation:** DOM focus synchronization is appropriate; keep an Effect, but gate it with a `shouldFocusBreadcrumbRef` set by directory navigation handlers instead of a generic did-mount flag. **Priority:** keep +- [x] UE249 `apps/web/src/pages/ProjectNotifications.tsx:104` - **Current:** Notifications load on project change while `loadNotifications` dependencies are suppressed. **Recommendation:** Keep the network fetch, but move pagination/filter state into `useProjectNotifications(projectId)` or stabilize `loadNotifications` with explicit params/refs so the Effect can list real dependencies without an eslint disable. **Priority:** medium +- [x] UE250 `apps/web/src/pages/ProjectSettings.tsx:64` - **Current:** Editable form state is overwritten from `project` whenever the project object changes. **Recommendation:** Replace prop-to-state syncing with keyed edit sections or a small reducer that resets drafts only on explicit reload/cancel/save, keeping saved values derived from `project` and dirty drafts local. **Priority:** medium +- [x] UE251 `apps/web/src/pages/ProjectTasks.tsx:85` - **Current:** Tasks are fetched when project or URL-derived filters change. **Recommendation:** Network sync is appropriate; extract `useProjectTasks(projectId, filters)` with cancellation/latest-request handling and expose `refresh` for create/delete/transition handlers. **Priority:** low +- [x] UE252 `apps/web/src/pages/ProjectTasks.tsx:87` - **Current:** Running workspaces are fetched once on page mount for the delegate dialog. **Recommendation:** Remove the eager mount fetch; load workspaces when the delegate dialog opens or use `useRunningWorkspaces({ enabled: !!delegateTargetTask })`. **Priority:** medium +- [x] UE253 `apps/web/src/pages/ProjectTriggerDetail.tsx:130` - **Current:** Trigger details and first execution page load after mount/route changes. **Recommendation:** Keep the network Effect, but consolidate it into `useTriggerDetail(projectId, triggerId)` with cancellation and refresh callbacks shared by run/edit/delete flows. **Priority:** low +- [x] UE254 `apps/web/src/pages/ProjectTriggers.tsx:70` - **Current:** Trigger list loads after mount/project changes. **Recommendation:** Network sync is appropriate; extract `useProjectTriggers(projectId)` returning `{ triggers, loading, error, refresh }` so actions call `refresh` instead of sharing page-local fetch state. **Priority:** low +- [x] UE255 `apps/web/src/pages/SamPrototype.tsx:64` - **Current:** Textarea height is imperatively resized after input value changes. **Recommendation:** This is layout mutation; prefer `useLayoutEffect` for programmatic value changes and call the same resize helper from the input event so height is updated before visible paint. **Priority:** medium +- [x] UE256 `apps/web/src/pages/SamPrototype.tsx:68` - **Current:** The chat scroll anchor is scrolled into view when messages change. **Recommendation:** DOM scroll synchronization is appropriate; keep as an Effect or extract `useAutoScroll(messagesEndRef, chat.messages.length)` and add user-scroll/reduced-motion guards if this prototype becomes product code. **Priority:** keep +- [x] UE257 `apps/web/src/pages/Settings.tsx:40` - **Current:** The settings shell fetches credentials once and provides them through context. **Recommendation:** Keep the network fetch, but wrap it in `useCredentials()` with cancellation/error/loading state so child settings pages reuse the same refresh contract. **Priority:** low +- [x] UE258 `apps/web/src/pages/SettingsComputeUsage.tsx:77` - **Current:** AI usage is fetched whenever the selected period changes. **Recommendation:** Network sync is appropriate; extract `useAiUsage(period)` with AbortController/latest-request protection so fast period changes cannot commit stale results. **Priority:** low +- [x] UE259 `apps/web/src/pages/SettingsComputeUsage.tsx:282` - **Current:** Budget fetch also populates several editable form fields. **Recommendation:** Keep the fetch, but split server data into `useAiBudget()` and initialize/reset the form through a keyed form reducer so loaded data does not directly synchronize every input state. **Priority:** medium +- [x] UE260 `apps/web/src/pages/SettingsComputeUsage.tsx:594` - **Current:** Compute usage and quota load once on page mount. **Recommendation:** Network sync is appropriate; move to `useComputeUsage()` with cancellation and an explicit `refresh` API if later save/actions need to reload it. **Priority:** low +- [x] UE261 `apps/web/src/pages/SettingsCredentials.tsx:399` - **Current:** Credentials, configurations, attachments, and projects are fetched together on mount. **Recommendation:** Keep the network fetch, but extract `useComposableCredentialsData()` with latest-request protection and a shared `reload` used by all mutation handlers. **Priority:** low +- [x] UE262 `apps/web/src/pages/SettingsNotifications.tsx:64` - **Current:** Notification preferences load on mount. **Recommendation:** Network sync is appropriate; move to `useNotificationPreferences()` with loading/error/save state and cancellation so the component only renders and toggles preferences. **Priority:** low +- [x] UE263 `apps/web/src/pages/TaskDetail.tsx:175` - **Current:** Task detail, events, sibling tasks, and running workspaces are fetched when the route task changes. **Recommendation:** Keep the network fetch, but extract `useTaskDetail(projectId, taskId)` with cancellation and return `refresh` for transitions, dependency edits, title save, and delegation. **Priority:** low +- [x] UE264 `apps/web/src/pages/ToolsCli.tsx:110` - **Current:** CLI version is fetched once on mount. **Recommendation:** Keep the network fetch, but place it in `useCliVersion()` or add an ignore/AbortController guard to prevent state updates after unmount. **Priority:** low +- [x] UE265 `apps/web/src/pages/UiStandards.tsx:224` - **Current:** Active UI standard loads on mount and populates editable fields. **Recommendation:** Keep the fetch, but separate `useActiveUiStandard()` from the edit form and reset form state only when loading a new standard or when the user explicitly cancels/resets. **Priority:** low +- [x] UE266 `apps/web/src/pages/Workspaces.tsx:42` - **Current:** Workspaces load immediately and poll every 10 seconds for the selected status filter. **Recommendation:** Timer/network synchronization is appropriate; keep the Effect or extract `useWorkspaces(statusFilter, pollMs)`, ideally with visibility-aware polling and no overlapping requests. **Priority:** keep +- [x] UE267 `apps/web/src/pages/project-chat/MobileSessionDrawer.tsx:75` - **Current:** The drawer subscribes to document Escape key presses while mounted. **Recommendation:** DOM subscription is appropriate; keep it, preferably as `useEscapeKey(handleClose, true)` with a stable callback ref. `useEffectEvent` is a future option after React and eslint-plugin-react-hooks are upgraded. **Priority:** keep +- [x] UE268 `apps/web/src/pages/project-chat/ProvisioningIndicator.tsx:64` - **Current:** A timer updates elapsed provisioning time until terminal status. **Recommendation:** Timer sync is appropriate; extract `useElapsedTime(startedAt, !isTerminal(status))` and initialize/reset elapsed immediately when `startedAt` changes to avoid stale display for the first tick. **Priority:** low +- [x] UE269 `apps/web/src/pages/project-chat/useProjectChatState.ts:178` - **Current:** Boot log panel state is forcibly closed when `provisioning` becomes null. **Recommendation:** Remove the Effect by deriving `const effectiveBootLogPanelOpen = bootLogPanelOpen && !!provisioning` and clearing the flag in the same event paths that clear provisioning. **Priority:** medium +- [x] UE270 `apps/web/src/pages/project-chat/useProjectChatState.ts:241` - **Current:** Task mode is synchronized from workspace profile until a ref says the user changed it. **Recommendation:** Remove this pure state-sync Effect; model task mode as `explicitTaskMode ?? defaultTaskModeFor(selectedWorkspaceProfile)` or update workspace profile and task mode together in one reducer/handler. **Priority:** high +- [x] UE271 `apps/web/src/pages/project-chat/useProjectChatState.ts:248` - **Current:** Cloud credential/trial state and provider catalogs are fetched once on mount. **Recommendation:** Network sync is appropriate; extract `useCloudCredentialAvailability()` with cancellation and a conditional provider-catalog fetch so chat state is not responsible for orchestration details. **Priority:** low +- [x] UE272 `apps/web/src/pages/project-chat/useProjectChatState.ts:268` - **Current:** Configured agents load on mount and select the first agent while suppressing dependencies. **Recommendation:** Keep the network fetch but move to `useConfiguredAgents()`; use functional `setSelectedAgentType(current => current ?? firstAgent.id)` and cancellation so exhaustive deps can be restored. **Priority:** medium +- [x] UE273 `apps/web/src/pages/project-chat/useProjectChatState.ts:293` - **Current:** Agent profiles are loaded through a page-local callback Effect. **Recommendation:** Replace this with the existing `useAgentProfiles(projectId)` hook or extend that hook to own selection fallback, then have create/update handlers call its `refresh`. **Priority:** medium +- [x] UE274 `apps/web/src/pages/project-chat/useProjectChatState.ts:300` - **Current:** The `executeIdea` query param imperatively pre-fills message state and removes itself. **Recommendation:** Prefer route/navigation initialization: pass the draft message as navigation state from the idea page, or initialize once from the query param with a ref guard so the Effect cannot overwrite user edits on later URL changes. **Priority:** medium +- [x] UE275 `apps/web/src/pages/project-chat/useProjectChatState.ts:343` - **Current:** Chat sessions load on project change and after WebSocket refreshes, with an unnecessary eslint suppression. **Recommendation:** Keep the network sync but move sessions/task-title loading into `useChatSessions(projectId)` with loading/refresh state and remove the suppression by using real dependencies. **Priority:** medium +- [x] UE276 `apps/web/src/pages/project-chat/useProjectChatState.ts:349` - **Current:** Provisioning status polling mixes interval setup, task fetches, workspace URL fetches, navigation, and session reloads; it also reads `provisioning.workspaceUrl` without depending on it. **Recommendation:** Keep polling as an external sync, but extract `useProvisioningPoller` with refs or complete dependencies, separate transition callbacks for ready/terminal states, and consider WebSocket task updates over interval polling. **Priority:** high +- [x] UE277 `apps/web/src/pages/project-chat/useProjectChatState.ts:383` - **Current:** Navigating to a session can fetch its task and recreate provisioning state. **Recommendation:** Keep the external fetch, but fold restoration into a session/task data hook or route loader that returns active provisioning state, with latest-request cancellation keyed by `sessionId` and `selectedTaskId`. **Priority:** medium +- [x] UE278 `apps/web/src/pages/project-chat/useProjectSkills.ts:10` - **Current:** Skills load when `projectId` changes and selection is cleared if no longer valid. **Recommendation:** This is already a custom network hook; keep the Effect but add loading/error/refresh plus cancellation/latest-request guarding for project switches. **Priority:** low +- [x] UE279 `apps/web/src/pages/sam-prototype/voice-input.ts:24` - **Current:** Unmount cleanup releases animation, audio context, and media stream resources. **Recommendation:** Browser resource cleanup is appropriate; keep it, but share a stable `cleanupRecordingResources` helper with stop/error paths and track timeout IDs to avoid post-unmount state updates. **Priority:** keep +- [x] UE280 `apps/web/src/pages/sam-prototype/webgl-background.ts:147` - **Current:** WebGL setup, resize subscription, and RAF rendering live in one Effect. **Recommendation:** Canvas/WebGL synchronization is appropriate; keep a custom hook but include `speed` and `noiseSize` in dependencies or refs, use `ResizeObserver` for canvas size, and clean up GL resources on teardown. **Priority:** medium + +### Chunk 09 (UE281-UE315) + +- [x] UE281 `apps/web/src/pages/workspace/WorkspaceChatView.tsx:123` - **Current:** Fetches chat session data after render through `loadSession`. **Recommendation:** Keep the network sync, but move it into a `useWorkspaceChatSession(projectId, sessionId)` data hook with abort/stale-response guarding. **Priority:** medium +- [x] UE282 `apps/web/src/pages/workspace/WorkspaceChatView.tsx:126` - **Current:** Resets virtual scroll state when `sessionId` changes. **Recommendation:** Remove the effect by keying the session view or Virtuoso subtree by `sessionId` so scroll state reinitializes naturally. **Priority:** medium +- [x] UE283 `apps/web/src/pages/workspace/WorkspaceChatView.tsx:132` - **Current:** Cleans up an idle timeout on unmount. **Recommendation:** Keep this timer cleanup; if reused, encapsulate the timeout ownership in a small `useIdleTimer` hook. **Priority:** keep +- [x] UE284 `apps/web/src/pages/workspace/index.tsx:95` - **Current:** One-time effect copies `sessionIdParam` into `viewMode` and suppresses exhaustive deps. **Recommendation:** Remove it by deriving initial/current view mode from the URL, or initialize state lazily from `sessionIdParam` without a mount effect. **Priority:** high +- [x] UE285 `apps/web/src/pages/workspace/index.tsx:100` - **Current:** Imperatively chooses a default conversation tab and navigates once after workspace/session data arrives. **Recommendation:** Keep only a small router-sync effect: compute the target tab in render/useMemo, make the URL the source of truth for `viewMode`, and drop the extra `setViewMode` mirror. **Priority:** medium +- [x] UE286 `apps/web/src/pages/workspace/index.tsx:137` - **Current:** Clears the terminal-activity throttle timeout on unmount. **Recommendation:** Keep this external timer cleanup; a reusable `useThrottledCallback` could own the ref and cleanup if more callers appear. **Priority:** keep +- [x] UE287 `apps/web/src/pages/workspace/index.tsx:255` - **Current:** Registers a document `mousedown` listener while the create menu is open. **Recommendation:** Keep the DOM subscription, but extract a reusable outside-click hook; `useEffectEvent` is a future option after React/hooks ESLint upgrade. **Priority:** low +- [x] UE288 `apps/web/src/pages/workspace/index.tsx:260` - **Current:** Registers a document Escape listener while the mobile menu is open. **Recommendation:** Keep the DOM subscription, preferably through a shared `useEscapeKey(enabled, onClose)` hook; `useEffectEvent` can simplify the callback later. **Priority:** low +- [x] UE289 `apps/web/src/pages/workspace/index.tsx:282` - **Current:** Lazily fetches the command-palette file index from an effect when the palette opens. **Recommendation:** Move the fetch to the palette-open event or a keyed lazy query, and key/cache it by workspace URL, token, and active worktree with stale-response guarding. **Priority:** high +- [x] UE290 `apps/web/src/pages/workspace/useSessionState.ts:67` - **Current:** Fetches configured agent options when the workspace is running. **Recommendation:** Keep the network sync; extract to a `useAgentOptions({ enabled: isRunning })` hook if this state is needed outside workspace session state. **Priority:** keep +- [x] UE291 `apps/web/src/pages/workspace/useSessionState.ts:221` - **Current:** Auto-resumes orphaned sessions from an effect, deduped with a ref. **Recommendation:** Keep the external recovery side effect but isolate it in `useOrphanAutoResume`, depending on a stable orphan ID list rather than the array object. **Priority:** medium +- [x] UE292 `apps/web/src/pages/workspace/useSessionState.ts:231` - **Current:** Clears the attempted-orphan ref in a separate effect when no orphans remain. **Recommendation:** Fold this into the orphan auto-resume hook by pruning the attempted set against current orphan IDs during the same recovery pass. **Priority:** low +- [x] UE293 `apps/web/src/pages/workspace/useSessionState.ts:270` - **Current:** Resets `dismissedOrphans` when the derived orphan count reaches zero. **Recommendation:** Remove the effect by storing dismissal against an orphan ID/key and deriving visibility from the current orphan set. **Priority:** medium +- [x] UE294 `apps/web/src/pages/workspace/useWorkspaceCore.ts:98` - **Current:** Copies `tokenRefreshError` into separate `terminalError` state. **Recommendation:** Remove the state-sync effect by returning a derived `effectiveTerminalError = tokenRefreshError ?? terminalError` or by separating token and URL errors. **Priority:** medium +- [x] UE295 `apps/web/src/pages/workspace/useWorkspaceCore.ts:169` - **Current:** Refetches workspace state once when the first terminal token arrives. **Recommendation:** Keep the network sync but split live-session loading into a token-keyed data hook/query instead of a `hasLoadedWithTokenRef` gate. **Priority:** medium +- [x] UE296 `apps/web/src/pages/workspace/useWorkspaceCore.ts:182` - **Current:** Polls workspace state with an interval while the workspace may be changing. **Recommendation:** Keep the polling effect; it is external timer/network synchronization, though a dedicated `useWorkspacePolling` hook would make the lifecycle clearer. **Priority:** keep +- [x] UE297 `apps/web/src/pages/workspace/useWorkspaceCore.ts:219` - **Current:** Clears a WebSocket URL cache ref when URL, workspace ID, or terminal mode changes. **Recommendation:** Remove the invalidation effect by storing those fields as the cache key and validating the key inside `resolveTerminalWsUrl`. **Priority:** low +- [x] UE298 `apps/web/src/pages/workspace/useWorkspaceCore.ts:242` - **Current:** Derives `wsUrl` from workspace URL/token in an effect and guards updates with `wsUrlSetRef`. **Recommendation:** Replace this derived-state effect with `useMemo` or a `useTerminalConnectionUrl` state machine keyed by workspace URL, token, and terminal mode. **Priority:** high +- [x] UE299 `apps/web/src/pages/workspace/useWorkspaceCore.ts:276` - **Current:** Polls workspace events from the VM agent every 10 seconds. **Recommendation:** Keep the external polling effect, but extract to `useWorkspaceEvents` with abort/stale-response guards and a named interval constant. **Priority:** low +- [x] UE300 `apps/web/src/pages/workspace/useWorkspaceNavigation.ts:80` - **Current:** Polls git status with retry delays and clears state when unavailable. **Recommendation:** Keep the external polling effect; a `useGitStatusPolling` hook with abortable fetches/cancellable retry sleep would better contain the lifecycle. **Priority:** low +- [x] UE301 `apps/web/src/pages/workspace/useWorkspaceNavigation.ts:157` - **Current:** Fetches worktrees when the workspace connection inputs become available. **Recommendation:** Keep the network sync; `refreshWorktrees` already provides an event path, and a small worktree data hook is optional. **Priority:** keep +- [x] UE302 `apps/web/src/pages/workspace/useWorkspaceNavigation.ts:162` - **Current:** Navigates away from an invalid `worktree` URL param after worktrees load. **Recommendation:** Keep as router synchronization, but depend on a stable worktree path key and centralize URL validation with worktree loading. **Priority:** low +- [x] UE303 `apps/web/tests/unit/components/project-message-view-recovery.test.ts:31` - **Current:** Test-only mirror hook debounces a connection banner with a timeout effect. **Recommendation:** Keep the timer pattern, but test the production hook or an extracted `useConnectionBannerDebounce` instead of a duplicate mirror. **Priority:** low +- [x] UE304 `apps/web/tests/unit/components/project-message-view-recovery.test.ts:151` - **Current:** Test-only mirror hook schedules ACP recovery retries with timers, refs, and suppressed deps. **Recommendation:** Replace the mirror with tests for the production recovery hook, or delete it if ACP recovery is no longer a live path. **Priority:** high +- [x] UE305 `apps/web/tests/unit/components/project-message-view-status-timer.test.ts:96` - **Current:** Test-only mirror cleans timers and aborts verification on session change/unmount. **Recommendation:** Keep the cleanup pattern, but extract the production session activity timer or test `useSessionLifecycle` directly to avoid mirror drift. **Priority:** low +- [x] UE306 `apps/web/tests/unit/pages/workspace.test.tsx:83` - **Current:** Mock `MultiTerminal` notifies `onSessionsChange` from an effect after internal mock state changes. **Recommendation:** Prefer calling the callback from the mock's create/close/activate state transitions, or make the mock controlled, so tests do not rely on an extra render effect. **Priority:** low +- [x] UE307 `packages/acp-client/src/components/AgentPanel.tsx:104` - **Current:** Scrolls Virtuoso to the bottom when replay transitions to ready/prompting. **Recommendation:** Keep this imperative DOM/third-party component synchronization in an effect; `useLayoutEffect` is only warranted if visible scroll flicker is observed. **Priority:** keep +- [x] UE308 `packages/acp-client/src/components/AgentPanel.tsx:134` - **Current:** Copies derived `slashFilter !== null` into `showPalette` state. **Recommendation:** Remove the effect by deriving palette visibility from `slashFilter` plus an explicit dismissed-filter flag that is reset in input/change handlers. **Priority:** medium +- [x] UE309 `packages/acp-client/src/components/ChatSettingsPanel.tsx:43` - **Current:** Copies loaded `settings` props into local draft form state. **Recommendation:** Avoid the prop-sync effect by keying/remounting the form per settings snapshot or lifting the draft state to the parent with a dirty-state guard. **Priority:** medium +- [x] UE310 `packages/acp-client/src/components/ChatSettingsPanel.tsx:51` - **Current:** Captures current focus, focuses the panel, and restores focus on unmount. **Recommendation:** Keep this DOM focus synchronization effect; extract to a `useRestoreFocus`/dialog focus hook if more panels need it. **Priority:** keep +- [x] UE311 `packages/acp-client/src/components/ChatSettingsPanel.tsx:60` - **Current:** Registers a document Escape listener for the settings panel. **Recommendation:** Keep the DOM subscription, preferably through a shared `useEscapeKey`; `useEffectEvent` is a future option once the repo upgrades. **Priority:** low +- [x] UE312 `packages/acp-client/src/components/MentionPalette.tsx:50` - **Current:** Resets selected index from an effect when filter or visibility changes. **Recommendation:** Remove the effect by keying the palette by filter/open state or resetting selection in the input/open event that changes the filter. **Priority:** medium +- [x] UE313 `packages/acp-client/src/components/MentionPalette.tsx:55` - **Current:** Scrolls the selected option into view after keyboard selection changes. **Recommendation:** Keep this DOM synchronization effect; switch to `useLayoutEffect` only if post-paint scroll jump is visible. **Priority:** keep +- [x] UE314 `packages/acp-client/src/components/MermaidDiagram.tsx:176` - **Current:** Mutates the injected Mermaid SVG DOM to set base viewBox and sizing after render. **Recommendation:** Prefer preprocessing the sanitized SVG string before injection; if DOM mutation remains necessary, use `useLayoutEffect` to avoid a first-paint sizing flash. **Priority:** medium +- [x] UE315 `packages/acp-client/src/components/MermaidDiagram.tsx:187` - **Current:** Uses a `resetToken` prop effect to imperatively reset the SVG viewBox. **Recommendation:** Move reset into the Reset button event via an imperative viewport ref, or model `viewBox` as React state so reset is handled without a command-style effect. **Priority:** medium + +### Chunk 10 (UE316-UE350) + +- [x] UE316 `packages/acp-client/src/components/MermaidDiagram.tsx:347` - **Current:** Asynchronously renders Mermaid through an imperative renderer and stores sanitized SVG state. **Recommendation:** Keep an Effect because Mermaid rendering/cleanup is an external DOM/library synchronization; optionally extract `useMermaidSvg(code, diagramId)` with stale-render cancellation. **Priority:** keep +- [x] UE317 `packages/acp-client/src/components/MermaidDiagram.tsx:368` - **Current:** Locks body scroll, listens for Escape, and restores focus while fullscreen is open. **Recommendation:** Keep as external browser synchronization, but fold it into a shared fullscreen/modal hook with scroll lock, Escape handling, and focus restore. **Priority:** low +- [x] UE318 `packages/acp-client/src/components/PlanModal.tsx:24` - **Current:** Focuses the dialog on open and restores prior focus on cleanup. **Recommendation:** Keep the DOM focus synchronization, but move it into a reusable modal focus hook shared with other dialogs. **Priority:** low +- [x] UE319 `packages/acp-client/src/components/PlanModal.tsx:34` - **Current:** Subscribes to document keydown to close on Escape. **Recommendation:** Keep the subscription, preferably via a shared `useEscapeKey(onClose, isOpen)` hook; `useEffectEvent` is only a future option after React/hooks-eslint upgrade. **Priority:** low +- [x] UE320 `packages/acp-client/src/components/PlanModal.tsx:44` - **Current:** Locks `document.body` scrolling while the modal is open. **Recommendation:** Keep as browser synchronization, but replace repeated inline logic with `useBodyScrollLock(isOpen)` that preserves/restores the previous overflow value. **Priority:** low +- [x] UE321 `packages/acp-client/src/components/SlashCommandPalette.tsx:46` - **Current:** Resets `selectedIndex` after `filter` or `visible` changes. **Recommendation:** Remove the Effect; reset selection in the state owner/open-filter event path, key/remount the palette, or store selection with a filter/version so render uses index 0 immediately. **Priority:** medium +- [x] UE322 `packages/acp-client/src/components/SlashCommandPalette.tsx:51` - **Current:** Scrolls the selected list item into view after selection changes. **Recommendation:** Keep DOM scroll synchronization, or replace with an active-row callback ref; include visibility/filter or active command identity if the selected DOM node changes without index changing. **Priority:** low +- [x] UE323 `packages/acp-client/src/components/VoiceButton.tsx:98` - **Current:** Cleans up animation frame, `AudioContext`, and microphone tracks on unmount. **Recommendation:** Keep as external media cleanup; extract a single `cleanupRecordingResources()` helper and also track/clear auto-recovery timeouts. **Priority:** low +- [x] UE324 `packages/acp-client/src/hooks/useAcpSession.ts:587` - **Current:** Starts and tears down the ACP WebSocket connection, reconnect timer, and transport refs. **Recommendation:** Keep an Effect for connection lifetime, but extract the transport/reconnect state machine into a narrower `useAcpConnection` helper. **Priority:** medium +- [x] UE325 `packages/acp-client/src/hooks/useAcpSession.ts:621` - **Current:** Subscribes to `visibilitychange` and reconnects when the tab becomes visible. **Recommendation:** Keep as document subscription; move into `useReconnectOnVisible` with latest callbacks in refs, with `useEffectEvent` only after upgrading from React 19.1. **Priority:** low +- [x] UE326 `packages/acp-client/src/hooks/useAcpSession.ts:656` - **Current:** Subscribes to browser online/offline events and coordinates reconnect/offline error state. **Recommendation:** Keep as window subscription; extract `useNetworkReconnect` so network status handling is separate from ACP message/state logic. **Priority:** low +- [x] UE327 `packages/acp-client/src/hooks/useAudioPlayback.ts:413` - **Current:** Cleans up fetches, intervals, audio, blob URLs, speech synthesis, and locks on unmount. **Recommendation:** Keep external resource cleanup, but centralize it in a stable cleanup helper/ref so the eslint disable is unnecessary. **Priority:** low +- [x] UE328 `packages/acp-client/src/hooks/usePrefersReducedMotion.ts:11` - **Current:** Uses state plus an Effect to subscribe to `matchMedia` changes. **Recommendation:** Replace with a small `useSyncExternalStore`-based media-query hook with client and server snapshots. **Priority:** medium +- [x] UE329 `packages/acp-client/src/hooks/useStreamingReveal.ts:46` - **Current:** Mixes derived snap logic with a `requestAnimationFrame` reveal loop. **Recommendation:** Keep an Effect only for the rAF timer; derive the non-animated text during render and handle shrink/reset through reducer/keyed state rather than post-render snapping. **Priority:** medium +- [x] UE330 `packages/acp-client/src/hooks/useStreamingReveal.ts:98` - **Current:** A second Effect cancels the rAF after `revealIndex` reaches the full text length. **Recommendation:** Remove this separate Effect by making the rAF loop stop itself when it reaches the target and relying on the main loop cleanup. **Priority:** medium +- [x] UE331 `packages/acp-client/src/hooks/useStreamingReveal.ts:106` - **Current:** Adds an unmount-only rAF cleanup Effect. **Recommendation:** Merge this cleanup into the single rAF loop Effect; React will run that cleanup on dependency changes and unmount. **Priority:** low +- [x] UE332 `packages/terminal/src/MultiTerminal.tsx:228` - **Current:** One large Effect owns WebSocket connect/reconnect, heartbeat, message routing, session reconciliation, and terminal disposal. **Recommendation:** Keep an external connection Effect, but split it into a `useMultiTerminalTransport` hook/state machine with separate message handlers and terminal lifecycle cleanup. **Priority:** high +- [x] UE333 `packages/terminal/src/MultiTerminal.tsx:581` - **Current:** Adds a window resize listener to fit the active xterm and send terminal size. **Recommendation:** Prefer the existing per-container `ResizeObserver` as the single resize path, or extract this into `useTerminalResize` if the window listener remains necessary. **Priority:** low +- [x] UE334 `packages/terminal/src/MultiTerminal.tsx:607` - **Current:** Uses double `requestAnimationFrame` after active tab changes to fit/focus xterm and send resize. **Recommendation:** Keep as post-commit DOM/xterm synchronization; do not switch to `useLayoutEffect` unless the fit must block paint. **Priority:** keep +- [x] UE335 `packages/terminal/src/MultiTerminal.tsx:680` - **Current:** Notifies `onSessionsChange` from an Effect derived from `sessionsArray`, which is recreated every render. **Recommendation:** Remove the Effect by lifting session snapshots to the state owner or calling the callback from session mutations; at minimum memoize snapshots/dependencies. **Priority:** high +- [x] UE336 `packages/terminal/src/Terminal.tsx:67` - **Current:** Initializes xterm, input handling, and resize observers in an Effect whose dependencies include connection/session-derived callbacks. **Recommendation:** Split into a mount/ref-callback `useXTerm` initializer with latest callback refs plus separate resize/socket effects so connection changes do not dispose/recreate xterm. **Priority:** high +- [x] UE337 `packages/terminal/src/Terminal.tsx:133` - **Current:** Mirrors `socket`/`connected` into `wsRef` and clears `sessionId` from an Effect. **Recommendation:** Remove the mirroring Effect; derive sending from `socket`/`connected` or update a latest-socket ref during render, and clear session state in the WebSocket reducer/close path. **Priority:** medium +- [x] UE338 `packages/terminal/src/Terminal.tsx:149` - **Current:** Subscribes to WebSocket messages and writes parsed terminal output/errors into xterm. **Recommendation:** Keep the subscription, but move protocol handling into `useTerminalProtocol` with latest refs and avoid stale `sendResize` after receiving a new session id. **Priority:** medium +- [x] UE339 `packages/terminal/src/Terminal.tsx:195` - **Current:** Starts a heartbeat interval while connected. **Recommendation:** Keep as timer synchronization, ideally owned by `useWebSocket` or a small `useWebSocketHeartbeat(socket, connected)` hook tied to socket lifetime. **Priority:** low +- [x] UE340 `packages/terminal/src/components/TabBar.tsx:73` - **Current:** Measures tab overflow and subscribes to scroll/window resize to show scroll buttons. **Recommendation:** Keep DOM measurement, but extract `useHorizontalOverflow` and prefer `ResizeObserver`/scroll events over rerunning because the `sessions` array identity changed. **Priority:** low +- [x] UE341 `packages/terminal/src/components/TabBar.tsx:95` - **Current:** Scrolls the active tab into view when `activeSessionId` changes. **Recommendation:** Keep DOM scroll synchronization, or replace `querySelector` with per-tab refs/callback refs to avoid selector coupling. **Priority:** low +- [x] UE342 `packages/terminal/src/components/TabItem.tsx:103` - **Current:** Focuses and selects the rename input after `isEditing` becomes true. **Recommendation:** Remove the Effect by using an input callback ref that focuses/selects when the editor mounts. **Priority:** low +- [x] UE343 `packages/terminal/src/components/TabOverflowMenu.tsx:57` - **Current:** Subscribes to document mousedown and keydown for outside click and Escape close. **Recommendation:** Keep external subscriptions but compose shared `useClickOutside` and `useEscapeKey` hooks with stable callback refs. **Priority:** low +- [x] UE344 `packages/terminal/src/hooks/useTerminalSessions.ts:104` - **Current:** Restores a ref counter from `sessionStorage` in an unmountless mount Effect. **Recommendation:** Remove the Effect by initializing the counter from a guarded lazy storage read before first session creation, or use `useSyncExternalStore` only if storage changes must be observed. **Priority:** medium +- [x] UE345 `packages/terminal/src/useWebSocket.ts:163` - **Current:** Opens the WebSocket and cleans up socket/reconnect timeout for the hook lifetime and URL changes. **Recommendation:** Keep as the hook's external connection boundary; consider reconnecting on resolver changes too if `resolveUrl` identity is intended to change connection behavior. **Priority:** keep +- [x] UE346 `packages/ui/src/components/Dialog.tsx:25` - **Current:** Always subscribes to document keydown and checks `isOpen` inside the handler. **Recommendation:** Replace with `useEscapeKey(onClose, isOpen)` so the document listener only exists while open; `useEffectEvent` is a future React 19.2+ option. **Priority:** low +- [x] UE347 `packages/ui/src/components/Dialog.tsx:33` - **Current:** Locks body scroll, focuses the dialog, and resets overflow to an empty string. **Recommendation:** Keep DOM synchronization but use a shared modal hook that preserves prior overflow and restores focus, instead of resetting global body style blindly. **Priority:** medium +- [x] UE348 `packages/ui/src/components/DropdownMenu.tsx:89` - **Current:** While open, subscribes for outside clicks plus resize/scroll repositioning. **Recommendation:** Keep external subscriptions, split into `useClickOutside` and positioning subscriptions, and keep initial measurement in `useLayoutEffect` only where pre-paint positioning is required. **Priority:** low +- [x] UE349 `packages/ui/src/hooks/useClickOutside.ts:8` - **Current:** Custom hook subscribes to document mousedown and calls the provided callback. **Recommendation:** Keep the hook, but store the callback in a latest ref to avoid resubscribing on every callback identity change; `useEffectEvent` can replace that after upgrade. **Priority:** low +- [x] UE350 `packages/ui/src/hooks/useEscapeKey.ts:4` - **Current:** Custom hook subscribes to document keydown and invokes the callback on Escape. **Recommendation:** Keep the hook, but use a latest-callback ref today and consider `useEffectEvent` only after React and eslint-plugin-react-hooks support it. **Priority:** low + diff --git a/apps/web/src/components/AgentCard.tsx b/apps/web/src/components/AgentCard.tsx index a250348b8..3350a7210 100644 --- a/apps/web/src/components/AgentCard.tsx +++ b/apps/web/src/components/AgentCard.tsx @@ -91,6 +91,7 @@ export function AgentCard({ Configuration { - 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); diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index e8a5c79e6..c960b9c06 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -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 { @@ -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} @@ -257,7 +253,7 @@ export function AppShell({ children }: AppShellProps) { 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(), []); @@ -114,15 +114,13 @@ export const ChatSession = React.forwardRef [workspaceId, sessionId] ); - useEffect(() => { - wsUrlCacheRef.current = null; - }, [wsHostInfo, workspaceId, sessionId, worktreePath]); - const resolveWsUrl = useCallback(async (): Promise => { 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) { return cached.url; } @@ -138,7 +136,7 @@ export const ChatSession = React.forwardRef 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({ @@ -151,10 +149,38 @@ export const ChatSession = React.forwardRef } }, [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[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. @@ -207,13 +233,12 @@ export const ChatSession = React.forwardRef 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({ @@ -226,24 +251,6 @@ export const ChatSession = React.forwardRef } }, [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(null); const [agentSettingsLoading, setAgentSettingsLoading] = useState(false); diff --git a/apps/web/src/components/GitDiffView.tsx b/apps/web/src/components/GitDiffView.tsx index 885e1512e..75b9f53d9 100644 --- a/apps/web/src/components/GitDiffView.tsx +++ b/apps/web/src/components/GitDiffView.tsx @@ -80,8 +80,12 @@ export const GitDiffView: FC = ({ } }, [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( @@ -93,6 +97,7 @@ export const GitDiffView: FC = ({ worktree ?? undefined ); setFullContent(result.content); + setFullContentKey(currentFullKey); } catch { // Fallback: just show diff if full file fails setFullContent(null); @@ -100,18 +105,20 @@ export const GitDiffView: FC = ({ } 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(); @@ -183,7 +190,7 @@ export const GitDiffView: FC = ({ setViewMode('full')} + onClick={handleToggleFull} /> diff --git a/apps/web/src/components/GitHubAppSection.tsx b/apps/web/src/components/GitHubAppSection.tsx index 1824f738b..ba1d1fc3f 100644 --- a/apps/web/src/components/GitHubAppSection.tsx +++ b/apps/web/src/components/GitHubAppSection.tsx @@ -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) { diff --git a/apps/web/src/components/GlobalCommandPalette.tsx b/apps/web/src/components/GlobalCommandPalette.tsx index 300a079d8..d43f4729f 100644 --- a/apps/web/src/components/GlobalCommandPalette.tsx +++ b/apps/web/src/components/GlobalCommandPalette.tsx @@ -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 @@ -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(() => { @@ -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': @@ -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" @@ -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 (
| 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; diff --git a/apps/web/src/components/ProjectAgentCard.tsx b/apps/web/src/components/ProjectAgentCard.tsx index 8a30dd781..c1d36a244 100644 --- a/apps/web/src/components/ProjectAgentCard.tsx +++ b/apps/web/src/components/ProjectAgentCard.tsx @@ -18,7 +18,7 @@ import { VALID_PERMISSION_MODES, } from '@simple-agent-manager/shared'; import { Alert, Button, Card, StatusBadge } from '@simple-agent-manager/ui'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { AgentKeyCard } from './AgentKeyCard'; import { ModelSelect } from './ModelSelect'; @@ -70,6 +70,8 @@ export function ProjectAgentCard({ onSaveDefault, onClearDefault, }: ProjectAgentCardProps) { + // State is initialized from props; parent uses a key that includes defaultValue + // so this component remounts with fresh state when the server data changes. const [model, setModel] = useState(defaultValue?.model ?? ''); const [permissionMode, setPermissionMode] = useState( defaultValue?.permissionMode ?? '', @@ -79,11 +81,6 @@ export function ProjectAgentCard({ const [error, setError] = useState(null); const [success, setSuccess] = useState(false); - useEffect(() => { - setModel(defaultValue?.model ?? ''); - setPermissionMode(defaultValue?.permissionMode ?? ''); - }, [defaultValue]); - const hasCredentialOverride = (projectCredentials?.length ?? 0) > 0; const activeUserCred = userCredentials.find((c) => c.isActive); const hasUserCredential = userCredentials.length > 0; diff --git a/apps/web/src/components/ProjectAgentsSection.tsx b/apps/web/src/components/ProjectAgentsSection.tsx index 162ea83e0..0bfdde408 100644 --- a/apps/web/src/components/ProjectAgentsSection.tsx +++ b/apps/web/src/components/ProjectAgentsSection.tsx @@ -16,6 +16,8 @@ import type { import { Alert, Spinner } from '@simple-agent-manager/ui'; import { useCallback, useEffect, useState } from 'react'; +// NOTE: useEffect is still used for the loadData fetch, not for prop-to-state sync. + import { useToast } from '../hooks/useToast'; import { deleteProjectAgentCredential, @@ -42,14 +44,12 @@ export function ProjectAgentsSection({ const [agents, setAgents] = useState([]); const [projectCreds, setProjectCreds] = useState([]); const [userCreds, setUserCreds] = useState([]); - const [defaults, setDefaults] = useState(initialAgentDefaults ?? {}); + // Derived from prop; no prop-sync effect needed. Mutations update the parent + // via onUpdated(), which passes back fresh initialAgentDefaults. + const defaults: ProjectAgentDefaults = initialAgentDefaults ?? {}; const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - setDefaults(initialAgentDefaults ?? {}); - }, [initialAgentDefaults]); - const loadData = useCallback(async () => { try { setError(null); @@ -111,7 +111,6 @@ export function ProjectAgentsSection({ } const payload = Object.keys(next).length === 0 ? null : next; const updated = await updateProject(projectId, { agentDefaults: payload }); - setDefaults(updated.agentDefaults ?? {}); onUpdated(updated.agentDefaults ?? null); }; @@ -120,7 +119,6 @@ export function ProjectAgentsSection({ delete next[agentType]; const payload = Object.keys(next).length === 0 ? null : next; const updated = await updateProject(projectId, { agentDefaults: payload }); - setDefaults(updated.agentDefaults ?? {}); onUpdated(updated.agentDefaults ?? null); }; @@ -158,7 +156,7 @@ export function ProjectAgentsSection({ const userAgentCreds = userCreds.filter((c) => c.agentType === agent.id); return ( 0 ? projectAgentCreds : null} userCredentials={userAgentCreds} diff --git a/apps/web/src/components/RepoSelector.tsx b/apps/web/src/components/RepoSelector.tsx index 3ffe988f9..d9e88f9f8 100644 --- a/apps/web/src/components/RepoSelector.tsx +++ b/apps/web/src/components/RepoSelector.tsx @@ -1,6 +1,6 @@ import type { Repository } from '@simple-agent-manager/shared'; import { Alert, Input, Spinner } from '@simple-agent-manager/ui'; -import { useCallback,useEffect, useRef, useState } from 'react'; +import { useCallback,useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { listRepositories } from '../lib/api'; @@ -29,7 +29,6 @@ export function RepoSelector({ placeholder = 'https://github.com/user/repo or select from list', }: RepoSelectorProps) { const [repositories, setRepositories] = useState([]); - const [filteredRepos, setFilteredRepos] = useState([]); const [showDropdown, setShowDropdown] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -67,16 +66,14 @@ export function RepoSelector({ fetchRepos(); }, [installationId]); - // Filter repositories based on input - useEffect(() => { + // Filter repositories based on input (derived at render time) + const filteredRepos = useMemo(() => { if (value.startsWith('http') || value.startsWith('git@')) { - setFilteredRepos([]); - return; + return []; } if (!value) { - setFilteredRepos(repositories.slice(0, 25)); - return; + return repositories.slice(0, 25); } const searchTerm = value.toLowerCase(); @@ -85,7 +82,7 @@ export function RepoSelector({ ); // Show more results (25) to help users with many repos find what they need - setFilteredRepos(filtered.slice(0, 25)); + return filtered.slice(0, 25); }, [value, repositories]); // Handle clicks outside dropdown diff --git a/apps/web/src/components/ScalingSettings.tsx b/apps/web/src/components/ScalingSettings.tsx index 7efa8fde0..878413cb0 100644 --- a/apps/web/src/components/ScalingSettings.tsx +++ b/apps/web/src/components/ScalingSettings.tsx @@ -10,7 +10,7 @@ import { type ScalingParamMeta, } from '@simple-agent-manager/shared'; import { Button } from '@simple-agent-manager/ui'; -import { useCallback,useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useToast } from '../hooks/useToast'; import { listCredentials,updateProject } from '../lib/api'; @@ -92,20 +92,59 @@ export function ScalingSettings({ projectId: string; project: Project; reload: () => Promise; +}) { + const [configuredProviders, setConfiguredProviders] = useState([]); + + // Fetch configured providers (legitimate data-fetch effect) + useEffect(() => { + listCredentials() + .then((creds) => { + const providers = [...new Set( + creds + .filter((c) => c.connected) + .map((c) => c.provider) + )]; + setConfiguredProviders(providers); + }) + .catch((err: unknown) => { console.error('Failed to load credentials', err); }); + }, []); + + // Key the form by project.updatedAt so it remounts with fresh state + // when the project is reloaded after a save, removing the prop-sync effect. + return ( + + ); +} + +function ScalingSettingsForm({ + projectId, + project, + reload, + configuredProviders, +}: { + projectId: string; + project: Project; + reload: () => Promise; + configuredProviders: CredentialProvider[]; }) { const toast = useToast(); - // Provider & Location state + // State initialized from props; no sync effect needed because the parent + // keys this component by project.updatedAt. const [selectedProvider, setSelectedProvider] = useState( project.defaultProvider ?? null ); const [selectedLocation, setSelectedLocation] = useState( project.defaultLocation ?? null ); - const [configuredProviders, setConfiguredProviders] = useState([]); const [savingLocation, setSavingLocation] = useState(false); - // Scaling params state const [scalingValues, setScalingValues] = useState>(() => { const initial: Record = {}; for (const p of SCALING_PARAMS) { @@ -118,32 +157,6 @@ export function ScalingSettings({ ); const [savingScaling, setSavingScaling] = useState(false); - // Fetch configured providers - useEffect(() => { - listCredentials() - .then((creds) => { - const providers = [...new Set( - creds - .filter((c) => c.connected) - .map((c) => c.provider) - )]; - setConfiguredProviders(providers); - }) - .catch((err: unknown) => { console.error('Failed to load credentials', err); }); - }, []); - - // Sync from project prop - useEffect(() => { - setSelectedProvider(project.defaultProvider ?? null); - setSelectedLocation(project.defaultLocation ?? null); - const updated: Record = {}; - for (const p of SCALING_PARAMS) { - updated[p.key] = (project[p.key as keyof Project] as number | null) ?? null; - } - setScalingValues(updated); - setNodeIdleTimeoutMs(project.nodeIdleTimeoutMs ?? null); - }, [project]); - const handleSaveProviderLocation = useCallback(async () => { setSavingLocation(true); try { diff --git a/apps/web/src/components/account-map/AccountMapCanvas.tsx b/apps/web/src/components/account-map/AccountMapCanvas.tsx index 42e3cae8e..2c21131cb 100644 --- a/apps/web/src/components/account-map/AccountMapCanvas.tsx +++ b/apps/web/src/components/account-map/AccountMapCanvas.tsx @@ -91,13 +91,18 @@ function AccountMapCanvasInner({ nodes: initialNodes, edges: initialEdges, isMob setEdges(initialEdges); }, [initialEdges, setEdges]); + // Latest-ref for isMobile so mouse/click handlers don't recreate on every + // resize event — the ref always holds the current value. + const isMobileRef = useRef(isMobile); + isMobileRef.current = isMobile; + const handleNodeMouseEnter = useCallback( (_event: React.MouseEvent, node: Node) => { - if (isMobile) return; + if (isMobileRef.current) return; if (timeoutRef.current) clearTimeout(timeoutRef.current); setTooltip({ x: _event.clientX + 12, y: _event.clientY + 12, node }); }, - [isMobile] + [] ); const handleNodeMouseLeave = useCallback(() => { @@ -106,13 +111,13 @@ function AccountMapCanvasInner({ nodes: initialNodes, edges: initialEdges, isMob const handleNodeClick = useCallback( (_event: React.MouseEvent, node: Node) => { - if (!isMobile) return; + if (!isMobileRef.current) return; // On mobile, show tooltip on tap setTooltip((prev) => prev?.node.id === node.id ? null : { x: 0, y: 0, node } ); }, - [isMobile] + [] ); const handlePaneClick = useCallback(() => { diff --git a/apps/web/src/components/account-map/hooks/useMapFilters.ts b/apps/web/src/components/account-map/hooks/useMapFilters.ts index e7dcad312..8ff427e91 100644 --- a/apps/web/src/components/account-map/hooks/useMapFilters.ts +++ b/apps/web/src/components/account-map/hooks/useMapFilters.ts @@ -1,5 +1,5 @@ import type { Edge,Node } from '@xyflow/react'; -import { useCallback, useMemo, useRef,useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; export type EntityType = 'project' | 'node' | 'workspace' | 'session' | 'task' | 'idea'; @@ -28,8 +28,6 @@ interface UseMapFiltersResult { hasActiveFilters: boolean; matchCount: number; totalCount: number; - /** True when type filters changed the visible node count — signals auto-reorganize. */ - filterNodeCountChanged: boolean; } const ALL_TYPES: EntityType[] = ['project', 'node', 'workspace', 'session', 'task']; @@ -37,7 +35,6 @@ const ALL_TYPES: EntityType[] = ['project', 'node', 'workspace', 'session', 'tas export function useMapFilters({ nodes, edges }: UseMapFiltersOptions): UseMapFiltersResult { const [searchQuery, setSearchQuery] = useState(''); const [activeFilters, setActiveFilters] = useState>(() => new Set(ALL_TYPES)); - const prevFilteredCountRef = useRef(null); const toggleFilter = useCallback((type: EntityType) => { setActiveFilters((prev) => { @@ -56,7 +53,7 @@ export function useMapFilters({ nodes, edges }: UseMapFiltersOptions): UseMapFil setSearchQuery(''); }, []); - const { filteredNodes, filteredEdges, matchCount, totalCount, filterNodeCountChanged } = useMemo(() => { + const { filteredNodes, filteredEdges, matchCount, totalCount } = useMemo(() => { const lowerQuery = searchQuery.toLowerCase().trim(); // Type filters: fully REMOVE nodes whose type is toggled off @@ -120,17 +117,11 @@ export function useMapFilters({ nodes, edges }: UseMapFiltersOptions): UseMapFil }, })); - // Detect if the type filter changed the visible node count (triggers auto-reorganize) - const currentCount = typeFiltered.length; - const changed = prevFilteredCountRef.current !== null && prevFilteredCountRef.current !== currentCount; - prevFilteredCountRef.current = currentCount; - return { filteredNodes: styledNodes, filteredEdges: styledEdges, matchCount: matchingIds.size, totalCount: typeFiltered.length, - filterNodeCountChanged: changed, }; }, [nodes, edges, searchQuery, activeFilters]); @@ -147,6 +138,5 @@ export function useMapFilters({ nodes, edges }: UseMapFiltersOptions): UseMapFil hasActiveFilters, matchCount, totalCount, - filterNodeCountChanged, }; } diff --git a/apps/web/src/components/admin/ObservabilityFilters.tsx b/apps/web/src/components/admin/ObservabilityFilters.tsx index 3feda60aa..2482786b8 100644 --- a/apps/web/src/components/admin/ObservabilityFilters.tsx +++ b/apps/web/src/components/admin/ObservabilityFilters.tsx @@ -1,5 +1,5 @@ import type { PlatformErrorLevel,PlatformErrorSource } from '@simple-agent-manager/shared'; -import { type FC,useEffect, useRef, useState } from 'react'; +import { type FC, useEffect, useRef, useState } from 'react'; import type { TimeRange } from '../../hooks/useAdminErrors'; import { useIsMobile } from '../../hooks/useIsMobile'; @@ -51,11 +51,6 @@ export const ObservabilityFilters: FC = ({ const [searchInput, setSearchInput] = useState(search); const debounceRef = useRef>(undefined); - // Sync external search changes - useEffect(() => { - setSearchInput(search); - }, [search]); - const isMobile = useIsMobile(); const handleSearchInput = (value: string) => { diff --git a/apps/web/src/components/agent-profiles/ProfileFormDialog.tsx b/apps/web/src/components/agent-profiles/ProfileFormDialog.tsx index ce93b3f79..5282a499d 100644 --- a/apps/web/src/components/agent-profiles/ProfileFormDialog.tsx +++ b/apps/web/src/components/agent-profiles/ProfileFormDialog.tsx @@ -18,7 +18,7 @@ import { VALID_PERMISSION_MODES, } from '@simple-agent-manager/shared'; import { Button, Dialog, Input } from '@simple-agent-manager/ui'; -import { type FC, type ReactNode, useEffect, useState } from 'react'; +import { type FC, type ReactNode, useEffect, useMemo, useState } from 'react'; import { getProject, getProviderCatalog } from '../../lib/api'; import { ModelSelect } from '../ModelSelect'; @@ -240,68 +240,74 @@ export const ProfileFormDialog: FC = ({ onSave, projectId, }) => { + // Key the form body so React remounts it with fresh state when the + // profile changes or the dialog re-opens, eliminating the prop-sync effect. + const formKey = useMemo( + () => (isOpen ? `${profile?.id ?? 'new'}-${Date.now()}` : 'closed'), + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: remount on each open + [isOpen, profile?.id], + ); + + return ( + + {isOpen && ( + + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Inner form body — keyed by the parent so it remounts with fresh state +// --------------------------------------------------------------------------- + +interface ProfileFormBodyProps { + profile: AgentProfile | null; + onSave: (data: CreateAgentProfileRequest | UpdateAgentProfileRequest) => Promise; + onClose: () => void; + projectId: string; +} + +function ProfileFormBody({ profile, onSave, onClose, projectId }: ProfileFormBodyProps) { const isEdit = !!profile; - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [agentType, setAgentType] = useState(DEFAULT_AGENT_TYPE); - const [model, setModel] = useState(''); - const [effort, setEffort] = useState(DEFAULT_AGENT_EFFORT); - const [permissionMode, setPermissionMode] = useState(''); - const [systemPromptAppend, setSystemPromptAppend] = useState(''); - const [maxTurns, setMaxTurns] = useState(''); - const [timeoutMinutes, setTimeoutMinutes] = useState(''); - const [vmSizeOverride, setVmSizeOverride] = useState(''); - const [workspaceProfile, setWorkspaceProfile] = useState(''); - const [devcontainerConfigName, setDevcontainerConfigName] = useState(''); - const [taskMode, setTaskMode] = useState(''); - const [githubCliPolicy, setGithubCliPolicy] = - useState(DEFAULT_GITHUB_CLI_POLICY); + // All state initialized from the profile prop; no sync effect needed. + const [name, setName] = useState(profile?.name ?? ''); + const [description, setDescription] = useState(profile?.description ?? ''); + const [agentType, setAgentType] = useState(profile?.agentType ?? DEFAULT_AGENT_TYPE); + const [model, setModel] = useState(profile?.model ?? ''); + const [effort, setEffort] = useState(profile?.effort ?? DEFAULT_AGENT_EFFORT); + const [permissionMode, setPermissionMode] = useState(profile?.permissionMode ?? ''); + const [systemPromptAppend, setSystemPromptAppend] = useState(profile?.systemPromptAppend ?? ''); + const [maxTurns, setMaxTurns] = useState( + profile?.maxTurns != null ? String(profile.maxTurns) : '', + ); + const [timeoutMinutes, setTimeoutMinutes] = useState( + profile?.timeoutMinutes != null ? String(profile.timeoutMinutes) : '', + ); + const [vmSizeOverride, setVmSizeOverride] = useState(profile?.vmSizeOverride ?? ''); + const [workspaceProfile, setWorkspaceProfile] = useState(profile?.workspaceProfile ?? ''); + const [devcontainerConfigName, setDevcontainerConfigName] = useState( + profile?.devcontainerConfigName ?? '', + ); + const [taskMode, setTaskMode] = useState(profile?.taskMode ?? ''); + const [githubCliPolicy, setGithubCliPolicy] = useState( + profile?.githubCliPolicy ?? DEFAULT_GITHUB_CLI_POLICY, + ); const [catalogs, setCatalogs] = useState([]); const [projectProvider, setProjectProvider] = useState(null); const [projectLocation, setProjectLocation] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - // Reset form when opening/closing or when profile changes + // Fetch provider catalog and project defaults (legitimate data-fetch effect) useEffect(() => { - if (isOpen && profile) { - setName(profile.name); - setDescription(profile.description ?? ''); - setAgentType(profile.agentType); - setModel(profile.model ?? ''); - setEffort(profile.effort ?? DEFAULT_AGENT_EFFORT); - setPermissionMode(profile.permissionMode ?? ''); - setSystemPromptAppend(profile.systemPromptAppend ?? ''); - setMaxTurns(profile.maxTurns != null ? String(profile.maxTurns) : ''); - setTimeoutMinutes(profile.timeoutMinutes != null ? String(profile.timeoutMinutes) : ''); - setVmSizeOverride(profile.vmSizeOverride ?? ''); - setWorkspaceProfile(profile.workspaceProfile ?? ''); - setDevcontainerConfigName(profile.devcontainerConfigName ?? ''); - setTaskMode(profile.taskMode ?? ''); - setGithubCliPolicy(profile.githubCliPolicy ?? DEFAULT_GITHUB_CLI_POLICY); - } else if (isOpen) { - setName(''); - setDescription(''); - setAgentType(DEFAULT_AGENT_TYPE); - setModel(''); - setEffort(DEFAULT_AGENT_EFFORT); - setPermissionMode(''); - setSystemPromptAppend(''); - setMaxTurns(''); - setTimeoutMinutes(''); - setVmSizeOverride(''); - setWorkspaceProfile(''); - setDevcontainerConfigName(''); - setTaskMode(''); - setGithubCliPolicy(DEFAULT_GITHUB_CLI_POLICY); - } - setError(null); - }, [isOpen, profile]); - - useEffect(() => { - if (!isOpen) return; - let cancelled = false; Promise.all([getProviderCatalog(), getProject(projectId)]) .then(([catalogResponse, project]) => { @@ -321,7 +327,7 @@ export const ProfileFormDialog: FC = ({ return () => { cancelled = true; }; - }, [isOpen, projectId]); + }, [projectId]); const effectiveProvider = profile?.provider ?? projectProvider; const activeCatalog = selectProviderCatalog(catalogs, effectiveProvider); @@ -383,270 +389,268 @@ export const ProfileFormDialog: FC = ({ }; return ( - -
{ - e.preventDefault(); - void handleSubmit(); - }} - > -

- {isEdit ? 'Edit Profile' : 'Create Agent Profile'} -

- - {error && ( -
- {error} -
- )} + { + e.preventDefault(); + void handleSubmit(); + }} + > +

+ {isEdit ? 'Edit Profile' : 'Create Agent Profile'} +

+ + {error && ( +
+ {error} +
+ )} - {/* Name & Description — always visible */} -
-