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
122 changes: 122 additions & 0 deletions apps/desktop/src/app/overlays/overlay-split-layout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import { OverlayMain, OverlaySidebar, OverlaySplitLayout } from './overlay-split-layout'

describe('OverlaySplitLayout', () => {
afterEach(() => {
cleanup()
})

it('renders a two-column grid with a fixed sidebar and flexible main', () => {
const { container } = render(
<OverlaySplitLayout>
<div>sidebar</div>
<div>main</div>
</OverlaySplitLayout>
)

const grid = container.firstElementChild as HTMLElement

expect(grid.className).toContain('grid')
expect(grid.className).toContain('grid-cols-[13rem_minmax(0,1fr)]')
})

it('collapses to a single column below 47.5rem', () => {
const { container } = render(
<OverlaySplitLayout>
<div>sidebar</div>
<div>main</div>
</OverlaySplitLayout>
)

const grid = container.firstElementChild as HTMLElement

expect(grid.className).toContain('max-[47.5rem]:grid-cols-1')
})
})

describe('OverlayMain', () => {
afterEach(() => {
cleanup()
})

it('left-aligns content (no mx-auto) so it sits flush against the sidebar', () => {
render(
<OverlayMain>
<p>content</p>
</OverlayMain>
)

const main = screen.getByRole('main')

expect(main.className).not.toContain('mx-auto')
})

it('caps content width on ultrawide displays via PAGE_MAX_W', () => {
render(
<OverlayMain>
<p>content</p>
</OverlayMain>
)

const main = screen.getByRole('main')

expect(main.className).toContain('max-w-[75rem]')
})

it('applies the responsive horizontal clamp gutter', () => {
render(
<OverlayMain>
<p>content</p>
</OverlayMain>
)

const main = screen.getByRole('main')

expect(main.className).toContain('px-[clamp(0.8333rem,2.6667vw,2.6667rem)]')
})

it('merges consumer className overrides', () => {
render(
<OverlayMain className="px-0 pb-0">
<p>content</p>
</OverlayMain>
)

const main = screen.getByRole('main')

expect(main.className).toContain('px-0')
expect(main.className).toContain('pb-0')
})
})

describe('OverlaySidebar', () => {
afterEach(() => {
cleanup()
})

it('renders an aside with the sidebar surface background', () => {
render(
<OverlaySidebar>
<nav>links</nav>
</OverlaySidebar>
)

const aside = screen.getByRole('complementary')

expect(aside.className).toContain('bg-(--ui-sidebar-surface-background)')
})

it('includes the shared overlay top clearance', () => {
render(
<OverlaySidebar>
<nav>links</nav>
</OverlaySidebar>
)

const aside = screen.getByRole('complementary')

expect(aside.className).toContain('pt-[calc(var(--titlebar-height)/2-0.4375rem)]')
})
})
4 changes: 3 additions & 1 deletion apps/desktop/src/app/overlays/overlay-split-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
// top clearance, the bottom gutter, and the horizontal clamp gutter
// (inlined from PAGE_INSET_X so only overlay panes tighten, not the
// shared page gutter). Narrow top drops toward the OverlayNav bar.
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
// Left-aligned (no mx-auto) so content sits flush against the sidebar;
// PAGE_MAX_W still caps width on ultrawide displays.
'flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
PAGE_MAX_W,
className
)}
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src/app/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Archive,
BarChart3,
Bell,
Box,
Download,
Globe,
Info,
Expand Down Expand Up @@ -40,13 +41,15 @@ import { KeybindSettings } from './keybind-settings'
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
import { NotificationsSettings } from './notifications-settings'
import { PluginsSettings } from './plugins-settings'
import { ProviderModelManager } from './provider-model-manager'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'

