Skip to content
Closed
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
14 changes: 9 additions & 5 deletions e2e-tests/playground.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,24 @@ async function selectOllamaModel(window: Page): Promise<void> {
}

async function clearPlaygroundState(window: Page): Promise<void> {
await window.getByRole('link', { name: 'Playground' }).click()
await window.getByRole('button', { name: 'Assistant' }).click()
await expect(
window.getByRole('heading', { name: 'Playground', level: 1 })
window.getByRole('button', { name: 'Close Assistant' })
).toBeVisible()

await waitForPlaygroundReady(window)

const clearChatButton = window.getByRole('button', { name: /clear chat/i })
const clearChatButton = window.getByRole('button', {
name: /new conversation/i,
})
if (await clearChatButton.isVisible().catch(() => false)) {
await clearChatButton.click()
await window.getByRole('button', { name: /delete/i }).click()
}

await removeOllamaProvider(window)

await window.getByRole('button', { name: 'Close Assistant' }).click()
}

test.describe('Playground chat with Ollama', () => {
Expand Down Expand Up @@ -188,9 +192,9 @@ test.describe('Playground chat with Ollama', () => {
.getByText('Running')
).toBeVisible({ timeout: 30_000 })

await window.getByRole('link', { name: 'Playground' }).click()
await window.getByRole('button', { name: 'Assistant' }).click()
await expect(
window.getByRole('heading', { name: 'Playground', level: 1 })
window.getByRole('button', { name: 'Close Assistant' })
).toBeVisible()

await openProviderSettingsDialog(window)
Expand Down
5 changes: 3 additions & 2 deletions e2e-tests/secrets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
test('creates and deletes a secret', async ({ window }) => {
deleteTestSecretViaCli() // Clean up leftover from previous failed runs

await window.getByRole('link', { name: 'Secrets' }).click()
await window.getByRole('link', { name: 'Settings' }).click()
await window.getByRole('tab', { name: 'Secrets' }).click()
await expect(
window.getByRole('heading', { name: 'Secrets', level: 1 })
window.getByRole('heading', { name: 'Secrets', level: 2 })
).toBeVisible()

await window.getByRole('button', { name: /add.*secret/i }).click()
Expand Down
17 changes: 4 additions & 13 deletions renderer/src/common/components/help/help-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/common/components/ui/dropdown-menu'
import { Button } from '@/common/components/ui/button'
import { cn } from '@/common/lib/utils'
import { NavIconButton } from '@/common/components/layout/top-nav/nav-icon-button'

export function HelpDropdown({ className }: { className?: string }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
`rounded-full text-white/90 hover:bg-white/10 hover:text-white
dark:hover:bg-white/10`,
className
)}
>
<HelpCircle className="size-4" />
Help
</Button>
<NavIconButton aria-label="Help" className={className}>
<HelpCircle className="size-5" />
</NavIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild>
Expand Down
102 changes: 102 additions & 0 deletions renderer/src/common/components/layout/assistant-drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react'
import { PanelRightClose, MessageCircle } from 'lucide-react'
import { ChatInterface } from '@/features/chat/components/chat-interface'
import { useAssistantDrawer } from '@/common/hooks/use-assistant-drawer'
import { cn } from '@/common/lib/utils'
import { WindowControls } from './top-nav/window-controls'
import { getOsDesignVariant } from '@/common/lib/os-design'
import { NavSeparator } from './top-nav/nav-separator'

const ANIMATION_DURATION_MS = 200

export function AssistantDrawer() {
const { isOpen, close } = useAssistantDrawer()
const [isMounted, setIsMounted] = useState(false)

useEffect(() => {
if (isOpen) {
// rAF is only here to satisfy the ESLint no-sync-setState-in-effect rule.
// The entry animation is handled by CSS (animate-in), which fires
// automatically on mount — no class toggling needed.
const frame = requestAnimationFrame(() => setIsMounted(true))
return () => cancelAnimationFrame(frame)
} else {
// Unmount after the slide-out animation rather than using
// visibility/display/pointer-events. In Electron, -webkit-app-region
// is not applied when pointer-events: none, so a hidden-but-mounted
// drawer would still claim its off-screen region as a drag area,
// making buttons in the navbar beneath it unclickable.
const timer = setTimeout(() => setIsMounted(false), ANIMATION_DURATION_MS)
return () => clearTimeout(timer)
}
}, [isOpen])

if (!isMounted) return null

return (
<>
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={close}
aria-hidden="true"
/>
)}
<div
className={cn(
'app-region-no-drag',
'fixed top-0 right-0 z-50 flex h-dvh flex-col',
'w-[700px] max-w-full',
'duration-200',
isOpen
? 'animate-in slide-in-from-right fill-mode-backwards'
: 'animate-out slide-out-to-right fill-mode-forwards'
)}
aria-label="Assistant"
>
<div
className={cn(
'bg-nav-background border-nav-border',
'flex h-16 shrink-0 items-center justify-between',
'border-b border-l'
)}
>
<div
className="app-region-no-drag border-nav-border flex h-full
items-center gap-3 border-l px-4 text-white"
>
<MessageCircle className="size-[22px]" />
<span className="font-serif text-2xl font-light tracking-tight">
Assistant
</span>
</div>
<div className="flex h-full items-center pl-2">
{/* macOS: separator left of close button (no window controls on the right) */}
{getOsDesignVariant() === 'mac' && <NavSeparator />}
<button
onClick={close}
aria-label="Close Assistant"
className={cn(
'app-region-no-drag',
'flex size-10 items-center justify-center rounded-full',
`text-white/90 transition-colors hover:bg-white/10
hover:text-white`
)}
>
<PanelRightClose className="size-5" />
</button>
{/* Windows: separator right of close button, between it and window controls */}
{getOsDesignVariant() !== 'mac' && <NavSeparator />}
<WindowControls />
</div>
</div>
<div
className="bg-background border-border flex min-h-0 flex-1 flex-col
overflow-hidden border-l px-8 pt-4 pb-8"
>
<ChatInterface hideTitle />
</div>
</div>
</>
)
}
18 changes: 5 additions & 13 deletions renderer/src/common/components/layout/top-nav/container.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import type { HTMLProps } from 'react'
import { twMerge } from 'tailwind-merge'
import { getOsDesignVariant } from '@/common/lib/os-design'

function getPlatformSpecificHeaderClasses() {
const platformClasses = {
darwin: 'pl-26 pt-0.5', // Left padding for traffic light buttons + top offset for title bar
win32: 'pr-2', // Right padding for visual spacing with window edge
linux: '', // No padding needed - custom controls are part of the layout
}

return (
platformClasses[
window.electronAPI.platform as keyof typeof platformClasses
] || ''
)
// Left padding to clear the macOS traffic-light buttons
return getOsDesignVariant() === 'mac' ? 'pl-26' : ''
}

export function TopNavContainer(props: HTMLProps<HTMLElement>) {
Expand All @@ -23,8 +15,8 @@ export function TopNavContainer(props: HTMLProps<HTMLElement>) {
props.className,
'bg-nav-background',
'border-nav-border h-16 border-b',
'px-6',
'grid grid-cols-[auto_1fr_auto] items-center gap-7',
'pl-6',
'grid grid-cols-[auto_3fr_auto] items-center',
'app-region-drag',
'w-full min-w-full',
getPlatformSpecificHeaderClasses()
Expand Down
112 changes: 52 additions & 60 deletions renderer/src/common/components/layout/top-nav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,27 @@ import { TopNavContainer } from './container'
import {
Server,
CloudDownload,
FlaskConical,
Lock,
Settings as SettingsIcon,
ArrowUpCircle,
MessageCircle,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useRouterState } from '@tanstack/react-router'
import { useAppVersion } from '@/common/hooks/use-app-version'
import { cn } from '@/common/lib/utils'
import { useAssistantDrawer } from '@/common/hooks/use-assistant-drawer'
import { getOsDesignVariant } from '@/common/lib/os-design'
import { NavSeparator } from './nav-separator'
import { NavIconButton } from './nav-icon-button'

interface NavButtonProps {
to: string
icon: LucideIcon
children: React.ReactNode
isActive?: boolean
badge?: React.ReactNode
}

function NavButton({
to,
icon: Icon,
children,
isActive,
badge,
}: NavButtonProps) {
function NavButton({ to, icon: Icon, children, isActive }: NavButtonProps) {
return (
<LinkViewTransition
to={to}
Expand All @@ -50,26 +46,20 @@ function NavButton({
: 'bg-transparent text-white/90 hover:bg-white/10 hover:text-white'
)}
>
<span className="relative">
<Icon className="size-4" />
{badge}
</span>
<Icon className="size-4" />
{children}
</LinkViewTransition>
)
}

function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
function useIsActive() {
const pathname = useRouterState({ select: (s) => s.location.pathname })

const isActive = (paths: string[]) =>
return (paths: string[]) =>
paths.some((p) => pathname.startsWith(p) || pathname === p)
}

const updateBadge = showUpdateBadge ? (
<span className="absolute -top-1 -right-1">
<ArrowUpCircle className="size-3 fill-blue-500" />
</span>
) : null
function TopNavLinks() {
const isActive = useIsActive()

return (
<NavigationMenu>
Expand All @@ -92,37 +82,6 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
Registry
</NavButton>
</NavigationMenuItem>
<NavigationMenuItem>
<NavButton
to="/playground"
icon={FlaskConical}
isActive={isActive(['/playground'])}
>
Playground
</NavButton>
</NavigationMenuItem>
<NavigationMenuItem>
<NavButton
to="/secrets"
icon={Lock}
isActive={isActive(['/secrets'])}
>
Secrets
</NavButton>
</NavigationMenuItem>
<NavigationMenuItem>
<NavButton
to="/settings"
icon={SettingsIcon}
isActive={isActive(['/settings'])}
badge={updateBadge}
>
Settings
</NavButton>
</NavigationMenuItem>
<NavigationMenuItem>
<HelpDropdown className="app-region-no-drag" />
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
)
Expand All @@ -131,6 +90,10 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
export function TopNav(props: HTMLProps<HTMLElement>) {
const { data: appVersion } = useAppVersion()
const isProduction = import.meta.env.MODE === 'production'
const isActive = useIsActive()
const showUpdateBadge = !!(appVersion?.isNewVersionAvailable && isProduction)
const { toggle: toggleAssistant, isOpen: isAssistantOpen } =
useAssistantDrawer()

useEffect(() => {
const cleanup = window.electronAPI.onUpdateDownloaded(() => {
Expand Down Expand Up @@ -163,14 +126,43 @@ export function TopNav(props: HTMLProps<HTMLElement>) {
return (
<TopNavContainer {...props}>
<div className="flex h-10 items-center">
<TopNavLinks
showUpdateBadge={
!!(appVersion?.isNewVersionAvailable && isProduction)
}
/>
<TopNavLinks />
</div>
<div className="flex items-center gap-2 justify-self-end">
<WindowControls />
<div className="flex h-full items-center justify-self-end">
<div className="flex h-full items-center gap-1 pl-2">
<HelpDropdown className="app-region-no-drag" />
<NavIconButton
asChild
isActive={isActive(['/settings'])}
aria-label="Settings"
className="app-region-no-drag relative"
>
<LinkViewTransition to="/settings">
<SettingsIcon className="size-5" />
{showUpdateBadge && (
<span className="absolute -top-0.5 -right-0.5">
<ArrowUpCircle className="size-3 fill-blue-500" />
</span>
)}
</LinkViewTransition>
</NavIconButton>
{/* macOS: separator between settings and assistant (no window controls on the right) */}
{getOsDesignVariant() === 'mac' && <NavSeparator />}
<NavIconButton
onClick={toggleAssistant}
aria-label="Assistant"
aria-expanded={isAssistantOpen}
isActive={isAssistantOpen}
className="app-region-no-drag"
>
<MessageCircle className="size-5" />
</NavIconButton>
</div>
{/* When the drawer is open it renders its own WindowControls in its
header, so we hide these to avoid duplicates. */}
{/* Windows: separator between icon group and window controls */}
{getOsDesignVariant() !== 'mac' && !isAssistantOpen && <NavSeparator />}
{!isAssistantOpen && <WindowControls />}
</div>
</TopNavContainer>
)
Expand Down
Loading
Loading