feat(chat): improve resilience and collapsible sidebar

问题描述:\n- 刷新页面、切后台或手机锁屏后,进行中的对话容易丢失,SSE 断开时前端还会插入假的错误气泡\n- 移动端首屏会话列表会短暂遮住聊天区\n- 桌面端侧栏无法折叠,在窄窗口和缩放场景占用过多横向空间\n\n复现路径:\n- 发起一轮对话,在模型仍在输出时刷新页面或锁屏后再回到页面\n- 在窄屏设备首次打开聊天页,观察会话列表首帧覆盖聊天内容\n- 在桌面端缩窄浏览器窗口,观察侧栏始终保持完整宽度\n\n修复思路:\n- 为 chat store 增加本地缓存、水合、in-flight 标记和轮询恢复,SSE 断开后静默从服务端回补真实结果\n- 将运行中指示统一到 isRunActive,让实时流式与恢复轮询共享同一状态\n- 在 ChatPanel 首帧同步读取媒体查询,避免移动端会话列表闪烁覆盖\n- 为侧栏增加可持久化的桌面折叠状态,并补充对应文案与回归测试
This commit is contained in:
zksnet
2026-04-18 00:00:24 +08:00
parent 4d98209d13
commit 7e777fd661
10 changed files with 890 additions and 48 deletions
@@ -11,7 +11,15 @@ const chatStore = useChatStore()
const message = useMessage()
const { t } = useI18n()
const showSessions = ref(true)
// Initialize synchronously from the media query so first paint is correct.
// On narrow viewports the session list is an absolute-positioned overlay
// (z-index 10) on top of the chat area; if we default to `true`, onMounted
// only flips it to `false` AFTER the first render, causing a visible flash
// where the session list covers the chat content ("auto-fixes after a
// moment" — that was the race).
const showSessions = ref(
typeof window === 'undefined' || !window.matchMedia('(max-width: 768px)').matches,
)
let mobileQuery: MediaQueryList | null = null
function handleSessionClick(sessionId: string) {
@@ -45,7 +45,7 @@ watch(
scrollToBottom,
);
watch(
() => chatStore.isStreaming,
() => chatStore.isRunActive,
(v) => {
if (v) scrollToBottom();
},
@@ -61,7 +61,7 @@ watch(currentToolCalls, scrollToBottom);
</div>
<MessageItem v-for="msg in displayMessages" :key="msg.id" :message="msg" />
<Transition name="fade">
<div v-if="chatStore.isStreaming" class="streaming-indicator">
<div v-if="chatStore.isRunActive" class="streaming-indicator">
<video
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
autoplay
@@ -36,17 +36,43 @@ async function handleUpdate() {
</script>
<template>
<aside class="sidebar" :class="{ open: appStore.sidebarOpen }">
<div class="sidebar-logo" @click="router.push('/hermes/chat')">
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
<video class="logo-dance" :src="isDark ? danceVideoDark : danceVideoLight" autoplay loop muted playsinline />
<aside class="sidebar" :class="{ open: appStore.sidebarOpen, collapsed: appStore.sidebarCollapsed }">
<div class="sidebar-logo">
<button
type="button"
class="logo-main"
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : 'Hermes'"
@click="router.push('/hermes/chat')"
>
<img src="/logo.png" alt="Hermes" class="logo-img" />
<span class="logo-text">Hermes</span>
</button>
<video
class="logo-dance"
:src="isDark ? danceVideoDark : danceVideoLight"
autoplay
loop
muted
playsinline
/>
<button
type="button"
class="sidebar-collapse-btn"
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')"
@click="appStore.toggleSidebarCollapsed"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline v-if="appStore.sidebarCollapsed" points="9 18 15 12 9 6" />
<polyline v-else points="15 18 9 12 15 6" />
</svg>
</button>
</div>
<nav class="sidebar-nav">
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.chat' }"
:title="t('sidebar.chat')"
@click="handleNav('hermes.chat')"
>
<svg
@@ -69,6 +95,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.jobs' }"
:title="t('sidebar.jobs')"
@click="handleNav('hermes.jobs')"
>
<svg
@@ -92,6 +119,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.models' }"
:title="t('sidebar.models')"
@click="handleNav('hermes.models')"
>
<svg
@@ -120,6 +148,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.channels' }"
:title="t('sidebar.channels')"
@click="handleNav('hermes.channels')"
>
<svg
@@ -140,6 +169,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.skills' }"
:title="t('sidebar.skills')"
@click="handleNav('hermes.skills')"
>
<svg
@@ -162,6 +192,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.memory' }"
:title="t('sidebar.memory')"
@click="handleNav('hermes.memory')"
>
<svg
@@ -184,6 +215,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.logs' }"
:title="t('sidebar.logs')"
@click="handleNav('hermes.logs')"
>
<svg
@@ -210,6 +242,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.usage' }"
:title="t('sidebar.usage')"
@click="handleNav('hermes.usage')"
>
<svg
@@ -253,6 +286,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.terminal' }"
:title="t('sidebar.terminal')"
@click="handleNav('hermes.terminal')"
>
<svg
@@ -274,6 +308,7 @@ async function handleUpdate() {
<button
class="nav-item"
:class="{ active: selectedKey === 'hermes.settings' }"
:title="t('sidebar.settings')"
@click="handleNav('hermes.settings')"
>
<svg
@@ -352,11 +387,10 @@ async function handleUpdate() {
.sidebar-logo {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
padding: 20px 12px;
margin: 0 -12px;
color: $text-primary;
cursor: pointer;
background-color: $bg-card;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
@@ -366,6 +400,20 @@ async function handleUpdate() {
position: relative;
overflow: hidden;
.logo-main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
border: none;
background: none;
padding: 0;
cursor: pointer;
color: inherit;
text-align: left;
}
.logo-text {
font-size: 18px;
font-weight: 600;
@@ -374,7 +422,8 @@ async function handleUpdate() {
.logo-dance {
position: absolute;
right: 12px;
// Give the 36-wide collapse button + 12px sidebar padding breathing room.
right: 54px;
top: 50%;
transform: translateY(-50%);
height: 100px;
@@ -386,6 +435,53 @@ async function handleUpdate() {
}
}
.sidebar-collapse-btn {
flex-shrink: 0;
// 36×36 meets the 44dp Material / 44pt Apple touch-target floor after
// typical zoom-out (e.g. Chrome mobile at 80% → ~29 physical px, still
// finger-friendly). The 14×14 SVG inside keeps the chevron visually small.
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-color;
background: $bg-card;
border-radius: $radius-sm;
color: $text-secondary;
cursor: pointer;
padding: 0;
position: relative;
z-index: 2;
transition: all $transition-fast;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.08);
// Invisible padding via ::before extends the hit box a further 6px on each
// side without affecting layout — makes the button easy to tap even with
// imprecise touch input.
&::before {
content: '';
position: absolute;
inset: -6px;
}
svg {
width: 14px;
height: 14px;
}
&:hover {
color: $text-primary;
border-color: $accent-muted;
background: $bg-card-hover;
}
&:active {
background: rgba(0, 0, 0, 0.06);
transform: scale(0.94);
}
}
.sidebar-nav {
flex: 1;
display: flex;
@@ -481,11 +577,78 @@ async function handleUpdate() {
border-radius: 4px;
}
// Desktop-only collapsed ("icon rail") state. Mobile continues to use the
// slide-in open/close behaviour below.
@media (min-width: #{$breakpoint-mobile + 1px}) {
.sidebar.collapsed {
width: $sidebar-collapsed-width;
padding: 0 6px 16px;
.sidebar-logo {
padding: 16px 6px;
margin: 0 -6px;
gap: 4px;
justify-content: center;
.logo-main {
flex: 0 0 auto;
justify-content: center;
}
.logo-text,
.logo-dance {
display: none;
}
}
.nav-item {
justify-content: center;
padding: 12px 0;
gap: 0;
span {
display: none;
}
}
:deep(.model-selector) {
display: none;
}
.sidebar-footer {
padding-top: 12px;
}
.status-row {
padding: 8px 0;
justify-content: center;
:deep(.input-sm),
.status-text {
display: none;
}
.status-indicator {
gap: 0;
}
}
.version-info {
display: none;
}
}
}
@media (max-width: $breakpoint-mobile) {
.logo-dance {
display: none;
}
// Desktop-only collapse toggle — mobile relies on the slide-in open state.
.sidebar-collapse-btn {
display: none;
}
.status-row {
flex-direction: column;
align-items: flex-start;