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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"prepare": "husky"
},
"dependencies": {
"@fingerprintjs/fingerprintjs-pro-react": "^2.7.1",
"@floating-ui/react": "^0.27.19",
"@kapaai/react-sdk": "^0.9.10",
"@modelcontextprotocol/sdk": "^1.29.0",
Expand Down Expand Up @@ -131,7 +132,7 @@
"@tanstack/devtools-vite": "^0.7.0",
"@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-query-devtools": "^5.100.11",
"@tanstack/redact": "^0.0.12",
"@tanstack/redact": "^0.0.14",
"@types/hast": "^3.0.4",
"@types/node": "^25.5.0",
"@types/pg": "^8.20.0",
Expand Down
13 changes: 8 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/components/AppDevtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export function AppDevtools() {

return (
<TanStackDevtools
config={{
customTrigger: <span className="hidden" aria-hidden="true" />,
}}
plugins={[
{
name: 'TanStack Query',
Expand Down
19 changes: 10 additions & 9 deletions src/components/DocsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,16 @@ export function DocsLayout({
const groupContent = (
<>
{group.collapsible ? (
<summary className="text-[.8em] font-bold leading-4 px-2 ts-sidebar-label">
<summary
className="text-[.8em] font-bold leading-4 px-2 ts-sidebar-label"
onClick={(event) => {
event.preventDefault()
setOpenGroups((prev) => ({
...prev,
[groupKey]: !(prev[groupKey] ?? false),
}))
}}
>
{group.label}
</summary>
) : (
Expand Down Expand Up @@ -1013,14 +1022,6 @@ export function DocsLayout({
key={`group-${i}`}
className="[&>summary]:before:mr-1 [&>summary]:marker:text-[0.8em] [&>summary]:marker:leading-4 relative select-none"
open={openGroups[groupKey] ?? false}
onToggle={(event) => {
const nextOpen = event.currentTarget.open
setOpenGroups((prev) =>
prev[groupKey] === nextOpen
? prev
: { ...prev, [groupKey]: nextOpen },
)
}}
>
{groupContent}
</details>
Expand Down
9 changes: 9 additions & 0 deletions src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type DropdownContentProps = {
align?: 'start' | 'center' | 'end'
sideOffset?: number
portal?: boolean
onFocus?: React.FocusEventHandler<HTMLDivElement>
onPointerEnter?: React.PointerEventHandler<HTMLDivElement>
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>
}

type DropdownItemProps = {
Expand Down Expand Up @@ -65,11 +68,17 @@ export function DropdownContent({
align = 'end',
sideOffset = 6,
portal = true,
onFocus,
onPointerEnter,
onPointerLeave,
}: DropdownContentProps) {
const content = (
<DropdownMenu.Content
align={align}
sideOffset={sideOffset}
onFocus={onFocus}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
className={twMerge(
'dropdown-content z-[1200] min-w-48 rounded-lg p-1.5',
'border border-gray-200 dark:border-gray-700',
Expand Down
33 changes: 30 additions & 3 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ const LazyNavbarAuthControls = React.lazy(() =>
default: m.NavbarAuthControls,
})),
)
const LazyAiDock = React.lazy(() =>
import('./SearchModal').then((m) => ({ default: m.AiDock })),
)
import { NavbarCartButton } from './NavbarCartButton'
import { Link, useLocation, useMatches } from '@tanstack/react-router'
import { NetlifyImage } from './NetlifyImage'
Expand All @@ -29,9 +32,10 @@ import {
Sparkles,
} from 'lucide-react'
import { ThemeToggle } from './ThemeToggle'
import { SearchButton } from './SearchButton'
import { AiDockButton, SearchButton } from './SearchButton'
import { libraries, SIDEBAR_LIBRARY_IDS, type LibrarySlim } from '~/libraries'
import { useClickOutside } from '~/hooks/useClickOutside'
import { useSearchContext } from '~/contexts/SearchContext'
import { GithubIcon } from '~/components/icons/GithubIcon'
import {
Dropdown,
Expand Down Expand Up @@ -137,6 +141,27 @@ const MobileCard = ({
</Card>
)

function AiDockMount() {
const { isAiDockOpen } = useSearchContext()
const [hasActivated, setHasActivated] = React.useState(isAiDockOpen)

React.useEffect(() => {
if (isAiDockOpen) {
setHasActivated(true)
}
}, [isAiDockOpen])

if (!hasActivated && !isAiDockOpen) {
return null
}

return (
<React.Suspense fallback={null}>
<LazyAiDock />
</React.Suspense>
)
}

export function Navbar({ children }: { children: React.ReactNode }) {
const matches = useMatches()

Expand Down Expand Up @@ -265,11 +290,12 @@ export function Navbar({ children }: { children: React.ReactNode }) {
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="hidden min-[750px]:block">{socialLinks}</div>
<ThemeToggle />
<div className="hidden sm:block">
<SearchButton />
<SearchButton iconOnly />
</div>
<ThemeToggle />
<NavbarCartButton />
<AiDockButton />
<div className="flex items-center gap-2">
{canLoadAuthControls ? (
<React.Suspense fallback={loginButtonFallback}>
Expand Down Expand Up @@ -747,6 +773,7 @@ export function Navbar({ children }: { children: React.ReactNode }) {
{children}
</div>
</div>
<AiDockMount />
</>
)
}
Expand Down
126 changes: 116 additions & 10 deletions src/components/SearchButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,133 @@ import * as React from 'react'
import { Command, Search } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { Button } from '~/ui'
import { useSearchContext } from '~/contexts/SearchContext'

interface SearchButtonProps {
className?: string
children?: React.ReactNode
iconOnly?: boolean
}

export function SearchButton({ className }: SearchButtonProps) {
function AiGlyph() {
return (
<span className="relative flex h-5 w-5 items-center justify-center">
<span className="text-xs font-black leading-none tracking-normal">
AI
</span>
</span>
)
}

export function SearchButton({
className,
iconOnly = false,
}: SearchButtonProps) {
const { openSearch } = useSearchContext()

return (
<Button
type="button"
data-search-trigger
data-search-trigger="true"
onClick={openSearch}
aria-label="Search TanStack docs"
aria-haspopup="dialog"
variant="ghost"
size="xs"
className={twMerge('gap-2 bg-gray-500/5 dark:bg-gray-500/30', className)}
variant={iconOnly ? 'icon' : 'ghost'}
color="gray"
size={iconOnly ? 'icon-sm' : 'xs'}
title="Search"
className={twMerge(
iconOnly
? 'h-7 w-7 rounded-md p-0'
: 'gap-2 bg-gray-500/5 dark:bg-gray-500/30',
className,
)}
>
<Search className="w-3.5 h-3.5" />
<span>Search...</span>
<div className="flex items-center bg-gray-500/10 dark:bg-gray-500/30 rounded px-1 py-0.5 gap-0.5 text-[10px] whitespace-nowrap">
<Command className="w-2.5 h-2.5" /> K
</div>
{iconOnly ? (
<span className="sr-only">Search</span>
) : (
<>
<span>Search...</span>
<div className="flex items-center bg-gray-500/10 dark:bg-gray-500/30 rounded px-1 py-0.5 gap-0.5 text-[10px] whitespace-nowrap">
<Command className="w-2.5 h-2.5" /> K
</div>
</>
)}
</Button>
)
}

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()
}
Comment on lines +61 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

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.


return (
<Button
type="button"
onClick={handleClick}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
aria-label={isAiDockOpen ? 'Close AI panel' : 'Open AI panel'}
aria-haspopup="dialog"
aria-pressed={isAiDockOpen}
variant="ghost"
size="icon-sm"
title="Ask AI"
className={twMerge(
'h-7 w-8 rounded-md p-0 bg-gray-500/5 dark:bg-gray-500/30',
isAiDockOpen &&
'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-400/15 dark:text-cyan-300 border-cyan-500/20 dark:border-cyan-300/20',
className,
)}
>
<AiGlyph />
<span className="sr-only">Ask AI</span>
</Button>
)
}
Loading
Loading