const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'providermanager',
'gateway',
'keybinds',
'keys',
Expand Down Expand Up @@ -199,6 +202,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
label: t.settings.nav.providers,
onSelect: () => setActiveView('providers')
},
{
active: activeView === 'providermanager',
icon: Box,
id: 'providermanager',
label: t.settings.nav.providersManager,
onSelect: () => setActiveView('providermanager')
},
{
active: activeView === 'gateway',
icon: Globe,
Expand Down Expand Up @@ -294,7 +304,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
)

return (
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose}>
<OverlayView closeLabel={t.settings.closeSettings} onClose={onClose} rootClassName="mx-auto max-w-[92rem]">
<OverlaySplitLayout>
<OverlayNav footer={navFooter} groups={navGroups} />

Expand Down Expand Up @@ -328,6 +338,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
<NotificationsSettings />
) : activeView === 'billing' ? (
<BillingSettings />
) : activeView === 'providermanager' ? (
<ProviderModelManager />
) : activeView === 'plugins' ? (
<PluginsSettings />
) : (
Expand Down
150 changes: 150 additions & 0 deletions apps/desktop/src/app/settings/model-add-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react'

import { ActionStatus } from '@/components/ui/action-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { AlertTriangle } from '@/lib/icons'
import type { CustomProviderModel } from '@/lib/custom-provider-config'

export function ModelAddDialog({
existingIds = [],
onClose,
onSave,
open
}: {
/** Existing model ids for the provider, for duplicate checks. */
existingIds?: string[]
onClose: () => void
onSave: (model: CustomProviderModel) => Promise<void> | void
open: boolean
}) {
const { t } = useI18n()
const p = t.providerManager

const [modelId, setModelId] = useState('')
const [modelName, setModelName] = useState('')
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
const [error, setError] = useState<null | string>(null)

useEffect(() => {
if (!open) {
return
}
setModelId('')
setModelName('')
setError(null)
setStatus('idle')
}, [open])

const trimmedId = modelId.trim()
const idExists = trimmedId !== '' && existingIds.includes(trimmedId)
const invalid = idExists
const busy = status === 'saving' || status === 'done'

async function handleSubmit(event: React.FormEvent) {
event.preventDefault()

if (!trimmedId) {
setError(p.modelIdRequired)
return
}

if (idExists) {
setError(p.modelExists)
return
}

setStatus('saving')
setError(null)

try {
await onSave({ id: trimmedId, name: modelName.trim() || undefined })
setStatus('done')
window.setTimeout(onClose, 600)
} catch (err) {
setStatus('idle')
setError(err instanceof Error ? err.message : p.modelIdRequired)
}
}

return (
<Dialog onOpenChange={value => !value && !busy && onClose()} open={open}>
<DialogContent
className="max-w-md"
onEscapeKeyDown={e => e.preventDefault()}
onInteractOutside={e => e.preventDefault()}
onPointerDownOutside={e => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{p.manualModelTitle}</DialogTitle>
<DialogDescription>{p.manualModelDescription}</DialogDescription>
</DialogHeader>

<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="model-add-id">
{p.modelId}
</label>
<Input
aria-invalid={idExists}
autoFocus
disabled={busy}
id="model-add-id"
onChange={event => setModelId(event.target.value)}
placeholder={p.modelIdPlaceholder}
value={modelId}
/>
</div>

<div className="grid gap-1.5">
<label className="text-xs font-medium" htmlFor="model-add-name">
{p.modelName}
</label>
<Input
disabled={busy}
id="model-add-name"
onChange={event => setModelName(event.target.value)}
placeholder={p.modelNamePlaceholder}
value={modelName}
/>
</div>

<details className="rounded-md border border-border/60 px-3 py-2">
<summary className="cursor-pointer text-xs font-medium">{p.advancedParameters}</summary>
<p className="mt-2 text-xs text-muted-foreground">{p.advancedParametersEmpty}</p>
</details>

{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}

<DialogFooter className="gap-2">
<Button disabled={busy} onClick={onClose} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={busy || !trimmedId || invalid} type="submit">
<ActionStatus
busy={t.common.saving}
done={t.common.done}
idle={t.common.save}
state={status}
/>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
Loading