fix: resolve streaming messages splitting into individual bubbles
Simplify addMessage/updateMessage to only write to messages.value, add syncMessagesToSession() to copy messages back on session switch and stream completion. Also fix mobile viewport, session list overlay, hamburger logo, and various responsive improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -45,7 +45,7 @@ useKeyboard()
|
||||
<NNotificationProvider>
|
||||
<div v-if="ready" class="app-layout" :class="{ 'no-sidebar': isLoginPage }">
|
||||
<button v-if="!isLoginPage" class="hamburger-btn" @click="appStore.toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
<img src="/logo.png" alt="Menu" style="width: 24px; height: 24px;" />
|
||||
</button>
|
||||
<div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" />
|
||||
<AppSidebar v-if="!isLoginPage" />
|
||||
@@ -64,7 +64,7 @@ useKeyboard()
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -79,7 +79,7 @@ useKeyboard()
|
||||
background-color: $bg-primary;
|
||||
|
||||
.no-sidebar & {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -379,6 +379,9 @@ function isImage(type: string): boolean {
|
||||
|
||||
&::placeholder {
|
||||
color: $text-muted;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ const { t } = useI18n()
|
||||
const showSessions = ref(true)
|
||||
let mobileQuery: MediaQueryList | null = null
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
chatStore.switchSession(sessionId)
|
||||
if (mobileQuery?.matches) showSessions.value = false
|
||||
}
|
||||
|
||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches && showSessions.value) {
|
||||
showSessions.value = false
|
||||
@@ -265,14 +270,20 @@ async function handleRenameConfirm() {
|
||||
<template>
|
||||
<div class="chat-panel">
|
||||
<!-- Session List -->
|
||||
<div class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
|
||||
<aside class="session-list" :class="{ collapsed: !showSessions }">
|
||||
<div class="session-list-header">
|
||||
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" circle>
|
||||
<div class="session-list-actions">
|
||||
<button class="session-close-btn" @click="showSessions = false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" circle>
|
||||
<template #icon>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-items">
|
||||
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div>
|
||||
@@ -289,7 +300,7 @@ async function handleRenameConfirm() {
|
||||
:key="s.id"
|
||||
class="session-item"
|
||||
:class="{ active: s.id === chatStore.activeSessionId }"
|
||||
@click="chatStore.switchSession(s.id)"
|
||||
@click="handleSessionClick(s.id)"
|
||||
@contextmenu="handleContextMenu($event, s.id)"
|
||||
>
|
||||
<div class="session-item-content">
|
||||
@@ -392,6 +403,7 @@ async function handleRenameConfirm() {
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@@ -409,6 +421,43 @@ async function handleRenameConfirm() {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
background: $bg-card;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
||||
width: 280px;
|
||||
|
||||
&.collapsed {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.session-close-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.session-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
@@ -419,6 +468,26 @@ async function handleRenameConfirm() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-close-btn {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: $text-secondary;
|
||||
padding: 4px;
|
||||
border-radius: $radius-sm;
|
||||
|
||||
&:hover {
|
||||
background: rgba($accent-primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.session-list-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -546,7 +615,7 @@ async function handleRenameConfirm() {
|
||||
|
||||
.session-item-delete {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
opacity: 0.5;
|
||||
padding: 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
|
||||
@@ -469,16 +469,16 @@ const formattedToolResult = computed(() => {
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
.msg-user .msg-body {
|
||||
max-width: 90%;
|
||||
.message.user .msg-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.msg-assistant .msg-body {
|
||||
max-width: 92%;
|
||||
.message.assistant .msg-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.msg-system .msg-body {
|
||||
max-width: 92%;
|
||||
.message.system .msg-body {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -315,7 +315,7 @@ function handleNav(key: string) {
|
||||
|
||||
.sidebar {
|
||||
width: $sidebar-width;
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
background-color: $bg-sidebar;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
@@ -448,6 +448,12 @@ function handleNav(key: string) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -459,6 +465,11 @@ function handleNav(key: string) {
|
||||
&.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
// Override global utility — sidebar is always 240px wide
|
||||
.input-sm {
|
||||
width: 90px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+41
-15
@@ -1,7 +1,7 @@
|
||||
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
|
||||
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/sessions'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from './app'
|
||||
|
||||
export interface Attachment {
|
||||
@@ -157,8 +157,10 @@ function mapHermesSession(s: SessionSummary): Session {
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const sessions = ref<Session[]>([])
|
||||
const activeSessionId = ref<string | null>(null)
|
||||
const isStreaming = ref(false)
|
||||
const streamSessionId = ref<string | null>(null)
|
||||
const _isStreaming = ref(false)
|
||||
const abortController = ref<AbortController | null>(null)
|
||||
const isStreaming = computed(() => _isStreaming.value && activeSessionId.value === streamSessionId.value)
|
||||
const isLoadingSessions = ref(false)
|
||||
const isLoadingMessages = ref(false)
|
||||
|
||||
@@ -196,6 +198,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
async function switchSession(sessionId: string) {
|
||||
// Sync current messages back to the streaming session before switching
|
||||
if (streamSessionId.value && sessionId !== streamSessionId.value) {
|
||||
syncMessagesToSession()
|
||||
}
|
||||
activeSessionId.value = sessionId
|
||||
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
|
||||
|
||||
@@ -263,6 +269,13 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function syncMessagesToSession() {
|
||||
const targetSession = sessions.value.find(s => s.id === streamSessionId.value)
|
||||
if (targetSession) {
|
||||
targetSession.messages = [...messages.value]
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(msg: Message) {
|
||||
messages.value.push(msg)
|
||||
}
|
||||
@@ -275,17 +288,19 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
|
||||
function updateSessionTitle() {
|
||||
if (!activeSession.value) return
|
||||
if (activeSession.value.title === 'New Chat') {
|
||||
const firstUser = messages.value.find(m => m.role === 'user')
|
||||
const target = sessions.value.find(s => s.id === (streamSessionId.value || activeSessionId.value))
|
||||
if (!target) return
|
||||
const msgs = target.messages.length > 0 ? target.messages : messages.value
|
||||
if (target.title === 'New Chat') {
|
||||
const firstUser = msgs.find(m => m.role === 'user')
|
||||
if (firstUser) {
|
||||
const title = firstUser.attachments?.length
|
||||
? firstUser.attachments.map(a => a.name).join(', ')
|
||||
: firstUser.content
|
||||
activeSession.value.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
|
||||
target.title = title.slice(0, 40) + (title.length > 40 ? '...' : '')
|
||||
}
|
||||
}
|
||||
activeSession.value.updatedAt = Date.now()
|
||||
target.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
async function sendMessage(content: string, attachments?: Attachment[]) {
|
||||
@@ -306,7 +321,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
addMessage(userMsg)
|
||||
updateSessionTitle()
|
||||
|
||||
isStreaming.value = true
|
||||
_isStreaming.value = true
|
||||
|
||||
try {
|
||||
// Build conversation history from past messages
|
||||
@@ -325,6 +340,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const appStore = useAppStore()
|
||||
// Use session-level model if set, otherwise fall back to global
|
||||
const sessionModel = activeSession.value?.model || appStore.selectedModel
|
||||
streamSessionId.value = activeSessionId.value
|
||||
const run = await startRun({
|
||||
input: inputText,
|
||||
conversation_history: history,
|
||||
@@ -340,7 +356,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -402,8 +418,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (lastMsg?.isStreaming) {
|
||||
updateMessage(lastMsg.id, { isStreaming: false })
|
||||
}
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
streamSessionId.value = null
|
||||
abortController.value = null
|
||||
syncMessagesToSession()
|
||||
updateSessionTitle()
|
||||
break
|
||||
|
||||
@@ -428,7 +446,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
messages.value[i] = { ...m, toolStatus: 'error' }
|
||||
}
|
||||
})
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
streamSessionId.value = null
|
||||
abortController.value = null
|
||||
break
|
||||
}
|
||||
@@ -439,8 +458,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (last?.isStreaming) {
|
||||
updateMessage(last.id, { isStreaming: false })
|
||||
}
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
streamSessionId.value = null
|
||||
abortController.value = null
|
||||
syncMessagesToSession()
|
||||
updateSessionTitle()
|
||||
},
|
||||
// onError
|
||||
@@ -460,8 +481,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
streamSessionId.value = null
|
||||
abortController.value = null
|
||||
syncMessagesToSession()
|
||||
},
|
||||
)
|
||||
} catch (err: any) {
|
||||
@@ -471,19 +494,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
content: `Error: ${err.message}`,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
streamSessionId.value = null
|
||||
abortController.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopStreaming() {
|
||||
abortController.value?.abort()
|
||||
isStreaming.value = false
|
||||
_isStreaming.value = false
|
||||
streamSessionId.value = null
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
if (lastMsg?.isStreaming) {
|
||||
updateMessage(lastMsg.id, { isStreaming: false })
|
||||
}
|
||||
abortController.value = null
|
||||
syncMessagesToSession()
|
||||
}
|
||||
|
||||
// Load sessions on init
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vh: 1vh;
|
||||
}
|
||||
|
||||
// Fix mobile viewport height (address bar)
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
--vh: 1dvh;
|
||||
}
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
@@ -31,7 +31,7 @@ onMounted(() => {
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.channels-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ async function handleSave() {
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.jobs-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ async function handleLogin() {
|
||||
@use "@/styles/variables" as *;
|
||||
|
||||
.login-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -153,7 +153,7 @@ onMounted(async () => {
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.logs-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ const displayUser = computed(() => (data.value?.user || '').replace(/§/g, '\n\n
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.memory-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ async function handleSaved() {
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.models-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ async function saveApiServer(values: Record<string, any>) {
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.settings-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ async function loadSkills() {
|
||||
function handleSelect(category: string, skill: string) {
|
||||
selectedCategory.value = category
|
||||
selectedSkill.value = skill
|
||||
if (window.innerWidth <= 768) {
|
||||
showSidebar.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -61,13 +64,14 @@ function handleSelect(category: string, skill: string) {
|
||||
:placeholder="t('skills.searchPlaceholder')"
|
||||
size="small"
|
||||
clearable
|
||||
class="search-input"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="skills-content">
|
||||
<div v-if="loading && categories.length === 0" class="skills-loading">Loading...</div>
|
||||
<div v-else class="skills-layout">
|
||||
<div class="mobile-backdrop" :class="{ active: showSidebar }" @click="showSidebar = false" />
|
||||
<div v-if="showSidebar" class="skills-sidebar">
|
||||
<SkillList
|
||||
:categories="categories"
|
||||
@@ -100,13 +104,13 @@ function handleSelect(category: string, skill: string) {
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.skills-view {
|
||||
height: 100vh;
|
||||
height: calc(100 * var(--vh));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 220px;
|
||||
width: 100px;
|
||||
|
||||
@media (max-width: $breakpoint-mobile) {
|
||||
width: 100%;
|
||||
@@ -181,9 +185,22 @@ function handleSelect(category: string, skill: string) {
|
||||
.skills-layout {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 9;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-detail {
|
||||
|
||||
Reference in New Issue
Block a user