mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user