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> <NNotificationProvider>
<div v-if="ready" class="app-layout" :class="{ 'no-sidebar': isLoginPage }"> <div v-if="ready" class="app-layout" :class="{ 'no-sidebar': isLoginPage }">
<button v-if="!isLoginPage" class="hamburger-btn" @click="appStore.toggleSidebar"> <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> </button>
<div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" /> <div v-if="!isLoginPage && appStore.sidebarOpen" class="mobile-backdrop" @click="appStore.closeSidebar" />
<AppSidebar v-if="!isLoginPage" /> <AppSidebar v-if="!isLoginPage" />
@@ -64,7 +64,7 @@ useKeyboard()
.app-layout { .app-layout {
display: flex; display: flex;
height: 100vh; height: calc(100 * var(--vh));
width: 100vw; width: 100vw;
overflow: hidden; overflow: hidden;
@@ -79,7 +79,7 @@ useKeyboard()
background-color: $bg-primary; background-color: $bg-primary;
.no-sidebar & { .no-sidebar & {
height: 100vh; height: calc(100 * var(--vh));
} }
} }
</style> </style>
+3
View File
@@ -379,6 +379,9 @@ function isImage(type: string): boolean {
&::placeholder { &::placeholder {
color: $text-muted; 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) const showSessions = ref(true)
let mobileQuery: MediaQueryList | null = null let mobileQuery: MediaQueryList | null = null
function handleSessionClick(sessionId: string) {
chatStore.switchSession(sessionId)
if (mobileQuery?.matches) showSessions.value = false
}
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) { function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && showSessions.value) { if (e.matches && showSessions.value) {
showSessions.value = false showSessions.value = false
@@ -265,14 +270,20 @@ async function handleRenameConfirm() {
<template> <template>
<div class="chat-panel"> <div class="chat-panel">
<!-- Session List --> <!-- Session List -->
<div class="session-backdrop" :class="{ active: showSessions }" @click="showSessions = false" />
<aside class="session-list" :class="{ collapsed: !showSessions }"> <aside class="session-list" :class="{ collapsed: !showSessions }">
<div class="session-list-header"> <div class="session-list-header">
<span v-if="showSessions" class="session-list-title">{{ t('chat.sessions') }}</span> <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> <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> <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> </template>
</NButton> </NButton>
</div>
</div> </div>
<div v-if="showSessions" class="session-items"> <div v-if="showSessions" class="session-items">
<div v-if="chatStore.isLoadingSessions && chatStore.sessions.length === 0" class="session-loading">{{ t('common.loading') }}</div> <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" :key="s.id"
class="session-item" class="session-item"
:class="{ active: s.id === chatStore.activeSessionId }" :class="{ active: s.id === chatStore.activeSessionId }"
@click="chatStore.switchSession(s.id)" @click="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)" @contextmenu="handleContextMenu($event, s.id)"
> >
<div class="session-item-content"> <div class="session-item-content">
@@ -392,6 +403,7 @@ async function handleRenameConfirm() {
.chat-panel { .chat-panel {
display: flex; display: flex;
height: 100%; height: 100%;
position: relative;
} }
.session-list { .session-list {
@@ -409,6 +421,43 @@ async function handleRenameConfirm() {
opacity: 0; opacity: 0;
pointer-events: none; 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 { .session-list-header {
@@ -419,6 +468,26 @@ async function handleRenameConfirm() {
flex-shrink: 0; 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 { .session-list-title {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -546,7 +615,7 @@ async function handleRenameConfirm() {
.session-item-delete { .session-item-delete {
flex-shrink: 0; flex-shrink: 0;
opacity: 0; opacity: 0.5;
padding: 2px; padding: 2px;
border: none; border: none;
background: none; background: none;
+6 -6
View File
@@ -469,16 +469,16 @@ const formattedToolResult = computed(() => {
} }
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
.msg-user .msg-body { .message.user .msg-body {
max-width: 90%; max-width: 100%;
} }
.msg-assistant .msg-body { .message.assistant .msg-body {
max-width: 92%; max-width: 100%;
} }
.msg-system .msg-body { .message.system .msg-body {
max-width: 92%; max-width: 100%;
} }
} }
</style> </style>
+12 -1
View File
@@ -315,7 +315,7 @@ function handleNav(key: string) {
.sidebar { .sidebar {
width: $sidebar-width; width: $sidebar-width;
height: 100vh; height: calc(100 * var(--vh));
background-color: $bg-sidebar; background-color: $bg-sidebar;
border-right: 1px solid $border-color; border-right: 1px solid $border-color;
display: flex; display: flex;
@@ -448,6 +448,12 @@ function handleNav(key: string) {
display: none; display: none;
} }
.status-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.sidebar { .sidebar {
position: fixed; position: fixed;
left: 0; left: 0;
@@ -459,6 +465,11 @@ function handleNav(key: string) {
&.open { &.open {
transform: translateX(0); transform: translateX(0);
} }
// Override global utility — sidebar is always 240px wide
.input-sm {
width: 90px;
}
} }
} }
</style> </style>
+41 -15
View File
@@ -1,7 +1,7 @@
import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat' import { startRun, streamRunEvents, type ChatMessage, type RunEvent } from '@/api/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/sessions' import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/sessions'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useAppStore } from './app' import { useAppStore } from './app'
export interface Attachment { export interface Attachment {
@@ -157,8 +157,10 @@ function mapHermesSession(s: SessionSummary): Session {
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const sessions = ref<Session[]>([]) const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null) 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 abortController = ref<AbortController | null>(null)
const isStreaming = computed(() => _isStreaming.value && activeSessionId.value === streamSessionId.value)
const isLoadingSessions = ref(false) const isLoadingSessions = ref(false)
const isLoadingMessages = ref(false) const isLoadingMessages = ref(false)
@@ -196,6 +198,10 @@ export const useChatStore = defineStore('chat', () => {
} }
async function switchSession(sessionId: string) { 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 activeSessionId.value = sessionId
activeSession.value = sessions.value.find(s => s.id === sessionId) || null 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) { function addMessage(msg: Message) {
messages.value.push(msg) messages.value.push(msg)
} }
@@ -275,17 +288,19 @@ export const useChatStore = defineStore('chat', () => {
} }
function updateSessionTitle() { function updateSessionTitle() {
if (!activeSession.value) return const target = sessions.value.find(s => s.id === (streamSessionId.value || activeSessionId.value))
if (activeSession.value.title === 'New Chat') { if (!target) return
const firstUser = messages.value.find(m => m.role === 'user') 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) { if (firstUser) {
const title = firstUser.attachments?.length const title = firstUser.attachments?.length
? firstUser.attachments.map(a => a.name).join(', ') ? firstUser.attachments.map(a => a.name).join(', ')
: firstUser.content : 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[]) { async function sendMessage(content: string, attachments?: Attachment[]) {
@@ -306,7 +321,7 @@ export const useChatStore = defineStore('chat', () => {
addMessage(userMsg) addMessage(userMsg)
updateSessionTitle() updateSessionTitle()
isStreaming.value = true _isStreaming.value = true
try { try {
// Build conversation history from past messages // Build conversation history from past messages
@@ -325,6 +340,7 @@ export const useChatStore = defineStore('chat', () => {
const appStore = useAppStore() const appStore = useAppStore()
// Use session-level model if set, otherwise fall back to global // Use session-level model if set, otherwise fall back to global
const sessionModel = activeSession.value?.model || appStore.selectedModel const sessionModel = activeSession.value?.model || appStore.selectedModel
streamSessionId.value = activeSessionId.value
const run = await startRun({ const run = await startRun({
input: inputText, input: inputText,
conversation_history: history, conversation_history: history,
@@ -340,7 +356,7 @@ export const useChatStore = defineStore('chat', () => {
content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`, content: `Error: startRun returned no run ID. Response: ${JSON.stringify(run)}`,
timestamp: Date.now(), timestamp: Date.now(),
}) })
isStreaming.value = false _isStreaming.value = false
return return
} }
@@ -402,8 +418,10 @@ export const useChatStore = defineStore('chat', () => {
if (lastMsg?.isStreaming) { if (lastMsg?.isStreaming) {
updateMessage(lastMsg.id, { isStreaming: false }) updateMessage(lastMsg.id, { isStreaming: false })
} }
isStreaming.value = false _isStreaming.value = false
streamSessionId.value = null
abortController.value = null abortController.value = null
syncMessagesToSession()
updateSessionTitle() updateSessionTitle()
break break
@@ -428,7 +446,8 @@ export const useChatStore = defineStore('chat', () => {
messages.value[i] = { ...m, toolStatus: 'error' } messages.value[i] = { ...m, toolStatus: 'error' }
} }
}) })
isStreaming.value = false _isStreaming.value = false
streamSessionId.value = null
abortController.value = null abortController.value = null
break break
} }
@@ -439,8 +458,10 @@ export const useChatStore = defineStore('chat', () => {
if (last?.isStreaming) { if (last?.isStreaming) {
updateMessage(last.id, { isStreaming: false }) updateMessage(last.id, { isStreaming: false })
} }
isStreaming.value = false _isStreaming.value = false
streamSessionId.value = null
abortController.value = null abortController.value = null
syncMessagesToSession()
updateSessionTitle() updateSessionTitle()
}, },
// onError // onError
@@ -460,8 +481,10 @@ export const useChatStore = defineStore('chat', () => {
timestamp: Date.now(), timestamp: Date.now(),
}) })
} }
isStreaming.value = false _isStreaming.value = false
streamSessionId.value = null
abortController.value = null abortController.value = null
syncMessagesToSession()
}, },
) )
} catch (err: any) { } catch (err: any) {
@@ -471,19 +494,22 @@ export const useChatStore = defineStore('chat', () => {
content: `Error: ${err.message}`, content: `Error: ${err.message}`,
timestamp: Date.now(), timestamp: Date.now(),
}) })
isStreaming.value = false _isStreaming.value = false
streamSessionId.value = null
abortController.value = null abortController.value = null
} }
} }
function stopStreaming() { function stopStreaming() {
abortController.value?.abort() abortController.value?.abort()
isStreaming.value = false _isStreaming.value = false
streamSessionId.value = null
const lastMsg = messages.value[messages.value.length - 1] const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.isStreaming) { if (lastMsg?.isStreaming) {
updateMessage(lastMsg.id, { isStreaming: false }) updateMessage(lastMsg.id, { isStreaming: false })
} }
abortController.value = null abortController.value = null
syncMessagesToSession()
} }
// Load sessions on init // Load sessions on init
+11
View File
@@ -8,6 +8,17 @@
box-sizing: border-box; box-sizing: border-box;
} }
:root {
--vh: 1vh;
}
// Fix mobile viewport height (address bar)
@supports (height: 100dvh) {
:root {
--vh: 1dvh;
}
}
html, body, #app { html, body, #app {
height: 100%; height: 100%;
width: 100%; width: 100%;
+1 -1
View File
@@ -31,7 +31,7 @@ onMounted(() => {
@use '@/styles/variables' as *; @use '@/styles/variables' as *;
.channels-view { .channels-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+1 -1
View File
@@ -21,7 +21,7 @@ onMounted(() => {
<style scoped lang="scss"> <style scoped lang="scss">
.chat-view { .chat-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+1 -1
View File
@@ -67,7 +67,7 @@ async function handleSave() {
@use '@/styles/variables' as *; @use '@/styles/variables' as *;
.jobs-view { .jobs-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+1 -1
View File
@@ -80,7 +80,7 @@ async function handleLogin() {
@use "@/styles/variables" as *; @use "@/styles/variables" as *;
.login-view { .login-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+1 -1
View File
@@ -153,7 +153,7 @@ onMounted(async () => {
@use '@/styles/variables' as *; @use '@/styles/variables' as *;
.logs-view { .logs-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; 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 *; @use '@/styles/variables' as *;
.memory-view { .memory-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+1 -1
View File
@@ -58,7 +58,7 @@ async function handleSaved() {
@use '@/styles/variables' as *; @use '@/styles/variables' as *;
.models-view { .models-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+1 -1
View File
@@ -102,7 +102,7 @@ async function saveApiServer(values: Record<string, any>) {
@use '@/styles/variables' as *; @use '@/styles/variables' as *;
.settings-view { .settings-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+23 -6
View File
@@ -44,6 +44,9 @@ async function loadSkills() {
function handleSelect(category: string, skill: string) { function handleSelect(category: string, skill: string) {
selectedCategory.value = category selectedCategory.value = category
selectedSkill.value = skill selectedSkill.value = skill
if (window.innerWidth <= 768) {
showSidebar.value = false
}
} }
</script> </script>
@@ -61,13 +64,14 @@ function handleSelect(category: string, skill: string) {
:placeholder="t('skills.searchPlaceholder')" :placeholder="t('skills.searchPlaceholder')"
size="small" size="small"
clearable clearable
class="search-input" style="width: 160px"
/> />
</header> </header>
<div class="skills-content"> <div class="skills-content">
<div v-if="loading && categories.length === 0" class="skills-loading">Loading...</div> <div v-if="loading && categories.length === 0" class="skills-loading">Loading...</div>
<div v-else class="skills-layout"> <div v-else class="skills-layout">
<div class="mobile-backdrop" :class="{ active: showSidebar }" @click="showSidebar = false" />
<div v-if="showSidebar" class="skills-sidebar"> <div v-if="showSidebar" class="skills-sidebar">
<SkillList <SkillList
:categories="categories" :categories="categories"
@@ -100,13 +104,13 @@ function handleSelect(category: string, skill: string) {
@use '@/styles/variables' as *; @use '@/styles/variables' as *;
.skills-view { .skills-view {
height: 100vh; height: calc(100 * var(--vh));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.search-input { .search-input {
width: 220px; width: 100px;
@media (max-width: $breakpoint-mobile) { @media (max-width: $breakpoint-mobile) {
width: 100%; width: 100%;
@@ -181,9 +185,22 @@ function handleSelect(category: string, skill: string) {
.skills-layout { .skills-layout {
position: relative; position: relative;
} }
}
min-width: 0; .mobile-backdrop {
min-height: 0; 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 { .empty-detail {