Skip to content

Commit 9145afc

Browse files
authored
feat: implement navbar redesign (#1715)
1 parent da3ddb0 commit 9145afc

File tree

19 files changed

+270
-161
lines changed

19 files changed

+270
-161
lines changed

docs/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,29 @@ it as `VITE_API_URL` in the `.env` file (locally) or in the CI environment.
153153
| `SENTRY_ORG` | `false` | `true` | `false` | Sentry organization. Used for sourcemap uploads at build-time to enable readable stacktraces. |
154154
| `SENTRY_PROJECT` | `false` | `true` | `false` | Sentry project name. Used for sourcemap uploads at build-time to enable readable stacktraces. |
155155

156+
## Developer notes: Simulating OS design variants
157+
158+
The app renders different window chrome depending on the platform — macOS uses
159+
the system traffic-light buttons and extra left padding, while Windows/Linux
160+
renders custom min/max/close controls.
161+
162+
To test either layout without switching machines, use the `OsDesign` helper
163+
exposed on `window` in the renderer DevTools console:
164+
165+
```js
166+
OsDesign.setMac() // macOS layout (traffic-light padding, no custom controls)
167+
OsDesign.setWindows() // Windows/Linux layout (custom min/max/close buttons)
168+
OsDesign.reset() // restore the real platform detection
169+
OsDesign.current() // log the currently active variant
170+
```
171+
172+
Each `set*` call writes to `sessionStorage` and reloads the page. The override
173+
is scoped to the current session and does not affect any behavioural logic (file
174+
paths, networking flags, etc.) — only the visual layout.
175+
176+
> **Tip:** Open DevTools with `Ctrl+Shift+I` (or `Cmd+Option+I` on macOS), then
177+
> run the commands in the Console tab.
178+
156179
## Developer notes: Using a custom thv binary (dev only)
157180

158181
During development, you can test the UI with a custom `thv` binary by running it

e2e-tests/secrets.spec.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ import {
88
test('creates and deletes a secret', async ({ window }) => {
99
deleteTestSecretViaCli() // Clean up leftover from previous failed runs
1010

11-
await window.getByRole('link', { name: 'Secrets' }).click()
11+
await window.getByRole('link', { name: 'Settings' }).click()
12+
await window.getByRole('tab', { name: 'Secrets' }).click()
1213
await expect(
13-
window.getByRole('heading', { name: 'Secrets', level: 1 })
14+
window.getByRole('heading', { name: 'Secrets', level: 2, exact: true })
1415
).toBeVisible()
1516

16-
await window.getByRole('button', { name: /add.*secret/i }).click()
17-
await window.getByRole('dialog').waitFor()
18-
await window.getByPlaceholder('Name').fill(TEST_SECRET_NAME)
19-
await window.getByPlaceholder('Secret').fill('e2e-test-value')
17+
await window.getByRole('button', { name: /add.*secret|new secret/i }).click()
18+
const dialog = window.getByRole('dialog')
19+
await dialog.waitFor()
20+
await dialog.getByPlaceholder('Name').fill(TEST_SECRET_NAME)
21+
await dialog.getByPlaceholder('Secret').fill('e2e-test-value')
2022
await window.getByRole('button', { name: 'Save' }).click()
2123

2224
await window.getByRole('dialog').waitFor({ state: 'hidden' })

renderer/src/common/components/help/help-dropdown.tsx

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,15 @@ import {
55
DropdownMenuItem,
66
DropdownMenuTrigger,
77
} from '@/common/components/ui/dropdown-menu'
8-
import { Button } from '@/common/components/ui/button'
9-
import { cn } from '@/common/lib/utils'
8+
import { NavIconButton } from '@/common/components/layout/top-nav/nav-icon-button'
109

1110
export function HelpDropdown({ className }: { className?: string }) {
1211
return (
1312
<DropdownMenu>
1413
<DropdownMenuTrigger asChild>
15-
<Button
16-
variant="ghost"
17-
className={cn(
18-
`rounded-full text-white/90 hover:bg-white/10 hover:text-white
19-
dark:hover:bg-white/10`,
20-
className
21-
)}
22-
>
23-
<HelpCircle className="size-4" />
24-
Help
25-
</Button>
14+
<NavIconButton aria-label="Help" className={className}>
15+
<HelpCircle className="size-5" />
16+
</NavIconButton>
2617
</DropdownMenuTrigger>
2718
<DropdownMenuContent align="end" className="w-48">
2819
<DropdownMenuItem asChild>

renderer/src/common/components/layout/top-nav/container.tsx

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,33 @@
11
import type { HTMLProps } from 'react'
2-
import { twMerge } from 'tailwind-merge'
2+
import { cn } from '@/common/lib/utils'
3+
import { getOsDesignVariant } from '@/common/lib/os-design'
34

45
function getPlatformSpecificHeaderClasses() {
5-
const platformClasses = {
6-
darwin: 'pl-26 pt-0.5', // Left padding for traffic light buttons + top offset for title bar
7-
win32: 'pr-2', // Right padding for visual spacing with window edge
8-
linux: '', // No padding needed - custom controls are part of the layout
6+
if (getOsDesignVariant() === 'mac') {
7+
// Left padding to clear the macOS traffic-light buttons
8+
return 'pl-26 pr-4'
99
}
1010

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

1821
export function TopNavContainer(props: HTMLProps<HTMLElement>) {
1922
return (
2023
<header
2124
{...props}
22-
className={twMerge(
25+
className={cn(
2326
props.className,
2427
'bg-nav-background',
2528
'border-nav-border h-16 border-b',
26-
'px-6',
27-
'grid grid-cols-[auto_1fr_auto] items-center gap-7',
29+
'pl-6',
30+
'grid grid-cols-[auto_1fr] items-center',
2831
'app-region-drag',
2932
'w-full min-w-full',
3033
getPlatformSpecificHeaderClasses()

renderer/src/common/components/layout/top-nav/index.tsx

Lines changed: 37 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,26 @@ import { TopNavContainer } from './container'
1313
import {
1414
Server,
1515
CloudDownload,
16-
FlaskConical,
17-
Lock,
1816
Settings as SettingsIcon,
1917
ArrowUpCircle,
18+
FlaskConical,
2019
} from 'lucide-react'
2120
import type { LucideIcon } from 'lucide-react'
2221
import { useRouterState } from '@tanstack/react-router'
2322
import { useAppVersion } from '@/common/hooks/use-app-version'
2423
import { cn } from '@/common/lib/utils'
24+
import { getOsDesignVariant } from '@/common/lib/os-design'
25+
import { NavSeparator } from './nav-separator'
26+
import { NavIconButton } from './nav-icon-button'
2527

2628
interface NavButtonProps {
2729
to: string
2830
icon: LucideIcon
2931
children: React.ReactNode
3032
isActive?: boolean
31-
badge?: React.ReactNode
3233
}
3334

34-
function NavButton({
35-
to,
36-
icon: Icon,
37-
children,
38-
isActive,
39-
badge,
40-
}: NavButtonProps) {
35+
function NavButton({ to, icon: Icon, children, isActive }: NavButtonProps) {
4136
return (
4237
<LinkViewTransition
4338
to={to}
@@ -50,26 +45,20 @@ function NavButton({
5045
: 'bg-transparent text-white/90 hover:bg-white/10 hover:text-white'
5146
)}
5247
>
53-
<span className="relative">
54-
<Icon className="size-4" />
55-
{badge}
56-
</span>
48+
<Icon className="size-4" />
5749
{children}
5850
</LinkViewTransition>
5951
)
6052
}
6153

62-
function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
54+
function useIsActive() {
6355
const pathname = useRouterState({ select: (s) => s.location.pathname })
64-
65-
const isActive = (paths: string[]) =>
56+
return (paths: string[]) =>
6657
paths.some((p) => pathname.startsWith(p) || pathname === p)
58+
}
6759

68-
const updateBadge = showUpdateBadge ? (
69-
<span className="absolute -top-1 -right-1">
70-
<ArrowUpCircle className="size-3 fill-blue-500" />
71-
</span>
72-
) : null
60+
function TopNavLinks() {
61+
const isActive = useIsActive()
7362

7463
return (
7564
<NavigationMenu>
@@ -101,28 +90,6 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
10190
Playground
10291
</NavButton>
10392
</NavigationMenuItem>
104-
<NavigationMenuItem>
105-
<NavButton
106-
to="/secrets"
107-
icon={Lock}
108-
isActive={isActive(['/secrets'])}
109-
>
110-
Secrets
111-
</NavButton>
112-
</NavigationMenuItem>
113-
<NavigationMenuItem>
114-
<NavButton
115-
to="/settings"
116-
icon={SettingsIcon}
117-
isActive={isActive(['/settings'])}
118-
badge={updateBadge}
119-
>
120-
Settings
121-
</NavButton>
122-
</NavigationMenuItem>
123-
<NavigationMenuItem>
124-
<HelpDropdown className="app-region-no-drag" />
125-
</NavigationMenuItem>
12693
</NavigationMenuList>
12794
</NavigationMenu>
12895
)
@@ -131,6 +98,8 @@ function TopNavLinks({ showUpdateBadge }: { showUpdateBadge?: boolean }) {
13198
export function TopNav(props: HTMLProps<HTMLElement>) {
13299
const { data: appVersion } = useAppVersion()
133100
const isProduction = import.meta.env.MODE === 'production'
101+
const isActive = useIsActive()
102+
const showUpdateBadge = !!(appVersion?.isNewVersionAvailable && isProduction)
134103

135104
useEffect(() => {
136105
const cleanup = window.electronAPI.onUpdateDownloaded(() => {
@@ -163,13 +132,31 @@ export function TopNav(props: HTMLProps<HTMLElement>) {
163132
return (
164133
<TopNavContainer {...props}>
165134
<div className="flex h-10 items-center">
166-
<TopNavLinks
167-
showUpdateBadge={
168-
!!(appVersion?.isNewVersionAvailable && isProduction)
169-
}
170-
/>
135+
<TopNavLinks />
171136
</div>
172-
<div className="flex items-center gap-2 justify-self-end">
137+
<div
138+
className="app-region-no-drag flex h-full items-center justify-self-end"
139+
>
140+
<div className="flex h-full items-center gap-1 pl-2">
141+
<HelpDropdown className="app-region-no-drag" />
142+
<NavIconButton
143+
asChild
144+
isActive={isActive(['/settings'])}
145+
aria-label="Settings"
146+
className="app-region-no-drag relative"
147+
>
148+
<LinkViewTransition to="/settings">
149+
<SettingsIcon className="size-5" />
150+
{showUpdateBadge && (
151+
<span className="absolute -top-0.5 -right-0.5">
152+
<ArrowUpCircle className="size-3 fill-blue-500" />
153+
</span>
154+
)}
155+
</LinkViewTransition>
156+
</NavIconButton>
157+
</div>
158+
{/* Windows: separator between icon group and window controls */}
159+
{getOsDesignVariant() !== 'mac' && <NavSeparator />}
173160
<WindowControls />
174161
</div>
175162
</TopNavContainer>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Button } from '@/common/components/ui/button'
2+
import { cn } from '@/common/lib/utils'
3+
import type { ComponentPropsWithoutRef } from 'react'
4+
5+
interface NavIconButtonProps extends ComponentPropsWithoutRef<typeof Button> {
6+
isActive?: boolean
7+
}
8+
9+
export function NavIconButton({
10+
isActive,
11+
className,
12+
...props
13+
}: NavIconButtonProps) {
14+
return (
15+
<Button
16+
variant="ghost"
17+
size="icon"
18+
className={cn(
19+
`rounded-full text-white/90 hover:bg-white/10 hover:text-white
20+
dark:hover:bg-white/10`,
21+
isActive && 'bg-nav-button-active-bg text-nav-button-active-text',
22+
className
23+
)}
24+
{...props}
25+
/>
26+
)
27+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function NavSeparator() {
2+
return <div className="border-nav-border mx-4 self-stretch border-l" />
3+
}

renderer/src/common/components/layout/top-nav/window-controls.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { Button } from '../../ui/button'
22
import { Minus, Square, X } from 'lucide-react'
33
import { useState, useEffect } from 'react'
4+
import { getOsDesignVariant } from '@/common/lib/os-design'
45

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

89
useEffect(() => {
910
// Check initial maximized state
10-
window.electronAPI.windowControls.isMaximized().then(setIsMaximized)
11+
let cancelled = false
12+
window.electronAPI.windowControls.isMaximized().then((v) => {
13+
if (!cancelled) setIsMaximized(v)
14+
})
15+
return () => {
16+
cancelled = true
17+
}
1118
}, [])
1219

1320
const handleMinimize = async () => {
@@ -25,12 +32,12 @@ export function WindowControls() {
2532
}
2633

2734
// Only show window controls on Windows and Linux (not macOS)
28-
if (window.electronAPI.isMac) {
35+
if (getOsDesignVariant() === 'mac') {
2936
return null
3037
}
3138

3239
return (
33-
<div className="app-region-no-drag flex items-center gap-0 text-white">
40+
<div className="app-region-no-drag flex items-center gap-0 pr-2 text-white">
3441
<Button
3542
variant="ghost"
3643
size="icon"

renderer/src/common/components/link-view-transition.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ const ORDERED_ROUTES: Route[] = [
99
'/mcp-optimizer',
1010
'/logs/$groupName/$serverName',
1111
'/registry',
12-
'/playground',
13-
'/secrets',
1412
]
1513

1614
type TransitionType = 'slide-left' | 'slide-right'

renderer/src/routes/__tests__/secrets.test.tsx renamed to renderer/src/common/components/settings/tabs/__tests__/secrets-tab.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { screen, waitFor } from '@testing-library/react'
22
import userEvent from '@testing-library/user-event'
33
import { createTestRouter } from '@/common/test/create-test-router'
4-
import { Secrets } from '../secrets'
4+
import { SecretsTab } from '../secrets-tab'
55
import { renderRoute } from '@/common/test/render-route'
66

7-
const router = createTestRouter(Secrets)
7+
const router = createTestRouter(SecretsTab)
88

99
beforeEach(() => {
1010
vi.clearAllMocks()
@@ -19,9 +19,9 @@ it('renders the table with secrets', async () => {
1919
).toBeInTheDocument()
2020
})
2121
expect(
22-
screen.getByRole('button', { name: /add secret/i })
22+
screen.getByRole('button', { name: /new secret/i })
2323
).toBeInTheDocument()
24-
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument()
24+
expect(screen.getByPlaceholderText('Search secrets')).toBeInTheDocument()
2525

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

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

5252
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()

0 commit comments

Comments
 (0)