settings icon back to the titlebar, fix statusbar-related layout issues

This commit is contained in:
Egor 2025-09-11 00:38:26 -07:00
parent dc922b333d
commit 96b1ecc7be
11 changed files with 225 additions and 212 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "1.1.2", "version": "1.2.0",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",

View file

@ -25,6 +25,7 @@ export const App = () => {
const [frontendPreference, setFrontendPreference] = const [frontendPreference, setFrontendPreference] =
useState<FrontendPreference>('koboldcpp'); useState<FrontendPreference>('koboldcpp');
const [ejectConfirmModalOpen, setEjectConfirmModalOpen] = useState(false); const [ejectConfirmModalOpen, setEjectConfirmModalOpen] = useState(false);
const isInterfaceScreen = currentScreen === 'interface';
const { const {
updateInfo: binaryUpdateInfo, updateInfo: binaryUpdateInfo,
@ -146,7 +147,7 @@ export const App = () => {
<AppShell <AppShell
header={{ height: TITLEBAR_HEIGHT }} header={{ height: TITLEBAR_HEIGHT }}
footer={{ height: STATUSBAR_HEIGHT }} footer={{ height: STATUSBAR_HEIGHT }}
padding={currentScreen === 'interface' ? 0 : 'md'} padding={isInterfaceScreen ? 0 : 'md'}
> >
<TitleBar <TitleBar
currentScreen={currentScreen || 'welcome'} currentScreen={currentScreen || 'welcome'}
@ -154,9 +155,21 @@ export const App = () => {
onTabChange={setActiveInterfaceTab} onTabChange={setActiveInterfaceTab}
onEject={handleEject} onEject={handleEject}
frontendPreference={frontendPreference} frontendPreference={frontendPreference}
onFrontendPreferenceChange={setFrontendPreference}
/> />
<AppShell.Main> <AppShell.Main
top={TITLEBAR_HEIGHT}
style={{
height: `calc(100vh - ${TITLEBAR_HEIGHT} - ${STATUSBAR_HEIGHT})`,
minHeight: `calc(100vh - ${TITLEBAR_HEIGHT} - ${STATUSBAR_HEIGHT})`,
position: 'relative',
overflow: 'auto',
paddingTop: 0,
top: TITLEBAR_HEIGHT,
paddingBottom: isInterfaceScreen ? 0 : '1rem',
}}
>
<ErrorBoundary> <ErrorBoundary>
{currentScreen === null ? ( {currentScreen === null ? (
<Center h="100%" style={{ minHeight: '25rem' }}> <Center h="100%" style={{ minHeight: '25rem' }}>
@ -191,7 +204,7 @@ export const App = () => {
</ScreenTransition> </ScreenTransition>
<ScreenTransition <ScreenTransition
isActive={currentScreen === 'interface'} isActive={isInterfaceScreen}
shouldAnimate={hasInitialized} shouldAnimate={hasInitialized}
> >
<InterfaceScreen <InterfaceScreen
@ -220,11 +233,7 @@ export const App = () => {
</AppShell.Main> </AppShell.Main>
<AppShell.Footer> <AppShell.Footer>
<StatusBar <StatusBar />
currentScreen={currentScreen}
frontendPreference={frontendPreference}
onFrontendPreferenceChange={setFrontendPreference}
/>
</AppShell.Footer> </AppShell.Footer>
<EjectConfirmModal <EjectConfirmModal

View file

@ -9,6 +9,7 @@ import {
Code, Code,
TextInput, TextInput,
Button, Button,
Box,
} from '@mantine/core'; } from '@mantine/core';
import { useState } from 'react'; import { useState } from 'react';
@ -543,68 +544,6 @@ export const CommandLineArgumentsModal = ({
} }
}; };
const renderArgument = (arg: ArgumentInfo) => (
<Stack
key={arg.flag}
gap="xs"
p="sm"
style={{ borderLeft: '3px solid var(--mantine-color-blue-4)' }}
>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap" style={{ flex: 1 }}>
<Code style={{ fontSize: '0.875em', fontWeight: 600 }}>
{arg.flag}
</Code>
{arg.aliases &&
arg.aliases.map((alias) => (
<Code key={alias} style={{ fontSize: '0.75em' }} c="dimmed">
{alias}
</Code>
))}
{arg.type && (
<Badge size="xs" variant="light" color="blue">
{arg.type}
</Badge>
)}
</Group>
{onAddArgument && (
<Button
size="xs"
variant="light"
onClick={() => handleAddArgument(arg)}
>
Add
</Button>
)}
</Group>
<Text size="sm" c="dimmed">
{arg.description}
</Text>
{(arg.metavar || arg.default !== undefined || arg.choices) && (
<Group gap="lg">
{arg.metavar && (
<Text size="xs" c="dimmed">
<strong>Format:</strong> {arg.metavar}
</Text>
)}
{arg.default !== undefined && (
<Text size="xs" c="dimmed">
<strong>Default:</strong> {String(arg.default)}
</Text>
)}
{arg.choices && (
<Text size="xs" c="dimmed">
<strong>Choices:</strong> {arg.choices.join(', ')}
</Text>
)}
</Group>
)}
</Stack>
);
return ( return (
<Modal <Modal
opened={opened} opened={opened}
@ -638,11 +577,97 @@ export const CommandLineArgumentsModal = ({
</Group> </Group>
</Accordion.Control> </Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<Stack gap="md">{args.map(renderArgument)}</Stack> <Stack gap="md">
{args.map((arg) => (
<Stack
key={arg.flag}
gap="xs"
p="sm"
style={{
borderLeft: '3px solid var(--mantine-color-blue-4)',
}}
>
<Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap" style={{ flex: 1 }}>
<Code
style={{ fontSize: '0.875em', fontWeight: 600 }}
>
{arg.flag}
</Code>
{arg.aliases &&
arg.aliases.map((alias) => (
<Code
key={alias}
style={{ fontSize: '0.75em' }}
c="dimmed"
>
{alias}
</Code>
))}
{arg.type && (
<Badge size="xs" variant="light" color="blue">
{arg.type}
</Badge>
)}
</Group>
{onAddArgument && (
<Button
size="xs"
variant="light"
onClick={() => handleAddArgument(arg)}
>
Add
</Button>
)}
</Group>
<Text size="sm" c="dimmed">
{arg.description}
</Text>
{(arg.metavar ||
arg.default !== undefined ||
arg.choices) && (
<Group gap="lg">
{arg.metavar && (
<Text size="xs" c="dimmed">
<strong>Format:</strong> {arg.metavar}
</Text>
)}
{arg.default !== undefined && (
<Text size="xs" c="dimmed">
<strong>Default:</strong> {String(arg.default)}
</Text>
)}
{arg.choices && (
<Text size="xs" c="dimmed">
<strong>Choices:</strong> {arg.choices.join(', ')}
</Text>
)}
</Group>
)}
</Stack>
))}
</Stack>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>
))} ))}
</Accordion> </Accordion>
<Box
style={{
backgroundColor: 'var(--mantine-color-body)',
padding: '0.5rem 1.5rem 0',
display: 'flex',
justifyContent: 'flex-end',
flexShrink: 0,
}}
>
<Button onClick={onClose} variant="filled">
Close
</Button>
</Box>
</Stack> </Stack>
</Modal> </Modal>
); );

View file

@ -1,17 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { Badge, Group, Tooltip, useComputedColorScheme } from '@mantine/core';
Box,
Flex,
ActionIcon,
Tooltip,
Badge,
Group,
useComputedColorScheme,
} from '@mantine/core';
import { Settings } from 'lucide-react';
import { SettingsModal } from '@/components/settings/SettingsModal';
import { safeExecute } from '@/utils/logger';
import type { FrontendPreference, Screen } from '@/types';
import type { import type {
CpuMetrics, CpuMetrics,
MemoryMetrics, MemoryMetrics,
@ -20,23 +8,14 @@ import type {
interface StatusBarProps { interface StatusBarProps {
maxDataPoints?: number; maxDataPoints?: number;
currentScreen: Screen | null;
frontendPreference: FrontendPreference;
onFrontendPreferenceChange: (preference: FrontendPreference) => void;
} }
export const StatusBar = ({ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => {
maxDataPoints = 60,
currentScreen,
frontendPreference: _frontendPreference,
onFrontendPreferenceChange,
}: StatusBarProps) => {
const [cpuMetrics, setCpuMetrics] = useState<CpuMetrics | null>(null); const [cpuMetrics, setCpuMetrics] = useState<CpuMetrics | null>(null);
const [memoryMetrics, setMemoryMetrics] = useState<MemoryMetrics | null>( const [memoryMetrics, setMemoryMetrics] = useState<MemoryMetrics | null>(
null null
); );
const [gpuMetrics, setGpuMetrics] = useState<GpuMetrics | null>(null); const [gpuMetrics, setGpuMetrics] = useState<GpuMetrics | null>(null);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const colorScheme = useComputedColorScheme('light', { const colorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: true, getInitialValueInEffect: true,
}); });
@ -74,8 +53,10 @@ export const StatusBar = ({
}, [maxDataPoints]); }, [maxDataPoints]);
return ( return (
<Box <Group
px="xs" px="xs"
gap="xs"
justify="flex-end"
style={{ style={{
backgroundColor: backgroundColor:
colorScheme === 'dark' colorScheme === 'dark'
@ -83,94 +64,59 @@ export const StatusBar = ({
: 'var(--mantine-color-gray-1)', : 'var(--mantine-color-gray-1)',
}} }}
> >
<Flex align="center" justify="space-between"> {cpuMetrics && memoryMetrics && (
<Group gap="xs"> <>
{cpuMetrics && memoryMetrics && ( <Tooltip label={`${cpuMetrics.usage.toFixed(1)}%`} position="top">
<> <Badge
<Tooltip label={`${cpuMetrics.usage.toFixed(1)}%`} position="top"> size="sm"
<Badge variant="light"
size="sm" style={{ minWidth: '5rem', textAlign: 'center' }}
variant="light" >
style={{ minWidth: '5rem', textAlign: 'center' }} CPU: {cpuMetrics.usage.toFixed(1)}%
> </Badge>
CPU: {cpuMetrics.usage.toFixed(1)}% </Tooltip>
</Badge>
</Tooltip>
<Tooltip <Tooltip
label={`${memoryMetrics.used.toFixed(1)} GB / ${memoryMetrics.total.toFixed(1)} GB (${memoryMetrics.usage.toFixed(1)}%)`} label={`${memoryMetrics.used.toFixed(1)} GB / ${memoryMetrics.total.toFixed(1)} GB (${memoryMetrics.usage.toFixed(1)}%)`}
position="top" position="top"
>
<Badge
size="sm"
variant="light"
style={{ minWidth: '5rem', textAlign: 'center' }}
>
RAM: {memoryMetrics.usage.toFixed(1)}%
</Badge>
</Tooltip>
</>
)}
{gpuMetrics?.gpus.map((gpu, index) => (
<Group gap="xs" key={`gpu-${index}`}>
<Tooltip label={`${gpu.usage.toFixed(1)}%`} position="top">
<Badge
size="sm"
variant="light"
style={{ minWidth: '5rem', textAlign: 'center' }}
>
GPU: {gpu.usage.toFixed(1)}%
</Badge>
</Tooltip>
<Tooltip
label={`${gpu.memoryUsed.toFixed(1)} GB / ${gpu.memoryTotal.toFixed(1)} GB (${gpu.memoryUsage.toFixed(1)}%)`}
position="top"
>
<Badge
size="sm"
variant="light"
style={{ minWidth: '5rem', textAlign: 'center' }}
>
VRAM: {gpu.memoryUsage.toFixed(1)}%
</Badge>
</Tooltip>
</Group>
))}
</Group>
<Tooltip label="Settings" position="top">
<ActionIcon
variant="subtle"
size="sm"
onClick={() => setSettingsModalOpen(true)}
aria-label="Open settings"
style={{
borderRadius: '0.25rem',
}}
> >
<Settings size="1rem" /> <Badge
</ActionIcon> size="sm"
</Tooltip> variant="light"
</Flex> style={{ minWidth: '5rem', textAlign: 'center' }}
>
RAM: {memoryMetrics.usage.toFixed(1)}%
</Badge>
</Tooltip>
</>
)}
<SettingsModal {gpuMetrics?.gpus.map((gpu, index) => (
isOnInterfaceScreen={currentScreen === 'interface'} <Group gap="xs" key={`gpu-${index}`}>
opened={settingsModalOpen} <Tooltip label={`${gpu.usage.toFixed(1)}%`} position="top">
onClose={async () => { <Badge
setSettingsModalOpen(false); size="sm"
const preference = await safeExecute( variant="light"
() => style={{ minWidth: '5rem', textAlign: 'center' }}
window.electronAPI.config.get( >
'frontendPreference' GPU: {gpu.usage.toFixed(1)}%
) as Promise<FrontendPreference>, </Badge>
'Failed to load frontend preference:' </Tooltip>
);
onFrontendPreferenceChange(preference || 'koboldcpp'); <Tooltip
}} label={`${gpu.memoryUsed.toFixed(1)} GB / ${gpu.memoryTotal.toFixed(1)} GB (${gpu.memoryUsage.toFixed(1)}%)`}
currentScreen={currentScreen || undefined} position="top"
/> >
</Box> <Badge
size="sm"
variant="light"
style={{ minWidth: '5rem', textAlign: 'center' }}
>
VRAM: {gpu.memoryUsage.toFixed(1)}%
</Badge>
</Tooltip>
</Group>
))}
</Group>
); );
}; };

View file

@ -7,11 +7,20 @@ import {
useComputedColorScheme, useComputedColorScheme,
AppShell, AppShell,
} from '@mantine/core'; } from '@mantine/core';
import { Minus, Square, X, Copy, CircleFadingArrowUp } from 'lucide-react'; import {
Minus,
Square,
X,
Copy,
CircleFadingArrowUp,
Settings,
} from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { soundAssets, playSound, initializeAudio } from '@/utils/sounds'; import { soundAssets, playSound, initializeAudio } from '@/utils/sounds';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker'; import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import { SettingsModal } from '@/components/settings/SettingsModal';
import { safeExecute } from '@/utils/logger';
import iconUrl from '/icon.png'; import iconUrl from '/icon.png';
import { FRONTENDS, PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants'; import { FRONTENDS, PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
import type { FrontendPreference, InterfaceTab, Screen } from '@/types'; import type { FrontendPreference, InterfaceTab, Screen } from '@/types';
@ -22,6 +31,7 @@ interface TitleBarProps {
onTabChange: (tab: InterfaceTab) => void; onTabChange: (tab: InterfaceTab) => void;
onEject: () => void; onEject: () => void;
frontendPreference: FrontendPreference; frontendPreference: FrontendPreference;
onFrontendPreferenceChange: (preference: FrontendPreference) => void;
} }
export const TitleBar = ({ export const TitleBar = ({
@ -30,6 +40,7 @@ export const TitleBar = ({
onTabChange, onTabChange,
onEject, onEject,
frontendPreference, frontendPreference,
onFrontendPreferenceChange,
}: TitleBarProps) => { }: TitleBarProps) => {
const computedColorScheme = useComputedColorScheme('light', { const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: true, getInitialValueInEffect: true,
@ -41,6 +52,7 @@ export const TitleBar = ({
const [isMouseSqueaking, setIsMouseSqueaking] = useState(false); const [isMouseSqueaking, setIsMouseSqueaking] = useState(false);
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const [isSelectOpen, setIsSelectOpen] = useState(false); const [isSelectOpen, setIsSelectOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const handleMinimize = () => { const handleMinimize = () => {
window.electronAPI.app.minimizeWindow(); window.electronAPI.app.minimizeWindow();
@ -225,6 +237,29 @@ export const TitleBar = ({
</ActionIcon> </ActionIcon>
)} )}
<ActionIcon
variant="subtle"
size={TITLEBAR_HEIGHT}
onClick={() => setSettingsModalOpen(true)}
aria-label="Open settings"
tabIndex={-1}
style={{
borderRadius: 0,
margin: 0,
}}
>
<Settings size="1.25rem" />
</ActionIcon>
<Box
style={{
width: '0.0625rem',
height: '1.25rem',
backgroundColor: 'var(--mantine-color-default-border)',
margin: '0 0.25rem',
}}
/>
{[ {[
{ {
icon: <Minus size="1rem" />, icon: <Minus size="1rem" />,
@ -263,6 +298,23 @@ export const TitleBar = ({
))} ))}
</Group> </Group>
</Box> </Box>
<SettingsModal
isOnInterfaceScreen={currentScreen === 'interface'}
opened={settingsModalOpen}
onClose={async () => {
setSettingsModalOpen(false);
const preference = await safeExecute(
() =>
window.electronAPI.config.get(
'frontendPreference'
) as Promise<FrontendPreference>,
'Failed to load frontend preference:'
);
onFrontendPreferenceChange(preference || 'koboldcpp');
}}
currentScreen={currentScreen || undefined}
/>
</AppShell.Header> </AppShell.Header>
); );
}; };

View file

@ -4,6 +4,7 @@ import {
OPENWEBUI, OPENWEBUI,
FRONTENDS, FRONTENDS,
TITLEBAR_HEIGHT, TITLEBAR_HEIGHT,
STATUSBAR_HEIGHT,
} from '@/constants'; } from '@/constants';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
@ -65,7 +66,7 @@ export const ServerTab = ({
<Box <Box
style={{ style={{
width: '100%', width: '100%',
height: `calc(100vh - ${TITLEBAR_HEIGHT})`, height: `calc(100vh - ${TITLEBAR_HEIGHT} - ${STATUSBAR_HEIGHT})`,
overflow: 'hidden', overflow: 'hidden',
}} }}
> >

View file

@ -12,7 +12,11 @@ import {
useComputedColorScheme, useComputedColorScheme,
} from '@mantine/core'; } from '@mantine/core';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { SERVER_READY_SIGNALS, TITLEBAR_HEIGHT } from '@/constants'; import {
SERVER_READY_SIGNALS,
STATUSBAR_HEIGHT,
TITLEBAR_HEIGHT,
} from '@/constants';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal'; import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
import { useLaunchConfigStore } from '@/stores/launchConfig'; import { useLaunchConfigStore } from '@/stores/launchConfig';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
@ -127,7 +131,7 @@ export const TerminalTab = forwardRef<TerminalTabRef, TerminalTabProps>(
return ( return (
<Box <Box
style={{ style={{
height: `calc(100vh - ${TITLEBAR_HEIGHT})`, height: `calc(100vh - ${TITLEBAR_HEIGHT} - ${STATUSBAR_HEIGHT})`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: isDark backgroundColor: isDark

View file

@ -55,7 +55,7 @@ export const BackendSelector = () => {
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
Backend Backend
</Text> </Text>
<InfoTooltip label="Select a backend to use. CUDA runs on NVIDIA GPUs, and is much faster. ROCm is the AMD equivalent. Vulkan and CLBlast work on all GPUs." /> <InfoTooltip label="Select a backend to use to run LLMs. CUDA runs on NVIDIA GPUs, and is much faster. ROCm is the AMD equivalent. Vulkan and CLBlast work on all GPUs." />
</Group> </Group>
<Select <Select
placeholder={ placeholder={

View file

@ -48,7 +48,7 @@ export const GeneralTab = ({
() => [ () => [
{ {
value: 'koboldcpp', value: 'koboldcpp',
label: 'Built-in Interface', label: 'Built-in',
badges: ['Text', 'Image'], badges: ['Text', 'Image'],
}, },
{ {

View file

@ -72,7 +72,6 @@ export const SettingsModal = ({
styles={{ styles={{
...MODAL_STYLES_WITH_TITLEBAR, ...MODAL_STYLES_WITH_TITLEBAR,
content: { content: {
...MODAL_STYLES_WITH_TITLEBAR.content,
paddingBottom: 0, paddingBottom: 0,
}, },
body: { body: {

View file

@ -1,28 +1,5 @@
@import '@mantine/core/styles.css'; @import '@mantine/core/styles.css';
.mantine-AppShell-main {
overflow: auto !important;
height: calc(100vh - 2.5rem) !important;
position: relative !important;
top: 2.5rem !important;
margin-top: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
min-height: calc(100vh - 2.5rem) !important;
}
.mantine-AppShell-root {
--app-shell-header-offset: 2.5rem;
--app-shell-navbar-offset: 0rem;
--app-shell-aside-offset: 0rem;
--app-shell-footer-offset: 0rem;
}
/* TODO: something (mantine?) is setting this sometimes to "none" on the terminal tab */
body {
user-select: auto !important;
}
/* Custom scrollbars */ /* Custom scrollbars */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 0.5rem; width: 0.5rem;