feat(desktop): macOS hidden-inset chrome and floating chat title

Apply hiddenInset titleBarStyle on darwin so the system titlebar is hidden
but native traffic lights remain. Reusable web sidebars inject a new
DesktopShellKey to reserve a 36px TopBar that holds the traffic-light
inset (drag region + right border) without colliding with the bot list,
and the sidebar stays pinned open in the Electron shell so window resize
doesn't fight the layout.

Overlay a centered "Title - BotName" header above the chat content with a
bottom shadow gradient that obscures scrolling messages, and reserve top
padding so the first message stays visible when content fits the viewport.
Route the sidebar + action by path (/settings/bots) so the chat router's
/settings/* interception forwards it to the settings window cleanly while
remaining a normal navigation in web.
This commit is contained in:
Acbox
2026-04-25 12:08:33 +08:00
parent fb8614a016
commit 273f7bb911
10 changed files with 115 additions and 45 deletions
+9
View File
@@ -33,9 +33,17 @@ function loadRendererEntry(window: BrowserWindow, entry: 'index' | 'settings'):
// output is what wires the IPC bridge into the renderer.
const PRELOAD_FILE = '../preload/index.mjs'
// On macOS we hide the system titlebar but keep the native traffic lights
// (`hiddenInset`). Renderers reserve space for them via a custom TopBar.
const macTitleBarOptions: Partial<Electron.BrowserWindowConstructorOptions>
= process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 14, y: 12 } }
: {}
function createChatWindow(): BrowserWindow {
const window = new BrowserWindow({
...CHAT_DEFAULTS,
...macTitleBarOptions,
show: false,
autoHideMenuBar: true,
title: 'Memoh',
@@ -63,6 +71,7 @@ function createChatWindow(): BrowserWindow {
function createSettingsWindow(parent: BrowserWindow | null): BrowserWindow {
const window = new BrowserWindow({
...SETTINGS_DEFAULTS,
...macTitleBarOptions,
show: false,
autoHideMenuBar: true,
title: 'Memoh · Settings',
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { provide } from 'vue'
import { RouterView } from 'vue-router'
import { Toaster } from '@memohai/ui'
import 'vue-sonner/style.css'
import { useSettingsStore } from '@memohai/web/store/settings'
import { DesktopShellKey } from '@memohai/web/lib/desktop-shell'
provide(DesktopShellKey, true)
useSettingsStore()
</script>
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { provide } from 'vue'
import { Toaster, SidebarInset } from '@memohai/ui'
import 'vue-sonner/style.css'
import MainLayout from '@memohai/web/layout/main-layout/index.vue'
import SettingsSidebar from '@memohai/web/components/settings-sidebar/index.vue'
import { useSettingsStore } from '@memohai/web/store/settings'
import { DesktopShellKey } from '@memohai/web/lib/desktop-shell'
provide(DesktopShellKey, true)
useSettingsStore()
</script>
+5
View File
@@ -34,6 +34,11 @@ declare module '@memohai/web/store/settings' {
export function useSettingsStore(): unknown
}
declare module '@memohai/web/lib/desktop-shell' {
import type { InjectionKey } from 'vue'
export const DesktopShellKey: InjectionKey<boolean>
}
declare module '@memohai/web/style.css'
// Fallback for every Vue SFC reachable through the @memohai/web/* wildcard
+1
View File
@@ -10,6 +10,7 @@
"./router": "./src/router.ts",
"./i18n": "./src/i18n.ts",
"./api-client": "./src/lib/api-client.ts",
"./lib/desktop-shell": "./src/lib/desktop-shell.ts",
"./style.css": "./src/style.css",
"./*": "./src/*"
},
@@ -1,6 +1,14 @@
<template>
<aside>
<Sidebar collapsible="icon">
<aside class="relative h-full">
<header
v-if="topInset"
class="fixed top-0 left-0 z-20 h-9 w-(--sidebar-width) bg-sidebar border-r border-sidebar-border [-webkit-app-region:drag]"
/>
<Sidebar
:collapsible="topInset ? 'none' : 'icon'"
:class="topInset ? 'pt-9 h-dvh border-r border-sidebar-border' : ''"
>
<SidebarHeader
v-if="!hideHeader"
class="p-0 border-0"
@@ -45,13 +53,13 @@
</SidebarGroup>
</SidebarContent>
<SidebarRail />
<SidebarRail v-if="!topInset" />
</Sidebar>
</aside>
</template>
<script setup lang="ts">
import { computed, type Component } from 'vue'
import { computed, inject, type Component } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -68,6 +76,7 @@ import {
SidebarMenuItem,
SidebarRail,
} from '@memohai/ui'
import { DesktopShellKey } from '@/lib/desktop-shell'
withDefaults(defineProps<{
// When true, the back-to-chat button in the sidebar header is hidden.
@@ -78,6 +87,8 @@ withDefaults(defineProps<{
hideHeader: false,
})
const topInset = inject(DesktopShellKey, false)
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
+31 -36
View File
@@ -1,7 +1,30 @@
<template>
<aside>
<Sidebar collapsible="icon">
<SidebarHeader class="p-0 border-0">
<aside class="relative h-full">
<header
v-if="topInset"
class="fixed top-0 left-0 z-20 h-9 w-(--sidebar-width) flex items-center pl-[78px] pr-2 gap-1 bg-sidebar border-r border-sidebar-border [-webkit-app-region:drag]"
>
<div class="ml-auto flex items-center gap-1 [-webkit-app-region:no-drag]">
<Button
variant="ghost"
size="icon"
class="size-6 text-muted-foreground hover:text-foreground shrink-0"
:aria-label="t('bots.createBot')"
@click="router.push('/settings/bots')"
>
<Plus class="size-3.5" />
</Button>
</div>
</header>
<Sidebar
:collapsible="topInset ? 'none' : 'icon'"
:class="topInset ? 'pt-9 h-dvh border-r border-sidebar-border' : ''"
>
<SidebarHeader
v-if="!topInset"
class="p-0 border-0"
>
<div class="h-10 flex items-center pl-2 group-data-[collapsible=icon]:pl-3 transition-[padding] duration-200 ease-linear">
<Button
variant="ghost"
@@ -58,33 +81,9 @@
</SidebarGroup>
</SidebarContent>
<SidebarRail />
<SidebarFooter class="relative border-0 px-2 pb-3.5 pt-2.5">
<div class="pointer-events-none absolute -top-30 left-0 h-38.25 w-full bg-linear-to-t from-(--sidebar-background) from-18% to-transparent z-10 group-data-[collapsible=icon]:hidden" />
<SidebarMenu class="gap-2.5">
<!-- <SidebarMenuItem>
<SidebarMenuButton
:tooltip="displayTitle"
class="h-10 px-2.5"
@click="router.push({ name: 'profile' })"
>
<div class="size-9 shrink-0 rounded-full border border-border bg-accent overflow-hidden p-[1.385px]">
<img
v-if="userInfo.avatarUrl"
:src="userInfo.avatarUrl"
:alt="displayTitle"
class="size-full rounded-full object-cover"
>
<span
v-else
class="size-full flex items-center justify-center text-[10px] font-medium text-muted-foreground"
>
{{ avatarFallback }}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem> -->
<SidebarMenuItem>
<SidebarMenuButton
:tooltip="t('sidebar.settings')"
@@ -100,19 +99,19 @@
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail v-if="!topInset" />
</Sidebar>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useQuery } from '@pinia/colada'
import { getBotsQuery } from '@memohai/sdk/colada'
import type { BotsBot } from '@memohai/sdk'
// import { useUserStore } from '@/store/user'
// import { useAvatarInitials } from '@/composables/useAvatarInitials'
import {
Button,
Sidebar,
@@ -130,21 +129,17 @@ import {
import { Plus, LoaderCircle, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-vue-next'
import BotItem from './bot-item.vue'
import { usePinnedBots } from '@/composables/usePinnedBots'
import { DesktopShellKey } from '@/lib/desktop-shell'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const { toggleSidebar } = useSidebar()
// const { userInfo } = useUserStore()
const topInset = inject(DesktopShellKey, false)
const { sortBots } = usePinnedBots()
const { data: botData, isLoading } = useQuery(getBotsQuery())
const bots = computed<BotsBot[]>(() => sortBots(botData.value?.items ?? []))
const isSettingsActive = computed(() => route.path.startsWith('/settings'))
// const displayTitle = computed(() =>
// userInfo.displayName || userInfo.username || userInfo.id || t('settings.user'),
// )
// const avatarFallback = useAvatarInitials(() => displayTitle.value, 'U')
</script>
+9 -2
View File
@@ -16,16 +16,23 @@
</section>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, inject } from 'vue'
import { SidebarProvider } from '@memohai/ui'
import { useMediaQuery } from '@vueuse/core'
import { DesktopShellKey } from '@/lib/desktop-shell'
const sidebarDefaultOpen = !document.cookie.includes('sidebar_state=false')
// In the desktop shell the sidebar collapse affordance is intentionally
// disabled we keep the sidebar pinned open and skip the small-screen
// auto-collapse watcher so window resizes don't fight the layout.
const desktopShell = inject(DesktopShellKey, false)
const sidebarDefaultOpen = desktopShell || !document.cookie.includes('sidebar_state=false')
const isOpen = ref(sidebarDefaultOpen)
const isSmallScreen = useMediaQuery('(max-width: 1024px)')
watch(isSmallScreen, (isSmall) => {
if (desktopShell) return
if (isSmall) {
isOpen.value = false
} else {
+6
View File
@@ -0,0 +1,6 @@
import type { InjectionKey } from 'vue'
// Provided by the Electron desktop shell to enable a macOS-style top inset
// (traffic-light reserve + custom TopBar) inside reusable web sidebars.
// Web (browser) does not provide this key, so consumers fall back to false.
export const DesktopShellKey: InjectionKey<boolean> = Symbol('memohai:desktop-shell')
@@ -1,6 +1,21 @@
<template>
<div class="flex-1 flex h-full min-w-0">
<div class="flex-1 flex flex-col h-full min-w-0">
<div class="flex-1 flex flex-col h-full min-w-0 relative">
<!-- Desktop floating title bar (overlays the chat area, fades into messages
via a bottom shadow gradient pointer-events:none lets scroll/clicks
fall through). -->
<header
v-if="topInset && currentBotId"
class="pointer-events-none absolute top-0 left-0 right-0 z-10 select-none"
>
<div class="h-10 flex items-center justify-center bg-background px-12">
<span class="text-xs font-medium text-foreground truncate">
{{ desktopTitle }}
</span>
</div>
<div class="h-5 bg-linear-to-b from-background to-transparent" />
</header>
<!-- No bot selected -->
<div
v-if="!currentBotId"
@@ -24,7 +39,10 @@
ref="scrollContainer"
class="h-full"
>
<div class="w-full max-w-4xl mx-auto px-10 py-6 space-y-6">
<div
class="w-full max-w-4xl mx-auto px-10 pb-6 space-y-6"
:class="topInset ? 'pt-15' : 'pt-6'"
>
<!-- Load older indicator -->
<div
v-if="loadingOlder"
@@ -380,7 +398,7 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect, watch, type Component } from 'vue'
import { ref, computed, inject, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect, watch, type Component } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { LoaderCircle, Image as ImageIcon, File as FileIcon, X, Paperclip, FolderOpen, Send, ChevronDown, Lightbulb, TerminalSquare, BarChart3, Trash2 } from 'lucide-vue-next'
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Popover, PopoverContent, PopoverTrigger, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@memohai/ui'
@@ -397,6 +415,7 @@ import { EFFORT_LABELS, EFFORT_OPACITY } from '@/pages/bots/components/reasoning
import { useMediaGallery } from '../composables/useMediaGallery'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import type { ChatAttachment } from '@/composables/api/useChat'
import { DesktopShellKey } from '@/lib/desktop-shell'
import { useScroll, useElementBounding } from '@vueuse/core'
import { useQuery } from '@pinia/colada'
import { getModels, getProviders, getBotsByBotIdSettings } from '@memohai/sdk'
@@ -544,8 +563,19 @@ const {
hasMoreOlder,
overrideModelId,
overrideReasoningEffort,
bots,
} = storeToRefs(chatStore)
const topInset = inject(DesktopShellKey, false)
const desktopTitle = computed(() => {
const sessionTitle = (activeSession.value?.title ?? '').trim()
const bot = bots.value.find((b) => b.id === currentBotId.value)
const botName = (bot?.display_name ?? bot?.id ?? '').trim()
if (sessionTitle && botName) return `${sessionTitle} - ${botName}`
return sessionTitle || botName
})
// ---- Model / provider queries ----
const { data: modelData } = useQuery({