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
23 changes: 23 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ it as `VITE_API_URL` in the `.env` file (locally) or in the CI environment.
| `SENTRY_ORG` | `false` | `true` | `false` | Sentry organization. Used for sourcemap uploads at build-time to enable readable stacktraces. |
| `SENTRY_PROJECT` | `false` | `true` | `false` | Sentry project name. Used for sourcemap uploads at build-time to enable readable stacktraces. |

## Developer notes: Simulating OS design variants

The app renders different window chrome depending on the platform — macOS uses
the system traffic-light buttons and extra left padding, while Windows/Linux
renders custom min/max/close controls.

To test either layout without switching machines, use the `OsDesign` helper
exposed on `window` in the renderer DevTools console:

```js
OsDesign.setMac() // macOS layout (traffic-light padding, no custom controls)
OsDesign.setWindows() // Windows/Linux layout (custom min/max/close buttons)
OsDesign.reset() // restore the real platform detection
OsDesign.current() // log the currently active variant
```

Each `set*` call writes to `sessionStorage` and reloads the page. The override
is scoped to the current session and does not affect any behavioural logic (file
paths, networking flags, etc.) — only the visual layout.

> **Tip:** Open DevTools with `Ctrl+Shift+I` (or `Cmd+Option+I` on macOS), then
> run the commands in the Console tab.

## Developer notes: Using a custom thv binary (dev only)

During development, you can test the UI with a custom `thv` binary by running it
Expand Down
14 changes: 8 additions & 6 deletions e2e-tests/secrets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ 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, exact: true })
).toBeVisible()

await window.getByRole('button', { name: /add.*secret/i }).click()
await window.getByRole('dialog').waitFor()
await window.getByPlaceholder('Name').fill(TEST_SECRET_NAME)
await window.getByPlaceholder('Secret').fill('e2e-test-value')
await window.getByRole('button', { name: /add.*secret|new secret/i }).click()
const dialog = window.getByRole('dialog')
await dialog.waitFor()
await dialog.getByPlaceholder('Name').fill(TEST_SECRET_NAME)
await dialog.getByPlaceholder('Secret').fill('e2e-test-value')
await window.getByRole('button', { name: 'Save' }).click()

await window.getByRole('dialog').waitFor({ state: 'hidden' })
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
29 changes: 16 additions & 13 deletions renderer/src/common/components/layout/top-nav/container.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
import type { HTMLProps } from 'react'
import { twMerge } from 'tailwind-merge'
import { cn } from '@/common/lib/utils'
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
if (getOsDesignVariant() === 'mac') {
// Left padding to clear the macOS traffic-light buttons
return 'pl-26 pr-4'
}

return (
platformClasses[
window.electronAPI.platform as keyof typeof platformClasses
] || ''
)
// Windows needs a small right padding for visual spacing against the window
// edge. This is the only known design difference between Windows and Linux —
// both share the same 'windows' OS design variant for everything else.
if (window.electronAPI.platform === 'win32') {
return 'pr-2'
}

return ''
}

export function TopNavContainer(props: HTMLProps<HTMLElement>) {
return (
<header
{...props}
className={twMerge(
className={cn(
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_1fr] items-center',
'app-region-drag',
'w-full min-w-full',
getPlatformSpecificHeaderClasses()
Expand Down
87 changes: 37 additions & 50 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,26 @@ import { TopNavContainer } from './container'
import {
Server,
CloudDownload,
FlaskConical,
Lock,
Settings as SettingsIcon,
ArrowUpCircle,
FlaskConical,
} 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 { 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 +45,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 Down Expand Up @@ -101,28 +90,6 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
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 +98,8 @@ 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)

useEffect(() => {
const cleanup = window.electronAPI.onUpdateDownloaded(() => {
Expand Down Expand Up @@ -163,13 +132,31 @@ 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">
<div
className="app-region-no-drag 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>
</div>
{/* Windows: separator between icon group and window controls */}
{getOsDesignVariant() !== 'mac' && <NavSeparator />}
<WindowControls />
</div>
</TopNavContainer>
Expand Down
27 changes: 27 additions & 0 deletions renderer/src/common/components/layout/top-nav/nav-icon-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Button } from '@/common/components/ui/button'
import { cn } from '@/common/lib/utils'
import type { ComponentPropsWithoutRef } from 'react'

interface NavIconButtonProps extends ComponentPropsWithoutRef<typeof Button> {
isActive?: boolean
}

export function NavIconButton({
isActive,
className,
...props
}: NavIconButtonProps) {
return (
<Button
variant="ghost"
size="icon"
className={cn(
`rounded-full text-white/90 hover:bg-white/10 hover:text-white
dark:hover:bg-white/10`,
isActive && 'bg-nav-button-active-bg text-nav-button-active-text',
className
)}
{...props}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function NavSeparator() {
return <div className="border-nav-border mx-4 self-stretch border-l" />
}
13 changes: 10 additions & 3 deletions renderer/src/common/components/layout/top-nav/window-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { Button } from '../../ui/button'
import { Minus, Square, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { getOsDesignVariant } from '@/common/lib/os-design'

export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false)

useEffect(() => {
// Check initial maximized state
window.electronAPI.windowControls.isMaximized().then(setIsMaximized)
let cancelled = false
window.electronAPI.windowControls.isMaximized().then((v) => {
if (!cancelled) setIsMaximized(v)
})
return () => {
cancelled = true
}
}, [])

const handleMinimize = async () => {
Expand All @@ -25,12 +32,12 @@ export function WindowControls() {
}

// Only show window controls on Windows and Linux (not macOS)
if (window.electronAPI.isMac) {
if (getOsDesignVariant() === 'mac') {
return null
}

return (
<div className="app-region-no-drag flex items-center gap-0 text-white">
<div className="app-region-no-drag flex items-center gap-0 pr-2 text-white">
<Button
variant="ghost"
size="icon"
Expand Down
2 changes: 0 additions & 2 deletions renderer/src/common/components/link-view-transition.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ const ORDERED_ROUTES: Route[] = [
'/mcp-optimizer',
'/logs/$groupName/$serverName',
'/registry',
'/playground',
'/secrets',
]

type TransitionType = 'slide-left' | 'slide-right'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createTestRouter } from '@/common/test/create-test-router'
import { Secrets } from '../secrets'
import { SecretsTab } from '../secrets-tab'
import { renderRoute } from '@/common/test/render-route'

const router = createTestRouter(Secrets)
const router = createTestRouter(SecretsTab)

beforeEach(() => {
vi.clearAllMocks()
Expand All @@ -19,9 +19,9 @@ it('renders the table with secrets', async () => {
).toBeInTheDocument()
})
expect(
screen.getByRole('button', { name: /add secret/i })
screen.getByRole('button', { name: /new secret/i })
).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Search secrets')).toBeInTheDocument()

expect(screen.getByText('Github')).toBeInTheDocument()
expect(screen.getByText('Grafana')).toBeInTheDocument()
Expand All @@ -46,7 +46,7 @@ it('renders add secret dialog when clicking add secret button', async () => {
).toBeInTheDocument()
})

const addSecretButton = screen.getByRole('button', { name: /add secret/i })
const addSecretButton = screen.getByRole('button', { name: /new secret/i })
await userEvent.click(addSecretButton)

expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
Expand Down
Loading
Loading