diff --git a/apps/desktop/eslint.config.mjs b/apps/desktop/eslint.config.mjs index 61abc0c90a0d..7e02d1d79367 100644 --- a/apps/desktop/eslint.config.mjs +++ b/apps/desktop/eslint.config.mjs @@ -39,5 +39,43 @@ export default [ rules: { 'no-restricted-globals': ['warn', 'document'] } + }, + { + // Ban mirroring reactive values into refs via useEffect — the "atom-mirrored + // ref" antipattern. A ref synced from a nanostores atom via useEffect lags the + // atom by one render, which creates stale-read bugs in callbacks that read the + // ref (cancelRun sent session.interrupt to the wrong session; steerPrompt, + // restoreToMessage, editMessage all had closure-priority stale reads). The fix + // is to read $atom.get() directly in callbacks instead. This rule catches the + // mirroring effect at lint time so the pattern can't reappear. Legitimate + // non-atom ref writes inside useEffect (DOM instance refs, mount flags, request + // tokens, prop mirrors) get an eslint-disable-next-line with a comment. + files: ['src/**/*.{ts,tsx}'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + // useEffect(() => { someRef.current = value }, [value]) + selector: + 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="AssignmentExpression"][body.left.type="MemberExpression"][body.left.property.name="current"]', + message: + 'Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.' + }, + { + // useEffect(() => { someRef.current = value; ... }, [value]) + selector: + 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="BlockStatement"]:has(AssignmentExpression[left.type="MemberExpression"][left.property.name="current"])', + message: + 'Do not mirror reactive values into refs via useEffect. Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.' + }, + { + // useEffect(() => { setMutableRef(ref, value) }, [value]) + selector: + 'CallExpression[callee.name="useEffect"] > ArrowFunctionExpression[body.type="BlockStatement"]:has(CallExpression[callee.name="setMutableRef"])', + message: + 'Do not mirror reactive values into refs via useEffect (setMutableRef included). Read $atom.get() directly in callbacks instead — refs synced from atoms lag one render and cause stale-read bugs.' + } + ] + } } ] diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index ab3281b8a99c..74f9e735e5ed 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -246,6 +246,7 @@ export function useComposerDraft({ // source otherwise), and (3) schedule the debounced per-session stash. // Browsing history / editing a queued prompt suppress the stash so recalled // text never clobbers the draft. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const sync = () => { const text = composerRuntime.getState().text @@ -342,6 +343,7 @@ export function useComposerDraft({ // window (e.g. a fast session switch immediately followed by Enter) would // ship A's attachments into B's turn (#59305). useLayoutEffect closes the // window by running before paint. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useLayoutEffect(() => { // A pending debounce timer from the outgoing session is now stale — its // scope was correct when scheduled, but the authoritative stash below @@ -368,6 +370,7 @@ export function useComposerDraft({ // pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R // inside the debounce/rAF window would drop trailing keystrokes without this. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const flushPendingDraftPersist = () => { const scope = draftScopeRef.current diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts index 0c2e4b61927c..8d1bf8c1ee56 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-placeholder.ts @@ -29,6 +29,7 @@ export function useComposerPlaceholder({ disabled, reconnecting, sessionId }: Us const prevSessionIdRef = useRef(sessionId) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevSessionIdRef.current prevSessionIdRef.current = sessionId diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index 4e813f548aef..dff3804bb692 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -322,6 +322,7 @@ export function useComposerQueue({ // never churns, so a change there is a real session switch and must NOT // migrate; only the runtime-derived key (queueSessionKey falsy → key is // sessionId) churns on a backend bounce/resume of the same conversation. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevQueueKeyRef.current prevQueueKeyRef.current = activeQueueSessionKey diff --git a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts index 6da699b602a1..32301cc8294e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-live-completion-adapter.ts @@ -49,6 +49,7 @@ export function useLiveCompletionAdapter(options: { useEffect(() => () => cancelTimer(), [cancelTimer]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (enabled) { return diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 0b71507bfd1a..79e1cc067daa 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -231,6 +231,7 @@ export function useComposerPopoutGestures({ [clearTimer, poppedOut] ) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { // Coalesce drag updates to one per frame — pointermove can fire several times // between paints on high-Hz mice, and each update re-renders + clamps. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 2ec43c83c0b8..33285f05aa16 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -60,18 +60,22 @@ export function useVoiceConversation({ const statusRef = useRef('idle') const wasEnabledRef = useRef(enabled) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { enabledRef.current = enabled }, [enabled]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { mutedRef.current = muted }, [muted]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { busyRef.current = busy }, [busy]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { statusRef.current = status }, [status]) @@ -508,6 +512,7 @@ export function useVoiceConversation({ // Drive the loop: when a voice-submitted reply appears, open a live speech // session (which feeds itself from then on). Otherwise start listening when // idle between turns. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!enabled || muted) { return @@ -542,6 +547,7 @@ export function useVoiceConversation({ } }, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (enabled && !wasEnabledRef.current) { void start() diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index aee735e4aea4..5093658af88c 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -91,6 +91,7 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const worktreeReq = useStore($newWorktreeRequest) const lastWorktreeReqRef = useRef(worktreeReq) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (worktreeReq === lastWorktreeReqRef.current) { return diff --git a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts index 42e4554b992e..186c226b84ac 100644 --- a/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts +++ b/apps/desktop/src/app/chat/hooks/use-file-drop-zone.ts @@ -44,6 +44,7 @@ export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOpt // DnD can't be cancelled at the OS level, so we drop the overlay and arm a // guard that swallows the trailing drop instead. Top escape layer + capture // stop so it doesn't also fire a handler behind the drag (see drag-session). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (dragKind === null) { return diff --git a/apps/desktop/src/app/chat/right-rail/preview-console.tsx b/apps/desktop/src/app/chat/right-rail/preview-console.tsx index 67df7fefc2f9..35001ab0029a 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-console.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-console.tsx @@ -166,6 +166,7 @@ export function PreviewConsolePanel({ const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs const stickScrollRafRef = useRef(null) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!consoleShouldStickRef.current) { return diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 13a20f61d623..a4eab020f696 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -591,6 +591,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar const filePath = filePathForTarget(target) const isImage = target.previewKind === 'image' + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { setUserMode(null) setEditing(false) diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx index f240c4b20670..9c8e4caf9ca7 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.tsx @@ -319,6 +319,7 @@ export function PreviewPane({ return () => setTitlebarToolGroup(TITLEBAR_GROUP_ID, []) }, [consoleOpen, consoleState, copy, devtoolsOpen, isWebPreview, setTitlebarToolGroup, toggleDevTools]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!consoleOpen) { return @@ -334,6 +335,7 @@ export function PreviewPane({ return () => window.cancelAnimationFrame(handle) }, [consoleOpen]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if ( !previewServerRestart || @@ -392,6 +394,7 @@ export function PreviewPane({ return () => window.clearTimeout(timer) }, [copy.stillWorking, previewServerRestart, restartingServer]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (reloadRequest === lastReloadRequestRef.current) { return @@ -498,6 +501,7 @@ export function PreviewPane({ } }, [appendConsoleEntry, copy, reloadPreview, target.kind, target.url]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const host = hostRef.current diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 6562880b58f5..410cf87e2e42 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -253,6 +253,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } // session.resume before the gateway is OPEN. Persisted tiles mount at boot // while it's still connecting — an ungated resume rejected there and // latched every restored tile into the error card. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!gatewayOpen || runtimeId || tile?.error || resumingRef.current) { return diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index a5d2b9e3eb42..ef39582e96aa 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -718,6 +718,7 @@ export function ChatSidebar({ // only the cheap per-repo `git worktree list`, never the heavy tree scan. const prevWorkingIdsRef = useRef(workingSessionIds) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const prev = prevWorkingIdsRef.current prevWorkingIdsRef.current = workingSessionIds @@ -754,6 +755,7 @@ export function ChatSidebar({ [currentCwd] ) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!inProject || !enteredProject) { lastProjectCwdSyncRef.current = null diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 52fa8de19c0a..e57fec120795 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -213,6 +213,7 @@ export function ProfileRail() { const createRequest = useStore($profileCreateRequest) const lastCreateRef = useRef(createRequest) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (createRequest === lastCreateRef.current) { return diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index 3c3bbbddd7fa..ed22e2cc7ebe 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -87,6 +87,7 @@ export function useDesktopIntegrations({ // Restore once on cold start — only when the renderer booted at the default // route (a hidden-then-shown window keeps its own route). Prefer the full // remembered route (covers pages); fall back to the last session id. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (restoredRef.current || locationPathname !== NEW_CHAT_ROUTE) { restoredRef.current = true diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 90b8569b19f2..60536b2267fb 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -450,6 +450,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const freshSessionRequest = useStore($freshSessionRequest) const lastFreshRef = useRef(freshSessionRequest) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (freshSessionRequest === lastFreshRef.current) { return @@ -464,6 +465,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { // invalidateQueries on swap doesn't touch them). const lastGatewayProfileRef = useRef(activeGatewayProfile) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (activeGatewayProfile === lastGatewayProfileRef.current) { return @@ -500,6 +502,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const startWorkSessionRequest = useStore($startWorkSessionRequest) const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) { return diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index 726b11ad9fbc..75e0020a7935 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -327,6 +327,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt // Sidebar → "open this job": resolve the focus id (or name) to a job, select // it, queue a scroll, then clear the one-shot focus so re-opening cron // normally doesn't re-trigger it. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!focusJobId) { return @@ -355,6 +356,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt ) // Scroll a sidebar-opened job into view once its list row is mounted. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const target = pendingScrollRef.current diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 72bd08c98b51..76c98c17a230 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -22,6 +22,7 @@ export function useGatewayRequest() { // message instead of the opaque "connection closed" that triggered the retry. const reauthErrorRef = useRef(null) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { gatewayStateRef.current = gatewayState }, [gatewayState]) diff --git a/apps/desktop/src/app/hooks/use-on-profile-switch.ts b/apps/desktop/src/app/hooks/use-on-profile-switch.ts index 04662a8be2ae..dfdfd84f2c63 100644 --- a/apps/desktop/src/app/hooks/use-on-profile-switch.ts +++ b/apps/desktop/src/app/hooks/use-on-profile-switch.ts @@ -10,6 +10,7 @@ export function useOnProfileSwitch(onSwitch: () => void): void { const profile = useStore($activeGatewayProfile) const first = useRef(true) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (first.current) { first.current = false diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index d635186e34ed..875ef0ca01fb 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -87,6 +87,7 @@ export function PetOverlayApp() { } // Mirror pushed state into the shared atoms so PetSprite/PetBubble just work. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const off = window.hermesDesktop?.petOverlay?.onState(payload => { setPetInfo(payload.info) @@ -185,6 +186,7 @@ export function PetOverlayApp() { // input keeps focus); focus it on open. The overlay is a non-activating panel // (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while // the composer needs the keyboard, then back to non-activating when it closes. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { composerOpenRef.current = composerOpen @@ -317,6 +319,7 @@ export function PetOverlayApp() { // wheel anchor we zoom toward the cursor (keep the pixel under it fixed); // otherwise we anchor the bottom-center (the pet's feet stay planted). New // bounds are persisted so the pet reopens at the right size. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!info.enabled || !info.spritesheetBase64) { return diff --git a/apps/desktop/src/app/profiles/index.tsx b/apps/desktop/src/app/profiles/index.tsx index 8f777b046ed7..b04d9c6c32b9 100644 --- a/apps/desktop/src/app/profiles/index.tsx +++ b/apps/desktop/src/app/profiles/index.tsx @@ -388,6 +388,7 @@ function SoulEditor({ profileName }: { profileName: string }) { const [error, setError] = useState(null) const requestRef = useRef(profileName) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { requestRef.current = profileName setLoading(true) diff --git a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx index 6c2dccb4bb7e..9d753c122866 100644 --- a/apps/desktop/src/app/right-sidebar/review/file-tree.tsx +++ b/apps/desktop/src/app/right-sidebar/review/file-tree.tsx @@ -105,6 +105,7 @@ export function ReviewFileTree() { const [animate, setAnimate] = useState(false) const armed = useRef(false) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!open) { armed.current = false @@ -112,6 +113,7 @@ export function ReviewFileTree() { } }, [open]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (open && !loading && !armed.current) { armed.current = true diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts index bb6aaeaff327..cec977c5d4de 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -28,6 +28,7 @@ export function useAgentTerminal({ active, id, procId }: { active: boolean; id: return { ...terminalTheme(renderedMode, ansi), background: surface, cursorAccent: surface } } + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const host = hostRef.current diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index c1b09487c6fa..34c800926ce3 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -421,6 +421,7 @@ export function useTerminalSession({ const [selectionStyle, setSelectionStyle] = useState(null) const [shellName, setShellName] = useState('shell') + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { onAddSelectionToChatRef.current = onAddSelectionToChat onShellRef.current = onShell @@ -478,6 +479,7 @@ export function useTerminalSession({ return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) }, [addSelectionToChat, readSelection]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const host = hostRef.current const terminalApi = window.hermesDesktop?.terminal @@ -974,6 +976,7 @@ export function useTerminalSession({ // the subscribe fires immediately, so a command set before this pane mounted // runs as soon as the session is ready. Cleared after writing so a later // remount can't replay a stale command. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!active || status !== 'open') { return diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts index 6f54453c71cd..5adf910bdb7f 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts @@ -52,6 +52,7 @@ export function useBackgroundQueueDrain({ const retryTimersRef = useRef([]) const [retryTick, setRetryTick] = useState(0) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { submitTextRef.current = submitText }, [submitText]) diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index 588f614a533f..ed12a91da421 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -96,6 +96,7 @@ export function useRouteResume({ // never touches this latch, so it can't spuriously trigger the reset). const prevResumeExhaustedRef = useRef(null) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const gatewayOpen = gatewayState === 'open' const pathnameChanged = lastPathnameRef.current !== locationPathname @@ -187,6 +188,7 @@ export function useRouteResume({ // again. resumeSession clears resumeFailedSessionId on its next attempt; a // success keeps it clear (the effect's guard then no-ops), a repeat failure // re-arms it and we back off further, capped at MAX_RESUME_RETRIES. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { // Detect the exhausted-latch armed->cleared edge for the current route. Only // resumeSession clears $resumeExhaustedSessionId (manual Retry / reconnect / diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 38cf05920898..2ef5dfe9aadf 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -225,6 +225,7 @@ export function useSessionActions({ // by A's delayed session.info event and visibly jump back to A. const storedIdRotation = useStore($activeSessionStoredIdRotation) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!storedIdRotation) { return diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 25521257c5bb..1b55e9c69156 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -87,6 +87,7 @@ export function useSessionStateCache({ // flush below tell a same-session refresh from a thread switch. const viewSessionIdRef = useRef(null) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { setMutableRef(busyRef, busy) }, [busy, busyRef]) diff --git a/apps/desktop/src/app/settings/computer-use-panel.tsx b/apps/desktop/src/app/settings/computer-use-panel.tsx index bf30b3b83f7b..2423df8aef54 100644 --- a/apps/desktop/src/app/settings/computer-use-panel.tsx +++ b/apps/desktop/src/app/settings/computer-use-panel.tsx @@ -76,6 +76,7 @@ export function ComputerUsePanel({ onConfiguredChange }: ComputerUsePanelProps) } }, []) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { activeRef.current = true void refresh() diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 18fa33d744aa..5625339bbf50 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -84,6 +84,7 @@ export function ConfigSettings({ // Background refetches thereafter must not clobber in-progress edits. const configSeeded = useRef(false) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (loadedConfig && !configSeeded.current) { configSeeded.current = true diff --git a/apps/desktop/src/app/settings/fallback-models-field.tsx b/apps/desktop/src/app/settings/fallback-models-field.tsx index bbeedf9b6020..d7aa7839dfe5 100644 --- a/apps/desktop/src/app/settings/fallback-models-field.tsx +++ b/apps/desktop/src/app/settings/fallback-models-field.tsx @@ -88,6 +88,7 @@ export function FallbackModelsField({ // Resync on real external changes (profile switch / config reload). Skip // when `value` is just our own commit echoing through the parent. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const persisted = normalizeEntries(value) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 85f85623f800..9cc9838b9d94 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -353,6 +353,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // setState updater) and hand it straight to the debounced autosave. const moaRef = useRef(null) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { moaRef.current = moa }, [moa]) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index f3cee6d3b4dc..6d81a5136bbc 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -243,6 +243,7 @@ function PostSetupRunner({ toolset, postSetupKey, installed = false, onComplete // Guard against overlapping polls / state updates after unmount. const activeRef = useRef(false) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { return () => { activeRef.current = false diff --git a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts index 8a49d3b983ca..bb92cfd10a99 100644 --- a/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts +++ b/apps/desktop/src/app/shell/hooks/use-overlay-routing.ts @@ -32,6 +32,7 @@ export function useOverlayRouting() { // so closing them returns there instead of bouncing to /. const returnPathRef = useRef(NEW_CHAT_ROUTE) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!overlayOpen) { returnPathRef.current = `${location.pathname}${location.search}${location.hash}` diff --git a/apps/desktop/src/app/skills/mcp-tab.tsx b/apps/desktop/src/app/skills/mcp-tab.tsx index 522261b0e104..cc7c335f3cd5 100644 --- a/apps/desktop/src/app/skills/mcp-tab.tsx +++ b/apps/desktop/src/app/skills/mcp-tab.tsx @@ -463,6 +463,7 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) { // in-progress edit — the draft is the user's until they save or reset. const draftSeeded = useRef(false) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { // profilePending: config still holds the PREVIOUS profile's record right // after a switch — seeding from it would latch the wrong profile's doc. @@ -525,6 +526,7 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) { // on a fresh success, errorUpdatedAt on a fresh failure. Releasing on error too // means a failed refetch surfaces the retry UI instead of leaving mutations // silently no-op forever. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if ( profilePending && diff --git a/apps/desktop/src/app/starmap/star-map.tsx b/apps/desktop/src/app/starmap/star-map.tsx index f16cc9db892b..2a23bc2ef5b8 100644 --- a/apps/desktop/src/app/starmap/star-map.tsx +++ b/apps/desktop/src/app/starmap/star-map.tsx @@ -264,6 +264,7 @@ export function StarMap({ }, []) // (Re)build the radial simulation whenever the graph or size changes. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { sizeRef.current = size @@ -299,6 +300,7 @@ export function StarMap({ } }, [graph, invalidate, resetFades, size]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { adjacencyRef.current = adjacency memByIdRef.current = memById @@ -311,12 +313,14 @@ export function StarMap({ document.fonts?.load('1em "JetBrains Mono"').then(invalidate, () => {}) }, [invalidate]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { selectedIdRef.current = selectedId invalidate() }, [invalidate, selectedId]) // A fresh graph resets the scrubber to "fully built" (the idle default). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { camRadiusRef.current = RING_OUTER snapMotionRef.current = false @@ -362,6 +366,7 @@ export function StarMap({ ) // Playback: sweep reveal 0 → 1 over SWEEP_MS, then stop (play once). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!playing) { return @@ -484,6 +489,7 @@ export function StarMap({ // Repaint + repalette when the theme/mode repaints (the shared observer fires // after applyTheme rewrites the class + inline vars on ). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { themeDirtyRef.current = true invalidate() @@ -493,6 +499,7 @@ export function StarMap({ // the window is focused — but each frame is cheap (live scramble + a blit of the // cached static layer). The expensive scene only re-renders when invalidate() // marks it dirty. Capped to ~30fps; interaction (force) bypasses the cap. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { let raf = 0 const ANIM_MS = 1000 / 30 @@ -674,6 +681,7 @@ export function StarMap({ }, []) // Size the backing canvas (DPR-aware). + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { sizeRef.current = size dprRef.current = Math.min(2, window.devicePixelRatio || 1) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 9015e9eee66e..b29e13493ef5 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -158,6 +158,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess [aui, rememberInitialDraft] ) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { draftRef.current = draft diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index 533dc5b373cf..afb27fb02f3b 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -40,6 +40,7 @@ export function useElapsedSeconds(active = true, timerKey?: string): number { lastKey.current = timerKey } + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (!active) { return diff --git a/apps/desktop/src/components/chat/code-editor.tsx b/apps/desktop/src/components/chat/code-editor.tsx index 0059ed621632..c38a9c60b8a8 100644 --- a/apps/desktop/src/components/chat/code-editor.tsx +++ b/apps/desktop/src/components/chat/code-editor.tsx @@ -209,6 +209,7 @@ export function CodeEditor({ onSaveRef.current = onSave formatJsonRef.current = formatJson + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const host = hostRef.current diff --git a/apps/desktop/src/components/chat/image-generation-placeholder.tsx b/apps/desktop/src/components/chat/image-generation-placeholder.tsx index 26806847311e..3f0e5a16c6ae 100644 --- a/apps/desktop/src/components/chat/image-generation-placeholder.tsx +++ b/apps/desktop/src/components/chat/image-generation-placeholder.tsx @@ -266,6 +266,7 @@ export const DiffusionCanvas: FC = () => { useResizeObserver(fitToContainer, canvasRef) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const probe = document.createElement('span') probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none' @@ -287,6 +288,7 @@ export const DiffusionCanvas: FC = () => { } }, []) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const canvas = canvasRef.current const ctx = canvas?.getContext('2d') diff --git a/apps/desktop/src/components/chat/preview-attachment.tsx b/apps/desktop/src/components/chat/preview-attachment.tsx index 9cc90dff53e3..a9e02a3d3e45 100644 --- a/apps/desktop/src/components/chat/preview-attachment.tsx +++ b/apps/desktop/src/components/chat/preview-attachment.tsx @@ -31,6 +31,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev cwdRef.current = cwd targetRef.current = target + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { mountedRef.current = true @@ -40,6 +41,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev } }, []) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { requestTokenRef.current += 1 setOpening(false) diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx index 3e38afeb3efa..4d4a3c1058fd 100644 --- a/apps/desktop/src/components/notifications.tsx +++ b/apps/desktop/src/components/notifications.tsx @@ -58,6 +58,7 @@ export function NotificationStack() { } }, [defaultStack.length]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const latest = notifications[0] diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 399ed1bf4857..4a14991bf39c 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -247,6 +247,7 @@ export function FloatingPet() { // Restore a popped-out pet on boot, once the pet has loaded (so we never spawn // an empty overlay window). Primary window only; runs at most once. const restoredRef = useRef(false) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { if (isSecondaryWindow() || restoredRef.current || !active) { return diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index b3a0fb66003b..a1e91f3ece10 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -121,10 +121,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite const rowOverrideRef = useRef(rowOverride) // Keep the override current without re-running the RAF setup effect. + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { overrideRef.current = stateOverride }, [stateOverride]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { rowOverrideRef.current = rowOverride }, [rowOverride]) @@ -152,6 +154,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite return img }, [info.spritesheetBase64, info.mime]) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { const canvas = canvasRef.current diff --git a/apps/desktop/src/i18n/context.tsx b/apps/desktop/src/i18n/context.tsx index 67f335826192..87c6337652d1 100644 --- a/apps/desktop/src/i18n/context.tsx +++ b/apps/desktop/src/i18n/context.tsx @@ -100,6 +100,7 @@ export function I18nProvider({ children, configClient = defaultConfigClient, ini const [saveError, setSaveError] = useState(null) const localeRef = useRef(locale) + // eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment) useEffect(() => { localeRef.current = locale setRuntimeI18nLocale(locale)