Replace the Presets side panel with an inline profile dropdown

Presets is no longer a collapsible NavMenu tab. It's now a compact
dropdown in the sidebar listing profiles, with a "+" button that prompts
for a name and creates+activates a copy of the current profile, and a
"×" button that deletes the active profile (disabled for Default).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 18:17:23 +01:00
co-authored by Claude Sonnet 5
parent 12381545c8
commit e69ce4e0ba
+59 -101
View File
@@ -7,11 +7,10 @@ import { DocsPanel } from './Docs'
import { HelpIcon } from './HelpIcon'
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
type PanelName = 'settings' | 'presets' | 'docs' | null
type PanelName = 'settings' | 'docs' | null
const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
settings: 'Settings',
presets: 'Presets',
docs: 'Docs',
}
@@ -59,17 +58,9 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-border p-3">
<div>
<div className="mb-4 text-lg font-semibold text-text">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-text-muted">
<PresetsDropdown panel={panel} />
<nav className="mt-2 flex flex-col gap-1 text-sm text-text-muted">
<span className="rounded bg-surface-2 px-2 py-1 text-text">Dashboard</span>
<button
type="button"
onClick={() => togglePanel('presets')}
className={`rounded px-2 py-1 text-left hover:bg-surface-2 ${
openPanel === 'presets' ? 'bg-surface-2 text-text' : ''
}`}
>
Presets
</button>
<button
type="button"
onClick={() => togglePanel('settings')}
@@ -114,7 +105,6 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
<div className="flex items-center gap-1.5">
<h2 className="text-sm font-semibold text-text">{PANEL_TITLES[openPanel]}</h2>
{openPanel === 'settings' && <HelpIcon sectionId="settings" label="Help: Settings" />}
{openPanel === 'presets' && <HelpIcon sectionId="presets" label="Help: Presets" />}
</div>
<button
type="button"
@@ -131,7 +121,6 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
</div>
)}
{openPanel === 'settings' && <SettingsPanel panel={panel} />}
{openPanel === 'presets' && <PresetsPanel panel={panel} />}
{openPanel === 'docs' && <DocsPanel target={docsPanelTarget} />}
</div>
)}
@@ -139,6 +128,62 @@ export function NavMenu({ stats }: { stats: Stats | null }) {
)
}
/// Replaces the old Presets side-panel tab: a compact dropdown of profiles plus "+" (create a copy of
/// the active profile, prompting for its name) and "×" (delete the active profile — disabled for
/// `Default`, which can never be deleted).
function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const activeProfile = panel.settings?.active_profile ?? null
async function handleCreate() {
const name = window.prompt('New profile name (copies the current profile):')?.trim()
if (!name) return
await panel.addProfile(name)
await panel.switchProfile(name)
}
async function handleDelete() {
if (!activeProfile || activeProfile === DEFAULT_PROFILE_NAME) return
await panel.removeProfile(activeProfile)
}
return (
<div className="flex items-center gap-1">
<select
value={activeProfile ?? ''}
onChange={(e) => panel.switchProfile(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-1.5 py-1 text-xs text-text focus:border-border-strong focus:outline-none"
>
{activeProfile === null && <option value="">No active profile</option>}
{panel.profiles.map((profile) => (
<option key={profile.name} value={profile.name}>
{profile.name}
</option>
))}
</select>
<button
type="button"
onClick={handleCreate}
className="shrink-0 rounded border border-border-soft px-1.5 py-1 text-xs text-text-secondary hover:bg-surface-2"
aria-label="New profile"
title="New profile (copies the current one)"
>
+
</button>
<button
type="button"
onClick={handleDelete}
disabled={!activeProfile || activeProfile === DEFAULT_PROFILE_NAME}
className="shrink-0 rounded border border-border-soft px-1.5 py-1 text-xs text-text-secondary hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Delete current profile"
title={activeProfile === DEFAULT_PROFILE_NAME ? "The Default profile can't be deleted" : 'Delete current profile'}
>
×
</button>
<HelpIcon sectionId="presets" label="Help: Presets" />
</div>
)
}
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [filename, setFilename] = useState('')
const [content, setContent] = useState('')
@@ -510,90 +555,3 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</div>
)
}
function PresetsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [newName, setNewName] = useState('')
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!newName.trim()) return
await panel.addProfile(newName.trim())
setNewName('')
}
return (
<div className="flex flex-col gap-4 text-sm text-text-secondary">
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
placeholder="new profile name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Create
</button>
</form>
{panel.profiles.length === 0 ? (
<div className="text-xs text-text-faint">No profiles yet.</div>
) : (
<ul className="flex flex-col gap-1">
{panel.profiles.map((profile) => {
const isActive = panel.settings?.active_profile === profile.name
return (
<li
key={profile.name}
className={`rounded border px-2 py-1.5 text-xs ${
isActive ? 'border-border-strong bg-surface-2' : 'border-border'
}`}
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => !isActive && panel.switchProfile(profile.name)}
disabled={isActive}
className={`truncate text-left font-medium ${
isActive ? 'cursor-default text-text' : 'text-text hover:underline'
}`}
title={isActive ? 'Active profile' : `Switch to ${profile.name}`}
>
{profile.name}
{isActive && <span className="ml-1 text-[10px] uppercase text-text-muted">active</span>}
</button>
{profile.name === DEFAULT_PROFILE_NAME ? (
<span className="ml-2 shrink-0 text-text-faintest" title="The Default profile can't be deleted">
—
</span>
) : (
<button
type="button"
onClick={() => panel.removeProfile(profile.name)}
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${profile.name}`}
>
delete
</button>
)}
</div>
<div className="mt-1 text-[10px] text-text-subtle">
{profile.param_count} params · {profile.metric_count} metrics · {profile.dashboard_count} dashboards
</div>
</li>
)
})}
</ul>
)}
<p className="text-[11px] text-text-faint">
A new profile starts as a copy of the currently active one (or empty, if none is active).
</p>
<p className="text-[11px] text-text-faint">
Switching profiles hot-swaps the running engine's declared params/metrics immediately — no restart needed.
</p>
</div>
)
}