Commit ec5a805cba9b36396d518503ad1d8f8b2af8092b
1 parent
9b1c3f14
feat(agent-chat): system agent 列表支持置顶,Master 常驻置顶
Master Agent 固定置顶,其余 agent 可通过卡片右侧"⋯"菜单手动置顶/取消置顶 (状态存 localStorage)。置顶的 agent 渲染成独立的框,跟下方可滚动列表分开, 避免了 sticky 定位下其他条目从底下滑过的观感问题。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRNPSJBNTPa77fzM1mZo9z
Showing
1 changed file
with
107 additions
and
13 deletions
web/src/views/AgentChat.vue
| ... | ... | @@ -12,11 +12,36 @@ |
| 12 | 12 | type="text" |
| 13 | 13 | placeholder="🔍 搜索 Agent..." |
| 14 | 14 | /> |
| 15 | + <div class="pinned-box" v-if="pinnedAgentsFlat.length"> | |
| 16 | + <div | |
| 17 | + v-for="a in pinnedAgentsFlat" :key="a.id" | |
| 18 | + class="agent-card" :class="{ active: agentId === a.id }" | |
| 19 | + :title="a.name" | |
| 20 | + @click="agentId = a.id; switchAgent()" | |
| 21 | + > | |
| 22 | + <span class="agent-card-emoji">{{ a.emoji || '🤖' }}</span> | |
| 23 | + <div class="agent-card-info" style="flex:1;"> | |
| 24 | + <div style="display:flex; justify-content:space-between; align-items:center;"> | |
| 25 | + <div class="agent-card-name" style="flex:1;"> | |
| 26 | + {{ a.name }} | |
| 27 | + <span v-if="a.name !== a.id" class="agent-card-id" style="margin-left:4px; font-weight:normal; font-family:monospace; opacity:0.55;" :title="a.id">{{ a.id }}</span> | |
| 28 | + <span class="agent-card-type" style="margin-left:4px; font-weight:normal;">{{ a.type }}</span> | |
| 29 | + </div> | |
| 30 | + <div class="agent-card-time" v-if="a.lastActiveAt">{{ formatTime(a.lastActiveAt) }}</div> | |
| 31 | + </div> | |
| 32 | + <div class="agent-card-msg" :title="a.lastMessage" v-if="a.lastMessage" style="margin-top:2px;">{{ a.lastMessage }}</div> | |
| 33 | + </div> | |
| 34 | + <button class="agent-card-menu-btn" @click.stop="toggleAgentMenu(a.id, $event)">⋯</button> | |
| 35 | + <div v-if="openAgentMenuId === a.id" class="agent-card-menu" @click.stop> | |
| 36 | + <div class="agent-card-menu-item" @click="togglePin(a)">{{ a.type === 'master' ? '🔒 常驻置顶' : '📌 取消置顶' }}</div> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 15 | 40 | <div class="agent-list"> |
| 16 | - <template v-for="(agents, compId) in groupedAgents" :key="compId"> | |
| 41 | + <template v-for="(group, compId) in groupedAgents" :key="compId"> | |
| 17 | 42 | <div class="agent-group-title" v-if="$route.params.id">{{ compId === 'system' ? '💻 系统 Agent' : `🏢 ${compId}` }}</div> |
| 18 | 43 | <div |
| 19 | - v-for="a in agents" :key="a.id" | |
| 44 | + v-for="a in group.rest" :key="a.id" | |
| 20 | 45 | class="agent-card" :class="{ active: agentId === a.id }" |
| 21 | 46 | :title="a.name" |
| 22 | 47 | @click="agentId = a.id; switchAgent()" |
| ... | ... | @@ -33,6 +58,10 @@ |
| 33 | 58 | </div> |
| 34 | 59 | <div class="agent-card-msg" :title="a.lastMessage" v-if="a.lastMessage" style="margin-top:2px;">{{ a.lastMessage }}</div> |
| 35 | 60 | </div> |
| 61 | + <button class="agent-card-menu-btn" @click.stop="toggleAgentMenu(a.id, $event)">⋯</button> | |
| 62 | + <div v-if="openAgentMenuId === a.id" class="agent-card-menu" @click.stop> | |
| 63 | + <div class="agent-card-menu-item" @click="togglePin(a)">📌 置顶</div> | |
| 64 | + </div> | |
| 36 | 65 | </div> |
| 37 | 66 | </template> |
| 38 | 67 | </div> |
| ... | ... | @@ -326,7 +355,7 @@ |
| 326 | 355 | </template> |
| 327 | 356 | |
| 328 | 357 | <script setup> |
| 329 | -import { ref, reactive, computed, nextTick, watch, onMounted } from 'vue' | |
| 358 | +import { ref, reactive, computed, nextTick, watch, onMounted, onUnmounted } from 'vue' | |
| 330 | 359 | import { useRoute, useRouter } from 'vue-router' |
| 331 | 360 | import AppNav from '@/components/AppNav.vue' |
| 332 | 361 | import FileNode from '@/components/FileNode.vue' |
| ... | ... | @@ -338,6 +367,30 @@ const agentId = ref(route.params.agentId || '') |
| 338 | 367 | const agentList = ref([]) |
| 339 | 368 | const agentSearchQuery = ref('') |
| 340 | 369 | |
| 370 | +const PINNED_AGENTS_KEY = 'lemonboss.pinnedAgentIds' | |
| 371 | +const pinnedAgentIds = ref(new Set(JSON.parse(localStorage.getItem(PINNED_AGENTS_KEY) || '[]'))) | |
| 372 | +const openAgentMenuId = ref(null) | |
| 373 | + | |
| 374 | +function isPinned(a) { | |
| 375 | + return a.type === 'master' || pinnedAgentIds.value.has(a.id) | |
| 376 | +} | |
| 377 | +function togglePin(a) { | |
| 378 | + if (a.type === 'master') return // master 常驻置顶,不可取消 | |
| 379 | + const next = new Set(pinnedAgentIds.value) | |
| 380 | + if (next.has(a.id)) next.delete(a.id) | |
| 381 | + else next.add(a.id) | |
| 382 | + pinnedAgentIds.value = next | |
| 383 | + localStorage.setItem(PINNED_AGENTS_KEY, JSON.stringify([...next])) | |
| 384 | + openAgentMenuId.value = null | |
| 385 | +} | |
| 386 | +function toggleAgentMenu(id, evt) { | |
| 387 | + evt.stopPropagation() | |
| 388 | + openAgentMenuId.value = openAgentMenuId.value === id ? null : id | |
| 389 | +} | |
| 390 | +function closeAgentMenu() { openAgentMenuId.value = null } | |
| 391 | +onMounted(() => document.addEventListener('click', closeAgentMenu)) | |
| 392 | +onUnmounted(() => document.removeEventListener('click', closeAgentMenu)) | |
| 393 | + | |
| 341 | 394 | const groupedAgents = computed(() => { |
| 342 | 395 | const q = agentSearchQuery.value.trim().toLowerCase() |
| 343 | 396 | const groups = {} |
| ... | ... | @@ -356,28 +409,45 @@ const groupedAgents = computed(() => { |
| 356 | 409 | groups[comp].push(a) |
| 357 | 410 | }) |
| 358 | 411 | |
| 359 | - // Master agent 置顶,其余按 lastActiveAt 降序 | |
| 412 | + const byRecency = (a, b) => { | |
| 413 | + const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0 | |
| 414 | + const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0 | |
| 415 | + return tb - ta | |
| 416 | + } | |
| 417 | + | |
| 418 | + // 每组拆成「置顶区」(master + 用户手动置顶)与「其余」,置顶区单独渲染成 | |
| 419 | + // 一个跟滚动列表分开的框(见 pinnedAgentsFlat),列表滚动时不会互相干扰 | |
| 420 | + const result = {} | |
| 360 | 421 | Object.keys(groups).forEach(k => { |
| 361 | - groups[k].sort((a, b) => { | |
| 422 | + const pinned = [] | |
| 423 | + const rest = [] | |
| 424 | + groups[k].forEach(a => (isPinned(a) ? pinned : rest).push(a)) | |
| 425 | + pinned.sort((a, b) => { | |
| 362 | 426 | if (a.type === 'master') return -1 |
| 363 | 427 | if (b.type === 'master') return 1 |
| 364 | - const ta = a.lastActiveAt ? new Date(a.lastActiveAt).getTime() : 0 | |
| 365 | - const tb = b.lastActiveAt ? new Date(b.lastActiveAt).getTime() : 0 | |
| 366 | - return tb - ta | |
| 428 | + return byRecency(a, b) | |
| 367 | 429 | }) |
| 430 | + rest.sort(byRecency) | |
| 431 | + result[k] = { pinned, rest } | |
| 368 | 432 | }) |
| 369 | - | |
| 433 | + | |
| 370 | 434 | // 确保 system 永远在最上面排第一 |
| 371 | - const sortedKeys = Object.keys(groups).sort((a,b) => { | |
| 435 | + const sortedKeys = Object.keys(result).sort((a,b) => { | |
| 372 | 436 | if (a === 'system') return -1; |
| 373 | 437 | if (b === 'system') return 1; |
| 374 | 438 | return a.localeCompare(b); |
| 375 | 439 | }) |
| 376 | - | |
| 440 | + | |
| 377 | 441 | const sortedGroups = {} |
| 378 | - sortedKeys.forEach(k => { sortedGroups[k] = groups[k] }) | |
| 442 | + sortedKeys.forEach(k => { sortedGroups[k] = result[k] }) | |
| 379 | 443 | return sortedGroups |
| 380 | 444 | }) |
| 445 | + | |
| 446 | +const pinnedAgentsFlat = computed(() => { | |
| 447 | + const list = [] | |
| 448 | + Object.values(groupedAgents.value).forEach(g => list.push(...g.pinned)) | |
| 449 | + return list | |
| 450 | +}) | |
| 381 | 451 | const activeTab = ref('chat') // 'chat' | 'detail' |
| 382 | 452 | |
| 383 | 453 | // 每个 agent 独立一份聊天态(messages/sending/streaming/思考栏/ACP 面板…), |
| ... | ... | @@ -1059,12 +1129,20 @@ onMounted(async () => { |
| 1059 | 1129 | border: 1px solid var(--border-primary); background: var(--bg-input); |
| 1060 | 1130 | color: var(--text-primary); font-size: 11px; box-sizing: border-box; |
| 1061 | 1131 | } |
| 1132 | +.pinned-box { | |
| 1133 | + flex-shrink: 0; margin: 0 6px 8px; padding: 4px; | |
| 1134 | + border: 1px solid var(--border-primary); border-radius: 10px; | |
| 1135 | + background: var(--bg-surface); | |
| 1136 | +} | |
| 1137 | +.pinned-box .agent-card { margin-bottom: 2px; } | |
| 1138 | +.pinned-box .agent-card:last-child { margin-bottom: 0; } | |
| 1062 | 1139 | .agent-list { flex: 1; overflow-y: auto; padding: 6px; } |
| 1063 | 1140 | .agent-group-title { font-size: 10px; font-weight: 700; color: var(--text-tertiary); padding: 8px 6px 4px; text-transform: uppercase; } |
| 1064 | 1141 | .agent-card { |
| 1065 | - display: flex; align-items: center; gap: 8px; | |
| 1142 | + display: flex; align-items: center; gap: 8px; position: relative; | |
| 1066 | 1143 | padding: 7px 10px; border-radius: 8px; cursor: pointer; |
| 1067 | 1144 | transition: all 0.15s; margin-bottom: 2px; border: 1px solid transparent; |
| 1145 | + background: var(--bg-primary); | |
| 1068 | 1146 | } |
| 1069 | 1147 | .agent-card:hover { background: var(--accent-bg); } |
| 1070 | 1148 | .agent-card.active { background: var(--bg-surface-active); border-color: var(--border-active); } |
| ... | ... | @@ -1074,6 +1152,22 @@ onMounted(async () => { |
| 1074 | 1152 | .agent-card-type { font-size: 9px; color: var(--text-faint); } |
| 1075 | 1153 | .agent-card-time { font-size: 9px; color: var(--text-faint); white-space: nowrap; margin-left: 6px; } |
| 1076 | 1154 | .agent-card-msg { font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } |
| 1155 | +.agent-card-menu-btn { | |
| 1156 | + flex-shrink: 0; width: 18px; height: 18px; padding: 0; border: none; border-radius: 4px; | |
| 1157 | + background: transparent; color: var(--text-faint); font-size: 13px; line-height: 1; cursor: pointer; | |
| 1158 | + display: flex; align-items: center; justify-content: center; | |
| 1159 | +} | |
| 1160 | +.agent-card-menu-btn:hover { background: var(--bg-surface-hover); color: var(--text-secondary); } | |
| 1161 | +.agent-card-menu { | |
| 1162 | + position: absolute; top: 100%; right: 8px; z-index: 10; min-width: 96px; | |
| 1163 | + background: var(--bg-primary); border: 1px solid var(--border-primary); border-radius: 6px; | |
| 1164 | + box-shadow: var(--shadow-lg); padding: 4px; | |
| 1165 | +} | |
| 1166 | +.agent-card-menu-item { | |
| 1167 | + padding: 5px 8px; border-radius: 4px; font-size: 11px; color: var(--text-secondary); | |
| 1168 | + white-space: nowrap; cursor: pointer; | |
| 1169 | +} | |
| 1170 | +.agent-card-menu-item:hover { background: var(--accent-bg); } | |
| 1077 | 1171 | |
| 1078 | 1172 | /* ═══ 第2列: 对话列表 / 详情 ═══ */ |
| 1079 | 1173 | .conv-sidebar { | ... | ... |