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:
ekko
2026-04-15 10:28:53 +08:00
parent f3927e2990
commit 9eaaa4270d
16 changed files with 179 additions and 42 deletions
+3 -3
View File
@@ -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>
+3
View File
@@ -379,6 +379,9 @@ function isImage(type: string): boolean {
&::placeholder {
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
+72 -3
View File
@@ -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;
+6 -6
View File
@@ -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>
+12 -1
View File
@@ -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
View File
@@ -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
+11
View File
@@ -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%;
+1 -1
View File
@@ -31,7 +31,7 @@ onMounted(() => {
@use '@/styles/variables' as *;
.channels-view {
height: 100vh;
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
+1 -1
View File
@@ -21,7 +21,7 @@ onMounted(() => {
<style scoped lang="scss">
.chat-view {
height: 100vh;
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -153,7 +153,7 @@ onMounted(async () => {
@use '@/styles/variables' as *;
.logs-view {
height: 100vh;
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+23 -6
View File
@@ -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 {