feat(web): add Activity Bar and right sidebar panel to chat page

Replace the old file manager panel with a multi-tab right sidebar system:
- Activity Bar with Terminal, Files, and Info tabs
- Resizable right panel with tab switching
- Extract shared Terminal component from bot-terminal.vue
- Add bottom preview layout mode to FileManager
- Delete session button with confirmation dialog
- Fix FileManager scroll in flex column layout (min-h-0)
This commit is contained in:
Acbox
2026-03-31 17:18:45 +08:00
parent bb14bcb3bc
commit c0490c9688
7 changed files with 694 additions and 544 deletions
+12 -4
View File
@@ -32,8 +32,10 @@ import FileViewer from './file-viewer.vue'
const props = withDefaults(defineProps<{
botId: string
syncUrl?: boolean
previewLayout?: 'side' | 'bottom'
}>(), {
syncUrl: true,
previewLayout: 'side',
})
const { t } = useI18n()
@@ -345,11 +347,16 @@ defineExpose({ navigateTo, openFileByPath })
</div>
<!-- Main content area -->
<div class="flex flex-1 min-h-0 overflow-hidden h-full ">
<div
class="flex flex-1 min-h-0 overflow-hidden h-full"
:class="previewLayout === 'bottom' ? 'flex-col' : ''"
>
<!-- File list -->
<ScrollArea
class=" border-border transition-colors "
:class="openFile ? 'w-80 shrink-0 border-r' : 'w-full'"
class="border-border transition-colors min-h-0"
:class="openFile
? (previewLayout === 'bottom' ? 'h-1/2 shrink-0 border-b' : 'w-80 shrink-0 border-r')
: (previewLayout === 'bottom' ? 'flex-1' : 'w-full')"
>
<FileList
:entries="entries"
@@ -365,7 +372,8 @@ defineExpose({ navigateTo, openFileByPath })
<!-- File viewer -->
<div
v-if="openFile"
class="flex-1 overflow-hidden max-h-full"
class="flex-1 overflow-hidden min-h-0"
:class="previewLayout === 'bottom' ? '' : 'max-h-full'"
>
<FileViewer
:bot-id="botId"
+466
View File
@@ -0,0 +1,466 @@
<script setup lang="ts">
import { ref, reactive, shallowReactive, onMounted, onBeforeUnmount, watch, computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SerializeAddon } from '@xterm/addon-serialize'
import { client } from '@memohai/sdk/client'
import { Button } from '@memohai/ui'
import { useTerminalCache } from '@/composables/useTerminalCache'
import type { TerminalCacheState } from '@/composables/useTerminalCache'
import '@xterm/xterm/css/xterm.css'
const props = withDefaults(defineProps<{
botId: string
visible?: boolean
}>(), {
visible: true,
})
const { t } = useI18n()
const { loadCache, saveCache } = useTerminalCache()
const TERMINAL_OPTIONS = {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
selectionBackground: '#33467c',
},
} as const
interface TerminalTab {
id: string
label: string
terminal: Terminal | null
fitAddon: FitAddon | null
serializeAddon: SerializeAddon | null
ws: WebSocket | null
status: 'idle' | 'connecting' | 'connected' | 'disconnected'
containerEl: HTMLDivElement | null
wsDisposables: Array<{ dispose(): void }>
}
function makeTab(id: string, label: string): TerminalTab {
return shallowReactive<TerminalTab>({
id,
label,
terminal: null,
fitAddon: null,
serializeAddon: null,
ws: null,
status: 'idle',
containerEl: null,
wsDisposables: [],
})
}
const tabs = reactive<TerminalTab[]>([])
const activeTabId = ref('')
const wrapperRef = ref<HTMLDivElement | null>(null)
let tabCounter = 0
let resizeObserver: ResizeObserver | null = null
let fitTimer: ReturnType<typeof setTimeout> | null = null
let cacheTimer: ReturnType<typeof setTimeout> | null = null
const CACHE_DEBOUNCE_MS = 2000
const activeTermTab = computed(() => tabs.find((t) => t.id === activeTabId.value))
function resolveTerminalWsUrl(botIdValue: string, cols: number, rows: number): string {
const baseUrl = String(client.getConfig().baseUrl || '').trim()
const token = localStorage.getItem('token') ?? ''
const path = `/bots/${encodeURIComponent(botIdValue)}/container/terminal/ws`
const query = `?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`
if (!baseUrl || baseUrl.startsWith('/')) {
const loc = window.location
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
const base = baseUrl || '/api'
return `${proto}//${loc.host}${base.replace(/\/+$/, '')}${path}${query}`
}
try {
const url = new URL(path, baseUrl)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString() + query
} catch {
const loc = window.location
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${loc.host}/api${path}${query}`
}
}
function createTerminalTab(label?: string, cachedContent?: string): TerminalTab {
tabCounter++
const id = `term-${Date.now()}-${tabCounter}`
const tab = makeTab(id, label ?? `${t('bots.terminal.defaultTabLabel')} ${tabCounter}`)
tabs.push(tab)
nextTick(() => {
const el = document.getElementById(id)
if (!el) return
tab.containerEl = el as HTMLDivElement
initTerminal(tab, cachedContent)
})
return tab
}
function initTerminal(tab: TerminalTab, cachedContent?: string) {
if (!tab.containerEl) return
const terminal = new Terminal({ ...TERMINAL_OPTIONS })
const fitAddon = new FitAddon()
const serializeAddon = new SerializeAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(serializeAddon)
terminal.open(tab.containerEl)
tab.terminal = terminal
tab.fitAddon = fitAddon
tab.serializeAddon = serializeAddon
if (cachedContent) {
terminal.write(cachedContent)
terminal.write('\x1b[2K\r')
}
nextTick(() => {
if (tab.id === activeTabId.value) fitAddon.fit()
connectWs(tab)
})
}
function connectWs(tab: TerminalTab) {
if (!tab.terminal) return
closeWs(tab)
if (tab.id === activeTabId.value) {
tab.fitAddon?.fit()
}
const cols = tab.terminal.cols
const rows = tab.terminal.rows
tab.status = 'connecting'
const url = resolveTerminalWsUrl(props.botId, cols, rows)
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
tab.ws = ws
ws.onopen = () => {
tab.status = 'connected'
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
tab.terminal?.write(new Uint8Array(event.data))
} else if (typeof event.data === 'string') {
tab.terminal?.write(event.data)
}
debouncedPersistCache()
}
ws.onclose = () => {
tab.status = 'disconnected'
tab.terminal?.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n')
}
ws.onerror = () => {
tab.status = 'disconnected'
}
for (const d of tab.wsDisposables) d.dispose()
tab.wsDisposables = []
tab.wsDisposables.push(
tab.terminal.onData((data) => {
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
tab.ws.send(new TextEncoder().encode(data))
}
debouncedPersistCache()
}),
tab.terminal.onResize(({ cols: c, rows: r }) => {
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
tab.ws.send(JSON.stringify({ type: 'resize', cols: c, rows: r }))
}
}),
)
}
function closeWs(tab: TerminalTab) {
if (tab.ws) {
tab.ws.onclose = null
tab.ws.onerror = null
tab.ws.onmessage = null
tab.ws.close()
tab.ws = null
}
}
function destroyTab(tab: TerminalTab) {
closeWs(tab)
for (const d of tab.wsDisposables) d.dispose()
tab.wsDisposables = []
tab.terminal?.dispose()
tab.terminal = null
tab.fitAddon = null
tab.serializeAddon = null
tab.containerEl = null
}
function handleAddTab() {
const tab = createTerminalTab()
activeTabId.value = tab.id
debouncedPersistCache()
}
function handleCloseTab(tabId: string) {
const idx = tabs.findIndex((t) => t.id === tabId)
if (idx < 0) return
const target = tabs[idx]
if (target) destroyTab(target)
tabs.splice(idx, 1)
if (tabs.length === 0) {
handleAddTab()
return
}
if (activeTabId.value === tabId) {
const nextIdx = Math.min(idx, tabs.length - 1)
const next = tabs[nextIdx]
if (next) {
activeTabId.value = next.id
nextTick(() => next.fitAddon?.fit())
}
}
debouncedPersistCache()
}
function handleSwitchTab(tabId: string) {
if (activeTabId.value === tabId) return
activeTabId.value = tabId
debouncedPersistCache()
nextTick(() => {
const tab = tabs.find((t) => t.id === tabId)
tab?.fitAddon?.fit()
})
}
function handleReconnect() {
const tab = activeTermTab.value
if (tab) connectWs(tab)
}
function persistCache() {
if (!props.botId || tabs.length === 0) return
const state: TerminalCacheState = {
activeTabId: activeTabId.value,
tabs: tabs.map((tab) => ({
id: tab.id,
label: tab.label,
content: tab.serializeAddon?.serialize() ?? '',
savedAt: Date.now(),
})),
}
saveCache(props.botId, state)
}
function debouncedPersistCache() {
if (cacheTimer) clearTimeout(cacheTimer)
cacheTimer = setTimeout(() => {
persistCache()
}, CACHE_DEBOUNCE_MS)
}
function restoreFromCache() {
const cached = loadCache(props.botId)
if (!cached || cached.tabs.length === 0) {
handleAddTab()
return
}
for (const cachedTab of cached.tabs) {
tabCounter++
const tab = makeTab(cachedTab.id, cachedTab.label)
tabs.push(tab)
const content = cachedTab.content
nextTick(() => {
const el = document.getElementById(tab.id)
if (!el) return
tab.containerEl = el as HTMLDivElement
initTerminal(tab, content)
})
}
const firstTab = cached.tabs[0]
const targetId = cached.tabs.find((ct) => ct.id === cached.activeTabId)
? cached.activeTabId
: firstTab?.id ?? ''
activeTabId.value = targetId
}
function onBeforeUnload() {
persistCache()
}
function cleanupAll() {
if (fitTimer) {
clearTimeout(fitTimer)
fitTimer = null
}
if (cacheTimer) {
clearTimeout(cacheTimer)
cacheTimer = null
}
resizeObserver?.disconnect()
resizeObserver = null
for (const tab of tabs) {
destroyTab(tab)
}
tabs.length = 0
}
function setupResizeObserver() {
if (resizeObserver || !wrapperRef.value) return
resizeObserver = new ResizeObserver(() => {
if (fitTimer) clearTimeout(fitTimer)
fitTimer = setTimeout(() => {
activeTermTab.value?.fitAddon?.fit()
}, 50)
})
resizeObserver.observe(wrapperRef.value)
}
function init() {
window.addEventListener('beforeunload', onBeforeUnload)
restoreFromCache()
nextTick(() => setupResizeObserver())
}
onMounted(() => {
if (props.visible) {
init()
}
})
watch(() => props.visible, (visible) => {
if (visible && tabs.length === 0) {
nextTick(() => init())
}
if (!visible && tabs.length > 0) {
persistCache()
}
if (visible) {
nextTick(() => activeTermTab.value?.fitAddon?.fit())
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
persistCache()
cleanupAll()
})
</script>
<template>
<div class="flex flex-col absolute inset-0 p-4">
<!-- Tab bar -->
<div class="flex items-center gap-1 mb-2 min-h-[36px]">
<div class="flex items-center gap-1 flex-1 overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border transition-colors whitespace-nowrap"
:class="tab.id === activeTabId
? 'bg-accent text-accent-foreground border-border'
: 'text-muted-foreground border-transparent hover:bg-accent/50'"
@click="handleSwitchTab(tab.id)"
>
<span
class="size-1.5 rounded-full shrink-0"
:class="{
'bg-green-500': tab.status === 'connected',
'bg-yellow-500': tab.status === 'connecting',
'bg-muted-foreground': tab.status === 'idle' || tab.status === 'disconnected',
}"
/>
<span>{{ tab.label }}</span>
<span
class="ml-1 size-4 inline-flex cursor-pointer items-center justify-center rounded hover:bg-destructive/20 hover:text-destructive"
role="button"
tabindex="0"
:title="t('bots.terminal.closeTab')"
@click.stop="handleCloseTab(tab.id)"
@keydown.enter.prevent.stop="handleCloseTab(tab.id)"
@keydown.space.prevent.stop="handleCloseTab(tab.id)"
>
&times;
</span>
</button>
<button
class="inline-flex items-center justify-center size-7 rounded-md border border-dashed border-border text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground transition-colors shrink-0"
:title="t('bots.terminal.newTab')"
@click="handleAddTab"
>
+
</button>
</div>
<div class="flex items-center gap-2 shrink-0 ml-2">
<span
v-if="activeTermTab"
class="inline-flex items-center gap-1.5 text-xs"
:class="{
'text-green-500': activeTermTab.status === 'connected',
'text-yellow-500': activeTermTab.status === 'connecting',
'text-muted-foreground': activeTermTab.status === 'idle' || activeTermTab.status === 'disconnected',
}"
>
{{ t(`bots.terminal.status.${activeTermTab.status}`) }}
</span>
<Button
v-if="activeTermTab?.status === 'disconnected'"
size="sm"
variant="outline"
@click="handleReconnect"
>
{{ t('bots.terminal.reconnect') }}
</Button>
</div>
</div>
<!-- Terminal area -->
<div
ref="wrapperRef"
class="flex-1 relative min-h-0 rounded-md overflow-hidden border border-border terminal-wrapper"
>
<div
v-for="tab in tabs"
:id="tab.id"
:key="tab.id"
class="absolute inset-0 terminal-container"
:style="{ display: tab.id === activeTabId ? 'block' : 'none' }"
/>
</div>
</div>
</template>
<style scoped>
.terminal-wrapper {
background-color: #1a1b26;
}
.terminal-container {
top: 8px;
left: 8px;
right: 8px;
bottom: 8px;
}
</style>
+1
View File
@@ -167,6 +167,7 @@
"toolSpawnCount": "{count} tasks",
"unknownUser": "{platform} User",
"files": "Files",
"clearMessages": "Clear Messages",
"modelOverride": "Model",
"modelDefault": "Default",
"reasoningEffort": "Reasoning",
+1
View File
@@ -163,6 +163,7 @@
"toolSpawnCount": "{count} 个任务",
"unknownUser": "{platform}用户",
"files": "文件管理",
"clearMessages": "清除消息",
"modelOverride": "模型",
"modelDefault": "默认",
"reasoningEffort": "推理",
@@ -1,462 +1,17 @@
<script setup lang="ts">
import { ref, reactive, shallowReactive, onMounted, onBeforeUnmount, watch, computed, nextTick } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { SerializeAddon } from '@xterm/addon-serialize'
import { client } from '@memohai/sdk/client'
import { Button } from '@memohai/ui'
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
import { useTerminalCache } from '@/composables/useTerminalCache'
import type { TerminalCacheState } from '@/composables/useTerminalCache'
import '@xterm/xterm/css/xterm.css'
import Terminal from '@/components/terminal/index.vue'
const route = useRoute()
const { t } = useI18n()
const { loadCache, saveCache } = useTerminalCache()
const botId = computed(() => route.params.botId as string)
const activeTab = useSyncedQueryParam('tab', 'overview')
const TERMINAL_OPTIONS = {
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
selectionBackground: '#33467c',
},
} as const
interface TerminalTab {
id: string
label: string
terminal: Terminal | null
fitAddon: FitAddon | null
serializeAddon: SerializeAddon | null
ws: WebSocket | null
status: 'idle' | 'connecting' | 'connected' | 'disconnected'
containerEl: HTMLDivElement | null
wsDisposables: Array<{ dispose(): void }>
}
function makeTab(id: string, label: string): TerminalTab {
return shallowReactive<TerminalTab>({
id,
label,
terminal: null,
fitAddon: null,
serializeAddon: null,
ws: null,
status: 'idle',
containerEl: null,
wsDisposables: [],
})
}
const tabs = reactive<TerminalTab[]>([])
const activeTabId = ref('')
const wrapperRef = ref<HTMLDivElement | null>(null)
let tabCounter = 0
let resizeObserver: ResizeObserver | null = null
let fitTimer: ReturnType<typeof setTimeout> | null = null
let cacheTimer: ReturnType<typeof setTimeout> | null = null
const CACHE_DEBOUNCE_MS = 2000
const activeTermTab = computed(() => tabs.find((t) => t.id === activeTabId.value))
function resolveTerminalWsUrl(botIdValue: string, cols: number, rows: number): string {
const baseUrl = String(client.getConfig().baseUrl || '').trim()
const token = localStorage.getItem('token') ?? ''
const path = `/bots/${encodeURIComponent(botIdValue)}/container/terminal/ws`
const query = `?token=${encodeURIComponent(token)}&cols=${cols}&rows=${rows}`
if (!baseUrl || baseUrl.startsWith('/')) {
const loc = window.location
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
const base = baseUrl || '/api'
return `${proto}//${loc.host}${base.replace(/\/+$/, '')}${path}${query}`
}
try {
const url = new URL(path, baseUrl)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return url.toString() + query
} catch {
const loc = window.location
const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${loc.host}/api${path}${query}`
}
}
function createTerminalTab(label?: string, cachedContent?: string): TerminalTab {
tabCounter++
const id = `term-${Date.now()}-${tabCounter}`
const tab = makeTab(id, label ?? `${t('bots.terminal.defaultTabLabel')} ${tabCounter}`)
tabs.push(tab)
nextTick(() => {
const el = document.getElementById(id)
if (!el) return
tab.containerEl = el as HTMLDivElement
initTerminal(tab, cachedContent)
})
return tab
}
function initTerminal(tab: TerminalTab, cachedContent?: string) {
if (!tab.containerEl) return
const terminal = new Terminal({ ...TERMINAL_OPTIONS })
const fitAddon = new FitAddon()
const serializeAddon = new SerializeAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(serializeAddon)
terminal.open(tab.containerEl)
tab.terminal = terminal
tab.fitAddon = fitAddon
tab.serializeAddon = serializeAddon
if (cachedContent) {
terminal.write(cachedContent)
terminal.write('\x1b[2K\r')
}
nextTick(() => {
if (tab.id === activeTabId.value) fitAddon.fit()
connectWs(tab)
})
}
function connectWs(tab: TerminalTab) {
if (!tab.terminal) return
closeWs(tab)
if (tab.id === activeTabId.value) {
tab.fitAddon?.fit()
}
const cols = tab.terminal.cols
const rows = tab.terminal.rows
tab.status = 'connecting'
const url = resolveTerminalWsUrl(botId.value, cols, rows)
const ws = new WebSocket(url)
ws.binaryType = 'arraybuffer'
tab.ws = ws
ws.onopen = () => {
tab.status = 'connected'
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
tab.terminal?.write(new Uint8Array(event.data))
} else if (typeof event.data === 'string') {
tab.terminal?.write(event.data)
}
debouncedPersistCache()
}
ws.onclose = () => {
tab.status = 'disconnected'
tab.terminal?.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n')
}
ws.onerror = () => {
tab.status = 'disconnected'
}
for (const d of tab.wsDisposables) d.dispose()
tab.wsDisposables = []
tab.wsDisposables.push(
tab.terminal.onData((data) => {
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
tab.ws.send(new TextEncoder().encode(data))
}
debouncedPersistCache()
}),
tab.terminal.onResize(({ cols: c, rows: r }) => {
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
tab.ws.send(JSON.stringify({ type: 'resize', cols: c, rows: r }))
}
}),
)
}
function closeWs(tab: TerminalTab) {
if (tab.ws) {
tab.ws.onclose = null
tab.ws.onerror = null
tab.ws.onmessage = null
tab.ws.close()
tab.ws = null
}
}
function destroyTab(tab: TerminalTab) {
closeWs(tab)
for (const d of tab.wsDisposables) d.dispose()
tab.wsDisposables = []
tab.terminal?.dispose()
tab.terminal = null
tab.fitAddon = null
tab.serializeAddon = null
tab.containerEl = null
}
function handleAddTab() {
const tab = createTerminalTab()
activeTabId.value = tab.id
debouncedPersistCache()
}
function handleCloseTab(tabId: string) {
const idx = tabs.findIndex((t) => t.id === tabId)
if (idx < 0) return
const target = tabs[idx]
if (target) destroyTab(target)
tabs.splice(idx, 1)
if (tabs.length === 0) {
handleAddTab()
return
}
if (activeTabId.value === tabId) {
const nextIdx = Math.min(idx, tabs.length - 1)
const next = tabs[nextIdx]
if (next) {
activeTabId.value = next.id
nextTick(() => next.fitAddon?.fit())
}
}
debouncedPersistCache()
}
function handleSwitchTab(tabId: string) {
if (activeTabId.value === tabId) return
activeTabId.value = tabId
debouncedPersistCache()
nextTick(() => {
const tab = tabs.find((t) => t.id === tabId)
tab?.fitAddon?.fit()
})
}
function handleReconnect() {
const tab = activeTermTab.value
if (tab) connectWs(tab)
}
function persistCache() {
if (!botId.value || tabs.length === 0) return
const state: TerminalCacheState = {
activeTabId: activeTabId.value,
tabs: tabs.map((tab) => ({
id: tab.id,
label: tab.label,
content: tab.serializeAddon?.serialize() ?? '',
savedAt: Date.now(),
})),
}
saveCache(botId.value, state)
}
function debouncedPersistCache() {
if (cacheTimer) clearTimeout(cacheTimer)
cacheTimer = setTimeout(() => {
persistCache()
}, CACHE_DEBOUNCE_MS)
}
function restoreFromCache() {
const cached = loadCache(botId.value)
if (!cached || cached.tabs.length === 0) {
handleAddTab()
return
}
for (const cachedTab of cached.tabs) {
tabCounter++
const tab = makeTab(cachedTab.id, cachedTab.label)
tabs.push(tab)
const content = cachedTab.content
nextTick(() => {
const el = document.getElementById(tab.id)
if (!el) return
tab.containerEl = el as HTMLDivElement
initTerminal(tab, content)
})
}
const firstTab = cached.tabs[0]
const targetId = cached.tabs.find((ct) => ct.id === cached.activeTabId)
? cached.activeTabId
: firstTab?.id ?? ''
activeTabId.value = targetId
}
function onBeforeUnload() {
persistCache()
}
function cleanupAll() {
if (fitTimer) {
clearTimeout(fitTimer)
fitTimer = null
}
if (cacheTimer) {
clearTimeout(cacheTimer)
cacheTimer = null
}
resizeObserver?.disconnect()
resizeObserver = null
for (const tab of tabs) {
destroyTab(tab)
}
tabs.length = 0
}
function setupResizeObserver() {
if (resizeObserver || !wrapperRef.value) return
resizeObserver = new ResizeObserver(() => {
if (fitTimer) clearTimeout(fitTimer)
fitTimer = setTimeout(() => {
activeTermTab.value?.fitAddon?.fit()
}, 50)
})
resizeObserver.observe(wrapperRef.value)
}
function init() {
window.addEventListener('beforeunload', onBeforeUnload)
restoreFromCache()
nextTick(() => setupResizeObserver())
}
onMounted(() => {
if (activeTab.value === 'terminal') {
init()
}
})
watch(activeTab, (tab) => {
if (tab === 'terminal' && tabs.length === 0) {
nextTick(() => init())
}
if (tab !== 'terminal' && tabs.length > 0) {
persistCache()
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
persistCache()
cleanupAll()
})
</script>
<template>
<div class="flex flex-col absolute inset-0 p-4">
<!-- Tab bar -->
<div class="flex items-center gap-1 mb-2 min-h-[36px]">
<div class="flex items-center gap-1 flex-1 overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md border transition-colors whitespace-nowrap"
:class="tab.id === activeTabId
? 'bg-accent text-accent-foreground border-border'
: 'text-muted-foreground border-transparent hover:bg-accent/50'"
@click="handleSwitchTab(tab.id)"
>
<span
class="size-1.5 rounded-full shrink-0"
:class="{
'bg-green-500': tab.status === 'connected',
'bg-yellow-500': tab.status === 'connecting',
'bg-muted-foreground': tab.status === 'idle' || tab.status === 'disconnected',
}"
/>
<span>{{ tab.label }}</span>
<span
class="ml-1 size-4 inline-flex cursor-pointer items-center justify-center rounded hover:bg-destructive/20 hover:text-destructive"
role="button"
tabindex="0"
:title="t('bots.terminal.closeTab')"
@click.stop="handleCloseTab(tab.id)"
@keydown.enter.prevent.stop="handleCloseTab(tab.id)"
@keydown.space.prevent.stop="handleCloseTab(tab.id)"
>
&times;
</span>
</button>
<button
class="inline-flex items-center justify-center size-7 rounded-md border border-dashed border-border text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground transition-colors shrink-0"
:title="t('bots.terminal.newTab')"
@click="handleAddTab"
>
+
</button>
</div>
<div class="flex items-center gap-2 shrink-0 ml-2">
<span
v-if="activeTermTab"
class="inline-flex items-center gap-1.5 text-xs"
:class="{
'text-green-500': activeTermTab.status === 'connected',
'text-yellow-500': activeTermTab.status === 'connecting',
'text-muted-foreground': activeTermTab.status === 'idle' || activeTermTab.status === 'disconnected',
}"
>
{{ t(`bots.terminal.status.${activeTermTab.status}`) }}
</span>
<Button
v-if="activeTermTab?.status === 'disconnected'"
size="sm"
variant="outline"
@click="handleReconnect"
>
{{ t('bots.terminal.reconnect') }}
</Button>
</div>
</div>
<!-- Terminal area -->
<div
ref="wrapperRef"
class="flex-1 relative min-h-0 rounded-md overflow-hidden border border-border terminal-wrapper"
>
<div
v-for="tab in tabs"
:id="tab.id"
:key="tab.id"
class="absolute inset-0 terminal-container"
:style="{ display: tab.id === activeTabId ? 'block' : 'none' }"
/>
</div>
</div>
<Terminal
:bot-id="botId"
:visible="activeTab === 'terminal'"
/>
</template>
<style scoped>
.terminal-wrapper {
background-color: #1a1b26;
}
.terminal-container {
top: 8px;
left: 8px;
right: 8px;
bottom: 8px;
}
</style>
+207 -88
View File
@@ -17,29 +17,6 @@
</div>
<template v-else>
<!-- Session header -->
<!-- <div class="border-b px-4 py-2 flex items-center justify-between min-h-12">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-xs font-medium truncate">
{{ activeSession?.title || $t('chat.untitledSession') }}
</h2>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
type="button"
size="sm"
variant="ghost"
:aria-label="$t('chat.newSession')"
@click="chatStore.createNewSession()"
>
<FontAwesomeIcon
:icon="['fas', 'plus']"
class="size-3.5"
/>
</Button>
</div>
</div> -->
<!-- Messages -->
<section class="flex-1 relative w-full px-3 sm:px-5 lg:px-8">
<section class="absolute inset-0">
@@ -80,7 +57,6 @@
</section>
</section>
<!-- Media gallery lightbox -->
<MediaGalleryLightbox
:items="galleryItems"
@@ -130,7 +106,7 @@
<InputGroup class="bg-transparent overflow-hidden shadow-none! ring-0! border-border!">
<InputGroupTextarea
v-model="inputText"
class="min-h-14 max-h-14 text-xs resize-none break-all!"
class="min-h-14 max-h-14 text-xs resize-none break-all!"
:placeholder="activeChatReadOnly ? $t('chat.readonlyHint') : $t('chat.inputPlaceholder')"
:disabled="!currentBotId || activeChatReadOnly"
style="scrollbar-width: none;"
@@ -212,18 +188,6 @@
class="size-3.5"
/>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
:disabled="!currentBotId"
:aria-label="$t('chat.files')"
@click="fileManagerOpen = true"
>
<FolderOpen
class="size-3.5"
/>
</Button>
<Button
v-if="!streaming"
type="button"
@@ -258,58 +222,153 @@
</template>
</div>
<!-- File manager panel -->
<!-- Right sidebar panel -->
<div
v-if="fileManagerOpen"
v-if="activeRightTab"
class="flex shrink-0 h-full relative"
:style="{ width: `${fileManagerWidth}px` }"
:style="{ width: `${rightPanelWidth}px` }"
>
<!-- Resize handle -->
<div
class="absolute top-0 left-0 w-1 h-full cursor-col-resize z-10 group"
@mousedown="onFmResizeStart"
@mousedown="onPanelResizeStart"
>
<div
class="w-full h-full transition-colors group-hover:bg-primary/20"
:class="{ 'bg-primary/30': isFmResizing }"
:class="{ 'bg-primary/30': isPanelResizing }"
/>
</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"
<div class="flex flex-col h-full flex-1 min-w-0 overflow-hidden border-l border-border bg-sidebar">
<!-- Panel tab bar -->
<div class="flex items-center h-12 shrink-0 border-b border-border">
<button
v-for="tab in rightTabs"
:key="tab.id"
class="flex items-center gap-1.5 px-4 h-full text-xs transition-colors border-b-2"
:class="activeRightTab === tab.id
? 'border-foreground text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'"
@click="activeRightTab = tab.id"
>
<X class="size-3.5" />
</Button>
<component
:is="tab.icon"
class="size-4"
/>
{{ tab.label }}
</button>
</div>
<!-- Panel content -->
<div class="flex-1 min-h-0 relative">
<FileManager
v-if="currentBotId"
ref="fileManagerRef"
:bot-id="currentBotId"
:sync-url="false"
/>
<div
v-show="activeRightTab === 'terminal'"
class="absolute inset-0"
>
<TerminalComponent
v-if="currentBotId"
:bot-id="currentBotId"
:visible="activeRightTab === 'terminal'"
/>
</div>
<div
v-show="activeRightTab === 'files'"
class="absolute inset-0"
>
<FileManager
v-if="currentBotId"
ref="fileManagerRef"
:bot-id="currentBotId"
:sync-url="false"
preview-layout="bottom"
/>
</div>
<div
v-if="activeRightTab === 'info'"
class="absolute inset-0 flex items-center justify-center"
>
<p class="text-sm text-muted-foreground">
Info
</p>
</div>
</div>
</div>
</div>
<!-- Activity Bar -->
<div class="flex flex-col items-center w-10 shrink-0 h-full border-l border-border bg-sidebar">
<div class="flex flex-col items-center gap-3 pt-4">
<button
v-for="tab in rightTabs"
:key="tab.id"
class="flex items-center justify-center size-7 rounded-md transition-colors"
:class="activeRightTab === tab.id
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
:title="tab.label"
@click="toggleRightPanel(tab.id)"
>
<component
:is="tab.icon"
class="size-4"
/>
</button>
</div>
<div class="mt-auto pb-4">
<button
class="flex items-center justify-center size-7 rounded-md text-destructive/60 hover:bg-destructive/10 hover:text-destructive transition-colors"
:title="$t('chat.deleteSession')"
:disabled="!sessionId"
@click="confirmDeleteSession"
>
<Trash2 class="size-4" />
</button>
</div>
</div>
<!-- Delete session confirmation dialog -->
<Dialog v-model:open="deleteSessionDialogOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ $t('chat.deleteSession') }}</DialogTitle>
<DialogDescription>{{ $t('chat.deleteSessionConfirm') }}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
:disabled="deleteSessionLoading"
@click="deleteSessionDialogOpen = false"
>
{{ $t('common.cancel') }}
</Button>
<Button
variant="destructive"
:disabled="deleteSessionLoading"
@click="handleDeleteSession"
>
<LoaderCircle
v-if="deleteSessionLoading"
class="mr-1 size-3 animate-spin"
/>
{{ $t('common.confirm') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount, provide, useTemplateRef, watchEffect, watch } from 'vue'
import { ref, computed, 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 } from 'lucide-vue-next'
import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Popover, PopoverContent, PopoverTrigger } from '@memohai/ui'
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'
import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia'
import MessageItem from './message-item.vue'
import MediaGalleryLightbox from './media-gallery-lightbox.vue'
import FileManager from '@/components/file-manager/index.vue'
import TerminalComponent from '@/components/terminal/index.vue'
import ModelOptions from '@/pages/bots/components/model-options.vue'
import ReasoningEffortSelect from '@/pages/bots/components/reasoning-effort-select.vue'
import { EFFORT_LABELS, EFFORT_OPACITY } from '@/pages/bots/components/reasoning-effort'
@@ -326,31 +385,60 @@ const { t } = useI18n()
const chatStore = useChatStore()
const fileInput = ref<HTMLInputElement | null>(null)
const pendingFiles = ref<File[]>([])
const fileManagerOpen = ref(false)
const fileManagerRef = ref<InstanceType<typeof FileManager> | null>(null)
const modelPopoverOpen = ref(false)
const reasoningPopoverOpen = ref(false)
const FM_MIN_WIDTH = 320
const FM_MAX_WIDTH = 800
const FM_DEFAULT_WIDTH = 520
// ---- Right sidebar panel ----
const fileManagerWidth = useLocalStorage('file-manager-panel-width', FM_DEFAULT_WIDTH)
const isFmResizing = ref(false)
type RightTabId = 'terminal' | 'files' | 'info'
function onFmResizeStart(e: MouseEvent) {
interface RightTab {
id: RightTabId
label: string
icon: Component
}
const rightTabs = computed<RightTab[]>(() => [
{ id: 'terminal', label: 'Terminal', icon: TerminalSquare },
{ id: 'files', label: t('chat.files'), icon: FolderOpen },
{ id: 'info', label: 'Info', icon: BarChart3 },
])
const activeRightTab = ref<RightTabId | null>(null)
function toggleRightPanel(tabId: RightTabId) {
activeRightTab.value = activeRightTab.value === tabId ? null : tabId
}
function openRightPanel(tabId: RightTabId) {
activeRightTab.value = tabId
}
watch(activeRightTab, () => {
nextTick(() => window.dispatchEvent(new Event('resize')))
})
const PANEL_MIN_WIDTH = 320
const PANEL_MAX_WIDTH = 800
const PANEL_DEFAULT_WIDTH = 504
const rightPanelWidth = useLocalStorage('chat-right-panel-width', PANEL_DEFAULT_WIDTH)
const isPanelResizing = ref(false)
function onPanelResizeStart(e: MouseEvent) {
e.preventDefault()
isFmResizing.value = true
isPanelResizing.value = true
const startX = e.clientX
const startWidth = fileManagerWidth.value
const startWidth = rightPanelWidth.value
function onMouseMove(ev: MouseEvent) {
const delta = startX - ev.clientX
fileManagerWidth.value = Math.min(FM_MAX_WIDTH, Math.max(FM_MIN_WIDTH, startWidth + delta))
rightPanelWidth.value = Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, startWidth + delta))
}
function onMouseUp() {
isFmResizing.value = false
isPanelResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
document.body.style.cursor = ''
@@ -368,6 +456,30 @@ onBeforeUnmount(() => {
document.body.style.userSelect = ''
})
// ---- Delete session ----
const deleteSessionDialogOpen = ref(false)
const deleteSessionLoading = ref(false)
function confirmDeleteSession() {
if (!sessionId.value) return
deleteSessionDialogOpen.value = true
}
async function handleDeleteSession() {
const sid = sessionId.value
if (!sid || deleteSessionLoading.value) return
deleteSessionLoading.value = true
try {
await chatStore.removeSession(sid)
deleteSessionDialogOpen.value = false
} finally {
deleteSessionLoading.value = false
}
}
// ---- File manager provider (for tool call components) ----
const FILE_MANAGER_ROOT = '/data'
function normalizeFileManagerPath(path: string): string {
@@ -385,7 +497,7 @@ function normalizeFileManagerPath(path: string): string {
provide(openInFileManagerKey, (path: string, isDir = false) => {
const normalizedPath = normalizeFileManagerPath(path)
fileManagerOpen.value = true
openRightPanel('files')
nextTick(() => {
if (!fileManagerRef.value) return
if (isDir) {
@@ -395,10 +507,14 @@ provide(openInFileManagerKey, (path: string, isDir = false) => {
}
})
})
// ---- Chat store refs ----
const {
messages,
streaming,
currentBotId,
sessionId,
activeChatReadOnly,
loadingOlder,
loadingChats,
@@ -407,6 +523,8 @@ const {
overrideReasoningEffort,
} = storeToRefs(chatStore)
// ---- Model / provider queries ----
const { data: modelData } = useQuery({
key: ['all-models'],
query: async () => {
@@ -501,6 +619,8 @@ function onReasoningSelected() {
reasoningPopoverOpen.value = false
}
// ---- Media gallery ----
const {
items: galleryItems,
openIndex: galleryOpenIndex,
@@ -508,8 +628,9 @@ const {
openBySrc: galleryOpenBySrc,
} = useMediaGallery(messages)
const inputText = ref('')
// ---- Input & scroll ----
const inputText = ref('')
onMounted(async () => {
try {
@@ -529,10 +650,9 @@ const elNode = useTemplateRef('scrollContainer')
const descEl = computed(() => elNode.value?.$el?.children[0]?.children[0])
const scrollEl = computed(() => descEl.value?.parentNode)
const isAutoScroll = ref(true)
const isInstant=ref(false)
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value&&isInstant.value ? 'smooth' : 'instant') })
const { height,bottom } = useElementBounding(descEl)
const isInstant = ref(false)
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value && isInstant.value ? 'smooth' : 'instant') })
const { height, bottom } = useElementBounding(descEl)
watchEffect(() => {
if (directions.top) {
@@ -543,7 +663,7 @@ watchEffect(() => {
}
})
watchEffect(() => {
watchEffect(() => {
if (isAutoScroll.value) {
y.value = height.value
}
@@ -553,14 +673,14 @@ let Throttle = true
watchEffect(() => {
if (directions.top && arrivedState.top && Throttle && hasMoreOlder.value && !loadingOlder.value) {
const prev=bottom.value
Throttle = false
const prev = bottom.value
Throttle = false
chatStore.loadOlderMessages().then((count) => {
setTimeout(() => {
if (count > 0) {
y.value = height.value-prev
Throttle = true
}
if (count > 0) {
y.value = height.value - prev
Throttle = true
}
})
})
}
@@ -572,7 +692,6 @@ function handleKeydown(e: KeyboardEvent) {
handleSend()
}
function handleFileInputChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
@@ -611,7 +730,7 @@ async function fileToAttachment(file: File): Promise<ChatAttachment> {
}
async function handleSend() {
isAutoScroll.value=true
isAutoScroll.value = true
const text = inputText.value.trim()
const files = [...pendingFiles.value]
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="flex h-full">
<div class="flex h-full overflow-hidden">
<template v-if="currentBotId">
<SessionSidebar />
<div class="flex-1 flex flex-col min-w-0">