PicoBot/web/src/components/Header/SessionSelector.tsx
ooodc 6f33ec7604 feat: 多通道消息支持与 Session 选择器
后端:
- list_channels 从 ChannelManager 动态查询通道列表(合并 websocket + 所有已注册通道)
- build_channel_list 移至 ChannelManager,网关层直接依赖领域层
- get_current_topic 自动创建默认话题(修复微信等通道无话题的问题)
- is_channel_writable: 仅 websocket 可写,其余通道只读

前端:
- 右上角通道选择器 + Session 选择器(Portal 渲染,固定宽度居中)
- 只读通道显示刷新按钮替代新建按钮
- 话题列表时间戳修复(秒→毫秒)
- 移除冗余的 SessionInfo、AI Ready、所属会话等 UI
- 修复 scheduler view 路由无条件拦截消息的 bug
2026-06-06 22:25:10 +08:00

177 lines
6.5 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { MessageSquare, ChevronDown } from 'lucide-react'
import type { SessionSummary } from '../../types/protocol'
interface SessionSelectorProps {
sessions: SessionSummary[]
selectedSessionId: string | null
onSelectSession: (sessionId: string) => void
}
export function SessionSelector({
sessions,
selectedSessionId,
onSelectSession,
}: SessionSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
const triggerRef = useRef<HTMLButtonElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const selected = sessions.find((s) => s.session_id === selectedSessionId)
const updatePosition = useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setDropdownPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
})
}
}, [])
useEffect(() => {
if (isOpen) {
updatePosition()
window.addEventListener('resize', updatePosition)
window.addEventListener('scroll', updatePosition, true)
return () => {
window.removeEventListener('resize', updatePosition)
window.removeEventListener('scroll', updatePosition, true)
}
}
}, [isOpen, updatePosition])
useEffect(() => {
if (!isOpen) return
const handleClick = (e: MouseEvent) => {
const target = e.target as Node
if (
dropdownRef.current && !dropdownRef.current.contains(target) &&
triggerRef.current && !triggerRef.current.contains(target)
) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [isOpen])
useEffect(() => {
if (!isOpen) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false)
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [isOpen])
if (sessions.length === 0) return null
return (
<>
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
disabled={sessions.length <= 1}
className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
transition-all duration-200 ease-out
${sessions.length <= 1
? 'border-[var(--border-color)] cursor-default'
: isOpen
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
}
`}
>
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-cyan)]" />
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center">
{selected?.title || '无会话'}
</span>
{sessions.length > 1 ? (
<span className="flex items-center gap-1 flex-shrink-0">
<span className="text-[10px] text-[var(--text-muted)]">{sessions.length}</span>
<ChevronDown
className={`h-3.5 w-3.5 text-[var(--text-muted)] transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</span>
) : (
/* 占位保持图标位置一致 */
<span className="w-4 flex-shrink-0" />
)}
</button>
{isOpen && sessions.length > 1 && createPortal(
<div
ref={dropdownRef}
className="fixed z-[9999] w-56 animate-slide-in"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
>
<div
className="
rounded-xl border border-[var(--border-color)]
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
shadow-2xl shadow-black/40
overflow-hidden
"
>
<div className="py-1 max-h-60 overflow-y-auto">
{sessions.map((s, index) => {
const isActive = s.session_id === selectedSessionId
return (
<button
key={s.session_id}
onClick={() => {
onSelectSession(s.session_id)
setIsOpen(false)
}}
className={`
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
transition-all duration-150
hover:bg-[var(--overlay-hover)]
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
`}
style={{
animationDelay: `${index * 40}ms`,
animation: 'fade-in 0.2s ease-out both',
}}
>
<div
className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
`}
/>
<MessageSquare
className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${
isActive ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]'
}`}
/>
<div className="flex-1 min-w-0">
<div
className={`text-sm truncate transition-colors duration-200 ${
isActive ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
}`}
>
{s.title}
</div>
</div>
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
{s.message_count}
</span>
</button>
)
})}
</div>
</div>
</div>,
document.body
)}
</>
)
}