feat(web): add resizable session sidebar and inline file manager panel

Replace fixed-width session sidebar with a draggable resize handle on
its right edge (180–480px, persisted to localStorage). Convert the file
manager from a Sheet overlay to an embedded right-side panel with a
left-edge resize handle (320–800px, also persisted).
This commit is contained in:
Acbox
2026-03-29 19:23:10 +08:00
parent 0e646625bf
commit 7825b49ff3
2 changed files with 388 additions and 278 deletions
@@ -1,5 +1,6 @@
<template>
<div class="flex-1 flex flex-col h-full">
<div class="flex-1 flex h-full min-w-0">
<div class="flex-1 flex flex-col h-full min-w-0">
<!-- No bot selected -->
<div
v-if="!currentBotId"
@@ -196,19 +197,37 @@
</div>
</div>
</template>
</div>
<!-- File manager sheet -->
<Sheet v-model:open="fileManagerOpen">
<SheetContent
side="right"
class="sm:max-w-2xl w-full p-0 flex flex-col"
<!-- File manager panel -->
<div
v-if="fileManagerOpen"
class="flex shrink-0 h-full relative"
:style="{ width: `${fileManagerWidth}px` }"
>
<SheetHeader class="px-4 pt-4 pb-0">
<SheetTitle>{{ $t('chat.files') }}</SheetTitle>
<SheetDescription class="sr-only">
{{ $t('chat.files') }}
</SheetDescription>
</SheetHeader>
<div
class="absolute top-0 left-0 w-1 h-full cursor-col-resize z-10 group"
@mousedown="onFmResizeStart"
>
<div
class="w-full h-full transition-colors group-hover:bg-primary/20"
:class="{ 'bg-primary/30': isFmResizing }"
/>
</div>
<div class="flex flex-col h-full flex-1 min-w-0 border-l border-border bg-sidebar">
<div class="flex items-center justify-between px-4 h-12 shrink-0">
<span class="text-sm font-medium text-foreground">{{ $t('chat.files') }}</span>
<Button
type="button"
size="icon"
variant="ghost"
class="size-6"
@click="fileManagerOpen = false"
>
<X class="size-3.5" />
</Button>
</div>
<div class="flex-1 min-h-0 relative">
<FileManager
v-if="currentBotId"
@@ -217,15 +236,16 @@
:sync-url="false"
/>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, provide, useTemplateRef, watchEffect} from 'vue'
import { ref, computed, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { LoaderCircle, Image as ImageIcon, File as FileIcon, X, Paperclip, FolderOpen, Send } from 'lucide-vue-next'
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memohai/ui'
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea } from '@memohai/ui'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import MessageItem from './message-item.vue'
@@ -242,6 +262,43 @@ const pendingFiles = ref<File[]>([])
const fileManagerOpen = ref(false)
const fileManagerRef = ref<InstanceType<typeof FileManager> | null>(null)
const FM_MIN_WIDTH = 320
const FM_MAX_WIDTH = 800
const FM_DEFAULT_WIDTH = 520
const fileManagerWidth = useLocalStorage('file-manager-panel-width', FM_DEFAULT_WIDTH)
const isFmResizing = ref(false)
function onFmResizeStart(e: MouseEvent) {
e.preventDefault()
isFmResizing.value = true
const startX = e.clientX
const startWidth = fileManagerWidth.value
function onMouseMove(ev: MouseEvent) {
const delta = startX - ev.clientX
fileManagerWidth.value = Math.min(FM_MAX_WIDTH, Math.max(FM_MIN_WIDTH, startWidth + delta))
}
function onMouseUp() {
isFmResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
onBeforeUnmount(() => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
})
const FILE_MANAGER_ROOT = '/data'
function normalizeFileManagerPath(path: string): string {
@@ -1,5 +1,9 @@
<template>
<div class="flex flex-col h-full w-[223px] shrink-0 bg-sidebar border-r border-border">
<div
class="flex shrink-0 h-full relative"
:style="{ width: `${sidebarWidth}px` }"
>
<div class="flex flex-col h-full flex-1 min-w-0 bg-sidebar border-r border-border">
<!-- <div class="h-[53px] flex items-center px-2 shrink-0">
<FontAwesomeIcon
:icon="['fas', 'comment-dots']"
@@ -127,10 +131,22 @@
</div>
</div>
</div>
<div
class="absolute top-0 right-0 w-1 h-full cursor-col-resize z-10 group"
@mousedown="onResizeStart"
>
<div
class="w-full h-full transition-colors group-hover:bg-primary/20"
:class="{ 'bg-primary/30': isResizing }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { Search, Plus, Globe, ChevronDown, Check, LoaderCircle } from 'lucide-vue-next'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
@@ -155,6 +171,43 @@ const router = useRouter()
const chatStore = useChatStore()
const { sessions, sessionId, currentBotId, loadingChats } = storeToRefs(chatStore)
const MIN_WIDTH = 180
const MAX_WIDTH = 480
const DEFAULT_WIDTH = 223
const sidebarWidth = useLocalStorage('session-sidebar-width', DEFAULT_WIDTH)
const isResizing = ref(false)
function onResizeStart(e: MouseEvent) {
e.preventDefault()
isResizing.value = true
const startX = e.clientX
const startWidth = sidebarWidth.value
function onMouseMove(ev: MouseEvent) {
const delta = ev.clientX - startX
sidebarWidth.value = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta))
}
function onMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
onBeforeUnmount(() => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
})
const searchQuery = ref('')
const filterType = ref<string>('chat')