From ddae50663174fbcd1ab78cabbf012002a9af3054 Mon Sep 17 00:00:00 2001 From: suryo12 Date: Sun, 24 May 2026 16:41:47 +0700 Subject: [PATCH 1/2] refactor(web): replace slideout panel window event with jotai atom (fixes #1358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `SLIDEOUT_PANEL_OPENED_EVENT` window event with a `slideoutOpenedTickAtom` jotai atom. The dispatcher in `SidebarSlideOutPanel` now bumps the tick via `useSetAtom`, and the listener in `Thread` reads it via `useAtomValue` and reacts on change behind a ref guard that skips the initial render — preserving the one-shot-per-open semantics of the previous event. This removes the implicit cross-module string contract, makes the signal traceable through React DevTools / jotai inspector, and lets TypeScript catch typos that the string-based event API silently swallowed. --- .../components/assistant-ui/thread.tsx | 21 +++++++++++-------- .../ui/sidebar/SidebarSlideOutPanel.tsx | 8 ++++--- surfsense_web/lib/layout-events.ts | 10 ++++++++- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 6876ce23e..24f070b4e 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -105,7 +105,7 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; -import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; +import { slideoutOpenedTickAtom } from "@/lib/layout-events"; import { cn } from "@/lib/utils"; const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; @@ -478,15 +478,18 @@ const Composer: FC = () => { editorRef.current?.focus(); }, [isDesktop, showDocumentPopover, showPromptPicker, threadId]); - // Close document picker when a slide-out panel (inbox, etc.) opens. + // Close document picker when a sidebar slide-out panel (inbox, etc.) opens. + const slideoutOpenedTick = useAtomValue(slideoutOpenedTickAtom); + const isFirstSlideoutTickRef = useRef(true); useEffect(() => { - const handler = () => { - setShowDocumentPopover(false); - setMentionQuery(""); - }; - window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); - return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); - }, []); + void slideoutOpenedTick; + if (isFirstSlideoutTickRef.current) { + isFirstSlideoutTickRef.current = false; + return; + } + setShowDocumentPopover(false); + setMentionQuery(""); + }, [slideoutOpenedTick]); // Sync editor text into assistant-ui's composer and mirror the chip // atom from the editor's reported ``docs``. The editor is the diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx index 3fa4dd5d3..52b2cf998 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx @@ -1,9 +1,10 @@ "use client"; +import { useSetAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; -import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; +import { slideoutOpenedTickAtom } from "@/lib/layout-events"; interface SidebarSlideOutPanelProps { open: boolean; @@ -29,12 +30,13 @@ export function SidebarSlideOutPanel({ children, }: SidebarSlideOutPanelProps) { const isMobile = useIsMobile(); + const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom); useEffect(() => { if (open) { - window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT)); + bumpSlideoutOpenedTick((tick) => tick + 1); } - }, [open]); + }, [open, bumpSlideoutOpenedTick]); const handleEscape = useCallback( (e: KeyboardEvent) => { diff --git a/surfsense_web/lib/layout-events.ts b/surfsense_web/lib/layout-events.ts index 45c52f7a4..27ea8de39 100644 --- a/surfsense_web/lib/layout-events.ts +++ b/surfsense_web/lib/layout-events.ts @@ -1 +1,9 @@ -export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened"; +import { atom } from "jotai"; + +/** + * Tick counter that increments each time a sidebar slide-out panel opens. + * Consumers read this with `useAtomValue` and react to it changing — guard + * the initial render with a ref so the effect only fires on subsequent + * opens, matching the one-shot semantics of the previous window event. + */ +export const slideoutOpenedTickAtom = atom(0); From d571cb23fad78057bae89903a6ebdff378ad932a Mon Sep 17 00:00:00 2001 From: suryo12 Date: Sun, 24 May 2026 18:13:36 +0700 Subject: [PATCH 2/2] refactor(web): use last-seen-tick comparison for slideout listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the boolean "skip first render" ref with a ref that stores the previously-seen tick value. The effect now compares against the stored value and only fires when it differs, which makes the dependency naturally used (removes the `void slideoutOpenedTick;` acknowledgement) and self-documents the intent of the guard. Behavior is unchanged — both forms preserve the one-shot-per-event semantics of the prior window-event implementation. The JSDoc on `slideoutOpenedTickAtom` is updated to describe the new pattern. --- surfsense_web/components/assistant-ui/thread.tsx | 12 ++++++------ surfsense_web/lib/layout-events.ts | 8 +++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 24f070b4e..c4f6fed05 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -479,14 +479,14 @@ const Composer: FC = () => { }, [isDesktop, showDocumentPopover, showPromptPicker, threadId]); // Close document picker when a sidebar slide-out panel (inbox, etc.) opens. + // React only on changes to the tick — comparing against the previously-seen + // value preserves the one-shot semantics of the prior window-event approach + // (no retroactive close on mount if a panel had already opened earlier). const slideoutOpenedTick = useAtomValue(slideoutOpenedTickAtom); - const isFirstSlideoutTickRef = useRef(true); + const lastSeenSlideoutTickRef = useRef(slideoutOpenedTick); useEffect(() => { - void slideoutOpenedTick; - if (isFirstSlideoutTickRef.current) { - isFirstSlideoutTickRef.current = false; - return; - } + if (lastSeenSlideoutTickRef.current === slideoutOpenedTick) return; + lastSeenSlideoutTickRef.current = slideoutOpenedTick; setShowDocumentPopover(false); setMentionQuery(""); }, [slideoutOpenedTick]); diff --git a/surfsense_web/lib/layout-events.ts b/surfsense_web/lib/layout-events.ts index 27ea8de39..755329c41 100644 --- a/surfsense_web/lib/layout-events.ts +++ b/surfsense_web/lib/layout-events.ts @@ -2,8 +2,10 @@ import { atom } from "jotai"; /** * Tick counter that increments each time a sidebar slide-out panel opens. - * Consumers read this with `useAtomValue` and react to it changing — guard - * the initial render with a ref so the effect only fires on subsequent - * opens, matching the one-shot semantics of the previous window event. + * Consumers read this with `useAtomValue` and store the last-seen value in + * a ref so the effect fires only when the tick changes. This preserves the + * one-shot semantics of the previous window-event implementation: a + * subscriber that mounts after a panel has already opened does not + * retroactively re-trigger. */ export const slideoutOpenedTickAtom = atom(0);