(null);
const workspaceSwitcherOpen = ref(false);
const workspaceAnchorRect = ref<{ left: number; top: number; right: number; bottom: number } | null>(null);
-const currentWorkspaceLabel = computed(() => {
- const list = Array.isArray(props.workspaces) ? props.workspaces : [];
- const current = list.find((item: any) => String(item?.workspace_id || '') === props.currentWorkspaceId);
- return current?.label || '';
-});
-
const toggleWorkspaceSwitcher = () => {
if (workspaceSwitcherOpen.value) {
workspaceSwitcherOpen.value = false;
@@ -679,6 +777,9 @@ const toggleWorkspaceSwitcher = () => {
};
const openActionMenuId = ref(null);
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
+/* 切换工作区重排期间为 true:禁用子对话 FLIP 位移与分组展开折叠过渡,
+ 使分组头与子对话全部瞬间移动(不影响新建/复制/删除动画) */
+const workspaceSwitchInstant = ref(false);
const hoverWorkspaceId = ref(null);
const openWorkspaceMenuId = ref(null);
const renameWorkspaceId = ref(null);
@@ -728,6 +829,15 @@ const shouldShowActionMenu = (conversationId: string) => {
);
};
+/* 删除离场动画定位修复:分组列表是 flex 容器,按 CSS Flexbox 规范,
+ 绝对定位子元素的静态位置会跑到容器顶部(而不是元素原来的行位置),
+ 导致删除非首条对话时离场元素瞬移到顶部。before-leave 时元素仍在文档流中,
+ 用 offsetTop 锁定原位,leave-active 的 position:absolute 生效后即停在原行。 */
+const lockLeaveItemPosition = (el: Element) => {
+ const item = el as HTMLElement;
+ item.style.top = `${item.offsetTop}px`;
+};
+
const closeActionMenu = () => {
openActionMenuId.value = null;
};
@@ -886,7 +996,19 @@ watch(
watch(
() => props.currentWorkspaceId,
- () => bumpCurrentWorkspaceToTop(),
+ async () => {
+ /* 切换工作区会触发 bumpCurrentWorkspaceToTop 重排分组:分组头是瞬时
+ 移动的,而 transition-group 会对子对话做 FLIP 位移动画,节奏不一致
+ 很诡异。重排期间挂上 switch-instant 让两者都瞬间完成。 */
+ if (isGroupByWorkspaceActive.value) {
+ workspaceSwitchInstant.value = true;
+ bumpCurrentWorkspaceToTop();
+ await nextTick();
+ workspaceSwitchInstant.value = false;
+ } else {
+ bumpCurrentWorkspaceToTop();
+ }
+ },
{ immediate: true }
);
@@ -913,6 +1035,24 @@ const sortedWorkspaces = computed(() => {
const displayConversations = computed(() =>
searchActive.value ? searchResults.value : conversations.value
);
+
+/* 跨工作区搜索结果:按与工作区分组列表一致的顺序排列(置顶 > 自定义顺序 > 名称) */
+const sortedSearchGroups = computed(() => {
+ const groups = Array.isArray(searchGroups.value) ? [...searchGroups.value] : [];
+ return groups.sort((a: any, b: any) => {
+ const aId = String(a?.workspace_id || '');
+ const bId = String(b?.workspace_id || '');
+ const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
+ const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
+ if (aPinned !== bPinned) return bPinned - aPinned;
+ const aIndex = workspaceOrder.value.indexOf(aId);
+ const bIndex = workspaceOrder.value.indexOf(bId);
+ if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
+ if (aIndex !== -1) return -1;
+ if (bIndex !== -1) return 1;
+ return String(a?.label || aId).localeCompare(String(b?.label || bId));
+ });
+});
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
diff --git a/static/src/stores/conversation.ts b/static/src/stores/conversation.ts
index b8bc578b..6029285e 100644
--- a/static/src/stores/conversation.ts
+++ b/static/src/stores/conversation.ts
@@ -22,9 +22,18 @@ export interface WorkspaceConversationGroup {
expanded: boolean;
}
+/** 跨工作区搜索结果分组(后端 /api/conversations/search?all_workspaces=1 返回) */
+export interface WorkspaceSearchGroup {
+ workspace_id: string;
+ label: string;
+ count: number;
+ results: ConversationSummary[];
+}
+
interface ConversationState {
conversations: ConversationSummary[];
searchResults: ConversationSummary[];
+ searchGroups: WorkspaceSearchGroup[];
conversationInsertAnimations: Record;
conversationListAnimationMode: 'idle' | 'create' | 'duplicate' | 'delete';
pendingDeletingConversationIds: string[];
@@ -52,6 +61,7 @@ export const useConversationStore = defineStore('conversation', {
state: (): ConversationState => ({
conversations: [],
searchResults: [],
+ searchGroups: [],
conversationInsertAnimations: {},
conversationListAnimationMode: 'idle',
pendingDeletingConversationIds: [],
@@ -82,6 +92,7 @@ export const useConversationStore = defineStore('conversation', {
resetConversations() {
this.conversations = [];
this.searchResults = [];
+ this.searchGroups = [];
this.conversationInsertAnimations = {};
this.conversationListAnimationMode = 'idle';
this.pendingDeletingConversationIds = [];
diff --git a/static/src/styles/components/sidebar/_conversation.scss b/static/src/styles/components/sidebar/_conversation.scss
index dfff0efb..698eb5fc 100644
--- a/static/src/styles/components/sidebar/_conversation.scss
+++ b/static/src/styles/components/sidebar/_conversation.scss
@@ -117,7 +117,6 @@
.sidebar-nav-label,
.conversation-search,
.conversation-list,
-.workspace-entry-current,
.personal-label {
opacity: 1;
transition: opacity 220ms ease;
@@ -127,7 +126,6 @@
.conversation-sidebar.collapsed .sidebar-nav-label,
.conversation-sidebar.collapsed .conversation-search,
.conversation-sidebar.collapsed .conversation-list,
-.conversation-sidebar.collapsed .workspace-entry-current,
.conversation-sidebar.collapsed .personal-label {
opacity: 0;
transition-delay: 0ms;
@@ -458,7 +456,9 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
.conversation-sidebar .conversation-action-wrap {
position: relative;
- width: 40px;
+ /* 宽度必须与条目第二列(32px)一致:40px 会向右溢出 8px,
+ 导致「…」按钮探出条目圆角背景外 */
+ width: 32px;
height: var(--conversation-row-height);
display: grid;
place-items: center;
@@ -608,20 +608,9 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi
flex: 0 0 auto;
}
-/* 工作区入口行:图标 + 名称 + 当前工作区名(第三列) */
+/* 工作区入口行:图标 + 名称(当前工作区名不再展示) */
.sidebar-nav-row.workspace-entry-btn {
- grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr) auto;
-}
-
-.workspace-entry-current {
- min-width: 0;
- max-width: 96px;
- padding-right: 10px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 11px;
- color: var(--claude-text-tertiary);
+ grid-template-columns: var(--conversation-icon-cell) minmax(0, 1fr);
}
.personal-page-btn svg {
@@ -764,6 +753,17 @@ body[data-theme='dark'] .workspace-group-header:hover {
color: var(--claude-text-secondary);
}
+/* 搜索结果分组头:仅作分类标识,不可交互、无 hover 高亮 */
+.workspace-group-header.search-result-group-header:hover,
+:root[data-theme='dark'] .workspace-group-header.search-result-group-header:hover,
+body[data-theme='dark'] .workspace-group-header.search-result-group-header:hover {
+ background: transparent;
+}
+
+.workspace-group-toggle.search-result-group-toggle {
+ cursor: default;
+}
+
.workspace-group-label {
min-width: 0;
font-size: 13px;
@@ -902,6 +902,16 @@ body[data-theme='dark'] .workspace-group-header:hover {
transition: grid-template-rows 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
+/* 切换工作区引发的重排/展开折叠:全部瞬间完成,不做滑动。
+ 注意只禁用位移类过渡(move + grid-rows),保留新建/复制等 enter 动画。 */
+.workspace-groups.switch-instant .workspace-group-children {
+ transition: none !important;
+}
+
+.workspace-groups.switch-instant .conversation-delete-move {
+ transition: none !important;
+}
+
.workspace-group-children.expanded {
grid-template-rows: 1fr;
}
@@ -924,9 +934,8 @@ body[data-theme='dark'] .workspace-group-header:hover {
flex-direction: column;
}
-.workspace-conversation-item {
- grid-template-columns: minmax(0, 1fr) 40px;
-}
+/* 注意:不要在这里用 .workspace-conversation-item 覆盖 grid-template-columns,
+ 特异性低于 .conversation-sidebar .conversation-item,写了也不会生效。 */
.workspace-no-conversations,
.workspace-conversations-loading {
diff --git a/utils/context_manager/conversation_mixin.py b/utils/context_manager/conversation_mixin.py
index 9eb0249a..a1edd32a 100644
--- a/utils/context_manager/conversation_mixin.py
+++ b/utils/context_manager/conversation_mixin.py
@@ -459,9 +459,9 @@ class ConversationMixin:
pass
return False
- def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
+ def search_conversations(self, query: str, limit: int = 20, multi_agent_mode: Optional[bool] = None) -> List[Dict]:
"""搜索对话"""
- return self.conversation_manager.search_conversations(query, limit)
+ return self.conversation_manager.search_conversations(query, limit, multi_agent_mode=multi_agent_mode)
def get_conversation_statistics(self) -> Dict:
"""获取对话统计"""
diff --git a/utils/conversation_manager/list_search_mixin.py b/utils/conversation_manager/list_search_mixin.py
index 9edea487..7fa3cde3 100644
--- a/utils/conversation_manager/list_search_mixin.py
+++ b/utils/conversation_manager/list_search_mixin.py
@@ -2,6 +2,7 @@
import json
import os
+import re
import time
import tempfile
import threading
@@ -314,59 +315,121 @@ class ListSearchMixin:
print(f"⌘ 归档对话失败 {conversation_id}: {e}")
return False
- def search_conversations(self, query: str, limit: int = 20) -> List[Dict]:
+ def search_conversations(self, query: str, limit: int = 20, multi_agent_mode: Optional[bool] = None) -> List[Dict]:
"""
- 搜索对话
-
+ 搜索对话(标题 / 首条用户消息首句,与原前端客户端搜索口径一致)
+
Args:
query: 搜索关键词
limit: 限制数量
-
+ multi_agent_mode: True 仅多智能体对话;False 仅常规对话;None 不过滤
+
Returns:
List[Dict]: 匹配的对话列表
"""
try:
index = self._load_index()
results = []
-
- query_lower = query.lower()
-
+
+ query_lower = (query or "").lower()
+ if not query_lower:
+ return []
+
for conv_id, metadata in index.items():
- # 搜索标题
- title = metadata.get("title", "").lower()
+ if multi_agent_mode is not None:
+ conv_ma = bool(metadata.get("multi_agent_mode", False))
+ if conv_ma != multi_agent_mode:
+ continue
+
+ entry = {
+ "id": conv_id,
+ "title": metadata.get("title"),
+ "created_at": metadata.get("created_at"),
+ "updated_at": metadata.get("updated_at"),
+ "project_path": metadata.get("project_path"),
+ }
+
+ # 搜索标题(权重最高)
+ title = (metadata.get("title") or "").lower()
if query_lower in title:
- score = 100 # 标题匹配权重最高
- results.append((score, {
- "id": conv_id,
- "title": metadata.get("title"),
- "created_at": metadata.get("created_at"),
- "updated_at": metadata.get("updated_at"),
- "project_path": metadata.get("project_path"),
- "match_type": "title"
- }))
+ results.append((100, entry["updated_at"] or "", {**entry, "match_type": "title"}))
continue
-
- # 搜索项目路径
- project_path = metadata.get("project_path", "").lower()
- if query_lower in project_path:
- results.append((50, {
- "id": conv_id,
- "title": metadata.get("title"),
- "created_at": metadata.get("created_at"),
- "updated_at": metadata.get("updated_at"),
- "project_path": metadata.get("project_path"),
- "match_type": "project_path"
- }))
-
- # 按分数排序
- results.sort(key=lambda x: x[0], reverse=True)
-
+
+ # 搜索首条用户消息首句
+ first_sentence = self._get_first_user_sentence(conv_id, metadata)
+ if first_sentence and query_lower in first_sentence:
+ results.append((80, entry["updated_at"] or "", {**entry, "match_type": "content"}))
+
+ # 分数优先,同分按更新时间新到旧
+ results.sort(key=lambda x: (x[0], x[1]), reverse=True)
+
# 返回前N个结果
- return [result[1] for result in results[:limit]]
+ return [item[2] for item in results[:limit]]
except Exception as e:
print(f"⌘ 搜索对话失败: {e}")
return []
+ def _get_first_user_sentence(self, conversation_id: str, metadata: Optional[Dict] = None) -> str:
+ """读取对话首条用户消息并提取首句(小写)。
+
+ 实例级缓存(terminal 按工作区缓存,manager 实例跨请求存活),
+ 以 updated_at 作为失效依据,避免每次按键都重读对话文件。
+ """
+ try:
+ cache = getattr(self, "_search_sentence_cache", None)
+ if cache is None:
+ cache = {}
+ self._search_sentence_cache = cache
+ updated_at = (metadata or {}).get("updated_at") or ""
+ cached = cache.get(conversation_id)
+ if cached and cached[0] == updated_at:
+ return cached[1]
+
+ sentence = ""
+ conversation_data = self.load_conversation(conversation_id)
+ if conversation_data:
+ for msg in conversation_data.get("messages") or []:
+ if not isinstance(msg, dict) or msg.get("role") != "user":
+ continue
+ sentence = self._extract_first_sentence(self._extract_message_text(msg))
+ break
+
+ sentence_lower = sentence.lower()
+ cache[conversation_id] = (updated_at, sentence_lower)
+ # 防止缓存无限增长
+ if len(cache) > 2000:
+ cache.clear()
+ return sentence_lower
+ except Exception:
+ return ""
+
+ @staticmethod
+ def _extract_message_text(msg: Dict) -> str:
+ """从消息中提取纯文本(与 build_review_lines 的 extract_text 口径一致)。"""
+ content = msg.get("content")
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ parts = []
+ for item in content:
+ if isinstance(item, dict) and item.get("type") == "text":
+ parts.append(item.get("text") or "")
+ elif isinstance(item, str):
+ parts.append(item)
+ return "".join(parts)
+ if isinstance(content, dict):
+ return content.get("text") or ""
+ return str(msg.get("text") or "")
+
+ @staticmethod
+ def _extract_first_sentence(text: str) -> str:
+ """与旧前端 extractFirstSentence 口径一致:取到第一个句末标点为止。"""
+ if not text:
+ return ""
+ normalized = re.sub(r"\s+", " ", str(text)).strip()
+ m = re.match(r"(.+?[。!?.!?])", normalized)
+ return m.group(1) if m else normalized
+
def cleanup_old_conversations(self, days: int = 30) -> int:
"""
清理旧对话(可选功能)