new titlebar component that'll work on wayland, move about dialog to the settings modal

This commit is contained in:
Egor 2025-08-31 11:55:26 -07:00
parent 0b829cbfe2
commit 2413f66c41
21 changed files with 564 additions and 667 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "gerbil", "name": "gerbil",
"productName": "Gerbil", "productName": "Gerbil",
"version": "0.9.1", "version": "0.9.2",
"description": "Run Large Language Models locally", "description": "Run Large Language Models locally",
"main": "out/main/index.js", "main": "out/main/index.js",
"homepage": "./", "homepage": "./",
@ -80,8 +80,8 @@
"vite": "^7.1.3" "vite": "^7.1.3"
}, },
"dependencies": { "dependencies": {
"@mantine/core": "^8.2.7", "@mantine/core": "^8.2.8",
"@mantine/hooks": "^8.2.7", "@mantine/hooks": "^8.2.8",
"axios": "^1.11.0", "axios": "^1.11.0",
"execa": "^9.6.0", "execa": "^9.6.0",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",

View file

@ -8,11 +8,11 @@ import { UpdateAvailableModal } from '@/components/UpdateAvailableModal';
import { SettingsModal } from '@/components/settings/SettingsModal'; import { SettingsModal } from '@/components/settings/SettingsModal';
import { EjectConfirmModal } from '@/components/EjectConfirmModal'; import { EjectConfirmModal } from '@/components/EjectConfirmModal';
import { ScreenTransition } from '@/components/ScreenTransition'; import { ScreenTransition } from '@/components/ScreenTransition';
import { AppHeader } from '@/components/AppHeader'; import { TitleBar } from '@/components/TitleBar';
import { useUpdateChecker } from '@/hooks/useUpdateChecker'; import { useUpdateChecker } from '@/hooks/useUpdateChecker';
import { useKoboldVersions } from '@/hooks/useKoboldVersions'; import { useKoboldVersions } from '@/hooks/useKoboldVersions';
import { UI } from '@/constants';
import { Logger } from '@/utils/logger'; import { Logger } from '@/utils/logger';
import { TITLEBAR_HEIGHT } from '@/constants';
import type { DownloadItem } from '@/types/electron'; import type { DownloadItem } from '@/types/electron';
import type { InterfaceTab, FrontendPreference, Screen } from '@/types'; import type { InterfaceTab, FrontendPreference, Screen } from '@/types';
@ -149,28 +149,24 @@ export const App = () => {
return ( return (
<AppShell <AppShell
header={{ height: currentScreen === 'welcome' ? 0 : UI.HEADER_HEIGHT }} header={{ height: TITLEBAR_HEIGHT }}
padding={currentScreen === 'interface' ? 0 : 'md'} padding={currentScreen === 'interface' ? 0 : 'md'}
> >
{currentScreen !== 'welcome' && ( <AppShell.Header style={{ display: 'flex', flexDirection: 'column' }}>
<AppHeader <TitleBar
currentScreen={currentScreen} currentScreen={currentScreen || 'welcome'}
activeInterfaceTab={activeInterfaceTab} currentTab={activeInterfaceTab}
setActiveInterfaceTab={setActiveInterfaceTab} onTabChange={setActiveInterfaceTab}
isImageGenerationMode={isImageGenerationMode}
frontendPreference={frontendPreference}
onEject={handleEject} onEject={handleEject}
onSettingsOpen={() => setSettingsOpened(true)} onOpenSettings={() => setSettingsOpened(true)}
/> />
)} </AppShell.Header>
<AppShell.Main <AppShell.Main
style={{ style={{
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
minHeight: minHeight:
currentScreen === 'welcome' currentScreen === 'welcome' ? '100vh' : 'calc(100vh - 2rem)',
? '100vh'
: `calc(100vh - ${UI.HEADER_HEIGHT}px)`,
}} }}
> >
{currentScreen === null ? ( {currentScreen === null ? (

View file

@ -1,190 +0,0 @@
import { useState } from 'react';
import {
AppShell,
Group,
ActionIcon,
rem,
Button,
Select,
Title,
Image,
Tooltip,
} from '@mantine/core';
import { Settings, ArrowLeft, CircleFadingArrowUp } from 'lucide-react';
import { soundAssets, playSound, initializeAudio } from '@/utils/sounds';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import iconUrl from '/icon.png';
import { FRONTENDS } from '@/constants';
import type { InterfaceTab, FrontendPreference, Screen } from '@/types';
interface AppHeaderProps {
currentScreen: Screen | null;
activeInterfaceTab: InterfaceTab;
setActiveInterfaceTab: (tab: InterfaceTab) => void;
isImageGenerationMode: boolean;
frontendPreference: FrontendPreference;
onEject: () => void;
onSettingsOpen: () => void;
}
export const AppHeader = ({
currentScreen,
activeInterfaceTab,
setActiveInterfaceTab,
isImageGenerationMode,
frontendPreference,
onEject,
onSettingsOpen,
}: AppHeaderProps) => {
const [logoClickCount, setLogoClickCount] = useState(0);
const [isElephantMode, setIsElephantMode] = useState(false);
const [isMouseSqueaking, setIsMouseSqueaking] = useState(false);
const { hasUpdate, openReleasePage } = useAppUpdateChecker();
const handleLogoClick = async () => {
await initializeAudio();
setLogoClickCount((prev) => prev + 1);
try {
if (logoClickCount >= 10 && Math.random() < 0.1) {
setIsElephantMode(true);
await playSound(soundAssets.elephant, 0.6);
setTimeout(() => {
setIsElephantMode(false);
}, 1500);
} else {
setIsMouseSqueaking(true);
const squeakNumber = Math.floor(Math.random() * 5);
await playSound(soundAssets.mouseSqueaks[squeakNumber], 0.4);
setTimeout(() => {
setIsMouseSqueaking(false);
}, 300);
}
} catch {
void 0;
}
};
return (
<AppShell.Header>
<Group h="100%" px="md" justify="space-between" align="center">
<div style={{ minWidth: '6.25rem' }}>
{currentScreen === 'interface' ? (
<Button
variant="light"
color="red"
leftSection={<ArrowLeft size={16} />}
onClick={onEject}
>
Eject
</Button>
) : (
<Group gap="sm" align="center">
<Image
src={iconUrl}
alt="Gerbil"
w={28}
h={28}
style={{
minWidth: 28,
minHeight: 28,
cursor: 'pointer',
userSelect: 'none',
transition: 'transform 0.15s ease-in-out',
transform: isElephantMode
? 'scale(1.3) rotate(5deg)'
: 'scale(1) rotate(0deg)',
animation: isElephantMode
? 'elephantShake 1.5s ease-in-out'
: isMouseSqueaking
? 'mouseSqueak 0.3s ease-in-out'
: 'none',
}}
onClick={handleLogoClick}
/>
<Title order={4} fw={500}>
Gerbil
</Title>
</Group>
)}
</div>
{currentScreen === 'interface' && (
<Select
value={activeInterfaceTab}
onChange={(value) =>
setActiveInterfaceTab((value || 'terminal') as InterfaceTab)
}
data={[
{
value: 'chat',
label:
frontendPreference === 'sillytavern'
? FRONTENDS.SILLYTAVERN
: isImageGenerationMode
? FRONTENDS.STABLE_UI
: FRONTENDS.KOBOLDAI_LITE,
},
{ value: 'terminal', label: 'Terminal' },
]}
allowDeselect={false}
styles={{
input: {
minWidth: '9.375rem',
textAlign: 'center',
border: 'none',
backgroundColor: 'transparent',
fontWeight: 500,
},
dropdown: {
minWidth: '9.375rem',
},
}}
/>
)}
<div
style={{
minWidth: '6.25rem',
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
alignItems: 'center',
}}
>
{hasUpdate && (
<Tooltip label="New release available" position="bottom">
<ActionIcon
variant="light"
color="orange"
size="xl"
onClick={openReleasePage}
aria-label="New release available"
>
<CircleFadingArrowUp
style={{ width: rem(20), height: rem(20) }}
/>
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Settings" position="bottom">
<ActionIcon
variant="subtle"
color="gray"
size="xl"
onClick={onSettingsOpen}
aria-label="Open settings"
style={{
transition: 'all 200ms ease',
}}
>
<Settings style={{ width: rem(20), height: rem(20) }} />
</ActionIcon>
</Tooltip>
</div>
</Group>
</AppShell.Header>
);
};

255
src/components/TitleBar.tsx Normal file
View file

@ -0,0 +1,255 @@
import {
Group,
ActionIcon,
Box,
Image,
Select,
useComputedColorScheme,
} from '@mantine/core';
import {
Minus,
Square,
X,
Settings,
Copy,
CircleFadingArrowUp,
} from 'lucide-react';
import { useState } from 'react';
import { soundAssets, playSound, initializeAudio } from '@/utils/sounds';
import { useAppUpdateChecker } from '@/hooks/useAppUpdateChecker';
import iconUrl from '/icon.png';
import type { InterfaceTab, Screen } from '@/types';
import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
interface TitleBarProps {
currentScreen: Screen;
currentTab?: InterfaceTab;
onTabChange?: (tab: InterfaceTab) => void;
onEject?: () => void;
onOpenSettings?: () => void;
}
export const TitleBar = ({
currentScreen,
currentTab,
onTabChange,
onEject,
onOpenSettings,
}: TitleBarProps) => {
const computedColorScheme = useComputedColorScheme('light', {
getInitialValueInEffect: false,
});
const { hasUpdate, openReleasePage } = useAppUpdateChecker();
const [logoClickCount, setLogoClickCount] = useState(0);
const [isElephantMode, setIsElephantMode] = useState(false);
const [isMouseSqueaking, setIsMouseSqueaking] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const handleMinimize = () => {
window.electronAPI.app.minimizeWindow();
};
const handleMaximize = async () => {
await window.electronAPI.app.maximizeWindow();
setIsMaximized(!isMaximized);
};
const handleClose = () => {
window.electronAPI.app.closeWindow();
};
const handleLogoClick = async () => {
await initializeAudio();
setLogoClickCount((prev) => prev + 1);
try {
if (logoClickCount >= 10 && Math.random() < 0.1) {
setIsElephantMode(true);
await playSound(soundAssets.elephant, 0.6);
setTimeout(() => {
setIsElephantMode(false);
}, 1500);
} else {
setIsMouseSqueaking(true);
const squeakNumber = Math.floor(Math.random() * 5);
await playSound(soundAssets.mouseSqueaks[squeakNumber], 0.4);
setTimeout(() => {
setIsMouseSqueaking(false);
}, 300);
}
} catch {
void 0;
}
};
return (
<Box
style={{
height: TITLEBAR_HEIGHT,
padding: '0.125rem 0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor:
computedColorScheme === 'dark'
? 'var(--mantine-color-dark-7)'
: 'var(--mantine-color-gray-0)',
borderBottom: '1px solid var(--mantine-color-default-border)',
WebkitAppRegion: 'drag',
userSelect: 'none',
position: 'relative',
}}
>
<Group gap="0.5rem" align="center" style={{ WebkitAppRegion: 'no-drag' }}>
<Image
src={iconUrl}
alt={PRODUCT_NAME}
w={24}
h={24}
style={{
minWidth: 24,
minHeight: 24,
cursor: 'pointer',
userSelect: 'none',
transition: 'transform 0.15s ease-in-out',
transform: isElephantMode
? 'scale(1.3) rotate(5deg)'
: 'scale(1) rotate(0deg)',
animation: isElephantMode
? 'elephantShake 1.5s ease-in-out'
: isMouseSqueaking
? 'mouseSqueak 0.3s ease-in-out'
: 'none',
}}
onClick={handleLogoClick}
/>
</Group>
<Box
style={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
WebkitAppRegion: 'no-drag',
}}
>
{currentScreen === 'interface' && (
<Select
placeholder="Interface"
value={currentTab}
onChange={(value) => {
if (value === 'eject') {
onEject?.();
} else {
onTabChange?.(value as InterfaceTab);
}
}}
data={[
{
value: 'chat',
label: 'Chat',
},
{ value: 'terminal', label: 'Terminal' },
{ value: 'eject', label: 'Eject' },
]}
allowDeselect={false}
variant="unstyled"
size="sm"
style={{
textAlign: 'center',
minWidth: '120px',
}}
styles={{
input: {
textAlign: 'center',
backgroundColor: 'transparent',
border: 'none',
fontWeight: 500,
userSelect: 'none',
cursor: 'pointer',
},
}}
/>
)}
</Box>
<Group gap="0.125rem" style={{ WebkitAppRegion: 'no-drag' }}>
{hasUpdate && (
<ActionIcon
variant="subtle"
color="orange"
size="2rem"
onClick={openReleasePage}
aria-label="New release available"
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
>
<CircleFadingArrowUp size="1.25rem" />
</ActionIcon>
)}
<ActionIcon
variant="subtle"
size="2rem"
onClick={onOpenSettings}
aria-label="Open settings"
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
>
<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" />,
onClick: handleMinimize,
color: undefined,
label: 'Minimize window',
},
{
icon: isMaximized ? <Copy size="1rem" /> : <Square size="1rem" />,
onClick: handleMaximize,
color: undefined,
label: isMaximized ? 'Restore window' : 'Maximize window',
},
{
icon: <X size="1.25rem" />,
onClick: handleClose,
color: 'red' as const,
label: 'Close window',
},
].map((button, index) => (
<ActionIcon
key={index}
variant="subtle"
size="2rem"
onClick={button.onClick}
color={button.color}
aria-label={button.label}
style={{
borderRadius: '0.25rem',
margin: '0.125rem',
}}
>
{button.icon}
</ActionIcon>
))}
</Group>
</Box>
);
};

View file

@ -1,6 +1,6 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Box, Text, Stack } from '@mantine/core'; import { Box, Text, Stack } from '@mantine/core';
import { UI, SILLYTAVERN, FRONTENDS } from '@/constants'; import { SILLYTAVERN, FRONTENDS } from '@/constants';
import type { ServerTabMode, FrontendPreference } from '@/types'; import type { ServerTabMode, FrontendPreference } from '@/types';
interface ServerTabProps { interface ServerTabProps {
@ -60,7 +60,7 @@ export const ServerTab = ({
<Box <Box
style={{ style={{
width: '100%', width: '100%',
height: `calc(100vh - ${UI.HEADER_HEIGHT}px)`, height: 'calc(100vh - 2rem)',
overflow: 'hidden', overflow: 'hidden',
}} }}
> >

View file

@ -8,7 +8,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import styles from '@/styles/layout.module.css'; import styles from '@/styles/layout.module.css';
import { UI, SERVER_READY_SIGNALS } from '@/constants'; import { SERVER_READY_SIGNALS } from '@/constants';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal'; import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
import { useLaunchConfigStore } from '@/stores/launchConfigStore'; import { useLaunchConfigStore } from '@/stores/launchConfigStore';
import type { FrontendPreference } from '@/types'; import type { FrontendPreference } from '@/types';
@ -104,7 +104,7 @@ export const TerminalTab = ({
return ( return (
<Box <Box
style={{ style={{
height: `calc(100vh - ${UI.HEADER_HEIGHT}px)`, height: 'calc(100vh - 2rem)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: isDark backgroundColor: isDark

View file

@ -125,6 +125,32 @@ export const AdvancedTab = () => {
disabled={!isGpuBackend} disabled={!isGpuBackend}
/> />
</Group> </Group>
<Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip
checked={noavx2}
onChange={handleNoavx2Change}
label="Disable AVX2"
tooltip={
!backendSupport?.noavx2 && !isLoading
? 'This binary does not support the no-AVX2 mode.'
: 'Do not use AVX2 instructions, a slower compatibility mode for older devices.'
}
disabled={isLoading || !backendSupport?.noavx2}
/>
<CheckboxWithTooltip
checked={failsafe}
onChange={handleFailsafeChange}
label="Failsafe"
tooltip={
!backendSupport?.failsafe && !isLoading
? 'This binary does not support failsafe mode.'
: 'Use failsafe mode, extremely slow CPU only compatibility mode that should work on all devices. Can be combined with useclblast if your device supports OpenCL.'
}
disabled={isLoading || !backendSupport?.failsafe}
/>
</Group>
</Stack> </Stack>
</div> </div>
@ -177,41 +203,6 @@ export const AdvancedTab = () => {
</Stack> </Stack>
</div> </div>
<div>
<Group gap="xs" align="center" mb="md">
<Text size="sm" fw={600}>
Hardware Compatibility
</Text>
</Group>
<Stack gap="md">
<Group gap="lg" align="flex-start" wrap="nowrap">
<CheckboxWithTooltip
checked={noavx2}
onChange={handleNoavx2Change}
label="Disable AVX2"
tooltip={
!backendSupport?.noavx2 && !isLoading
? 'This binary does not support the no-AVX2 mode.'
: 'Do not use AVX2 instructions, a slower compatibility mode for older devices.'
}
disabled={isLoading || !backendSupport?.noavx2}
/>
<CheckboxWithTooltip
checked={failsafe}
onChange={handleFailsafeChange}
label="Failsafe"
tooltip={
!backendSupport?.failsafe && !isLoading
? 'This binary does not support failsafe mode.'
: 'Use failsafe mode, extremely slow CPU only compatibility mode that should work on all devices. Can be combined with useclblast if your device supports OpenCL.'
}
disabled={isLoading || !backendSupport?.failsafe}
/>
</Group>
</Stack>
</div>
<div> <div>
<Group gap="xs" align="center" mb="xs"> <Group gap="xs" align="center" mb="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>

View file

@ -55,6 +55,7 @@ export const GeneralTab = () => {
max={131072} max={131072}
step={1} step={1}
onChange={handleContextSizeChangeWithStep} onChange={handleContextSizeChangeWithStep}
style={{ marginBottom: '0.5rem' }}
/> />
</div> </div>
</Stack> </Stack>

View file

@ -314,7 +314,7 @@ export const LaunchScreen = ({
onChange={setActiveTab} onChange={setActiveTab}
styles={{ styles={{
root: { root: {
maxHeight: '22rem', maxHeight: '24rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}, },
@ -350,7 +350,7 @@ export const LaunchScreen = ({
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
<Group justify="flex-end" pt="md"> <Group justify="flex-end">
<WarningDisplay warnings={combinedWarnings}> <WarningDisplay warnings={combinedWarnings}>
<Button <Button
radius="md" radius="md"

View file

@ -1,3 +1,4 @@
import { PRODUCT_NAME } from '@/constants';
import { import {
Container, Container,
Card, Card,
@ -23,7 +24,7 @@ export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
<Stack gap="lg" align="center"> <Stack gap="lg" align="center">
<Stack gap="md" align="center"> <Stack gap="md" align="center">
<Title order={1} ta="center"> <Title order={1} ta="center">
Gerbil {PRODUCT_NAME}
</Title> </Title>
<Text size="lg" c="dimmed" ta="center" maw={600}> <Text size="lg" c="dimmed" ta="center" maw={600}>
Run Large Language Models locally Run Large Language Models locally

View file

@ -0,0 +1,126 @@
import { useState, useEffect } from 'react';
import {
Text,
Stack,
Anchor,
Group,
Card,
Image,
Center,
Badge,
rem,
} from '@mantine/core';
import { Github } from 'lucide-react';
import type { VersionInfo } from '@/types/electron';
import { PRODUCT_NAME } from '@/constants';
import iconUrl from '/icon.png';
export const AboutTab = () => {
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
useEffect(() => {
const loadVersionInfo = async () => {
try {
const info = await window.electronAPI.app.getVersionInfo();
setVersionInfo(info);
} catch (error) {
window.electronAPI.logs.logError(
'Failed to load version info',
error as Error
);
}
};
loadVersionInfo();
}, []);
if (!versionInfo) {
return (
<Center h="100%">
<Text c="dimmed">Loading version information...</Text>
</Center>
);
}
const versionItems = [
{ label: 'App Version', value: versionInfo.appVersion },
{ label: 'Electron', value: versionInfo.electronVersion },
{ label: 'Node.js', value: versionInfo.nodeVersion },
{ label: 'Chromium', value: versionInfo.chromeVersion },
{ label: 'V8', value: versionInfo.v8Version },
{
label: 'Operating System',
value: `${versionInfo.platform} ${versionInfo.arch} (${versionInfo.osVersion})`,
},
];
return (
<Stack gap="md">
<Card withBorder radius="md" p="xs">
<Group align="center" gap="lg" wrap="nowrap">
<Image
src={iconUrl}
alt={PRODUCT_NAME}
w={64}
h={64}
style={{ minWidth: 64, minHeight: 64 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Group align="center" gap="md" wrap="nowrap">
<Text size="xl" fw={700}>
{PRODUCT_NAME}
</Text>
<Badge
variant="light"
color="blue"
size="lg"
style={{ textTransform: 'none' }}
>
v{versionInfo.appVersion}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt="xs">
A desktop app to easily run Large Language Models locally.
</Text>
<Anchor
href="https://github.com/lone-cloud/gerbil"
target="_blank"
onClick={(e) => {
e.preventDefault();
window.electronAPI.app.openExternal(
'https://github.com/lone-cloud/gerbil'
);
}}
style={{ textDecoration: 'none' }}
>
<Group gap="xs" align="center">
<Github style={{ width: rem(16), height: rem(16) }} />
<Text size="sm" fw={500}>
GitHub
</Text>
</Group>
</Anchor>
</div>
</Group>
</Card>
<Card withBorder radius="md" p="xs">
<Stack gap="xs">
{versionItems.map((item, index) => (
<Group key={index} gap="md" align="center" wrap="nowrap">
<Text size="sm" fw={500} c="dimmed" style={{ minWidth: '120px' }}>
{item.label}:
</Text>
<Text
size="sm"
ff="monospace"
style={{
wordBreak: 'break-all',
flex: 1,
}}
>
{item.value}
</Text>
</Group>
))}
</Stack>
</Card>
</Stack>
);
};

View file

@ -1,9 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal, Tabs, Text, Group, rem, Button, Box } from '@mantine/core'; import { Modal, Tabs, Text, Group, rem, Button, Box } from '@mantine/core';
import { Settings, Palette, SlidersHorizontal, GitBranch } from 'lucide-react'; import {
Settings,
Palette,
SlidersHorizontal,
GitBranch,
Info,
} from 'lucide-react';
import { GeneralTab } from '@/components/settings/GeneralTab'; import { GeneralTab } from '@/components/settings/GeneralTab';
import { VersionsTab } from '@/components/settings/VersionsTab'; import { VersionsTab } from '@/components/settings/VersionsTab';
import { AppearanceTab } from '@/components/settings/AppearanceTab'; import { AppearanceTab } from '@/components/settings/AppearanceTab';
import { AboutTab } from '@/components/settings/AboutTab';
import type { Screen } from '@/types'; import type { Screen } from '@/types';
interface SettingsModalProps { interface SettingsModalProps {
@ -92,6 +99,10 @@ export const SettingsModal = ({
paddingLeft: '24px', paddingLeft: '24px',
paddingRight: '24px', paddingRight: '24px',
}, },
tabLabel: {
textAlign: 'left',
justifyContent: 'flex-start',
},
}} }}
> >
<Tabs.List> <Tabs.List>
@ -121,6 +132,12 @@ export const SettingsModal = ({
> >
Appearance Appearance
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab
value="about"
leftSection={<Info style={{ width: rem(16), height: rem(16) }} />}
>
About
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="general"> <Tabs.Panel value="general">
@ -136,12 +153,16 @@ export const SettingsModal = ({
<Tabs.Panel value="appearance"> <Tabs.Panel value="appearance">
<AppearanceTab /> <AppearanceTab />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="about">
<AboutTab />
</Tabs.Panel>
</Tabs> </Tabs>
<Box <Box
style={{ style={{
backgroundColor: 'var(--mantine-color-body)', backgroundColor: 'var(--mantine-color-body)',
padding: '1.5rem', padding: '0.5rem 1.5rem 1.5rem',
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
flexShrink: 0, flexShrink: 0,

View file

@ -2,11 +2,7 @@ export const PRODUCT_NAME = 'Gerbil';
export const CONFIG_FILE_NAME = 'config.json'; export const CONFIG_FILE_NAME = 'config.json';
export const UI = { export const TITLEBAR_HEIGHT = '2.5rem';
HEADER_HEIGHT: 60,
} as const;
export const { HEADER_HEIGHT } = UI;
export const SERVER_READY_SIGNALS = { export const SERVER_READY_SIGNALS = {
KOBOLDCPP: 'Please connect to custom endpoint at', KOBOLDCPP: 'Please connect to custom endpoint at',

View file

@ -55,7 +55,8 @@ export class GerbilApp {
this.hardwareManager, this.hardwareManager,
this.binaryManager, this.binaryManager,
this.logManager, this.logManager,
this.sillyTavernManager this.sillyTavernManager,
this.windowManager
); );
} }
@ -93,7 +94,6 @@ export class GerbilApp {
app.setAppUserModelId('com.gerbil.app'); app.setAppUserModelId('com.gerbil.app');
} }
this.windowManager.setupApplicationMenu();
this.windowManager.createMainWindow(); this.windowManager.createMainWindow();
this.ipcHandlers.setupHandlers(); this.ipcHandlers.setupHandlers();

View file

@ -1,8 +1,10 @@
import { ipcMain, shell, app } from 'electron'; import { ipcMain, shell, app } from 'electron';
import * as os from 'os';
import type { KoboldCppManager } from '@/main/managers/KoboldCppManager'; import type { KoboldCppManager } from '@/main/managers/KoboldCppManager';
import type { ConfigManager } from '@/main/managers/ConfigManager'; import type { ConfigManager } from '@/main/managers/ConfigManager';
import type { LogManager } from '@/main/managers/LogManager'; import type { LogManager } from '@/main/managers/LogManager';
import type { SillyTavernManager } from '@/main/managers/SillyTavernManager'; import type { SillyTavernManager } from '@/main/managers/SillyTavernManager';
import type { WindowManager } from '@/main/managers/WindowManager';
import { HardwareManager } from '@/main/managers/HardwareManager'; import { HardwareManager } from '@/main/managers/HardwareManager';
import { BinaryManager } from '@/main/managers/BinaryManager'; import { BinaryManager } from '@/main/managers/BinaryManager';
@ -13,6 +15,7 @@ export class IPCHandlers {
private sillyTavernManager: SillyTavernManager; private sillyTavernManager: SillyTavernManager;
private hardwareManager: HardwareManager; private hardwareManager: HardwareManager;
private binaryManager: BinaryManager; private binaryManager: BinaryManager;
private windowManager: WindowManager;
constructor( constructor(
koboldManager: KoboldCppManager, koboldManager: KoboldCppManager,
@ -20,7 +23,8 @@ export class IPCHandlers {
hardwareManager: HardwareManager, hardwareManager: HardwareManager,
binaryManager: BinaryManager, binaryManager: BinaryManager,
logManager: LogManager, logManager: LogManager,
sillyTavernManager: SillyTavernManager sillyTavernManager: SillyTavernManager,
windowManager: WindowManager
) { ) {
this.koboldManager = koboldManager; this.koboldManager = koboldManager;
this.configManager = configManager; this.configManager = configManager;
@ -28,6 +32,7 @@ export class IPCHandlers {
this.sillyTavernManager = sillyTavernManager; this.sillyTavernManager = sillyTavernManager;
this.hardwareManager = hardwareManager; this.hardwareManager = hardwareManager;
this.binaryManager = binaryManager; this.binaryManager = binaryManager;
this.windowManager = windowManager;
} }
private async launchKoboldCppWithCustomFrontends(args: string[] = []) { private async launchKoboldCppWithCustomFrontends(args: string[] = []) {
@ -146,6 +151,17 @@ export class IPCHandlers {
ipcMain.handle('app:getVersion', () => app.getVersion()); ipcMain.handle('app:getVersion', () => app.getVersion());
ipcMain.handle('app:getVersionInfo', () => ({
appVersion: app.getVersion(),
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
chromeVersion: process.versions.chrome,
v8Version: process.versions.v8,
osVersion: os.release(),
platform: process.platform,
arch: process.arch,
}));
ipcMain.handle('app:openExternal', async (_, url) => { ipcMain.handle('app:openExternal', async (_, url) => {
try { try {
await shell.openExternal(url); await shell.openExternal(url);
@ -155,6 +171,25 @@ export class IPCHandlers {
} }
}); });
ipcMain.handle('app:minimizeWindow', () => {
const mainWindow = this.windowManager.getMainWindow();
mainWindow?.minimize();
});
ipcMain.handle('app:maximizeWindow', () => {
const mainWindow = this.windowManager.getMainWindow();
if (mainWindow?.isMaximized()) {
mainWindow.restore();
} else {
mainWindow?.maximize();
}
});
ipcMain.handle('app:closeWindow', () => {
const mainWindow = this.windowManager.getMainWindow();
mainWindow?.close();
});
ipcMain.handle('logs:logError', (_, message: string, error?: Error) => { ipcMain.handle('logs:logError', (_, message: string, error?: Error) => {
this.logManager.logError(message, error); this.logManager.logError(message, error);
}); });

View file

@ -1,14 +1,16 @@
import { BrowserWindow, app, Menu, shell, nativeImage, screen } from 'electron'; import { BrowserWindow, app, shell, nativeImage, screen, Menu } from 'electron';
import * as os from 'os';
import { join } from 'path'; import { join } from 'path';
import { stripVTControlCharacters } from 'util'; import { stripVTControlCharacters } from 'util';
import { GITHUB_API, PRODUCT_NAME } from '../../constants'; import { PRODUCT_NAME } from '../../constants';
import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc'; import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc';
import { readTextFile } from '@/utils/fs';
export class WindowManager { export class WindowManager {
private mainWindow: BrowserWindow | null = null; private mainWindow: BrowserWindow | null = null;
private isDevelopment(): boolean {
return process.env.NODE_ENV === 'development' || !app.isPackaged;
}
private getIconPath(): string { private getIconPath(): string {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
const projectRoot = join(__dirname, '../..'); const projectRoot = join(__dirname, '../..');
@ -17,20 +19,17 @@ export class WindowManager {
return join(process.resourcesPath, 'assets/icon.png'); return join(process.resourcesPath, 'assets/icon.png');
} }
private isDevelopment(): boolean {
return process.env.NODE_ENV === 'development' || !app.isPackaged;
}
createMainWindow(): BrowserWindow { createMainWindow(): BrowserWindow {
const iconPath = this.getIconPath(); const iconPath = this.getIconPath();
const iconImage = nativeImage.createFromPath(iconPath); const iconImage = nativeImage.createFromPath(iconPath);
const { workAreaSize } = screen.getPrimaryDisplay(); const { workAreaSize } = screen.getPrimaryDisplay();
const windowHeight = Math.floor(workAreaSize.height * 0.9); const windowHeight = Math.floor(workAreaSize.height * 0.85);
this.mainWindow = new BrowserWindow({ this.mainWindow = new BrowserWindow({
width: 1000, width: 1000,
height: windowHeight, height: windowHeight,
frame: false,
icon: iconImage, icon: iconImage,
title: PRODUCT_NAME, title: PRODUCT_NAME,
show: false, show: false,
@ -47,29 +46,6 @@ export class WindowManager {
if (process.platform === 'linux') { if (process.platform === 'linux') {
this.mainWindow.setIcon(iconImage); this.mainWindow.setIcon(iconImage);
if (
process.env.WAYLAND_DISPLAY ||
process.env.XDG_SESSION_TYPE === 'wayland'
) {
this.mainWindow.setRepresentedFilename('');
const retrySetIcon = () => {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.setIcon(iconImage);
}
};
setTimeout(retrySetIcon, 50);
setTimeout(retrySetIcon, 100);
setTimeout(retrySetIcon, 250);
setTimeout(retrySetIcon, 500);
setTimeout(retrySetIcon, 1000);
this.mainWindow.on('show', retrySetIcon);
this.mainWindow.on('focus', retrySetIcon);
this.mainWindow.on('restore', retrySetIcon);
}
} }
this.mainWindow.once('ready-to-show', () => { this.mainWindow.once('ready-to-show', () => {
@ -119,36 +95,10 @@ export class WindowManager {
}); });
this.setupContextMenu(); this.setupContextMenu();
return this.mainWindow; return this.mainWindow;
} }
getMainWindow(): BrowserWindow | null {
return this.mainWindow;
}
sendToRenderer<T extends IPCChannel>(
channel: T,
...args: IPCChannelPayloads[T]
): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, ...args);
}
}
sendKoboldOutput(message: string, raw?: boolean): void {
const cleanMessage = stripVTControlCharacters(message);
this.sendToRenderer(
'kobold-output',
raw ? cleanMessage : `${cleanMessage}\n`
);
}
public cleanup() {
if (this.mainWindow) {
this.mainWindow.removeAllListeners();
}
}
private setupContextMenu() { private setupContextMenu() {
if (!this.mainWindow) return; if (!this.mainWindow) return;
@ -191,172 +141,30 @@ export class WindowManager {
}); });
} }
setupApplicationMenu() { getMainWindow(): BrowserWindow | null {
const isDev = this.isDevelopment(); return this.mainWindow;
const template = [
{
label: 'File',
submenu: [
{
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
app.quit();
},
},
],
},
{
label: 'Edit',
submenu: [
{ label: 'Undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
{ label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', role: 'redo' },
{ type: 'separator' },
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
],
},
{
label: 'View',
submenu: [
...(isDev
? [
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
role: 'reload' as const,
},
{
label: 'Force Reload',
accelerator: 'CmdOrCtrl+Shift+R',
role: 'forceReload' as const,
},
{
label: 'Toggle Developer Tools',
accelerator: 'F12',
click: () => {
if (this.mainWindow) {
this.mainWindow.webContents.toggleDevTools();
}
},
},
{ type: 'separator' as const },
]
: []),
{
label: 'Actual Size',
accelerator: 'CmdOrCtrl+0',
role: 'resetZoom',
},
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+Plus', role: 'zoomIn' },
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', role: 'zoomOut' },
{ type: 'separator' },
{
label: 'Toggle Fullscreen',
accelerator: 'F11',
role: 'togglefullscreen',
},
],
},
{
label: 'Window',
submenu: [
{ label: 'Minimize', accelerator: 'CmdOrCtrl+M', role: 'minimize' },
{ label: 'Close', accelerator: 'CmdOrCtrl+W', role: 'close' },
],
},
{
label: 'Help',
submenu: [
{
label: 'KoboldCpp Wiki',
click: () => {
shell.openExternal(
`https://github.com/${GITHUB_API.KOBOLDCPP_REPO}/wiki`
);
},
},
{
label: 'View Error Logs',
click: () => {
const logsDir = join(app.getPath('userData'), 'logs');
shell.openPath(logsDir);
},
},
{ type: 'separator' },
{
label: 'About',
click: async () => {
await this.showAboutDialog();
},
},
],
},
];
const menu = Menu.buildFromTemplate(
template as Parameters<typeof Menu.buildFromTemplate>[0]
);
Menu.setApplicationMenu(menu);
} }
private async showAboutDialog() { sendToRenderer<T extends IPCChannel>(
const packagePath = join(app.getAppPath(), 'package.json'); channel: T,
const packageContent = await readTextFile(packagePath); ...args: IPCChannelPayloads[T]
const packageInfo = packageContent ? JSON.parse(packageContent) : {}; ): void {
const electronVersion = process.versions.electron; if (this.mainWindow && !this.mainWindow.isDestroyed()) {
const chromeVersion = process.versions.chrome; this.mainWindow.webContents.send(channel, ...args);
const nodeVersion = process.versions.node; }
const v8Version = process.versions.v8; }
const osInfo = `${process.platform} ${process.arch} ${os.release()}`;
sendKoboldOutput(message: string, raw?: boolean): void {
const versionText = `Version: ${packageInfo.version} const cleanMessage = stripVTControlCharacters(message);
Electron: ${electronVersion} this.sendToRenderer(
Chromium: ${chromeVersion} 'kobold-output',
Node.js: ${nodeVersion} raw ? cleanMessage : `${cleanMessage}\n`
V8: ${v8Version} );
OS: ${osInfo}`; }
const iconPath = this.getIconPath(); public cleanup() {
const iconImage = nativeImage.createFromPath(iconPath); if (this.mainWindow) {
this.mainWindow.removeAllListeners();
const aboutWindow = new BrowserWindow({
width: 400,
height: 450,
icon: iconImage,
modal: true,
parent: this.mainWindow!,
resizable: false,
minimizable: false,
maximizable: false,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: join(__dirname, '../preload/index.js'),
},
});
aboutWindow.setMenu(null);
const htmlPath = this.getTemplatePath('about-modal.html');
await aboutWindow.loadFile(htmlPath);
aboutWindow.webContents.executeJavaScript(
`setVersionInfo(\`${versionText}\`)`
);
aboutWindow.once('ready-to-show', () => {
aboutWindow.show();
});
}
private getTemplatePath(filename: string): string {
if (process.env.NODE_ENV === 'development') {
return join(__dirname, '../../src/main/templates', filename);
} }
return join(process.resourcesPath, 'templates', filename);
} }
} }

View file

@ -1,162 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>About Gerbil</title>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 1.25rem;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 25rem;
margin: 0 auto;
background: white;
padding: 1.875rem;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.625rem rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
margin: 0 0 1.25rem 0;
color: #2c3e50;
font-size: 1.5rem;
}
.version-info {
text-align: left;
background: #f8f9fa;
padding: 0.9375rem;
border-radius: 0.25rem;
margin: 1.25rem 0;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.75rem;
line-height: 1.4;
}
.github-link {
color: #0366d6;
text-decoration: none;
font-weight: 500;
}
.github-link:hover {
text-decoration: underline;
}
.buttons {
margin-top: 1.25rem;
display: flex;
gap: 0.625rem;
justify-content: center;
}
button {
padding: 0.5rem 1rem;
border: 0.0625rem solid #ddd;
border-radius: 0.25rem;
background: white;
cursor: pointer;
font-size: 0.875rem;
}
button:hover {
background: #f0f0f0;
}
.primary {
background: #228be6;
color: white;
border-color: #228be6;
}
.primary:hover {
background: #1c7ed6;
}
/* Dark mode styles - moved to end to ensure proper override */
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a !important;
color: #e0e0e0 !important;
}
.container {
background: #2d2d2d !important;
box-shadow: 0 0.125rem 0.625rem rgba(0, 0, 0, 0.3) !important;
}
h1 {
color: #ffffff !important;
}
.version-info {
background: #3a3a3a !important;
color: #e0e0e0 !important;
}
.github-link {
color: #58a6ff !important;
}
.github-link:hover {
color: #79c0ff !important;
}
button {
background: #3a3a3a !important;
border-color: #555 !important;
color: #e0e0e0 !important;
}
button:hover {
background: #4a4a4a !important;
}
.primary {
background: #228be6 !important;
border-color: #228be6 !important;
color: white !important;
}
.primary:hover {
background: #1c7ed6 !important;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Gerbil</h1>
<div class="version-info" id="version-info">
<!-- Version information will be injected here -->
</div>
<p>
<a href="#" class="github-link" onclick="openGitHub()">
View on GitHub →
</a>
</p>
<div class="buttons">
<button class="primary" onclick="closeDialog()">Close</button>
</div>
</div>
<script>
function setVersionInfo(info) {
document.getElementById('version-info').innerHTML = info.replace(
/\n/g,
'<br>'
);
}
function openGitHub() {
// Send IPC message directly since this is a simple modal
if (window.electronAPI && window.electronAPI.app) {
window.electronAPI.app.openExternal(
'https://github.com/lone-cloud/gerbil'
);
}
}
function closeDialog() {
window.close();
}
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDialog();
}
});
</script>
</body>
</html>

View file

@ -78,6 +78,10 @@ const koboldAPI: KoboldAPI = {
const appAPI: AppAPI = { const appAPI: AppAPI = {
openExternal: (url) => ipcRenderer.invoke('app:openExternal', url), openExternal: (url) => ipcRenderer.invoke('app:openExternal', url),
getVersion: () => ipcRenderer.invoke('app:getVersion'), getVersion: () => ipcRenderer.invoke('app:getVersion'),
getVersionInfo: () => ipcRenderer.invoke('app:getVersionInfo'),
minimizeWindow: () => ipcRenderer.invoke('app:minimizeWindow'),
maximizeWindow: () => ipcRenderer.invoke('app:maximizeWindow'),
closeWindow: () => ipcRenderer.invoke('app:closeWindow'),
}; };
const configAPI: ConfigAPI = { const configAPI: ConfigAPI = {

View file

@ -45,38 +45,38 @@ body {
} }
} }
/* AppHeader animations */ /* TitleBar gerbil icon animations */
@keyframes elephantShake { @keyframes elephantShake {
0%, 0%,
100% { 100% {
transform: scale(1.3) rotate(5deg) translateX(0); transform: scale(1.1) rotate(5deg) translateX(0);
} }
10% { 10% {
transform: scale(1.4) rotate(-3deg) translateX(-2px); transform: scale(1.2) rotate(-3deg) translateX(-2px);
} }
20% { 20% {
transform: scale(1.5) rotate(8deg) translateX(2px); transform: scale(1.3) rotate(8deg) translateX(2px);
} }
30% { 30% {
transform: scale(1.4) rotate(-5deg) translateX(-1px); transform: scale(1.2) rotate(-5deg) translateX(-1px);
} }
40% { 40% {
transform: scale(1.6) rotate(10deg) translateX(3px); transform: scale(1.4) rotate(10deg) translateX(3px);
} }
50% { 50% {
transform: scale(1.3) rotate(-8deg) translateX(-2px); transform: scale(1.2) rotate(-8deg) translateX(-2px);
} }
60% { 60% {
transform: scale(1.5) rotate(6deg) translateX(1px); transform: scale(1.3) rotate(6deg) translateX(1px);
} }
70% { 70% {
transform: scale(1.2) rotate(-4deg) translateX(-1px); transform: scale(1) rotate(-4deg) translateX(-1px);
} }
80% { 80% {
transform: scale(1.4) rotate(3deg) translateX(2px); transform: scale(1.2) rotate(3deg) translateX(2px);
} }
90% { 90% {
transform: scale(1.1) rotate(-2deg) translateX(-1px); transform: scale(1) rotate(-2deg) translateX(-1px);
} }
} }

View file

@ -131,9 +131,24 @@ export interface KoboldAPI {
removeAllListeners: (channel: string) => void; removeAllListeners: (channel: string) => void;
} }
export interface VersionInfo {
appVersion: string;
electronVersion: string;
nodeVersion: string;
chromeVersion: string;
v8Version: string;
osVersion: string;
platform: string;
arch: string;
}
export interface AppAPI { export interface AppAPI {
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
getVersion: () => Promise<string>; getVersion: () => Promise<string>;
getVersionInfo: () => Promise<VersionInfo>;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
} }
export interface ConfigAPI { export interface ConfigAPI {

View file

@ -871,9 +871,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@mantine/core@npm:^8.2.7": "@mantine/core@npm:^8.2.8":
version: 8.2.7 version: 8.2.8
resolution: "@mantine/core@npm:8.2.7" resolution: "@mantine/core@npm:8.2.8"
dependencies: dependencies:
"@floating-ui/react": "npm:^0.26.28" "@floating-ui/react": "npm:^0.26.28"
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
@ -882,19 +882,19 @@ __metadata:
react-textarea-autosize: "npm:8.5.9" react-textarea-autosize: "npm:8.5.9"
type-fest: "npm:^4.27.0" type-fest: "npm:^4.27.0"
peerDependencies: peerDependencies:
"@mantine/hooks": 8.2.7 "@mantine/hooks": 8.2.8
react: ^18.x || ^19.x react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x react-dom: ^18.x || ^19.x
checksum: 10c0/d10ba6ef7ac552b6bd5638b9a16c4d2405391102330d94c8df7a7d87756cdf4ec341a350eab252575eb5990f43dc4122ea13244753a9169338a43d2b996f7594 checksum: 10c0/9933ae22ab2faa852d8a941bdbb427c98d160b253f2977f10aad7024cd725fbca56d36a22560f3c75f8716b3d6651a599fa6d996cb131315c997242206cc4ebb
languageName: node languageName: node
linkType: hard linkType: hard
"@mantine/hooks@npm:^8.2.7": "@mantine/hooks@npm:^8.2.8":
version: 8.2.7 version: 8.2.8
resolution: "@mantine/hooks@npm:8.2.7" resolution: "@mantine/hooks@npm:8.2.8"
peerDependencies: peerDependencies:
react: ^18.x || ^19.x react: ^18.x || ^19.x
checksum: 10c0/54dfbc36acad9dbb5e3a29d0944041f7ff31e61416cbd26ecf97c37c3a779a1b0028e05a0e6daa44cf3c3c2406c50b8f2107eb6fee3cfb6647b7524965556686 checksum: 10c0/0097d8f05ee684c0e78f6160aa23d431e27ef15d32c66010f369d35e86497571a52dc56824fb659ca1c66ff3dd9e6513fe4c8c9bb3e1d98118ba176b4b9feeeb
languageName: node languageName: node
linkType: hard linkType: hard
@ -3706,8 +3706,8 @@ __metadata:
resolution: "gerbil@workspace:." resolution: "gerbil@workspace:."
dependencies: dependencies:
"@eslint/js": "npm:^9.34.0" "@eslint/js": "npm:^9.34.0"
"@mantine/core": "npm:^8.2.7" "@mantine/core": "npm:^8.2.8"
"@mantine/hooks": "npm:^8.2.7" "@mantine/hooks": "npm:^8.2.8"
"@types/node": "npm:^24.3.0" "@types/node": "npm:^24.3.0"
"@types/react": "npm:^19.1.12" "@types/react": "npm:^19.1.12"
"@types/react-dom": "npm:^19.1.9" "@types/react-dom": "npm:^19.1.9"