Commit ec5a805cba9b36396d518503ad1d8f8b2af8092b

Authored by yibo
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 || &#39;&#39;)
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(() =&gt; {
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 () =&gt; {
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 () =&gt; {
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 {
... ...