Add AI dock drawer#978
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughDockable AI chat added with persisted, resizable width; search/chat panels made surface-aware (modal/dock); SearchContext gains AI-dock lifecycle and hover-close scheduling; navbar integrates an icon-only AiDockButton and lazy-loads the dock. ChangesAI Dock Feature with Resizable Panel
sequenceDiagram
participant User
participant AiDockButton
participant SearchContext
participant Navbar
participant AiDock
participant localStorage
User->>AiDockButton: hover or click
AiDockButton->>SearchContext: openAiDock()
SearchContext-->>Navbar: isAiDockOpen = true
Navbar->>AiDock: AiDockMount mounts LazyAiDock
AiDock->>localStorage: readDockWidth()
localStorage-->>AiDock: persisted width or fallback
User->>AiDock: resize handle drag
AiDock->>localStorage: writeDockWidth() on release
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/components/SearchButton.tsx`:
- Around line 58-106: The button currently tracks hover-open with a local
openedByHoverRef which the AiDock close logic can't see; move the "opened by
hover" mode into the shared search context so hover-close only applies to
hover-opened sessions. Add a boolean state and updater in the search context
(e.g., aiDockOpenedByHover + setAiDockOpenedByHover or an openAiDock(mode:
'hover'|'explicit') API) and update useSearchContext to expose it. In
AiDockButton (replace openedByHoverRef usage) call the new context updater when
opening via hover and clear it on explicit click; continue using
cancelAiDockHoverClose/scheduleAiDockHoverClose but change
scheduleAiDockHoverClose/close logic inside the AiDock component to check the
shared aiDockOpenedByHover flag (or mode) before scheduling/performing
hover-close. Update closeAiDock to clear the hover flag as well.
In `@src/components/SearchModal.tsx`:
- Around line 2928-3186: Split AiDock into its own client-only component file:
create a new component (e.g., AiDock.tsx) and move the AiDock function and only
the helper symbols it directly needs (useSearchContext, readAiDockWidth,
writeAiDockWidth, clampAiDockWidth, getAiDockMaxWidth,
AI_DOCK_MIN_WIDTH/AI_DOCK_MAXIMIZED_WIDTH/constants, and any local hooks/refs)
into that file; keep references to InstantSearch, SearchFiltersProvider,
DynamicFilters, SearchPanel, searchClient, and searchIndexName in the new file
so the component can render independently. In SearchModal.tsx remove the AiDock
implementation and replace usages with an import from the new client file (or a
lazy/dynamic client-only import), ensuring SearchModal no longer imports heavy
dependencies required only by AiDock; keep all other SearchModal internals
unchanged. Ensure exported symbol name remains AiDock and preserve prop/closure
usage of newChatRequestId, isAiDockOpen, cancelAiDockHoverClose,
scheduleAiDockHoverClose, and any state logic that AiDock needs from
useSearchContext so behavior is identical.
- Around line 3123-3183: The off-screen dock remains keyboard-focusable because
only aria-hidden and pointer-events-none are applied; toggle the inert state
when closed so the subtree is removed from the tab order. Modify the mounted
container referenced by contentRef (the div with ref={contentRef}) to set/remove
the inert attribute when isAiDockOpen changes (e.g., in a useEffect:
contentRef.current.inert = !isAiDockOpen), and keep aria-hidden as-is; if you
need to support browsers without native inert, include the inert polyfill or
fallback to programmatically set tabIndex=-1 on interactive descendants inside
the container. Ensure this logic targets the same contentRef/aside subtree so
the search input and buttons become unfocusable when closed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 785dcf10-bb96-455a-bf67-1be391fc0ea7
📒 Files selected for processing (8)
src/components/AppDevtools.tsxsrc/components/Dropdown.tsxsrc/components/Navbar.tsxsrc/components/SearchButton.tsxsrc/components/SearchModal.tsxsrc/components/ThemeToggle.tsxsrc/contexts/SearchContext.tsxsrc/styles/app.css
| export function AiDockButton({ className }: { className?: string }) { | ||
| const { | ||
| cancelAiDockHoverClose, | ||
| closeAiDock, | ||
| isAiDockDirty, | ||
| isAiDockOpen, | ||
| openAiDock, | ||
| scheduleAiDockHoverClose, | ||
| } = useSearchContext() | ||
| const openedByHoverRef = React.useRef(false) | ||
|
|
||
| const handlePointerEnter = (event: React.PointerEvent<HTMLButtonElement>) => { | ||
| if (event.pointerType === 'touch') { | ||
| return | ||
| } | ||
|
|
||
| cancelAiDockHoverClose() | ||
|
|
||
| if (isAiDockDirty && !isAiDockOpen) { | ||
| openedByHoverRef.current = true | ||
| openAiDock() | ||
| } | ||
| } | ||
|
|
||
| const handlePointerLeave = (event: React.PointerEvent<HTMLButtonElement>) => { | ||
| if (event.pointerType === 'touch') { | ||
| return | ||
| } | ||
|
|
||
| openedByHoverRef.current = false | ||
|
|
||
| if (isAiDockDirty) { | ||
| scheduleAiDockHoverClose() | ||
| } | ||
| } | ||
|
|
||
| const handleClick = () => { | ||
| if (openedByHoverRef.current) { | ||
| openedByHoverRef.current = false | ||
| return | ||
| } | ||
|
|
||
| if (isAiDockOpen) { | ||
| closeAiDock() | ||
| return | ||
| } | ||
|
|
||
| openAiDock() | ||
| } |
There was a problem hiding this comment.
Track hover-open vs explicit-open separately.
Right now the button only keeps openedByHoverRef locally, while AiDock always schedules a close on panel leave. That means a dock opened intentionally with a click is still treated like a hover session and collapses as soon as the pointer leaves the panel, which breaks the “persistent dock drawer” behavior in this PR. The open mode needs to be shared with the dock close logic so hover-close only applies to hover-opened sessions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/SearchButton.tsx` around lines 58 - 106, The button currently
tracks hover-open with a local openedByHoverRef which the AiDock close logic
can't see; move the "opened by hover" mode into the shared search context so
hover-close only applies to hover-opened sessions. Add a boolean state and
updater in the search context (e.g., aiDockOpenedByHover +
setAiDockOpenedByHover or an openAiDock(mode: 'hover'|'explicit') API) and
update useSearchContext to expose it. In AiDockButton (replace openedByHoverRef
usage) call the new context updater when opening via hover and clear it on
explicit click; continue using cancelAiDockHoverClose/scheduleAiDockHoverClose
but change scheduleAiDockHoverClose/close logic inside the AiDock component to
check the shared aiDockOpenedByHover flag (or mode) before scheduling/performing
hover-close. Update closeAiDock to clear the hover flag as well.
| export function AiDock() { | ||
| const { | ||
| cancelAiDockHoverClose, | ||
| isAiDockOpen, | ||
| newChatRequestId, | ||
| scheduleAiDockHoverClose, | ||
| } = useSearchContext() | ||
| const [hasActivated, setHasActivated] = React.useState(isAiDockOpen) | ||
| const [isDockVisible, setIsDockVisible] = React.useState(false) | ||
| const [isDockMaximized, setIsDockMaximized] = React.useState(false) | ||
| const [dockWidth, setDockWidth] = React.useState(readAiDockWidth) | ||
| const [viewportWidth, setViewportWidth] = React.useState(() => | ||
| typeof window === 'undefined' | ||
| ? AI_DOCK_DEFAULT_WIDTH / AI_DOCK_MAX_WIDTH_RATIO | ||
| : window.innerWidth, | ||
| ) | ||
| const [isResizingDock, setIsResizingDock] = React.useState(false) | ||
| const contentRef = React.useRef<HTMLDivElement>(null) | ||
| const isResizingDockRef = React.useRef(false) | ||
| const displayedDockWidth = clampAiDockWidth(dockWidth, viewportWidth) | ||
| const dockMaxWidth = getAiDockMaxWidth(viewportWidth) | ||
| const dockStyle: AiDockStyle = { | ||
| '--ai-dock-width': `${displayedDockWidth}px`, | ||
| '--ai-dock-max-width': `${AI_DOCK_MAXIMIZED_WIDTH}px`, | ||
| } | ||
|
|
||
| React.useEffect(() => { | ||
| if (!isAiDockOpen) { | ||
| setIsDockVisible(false) | ||
| return | ||
| } | ||
|
|
||
| setHasActivated(true) | ||
|
|
||
| let enterFrame = 0 | ||
| const mountFrame = requestAnimationFrame(() => { | ||
| enterFrame = requestAnimationFrame(() => { | ||
| setIsDockVisible(true) | ||
| }) | ||
| }) | ||
|
|
||
| return () => { | ||
| cancelAnimationFrame(mountFrame) | ||
| cancelAnimationFrame(enterFrame) | ||
| } | ||
| }, [isAiDockOpen]) | ||
|
|
||
| React.useEffect(() => { | ||
| if (typeof window === 'undefined') { | ||
| return | ||
| } | ||
|
|
||
| const handleResize = () => { | ||
| setViewportWidth(window.innerWidth) | ||
| } | ||
|
|
||
| handleResize() | ||
| window.addEventListener('resize', handleResize) | ||
|
|
||
| return () => { | ||
| window.removeEventListener('resize', handleResize) | ||
| } | ||
| }, []) | ||
|
|
||
| React.useEffect(() => { | ||
| if (!isAiDockOpen || !isDockVisible) { | ||
| return | ||
| } | ||
|
|
||
| const frame = requestAnimationFrame(() => { | ||
| contentRef.current | ||
| ?.querySelector<HTMLInputElement>('input[type="search"]') | ||
| ?.focus({ preventScroll: true }) | ||
| }) | ||
|
|
||
| return () => cancelAnimationFrame(frame) | ||
| }, [isAiDockOpen, isDockVisible]) | ||
|
|
||
| const toggleDockMaximized = React.useCallback(() => { | ||
| setIsDockMaximized((current) => !current) | ||
| }, []) | ||
|
|
||
| const handleResizePointerDown = React.useCallback( | ||
| (event: React.PointerEvent<HTMLDivElement>) => { | ||
| if (isDockMaximized || typeof window === 'undefined') { | ||
| return | ||
| } | ||
|
|
||
| event.preventDefault() | ||
| event.stopPropagation() | ||
| cancelAiDockHoverClose() | ||
|
|
||
| const startX = event.clientX | ||
| const startWidth = displayedDockWidth | ||
| let nextWidth = startWidth | ||
| const previousCursor = document.body.style.cursor | ||
| const previousUserSelect = document.body.style.userSelect | ||
|
|
||
| isResizingDockRef.current = true | ||
| setIsResizingDock(true) | ||
| document.body.style.cursor = 'ew-resize' | ||
| document.body.style.userSelect = 'none' | ||
|
|
||
| const stopResizing = () => { | ||
| window.removeEventListener('pointermove', handlePointerMove) | ||
| window.removeEventListener('pointerup', stopResizing) | ||
| window.removeEventListener('pointercancel', stopResizing) | ||
| document.body.style.cursor = previousCursor | ||
| document.body.style.userSelect = previousUserSelect | ||
| isResizingDockRef.current = false | ||
| setIsResizingDock(false) | ||
| writeAiDockWidth(nextWidth) | ||
| } | ||
|
|
||
| const handlePointerMove = (moveEvent: PointerEvent) => { | ||
| const currentViewportWidth = window.innerWidth | ||
| nextWidth = clampAiDockWidth( | ||
| startWidth + startX - moveEvent.clientX, | ||
| currentViewportWidth, | ||
| ) | ||
| setViewportWidth(currentViewportWidth) | ||
| setDockWidth(nextWidth) | ||
| } | ||
|
|
||
| window.addEventListener('pointermove', handlePointerMove) | ||
| window.addEventListener('pointerup', stopResizing) | ||
| window.addEventListener('pointercancel', stopResizing) | ||
| }, | ||
| [cancelAiDockHoverClose, displayedDockWidth, isDockMaximized], | ||
| ) | ||
|
|
||
| const handleResizeKeyDown = React.useCallback( | ||
| (event: React.KeyboardEvent<HTMLDivElement>) => { | ||
| if (isDockMaximized) { | ||
| return | ||
| } | ||
|
|
||
| const step = event.shiftKey ? 48 : 16 | ||
| let nextWidth: number | null = null | ||
|
|
||
| if (event.key === 'ArrowLeft') { | ||
| nextWidth = displayedDockWidth + step | ||
| } else if (event.key === 'ArrowRight') { | ||
| nextWidth = displayedDockWidth - step | ||
| } else if (event.key === 'Home') { | ||
| nextWidth = AI_DOCK_MIN_WIDTH | ||
| } else if (event.key === 'End') { | ||
| nextWidth = dockMaxWidth | ||
| } | ||
|
|
||
| if (nextWidth === null) { | ||
| return | ||
| } | ||
|
|
||
| event.preventDefault() | ||
|
|
||
| const clampedWidth = clampAiDockWidth(nextWidth, viewportWidth) | ||
| setDockWidth(clampedWidth) | ||
| writeAiDockWidth(clampedWidth) | ||
| }, | ||
| [displayedDockWidth, dockMaxWidth, isDockMaximized, viewportWidth], | ||
| ) | ||
|
|
||
| const handlePointerEnter = (event: React.PointerEvent<HTMLElement>) => { | ||
| if (event.pointerType === 'touch') { | ||
| return | ||
| } | ||
|
|
||
| cancelAiDockHoverClose() | ||
| } | ||
|
|
||
| const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => { | ||
| if (event.pointerType === 'touch') { | ||
| return | ||
| } | ||
|
|
||
| if (isResizingDockRef.current) { | ||
| return | ||
| } | ||
|
|
||
| if ( | ||
| event.relatedTarget instanceof Element && | ||
| event.relatedTarget.closest('.dropdown-content') | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| scheduleAiDockHoverClose() | ||
| } | ||
|
|
||
| if (!hasActivated) { | ||
| return null | ||
| } | ||
|
|
||
| return ( | ||
| <div | ||
| ref={contentRef} | ||
| aria-label="TanStack AI" | ||
| aria-hidden={!isAiDockOpen} | ||
| style={dockStyle} | ||
| className={twMerge( | ||
| 'fixed top-[var(--navbar-height)] right-0 bottom-0 z-[1000] w-full max-w-full pointer-events-none', | ||
| isDockMaximized | ||
| ? 'sm:w-[min(var(--ai-dock-max-width),100vw)]' | ||
| : 'sm:w-[var(--ai-dock-width)]', | ||
| !isResizingDock && 'transition-[width] duration-300 ease-out', | ||
| )} | ||
| > | ||
| <aside | ||
| onPointerEnter={handlePointerEnter} | ||
| onPointerLeave={handlePointerLeave} | ||
| className={twMerge( | ||
| 'pointer-events-auto absolute right-0 top-0 h-full w-full max-w-full text-left outline-none transition-[transform,translate,opacity] duration-300 ease-out', | ||
| isDockVisible | ||
| ? 'translate-x-0 opacity-100' | ||
| : 'translate-x-full opacity-0 pointer-events-none', | ||
| )} | ||
| > | ||
| {!isDockMaximized ? ( | ||
| <div | ||
| role="separator" | ||
| aria-label="Resize AI panel" | ||
| aria-orientation="vertical" | ||
| aria-valuemin={AI_DOCK_MIN_WIDTH} | ||
| aria-valuemax={dockMaxWidth} | ||
| aria-valuenow={displayedDockWidth} | ||
| tabIndex={0} | ||
| onPointerDown={handleResizePointerDown} | ||
| onKeyDown={handleResizeKeyDown} | ||
| className="group/resize absolute left-0 top-0 z-40 hidden h-full w-3 -translate-x-1.5 cursor-ew-resize touch-none outline-none sm:block" | ||
| > | ||
| <div | ||
| className={twMerge( | ||
| 'absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-transparent transition-colors', | ||
| isResizingDock | ||
| ? 'bg-cyan-500/70' | ||
| : 'group-hover/resize:bg-cyan-500/50 group-focus/resize:bg-cyan-500/60', | ||
| )} | ||
| /> | ||
| </div> | ||
| ) : null} | ||
| <h2 className="sr-only">TanStack AI</h2> | ||
| <InstantSearch searchClient={searchClient} indexName={searchIndexName}> | ||
| <SearchFiltersProvider> | ||
| <DynamicFilters /> | ||
| <SearchPanel | ||
| isFullHeight | ||
| onToggleFullHeight={() => {}} | ||
| newChatRequestId={newChatRequestId} | ||
| surface="dock" | ||
| isDockMaximized={isDockMaximized} | ||
| onToggleDockMaximized={toggleDockMaximized} | ||
| /> | ||
| </SearchFiltersProvider> | ||
| </InstantSearch> | ||
| </aside> | ||
| </div> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Split AiDock out of SearchModal.tsx before merge.
Exporting the dock from this module makes the navbar lazy import pull src/components/SearchModal.tsx’s full dependency graph into the client-reference bundle. CI is already failing on that bundle with Rolldown failed to resolve import "@fingerprintjs/fingerprintjs-pro-react" from "virtual:vite-rsc/client-references/group/facade:src/components/SearchModal.tsx", so this PR does not build as-is. Move AiDock (and only the shared internals it needs) into a dedicated client component instead of lazy-importing it from the modal module.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/SearchModal.tsx` around lines 2928 - 3186, Split AiDock into
its own client-only component file: create a new component (e.g., AiDock.tsx)
and move the AiDock function and only the helper symbols it directly needs
(useSearchContext, readAiDockWidth, writeAiDockWidth, clampAiDockWidth,
getAiDockMaxWidth, AI_DOCK_MIN_WIDTH/AI_DOCK_MAXIMIZED_WIDTH/constants, and any
local hooks/refs) into that file; keep references to InstantSearch,
SearchFiltersProvider, DynamicFilters, SearchPanel, searchClient, and
searchIndexName in the new file so the component can render independently. In
SearchModal.tsx remove the AiDock implementation and replace usages with an
import from the new client file (or a lazy/dynamic client-only import), ensuring
SearchModal no longer imports heavy dependencies required only by AiDock; keep
all other SearchModal internals unchanged. Ensure exported symbol name remains
AiDock and preserve prop/closure usage of newChatRequestId, isAiDockOpen,
cancelAiDockHoverClose, scheduleAiDockHoverClose, and any state logic that
AiDock needs from useSearchContext so behavior is identical.
Source: Pipeline failures
| <div | ||
| ref={contentRef} | ||
| aria-label="TanStack AI" | ||
| aria-hidden={!isAiDockOpen} | ||
| style={dockStyle} | ||
| className={twMerge( | ||
| 'fixed top-[var(--navbar-height)] right-0 bottom-0 z-[1000] w-full max-w-full pointer-events-none', | ||
| isDockMaximized | ||
| ? 'sm:w-[min(var(--ai-dock-max-width),100vw)]' | ||
| : 'sm:w-[var(--ai-dock-width)]', | ||
| !isResizingDock && 'transition-[width] duration-300 ease-out', | ||
| )} | ||
| > | ||
| <aside | ||
| onPointerEnter={handlePointerEnter} | ||
| onPointerLeave={handlePointerLeave} | ||
| className={twMerge( | ||
| 'pointer-events-auto absolute right-0 top-0 h-full w-full max-w-full text-left outline-none transition-[transform,translate,opacity] duration-300 ease-out', | ||
| isDockVisible | ||
| ? 'translate-x-0 opacity-100' | ||
| : 'translate-x-full opacity-0 pointer-events-none', | ||
| )} | ||
| > | ||
| {!isDockMaximized ? ( | ||
| <div | ||
| role="separator" | ||
| aria-label="Resize AI panel" | ||
| aria-orientation="vertical" | ||
| aria-valuemin={AI_DOCK_MIN_WIDTH} | ||
| aria-valuemax={dockMaxWidth} | ||
| aria-valuenow={displayedDockWidth} | ||
| tabIndex={0} | ||
| onPointerDown={handleResizePointerDown} | ||
| onKeyDown={handleResizeKeyDown} | ||
| className="group/resize absolute left-0 top-0 z-40 hidden h-full w-3 -translate-x-1.5 cursor-ew-resize touch-none outline-none sm:block" | ||
| > | ||
| <div | ||
| className={twMerge( | ||
| 'absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-transparent transition-colors', | ||
| isResizingDock | ||
| ? 'bg-cyan-500/70' | ||
| : 'group-hover/resize:bg-cyan-500/50 group-focus/resize:bg-cyan-500/60', | ||
| )} | ||
| /> | ||
| </div> | ||
| ) : null} | ||
| <h2 className="sr-only">TanStack AI</h2> | ||
| <InstantSearch searchClient={searchClient} indexName={searchIndexName}> | ||
| <SearchFiltersProvider> | ||
| <DynamicFilters /> | ||
| <SearchPanel | ||
| isFullHeight | ||
| onToggleFullHeight={() => {}} | ||
| newChatRequestId={newChatRequestId} | ||
| surface="dock" | ||
| isDockMaximized={isDockMaximized} | ||
| onToggleDockMaximized={toggleDockMaximized} | ||
| /> | ||
| </SearchFiltersProvider> | ||
| </InstantSearch> | ||
| </aside> |
There was a problem hiding this comment.
Remove the closed dock from the tab order.
After the first activation, the dock stays mounted even when isAiDockOpen is false. In the closed state this only applies aria-hidden plus pointer-events-none, so the search input and buttons inside the off-screen panel can still be reached by keyboard navigation. Please make the hidden subtree inert/otherwise unfocusable while closed.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/SearchModal.tsx` around lines 3123 - 3183, The off-screen dock
remains keyboard-focusable because only aria-hidden and pointer-events-none are
applied; toggle the inert state when closed so the subtree is removed from the
tab order. Modify the mounted container referenced by contentRef (the div with
ref={contentRef}) to set/remove the inert attribute when isAiDockOpen changes
(e.g., in a useEffect: contentRef.current.inert = !isAiDockOpen), and keep
aria-hidden as-is; if you need to support browsers without native inert, include
the inert polyfill or fallback to programmatically set tabIndex=-1 on
interactive descendants inside the container. Ensure this logic targets the same
contentRef/aside subtree so the search input and buttons become unfocusable when
closed.
a5fb18b to
21808a3
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/components/SearchButton.tsx (1)
69-106:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftDirty dock sessions still can't be pinned open with a mouse.
On desktop, Line 76 hover-opens the dirty dock before the click arrives, so Line 95 always eats that click instead of converting the session into an explicit open. Line 89 then still schedules hover-close on leave, which breaks the persistent drawer behavior this PR is adding. The open intent needs to move into shared search state so both the trigger and the dock close logic can distinguish hover previews from explicit opens.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/components/SearchButton.tsx` around lines 69 - 106, The hover-open vs explicit-open intent must be moved from the local openedByHoverRef to shared search state so both the trigger and dock logic can tell a preview from a user-open; update handlePointerEnter, handlePointerLeave, and handleClick to stop relying on openedByHoverRef and instead set/clear a shared flag (e.g., aiDockOpenIntent or setAiDockExplicitOpen) in the central search state when the user explicitly opens the dock, and ensure openAiDock/closeAiDock calls consult that flag: on pointer enter keep preview behavior but do not mark explicit intent, on click if the dock was only preview-open set the shared explicit-open flag and call openAiDock to promote it to persistent, and on pointer leave do not schedule hover-close if the shared explicit-open flag is true (only schedule/perform hover-close for previews). Also remove/replace openedByHoverRef usage throughout so both the trigger and dock close logic use the shared aiDockOpenIntent flag alongside isAiDockDirty/isAiDockOpen, and keep cancelAiDockHoverClose/scheduleAiDockHoverClose behavior scoped to preview-only sessions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Duplicate comments:
In `@src/components/SearchButton.tsx`:
- Around line 69-106: The hover-open vs explicit-open intent must be moved from
the local openedByHoverRef to shared search state so both the trigger and dock
logic can tell a preview from a user-open; update handlePointerEnter,
handlePointerLeave, and handleClick to stop relying on openedByHoverRef and
instead set/clear a shared flag (e.g., aiDockOpenIntent or
setAiDockExplicitOpen) in the central search state when the user explicitly
opens the dock, and ensure openAiDock/closeAiDock calls consult that flag: on
pointer enter keep preview behavior but do not mark explicit intent, on click if
the dock was only preview-open set the shared explicit-open flag and call
openAiDock to promote it to persistent, and on pointer leave do not schedule
hover-close if the shared explicit-open flag is true (only schedule/perform
hover-close for previews). Also remove/replace openedByHoverRef usage throughout
so both the trigger and dock close logic use the shared aiDockOpenIntent flag
alongside isAiDockDirty/isAiDockOpen, and keep
cancelAiDockHoverClose/scheduleAiDockHoverClose behavior scoped to preview-only
sessions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4efce74e-51dc-46a9-925e-cc6affb0ae14
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (10)
package.jsonsrc/components/AppDevtools.tsxsrc/components/Dropdown.tsxsrc/components/Navbar.tsxsrc/components/SearchButton.tsxsrc/components/SearchModal.tsxsrc/components/ThemeToggle.tsxsrc/contexts/SearchContext.tsxsrc/styles/app.cssvite.config.ts
🚧 Files skipped from review as they are similar to previous changes (9)
- vite.config.ts
- src/components/Dropdown.tsx
- src/components/AppDevtools.tsx
- package.json
- src/components/Navbar.tsx
- src/contexts/SearchContext.tsx
- src/styles/app.css
- src/components/ThemeToggle.tsx
- src/components/SearchModal.tsx
Summary
Adds a persistent AI dock drawer that can be opened from the navbar and used alongside the site without changing page layout. The dock supports hover reopen behavior after it has active content, chat history, compact search results, a draggable minimal width, and a maximized mode capped at a reasonable width.
Notable changes
Validation
pnpm test:tscpnpm run format && pnpm run test; it passed with existing lint warnings unrelated to this change.Summary by CodeRabbit
New Features
Improvements
Style