ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Ekko Studio 会话侧栏「最近会话」分类折叠保持机制:从 Recent 快捷入口切换到不破坏真实分类的源码级解析

Ekko Studio 会话侧栏「最近会话」分类折叠保持机制:从 Recent 快捷入口切换到不破坏真实分类的源码级解析 AI 应用人工智能AI Agent本地部署前端后端工作流自动化【免费下载链接】ekko-studioEkko Studio is a local-first AI workspace for multi-agent chat, coding, and visual workflows, available on desktop and the web.项目地址https://gitcode.com/gh_mirrors/he/ekko-studio点击查看免费下载导读本文围绕 Ekko Studiohermes-studio会话侧栏中「最近会话Recent」快捷入口的交互行为展开核心问题是当用户从 Recent 分组点击进入某个直聊会话时应用不应展开该会话的真实分类分组也不应覆盖用户已保存的分组折叠状态。文章将结合 变更说明文档 与客户端源码逐一拆解 Recent 分组的构建逻辑、折叠状态持久化、以及抑制分类展开机制category reveal suppression的实现细节帮助读者理解如何在多分类会话侧栏中安全地叠加一个纯快捷入口而不干扰用户的空间组织意图。一、背景Recent 分组为什么会对分类折叠造成干扰Ekko Studio 的会话侧栏位于 ChatPanel.vue并非简单的会话列表而是由三类区域叠加而成置顶会话Pinned用户手动固定的会话永远排在列表最上方最近会话Recent按updatedAt严格降序动态生成的快捷分组默认展示最近 10 条可配置真实分类分组Categorized用户创建的会话分类Category未分类会话归入未分类Uncategorized分组。Recent 分组的特殊性在于它是动态派生视图其中的会话同时存在于下方的真实分类分组中。从设计意图上看partitionRecentSessions 中注释明确写道最近是快捷入口不从真实分类中移除对应会话Recent 只负责快速定位最近活跃的会话而分类折叠状态才是用户维护的空间组织。因此在引入本变更之前存在一类体验问题当用户从 Recent 点击某个会话时路由与activeSessionId的变化会触发自动展开活跃会话所在分类的逻辑见下文 Watch 分析导致用户精心折叠的分类被强制展开甚至将折叠状态覆盖写入持久化存储破坏原有的侧栏布局。本变更docs/chat-chain-changes/2026-08-26-recent-session-category-collapse.md确立了如下行为契约Recent 保持快捷入口定位选中会话可以高亮highlight但不得改变用户的分类折叠选择直接 URL 访问或普通导航非 Recent 点击仍然可以揭示reveal活跃分类。二、Recent 分组的构建动态派生且不触碰真实分类2.1 分区函数 partitionRecentSessionsRecent 分组的核心实现在 session-category-groups.ts关键函数为partitionRecentSessionsexport interface RecentSessionPartitionT { group: VisibleSessionCategoryGroupT; remaining: T[]; } export function partitionRecentSessionsT extends RecentSessionAssignment( sessions: readonly T[], limit: number, label: string, ): RecentSessionPartitionT { const safeLimit Math.min(100, Math.max(1, Math.floor(Number(limit) || 10))); const recent [...sessions] .sort((a, b) (b.updatedAt || 0) - (a.updatedAt || 0)) .slice(0, safeLimit); return { group: { key: recent, label, sessions: recent, }, // “最近”是快捷入口不从真实分类中移除对应会话。 remaining: [...sessions], }; }要点拆解limit安全钳制Math.min(100, Math.max(1, Math.floor(Number(limit) || 10)))保证数量在 1100 之间非法输入如NaN回退到默认值 10。这个钳制逻辑与session-browser-prefsstore 中的setRecentCount完全一致。严格按活动时间排序(b.updatedAt || 0) - (a.updatedAt || 0)实现updatedAt降序无时间戳的会话按 0 处理排在最末。remaining返回完整原数组Recent 分组只是复制出前 N 条会话remaining保持全量会话因此真实分类分组categorizedSessions的构建完全不受 Recent 影响。这正是Recent 是快捷入口不从真实分类中移除对应会话这一契约的代码级体现。2.2 单元测试印证tests/client/session-category-groups.test.ts 中两条用例直接验证上述契约it(builds a dynamic recent group by strict activity time without changing real categories, () { const sessions [ { id: older, categoryId: 1, updatedAt: 100 }, { id: newest, categoryId: null, updatedAt: 300 }, { id: middle, categoryId: 2, updatedAt: 200 }, ] expect(buildRecentSessionCategoryGroup(sessions, 2, Recent)).toEqual({ key: recent, label: Recent, sessions: [sessions[1], sessions[2]], }) expect(sessions.map(s s.categoryId)).toEqual([1, null, 2]) // 原数组未被修改 }) it(keeps recent sessions in their sidebar categories, () { // partition.remaining 传入真实分类构建Recent 中的会话仍保留在原分类里 })categoryId断言验证了排序与截取不会污染会话自身的分类归属第二条用例则验证 Recent 与真实分类可以同时存在。2.3 ChatPanel 中的接线在 ChatPanel.vue 中const recentSessionPartition computed(() partitionRecentSessions( chatStore.sessions.filter((session) !session.isPinned), sessionBrowserPrefsStore.recentCount, t(chat.recent), )) const recentSessions computed(() recentSessionPartition.value.group) const nonRecentSessions computed(() recentSessionPartition.value.remaining)置顶会话被排除在 Recent 之外!session.isPinned避免与置顶区域重复recentCount来自sessionBrowserPrefsStorePinia store用户可在 UI 上通过最近数量弹窗调整unpinnedSessions由nonRecentSessions过滤得到再喂给buildVisibleSessionCategoryGroups构建真实分类分组。2.4 Recent 分组自身也是可折叠的Recent 分组头部同样提供折叠/展开开关其状态独立于真实分类折叠存储在session-browser-prefsstore 中const recentCollapsed refboolean(loadJsonboolean(RECENT_COLLAPSED_KEY, false)) function setRecentCollapsed(value: boolean) { recentCollapsed.value value saveJson(RECENT_COLLAPSED_KEY, value) }对应的持久化键为hermes_recent_sessions_collapsed_v1默认展开false每次切换立即写入localStorage。相关 store 源码位于 session-browser-prefs.ts测试见 session-browser-prefs.test.ts。三、折叠状态的持久化与自动揭示逻辑3.1 折叠集合的存取真实分类的折叠状态保存在localStorage键为hermes_chat_collapsed_categories值为分类 key 的字符串数组const COLLAPSED_CATEGORIES_STORAGE_KEY hermes_chat_collapsed_categories; function loadCollapsedCategories(): Setstring { try { const value JSON.parse(localStorage.getItem(COLLAPSED_CATEGORIES_STORAGE_KEY) || []); return new Set(Array.isArray(value) ? value.map(String) : []); } catch { return new Set(); } } function persistCollapsedCategories() { localStorage.setItem(COLLAPSED_CATEGORIES_STORAGE_KEY, JSON.stringify([...collapsedCategories.value])); }分类 key 的格式为category-${categoryId}如category-3未分类固定为category-none见activeSessionCategoryKey计算属性读取时对非法 JSON 容错回退为空Set写入时总是完整覆盖。3.2 Watch何时自动展开活跃分类侧栏通过一个组合 Watch 监听四路信号分类加载状态、分类分组结构、活跃会话 ID、活跃分类 key然后决定是否揭示展开活跃分类watch( [ () sessionCategoriesLoaded.value, () categorizedSessions.value.map((group) group.key).join(\u0000), () chatStore.activeSessionId, activeSessionCategoryKey, ], ([loaded, , sessionId, activeKey], [previousLoaded, , previousSessionId, previousActiveKey]) { if (!sessionCategoriesLoaded.value || categorizedSessions.value.length 0) return; const activeSession chatStore.sessions.find((session) session.id chatStore.activeSessionId); if (categoryRevealSuppressedSessionId.value activeSession?.id) return; // ← 抑制点 setCategoryRevealSuppressedSessionId(null); // 只有导航或分类变更才应揭示活跃会话后台列表刷新必须保留手动折叠。 const shouldReveal loaded ! previousLoaded || sessionId ! previousSessionId || activeKey ! previousActiveKey; if (shouldReveal collapsedCategories.value.has(activeKey)) { collapsedCategories.value new Set( [...collapsedCategories.value].filter((key) key ! activeKey), ); persistCollapsedCategories(); } // 首次加载无存储记录时默认只展开活跃分类所在分组 if (localStorage.getItem(COLLAPSED_CATEGORIES_STORAGE_KEY) ! null) return; const expandedKey categorizedSessions.value.some((group) group.key activeKey) ? activeKey : categorizedSessions.value[0]?.key; collapsedCategories.value new Set( categorizedSessions.value.map((group) group.key).filter((key) key ! expandedKey), ); persistCollapsedCategories(); }, { immediate: true }, )值得注意的两个防御性设计shouldReveal条件只有分类刚加载完成 / 活跃会话切换 / 活跃分类变更三类信号才会触发展开后台会话列表刷新如新消息到达、时间戳更新不会展开任何分组避免刷新一次列表折叠布局就被打乱首次初始化当hermes_chat_collapsed_categories从未写入时自动生成一个默认只展开活跃分类所在分组、其余全部折叠的初始布局随后立即持久化——这是对新用户侧栏的首屏体验优化。四、核心机制Recent 点击的揭示抑制Reveal Suppression4.1 两个入口的差异化处理ChatPanel 中区分了两种会话点击入口ChatPanel.vueasync function handleSessionClick( sessionId: string, options: { preserveCategoryCollapse?: boolean } {}, ) { if (!options.preserveCategoryCollapse) { setCategoryRevealSuppressedSessionId(null); // 普通导航清除抑制 } chatStore.clearSessionCompletedUnread(sessionId); await router.push({ name: chatStore.runtimeMode global_agent ? hermes.globalAgentSession : hermes.session, params: { sessionId }, }); if (chatStore.activeSessionId ! sessionId) { await chatStore.switchSession(sessionId); } if (mobileQuery?.matches) showSessions.value false; } async function handleRecentSessionClick(sessionId: string) { // Recent 是快捷入口选择它不得覆盖真实分类保存的折叠状态。 setCategoryRevealSuppressedSessionId(sessionId); // 先登记抑制目标 await handleSessionClick(sessionId, { preserveCategoryCollapse: true }); }关键点普通导航分类分组、置顶、搜索等调用handleSessionClick且不带preserveCategoryCollapse进入时先清除抑制标记随后 Watch 检测到活跃会话变化正常展开其真实分类Recent 点击调用handleRecentSessionClick先把sessionId登记为揭示抑制目标再以preserveCategoryCollapse: true走同一导航链路Watch 中因命中抑制条件categoryRevealSuppressedSessionId.value activeSession?.id而提前 return折叠集合保持不变。4.2 抑制标记的会话级持久化抑制标记并非仅存在内存中而是写入sessionStorage键为hermes_chat_recent_category_reveal_suppression值就是会话 IDconst RECENT_CATEGORY_REVEAL_SUPPRESSION_STORAGE_KEY hermes_chat_recent_category_reveal_suppression; function loadCategoryRevealSuppressedSessionId(): string | null { try { return sessionStorage.getItem(RECENT_CATEGORY_REVEAL_SUPPRESSION_STORAGE_KEY); } catch { return null; } } const categoryRevealSuppressedSessionId refstring | null( loadCategoryRevealSuppressedSessionId(), ); function setCategoryRevealSuppressedSessionId(sessionId: string | null) { categoryRevealSuppressedSessionId.value sessionId; try { if (sessionId) sessionStorage.setItem(RECENT_CATEGORY_REVEAL_SUPPRESSION_STORAGE_KEY, sessionId); else sessionStorage.removeItem(RECENT_CATEGORY_REVEAL_SUPPRESSION_STORAGE_KEY); } catch { /* session storage 不可用时仅保持内存行为 */ } }使用sessionStorage而非localStorage的设计考量抑制只对当前标签页内的一次 Recent 点击生效属于会话级临时状态若用户之后通过 URL 直达、刷新或从真实分类点击同一会话抑制已被清除普通导航会调用setCategoryRevealSuppressedSessionId(null)分类会正常揭示——与文档声明Direct URL 和普通导航仍可揭示活跃分类完全吻合。4.3 执行时序一次完整的 Recent 点击流程handleRecentSessionClick(sessionId)将sessionId写入sessionStorage与内存 refhandleSessionClick(..., { preserveCategoryCollapse: true })跳过清除抑制执行clearSessionCompletedUnread、router.push与switchSessionactiveSessionId变化触发 WatchWatch 中activeSession?.id categoryRevealSuppressedSessionId.value成立直接 return——不展开分类、不写折叠存储用户下一次普通导航或直接 URL 访问时抑制被清除行为恢复为揭示活跃分类。五、可配置项与存储键速查本特性涉及的全部配置与存储键整理如下均可在对应源码中核实配置/存储键类型默认值说明源码位置hermes_chat_collapsed_categorieslocalStorage[]空数组真实分类折叠状态值为分类 key 数组category-id/category-noneChatPanel.vuehermes_chat_recent_category_reveal_suppressionsessionStorage无当前标签页内抑制分类揭示的会话 IDChatPanel.vuehermes_recent_session_count_v1localStorage10Recent 分组展示的会话条数范围钳制在 1100session-browser-prefs.tshermes_recent_sessions_collapsed_v1localStoragefalseRecent 分组自身的折叠开关session-browser-prefs.tshermes_show_recent_sessions_v1localStoragetrue是否显示 Recent 分组session-browser-prefs.tshermes_human_only_v1_profilelocalStoragetrue按 profile 区分的仅人类会话过滤开关session-browser-prefs.tsRecent 条数上限的钳制逻辑同时存在于 store 的setRecentCount与partitionRecentSessions两处保证无论 UI 输入如何最终排序截取都在 1100 的安全区间内。六、行为契约总结与验证方式6.1 最终行为契约从Recent 分组点击会话会话被高亮并切换到对应对话但不展开其真实分类、不覆盖已保存的折叠状态直接 URL 访问如通过hermes.session路由携带sessionId进入或普通导航从分类分组、置顶、会话搜索等入口点击活跃分类照常揭示后台列表刷新时间戳、新消息导致的列表重排不会触发任何自动展开手动折叠始终被尊重。6.2 如何验证创建至少两个会话分类并手动折叠其中一个从 Recent 分组点击折叠分类下的会话该分类应保持折叠会话正常打开刷新页面或通过 URL 直达同一会话该分类应被展开揭示在侧栏中直接点击同一会话所在分类下的条目分类展开随后折叠状态重新持久化。相关单元测试可参考 session-category-groups.test.ts分区与保留语义与 session-browser-prefs.test.ts偏好持久化以及 e2e 用例 session-categories.spec.ts。结语本变更在 2026-08-26 变更说明 中仅用两句话概括了行为契约但其落地涉及三个层面的协同设计纯派生的 Recent 分区不触碰真实分类、严格区分触发源的自动揭示 Watch仅导航/分类变更生效、以及会话级 sessionStorage 抑制标记Recent 点击临时冻结揭示逻辑。这种快捷视图叠加于用户组织之上、且绝不干扰用户组织的模式对于任何需要在列表侧栏中提供最近/常用快捷入口的应用都具备直接的借鉴价值。赞分享AI 应用人工智能AI Agent本地部署前端后端工作流自动化【免费下载链接】ekko-studioEkko Studio is a local-first AI workspace for multi-agent chat, coding, and visual workflows, available on desktop and the web.项目地址https://gitcode.com/gh_mirrors/he/ekko-studio点击查看免费下载相关推荐Ekko Studio 会话侧栏「最近」快捷入口刷新后保留分类折叠状态的实现解析Ekko Studio 会话侧栏「最近」快捷入口刷新后保留分类折叠状态的实现解析 导读 本文深入解析 Ekko Studiohermes studio 仓库AI 应用人工智能AI Agent本地部署前端后端工作流自动化Ekko Studio 会话侧栏分类折叠状态保持机制轮询刷新下的手动折叠持久化实现解析Ekko Studio 会话侧栏分类折叠状态保持机制轮询刷新下的手动折叠持久化实现解析 在 Ekko Studiohermes studio的聊天会话侧栏AI 应用人工智能AI Agent本地部署前端后端工作流自动化VPet 桌面宠物 MOD 制作跑通第一只自定义桌宠要改哪几个文件VPet 桌面宠物 MOD 制作跑通第一只自定义桌宠要改哪几个文件 VPet 是一个开源的桌面宠物模拟器把一张张 PNG 序列帧把连续动作拆成静图拼起来桌面应用游戏开发上一篇【免费下载】 让小米P2P摄像头焕发新生videoP2Proxy项目推荐下一篇file-transfer-go API参考完整接口文档和使用示例创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表