From 146790c58c9a6efe7111eaba0a7b2e0f954a30fc Mon Sep 17 00:00:00 2001 From: fellow99 Date: Mon, 13 Apr 2026 07:24:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=9D=A2=E6=9D=BF=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=BF=AB=E6=8D=B7=E9=9D=A2=E6=9D=BF=E6=98=AF=20OpenCodeUI=20?= =?UTF-8?q?=E4=B8=AD=E6=8F=90=E4=BE=9B=E8=B7=A8=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=92=8C=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=9A=84=E5=BF=AB=E6=8D=B7=E5=85=A5=E5=8F=A3=E3=80=82=E5=AE=83?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=BA=86=E4=BE=A7=E8=BE=B9=E6=A0=8F=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=20OpenCode=20=E5=93=81=E7=89=8C=E6=A0=87=E8=AF=86?= =?UTF-8?q?=E5=8E=9F=E6=9C=89=E7=9A=84=E9=A1=B5=E9=9D=A2=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=EF=BC=8C=E6=94=B9=E4=B8=BA=E5=BC=B9=E5=87=BA?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E6=B5=AE=E5=8A=A8=E9=9D=A2=E6=9D=BF=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E4=B8=89=E7=BA=A7=E6=A0=91=E5=BD=A2=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=88=E6=9C=8D=E5=8A=A1=E5=99=A8=20=E2=86=92=20=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=20=E2=86=92=20=E4=BC=9A=E8=AF=9D=EF=BC=89=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E6=89=80=E6=9C=89=E5=B7=B2=E9=85=8D=E7=BD=AE=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E5=8F=8A=E5=85=B6=E6=B4=BB=E8=B7=83=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/sidebar/ServerQuickPanel.tsx | 617 ++++++++++++++++++ src/features/chat/sidebar/SidePanel.tsx | 43 +- src/locales/en/chat.json | 5 +- src/locales/zh-CN/chat.json | 5 +- 4 files changed, 664 insertions(+), 6 deletions(-) create mode 100644 src/features/chat/sidebar/ServerQuickPanel.tsx diff --git a/src/features/chat/sidebar/ServerQuickPanel.tsx b/src/features/chat/sidebar/ServerQuickPanel.tsx new file mode 100644 index 00000000..14f67488 --- /dev/null +++ b/src/features/chat/sidebar/ServerQuickPanel.tsx @@ -0,0 +1,617 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk/v2/client' +import { serverStore, makeBasicAuthHeader, type ServerConfig, type ServerHealth } from '../../../store/serverStore' +import { + FolderIcon, + ChevronDownIcon, + WifiIcon, + WifiOffIcon, + SpinnerIcon, + KeyIcon, + GlobeIcon, + CloseIcon, + MessageSquareIcon, +} from '../../../components/Icons' +import { getDirectoryName } from '../../../utils' +import { formatPathForApi } from '../../../utils/directoryUtils' +import type { SessionStatus } from '../../../types/api/session' + +// ============================================ +// Types +// ============================================ + +/** + * 从 SDK SessionStatus 联合类型中提取 status 字符串。 + * SDK 的 SessionStatus 为 { type: "idle" } | { type: "busy" } | { type: "retry" }, + * 这里统一映射为面板内部使用的简化状态枚举。 + */ +function extractStatusType(status: SessionStatus | undefined): SessionInfo['status'] { + if (!status) return 'unknown' + return status.type as SessionInfo['status'] +} + +/** + * 面板内展示的 session 信息(从 SDK 数据简化而来)。 + */ +interface SessionInfo { + id: string + title: string + directory?: string + status: 'idle' | 'busy' | 'paused' | 'unknown' +} + +/** + * 单个服务器下的工程分组。 + */ +interface ProjectGroup { + id: string + name: string + directory?: string + sessions: SessionInfo[] +} + +/** + * 面板中每个服务器节点的数据结构。 + */ +interface ServerNode { + server: ServerConfig + health: ServerHealth | null + projects: ProjectGroup[] + isLoading: boolean + error: string | null +} + +// ============================================ +// SDK client per server (independent of active server) +// ============================================ + +/** + * 缓存已创建的 SDK client 实例,按 "baseUrl|auth" 作为 key。 + * 避免每次渲染都重建 client,减少不必要的 HTTP 连接开销。 + */ +const clientCache = new Map() + +/** + * 为指定服务器创建或复用 SDK client。 + * 该 client 独立于当前 active server,使用目标服务器自己的 baseUrl 和认证信息。 + */ +function getServerClient(server: ServerConfig): OpencodeClient { + const authPart = server.auth?.password ? `${server.auth.username}:${server.auth.password}` : 'no-auth' + const cacheKey = `${server.url}|${authPart}` + + if (clientCache.has(cacheKey)) { + return clientCache.get(cacheKey)! + } + + const headers: Record = {} + if (server.auth?.password) { + headers['Authorization'] = makeBasicAuthHeader(server.auth) + } + + const client = createOpencodeClient({ + baseUrl: server.url, + headers, + }) + + clientCache.set(cacheKey, client) + return client +} + +// ============================================ +// ServerQuickPanel Component +// ============================================ + +interface ServerQuickPanelProps { + /** 触发按钮的 ref,用于定位面板弹出位置 */ + triggerRef: React.RefObject + /** 关闭面板的回调 */ + onClose: () => void + /** 选中 session 时的回调,传入 session 信息和所属 serverId */ + onSelectSession: (session: SessionInfo, serverId: string) => void +} + +/** + * 服务器快捷面板 — 以树形结构展示所有已配置服务器 → 工程 → 活跃 session。 + * 点击侧边栏 header 中的 "OpenCode" logo 触发弹出。 + * + * 面板使用 createPortal 渲染到 document.body,通过绝对定位跟随触发按钮。 + * 支持点击外部区域和 ESC 键关闭。 + */ +export function ServerQuickPanel({ triggerRef, onClose, onSelectSession }: ServerQuickPanelProps) { + const { t } = useTranslation(['chat', 'common']) + + /** 所有服务器节点数据(含项目列表和 session 列表) */ + const [serverNodes, setServerNodes] = useState([]) + /** 已展开的服务器 ID 集合 */ + const [expandedServers, setExpandedServers] = useState>(new Set()) + /** 已展开的工程 ID 集合 */ + const [expandedProjects, setExpandedProjects] = useState>(new Set()) + /** 面板是否已完成入场动画(控制 opacity/scale transition) */ + const [isVisible, setIsVisible] = useState(false) + /** 面板弹出位置和尺寸 */ + const [panelPos, setPanelPos] = useState({ top: 0, left: 0, width: 320 }) + + /** 面板 DOM ref,用于点击外部检测 */ + const panelRef = useRef(null) + /** 标记面板是否正在关闭中,防止关闭动画期间重复触发关闭 */ + const isClosingRef = useRef(false) + + /** 计算面板弹出位置:位于触发按钮正下方,确保不超出视口 */ + useEffect(() => { + const trigger = triggerRef.current + if (!trigger) return + + const rect = trigger.getBoundingClientRect() + const panelWidth = 320 + const gap = 8 + + const left = Math.min(rect.left, window.innerWidth - panelWidth - 16) + + setPanelPos({ + top: rect.bottom + gap, + left: Math.max(8, left), + width: panelWidth, + }) + + requestAnimationFrame(() => setIsVisible(true)) + }, [triggerRef]) + + /** 获取所有服务器的项目和 session 数据 */ + useEffect(() => { + const servers = serverStore.getServers() + const healthMap = serverStore.getAllHealth() + + // 初始化所有节点为加载中状态 + const initialNodes: ServerNode[] = servers.map(server => ({ + server, + health: healthMap.get(server.id) ?? null, + projects: [], + isLoading: true, + error: null, + })) + + setServerNodes(initialNodes) + + // 自动展开当前活跃的服务器 + const activeServer = serverStore.getActiveServer() + if (activeServer) { + setExpandedServers(new Set([activeServer.id])) + } + + let cancelled = false + + const fetchAll = async () => { + await Promise.allSettled( + servers.map(async server => { + try { + const client = getServerClient(server) + + // 获取项目列表 + let projects: ProjectGroup[] = [] + try { + const projectList = await client.project.list({ directory: formatPathForApi(undefined) }) + + projects = (projectList.data ?? []).map(project => ({ + id: project.id, + name: project.name || getDirectoryName(project.worktree), + directory: project.worktree, + sessions: [], + })) + } catch { + // 项目列表获取失败时,使用一个兜底的 "All Projects" 分组 + projects = [ + { + id: '__all__', + name: t('sidebar.allProjects'), + sessions: [], + }, + ] + } + + // 并行获取每个项目的 session 列表和状态 + await Promise.allSettled( + projects.map(async project => { + try { + const sessionsData = await client.session.list({ + directory: formatPathForApi(project.directory), + roots: true, + limit: 50, + }) + const sessionList = sessionsData.data ?? [] + + // 获取 session 状态(非关键请求,失败不影响整体展示) + let statusMap: Record = {} + try { + const statusData = await client.session.status({ + directory: formatPathForApi(project.directory), + }) + statusMap = statusData.data ?? {} + } catch { + // Status fetch failure is non-critical + } + + project.sessions = sessionList + .map(session => ({ + id: session.id, + title: session.title || 'Untitled', + directory: session.directory, + status: extractStatusType(statusMap[session.id]), + })) + .sort((a, b) => { + // busy session 排在前面 + if (a.status === 'busy' && b.status !== 'busy') return -1 + if (a.status !== 'busy' && b.status === 'busy') return 1 + return 0 + }) + } catch { + project.sessions = [] + } + }), + ) + + if (!cancelled) { + setServerNodes(prev => + prev.map(node => + node.server.id === server.id ? { ...node, projects, isLoading: false, error: null } : node, + ), + ) + } + } catch (err) { + if (!cancelled) { + setServerNodes(prev => + prev.map(node => + node.server.id === server.id + ? { + ...node, + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to fetch', + } + : node, + ), + ) + } + } + }), + ) + } + + fetchAll() + + return () => { + cancelled = true + } + }, [t]) + + /** 点击面板外部区域自动关闭 */ + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (isClosingRef.current) return + const target = e.target as Node + if (triggerRef.current?.contains(target)) return + if (panelRef.current?.contains(target)) return + handleClose() + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [triggerRef]) + + /** ESC 键关闭面板 */ + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose() + } + document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, []) + + /** 关闭面板:先触发退出动画,延迟后调用 onClose 卸载组件 */ + const handleClose = useCallback(() => { + isClosingRef.current = true + setIsVisible(false) + setTimeout(() => onClose(), 150) + }, [onClose]) + + /** 切换服务器的展开/收起状态 */ + const toggleServer = useCallback((serverId: string) => { + setExpandedServers(prev => { + const next = new Set(prev) + if (next.has(serverId)) next.delete(serverId) + else next.add(serverId) + return next + }) + }, []) + + /** 切换工程的展开/收起状态 */ + const toggleProject = useCallback((projectId: string) => { + setExpandedProjects(prev => { + const next = new Set(prev) + if (next.has(projectId)) next.delete(projectId) + else next.add(projectId) + return next + }) + }, []) + + /** 选中 session:先通知父组件,再关闭面板 */ + const handleSelectSession = useCallback( + (session: SessionInfo, serverId: string) => { + onSelectSession(session, serverId) + handleClose() + }, + [onSelectSession, handleClose], + ) + + /** 统计所有服务器下的 session 总数 */ + const totalSessions = useMemo(() => { + return serverNodes.reduce((sum, node) => { + return sum + node.projects.reduce((s, p) => s + p.sessions.length, 0) + }, 0) + }, [serverNodes]) + + const floatingPanel = createPortal( +
+ {/* Header */} +
+
+

{t('sidebar.servers')}

+

+ {serverNodes.length} {t('sidebar.servers').toLowerCase()} · {totalSessions}{' '} + {t('sidebar.sessions').toLowerCase()} +

+
+ +
+ + {/* Server List */} +
+ {serverNodes.map(node => ( + + ))} +
+
, + document.body, + ) + + return floatingPanel +} + +// ============================================ +// Server Tree Item (expandable) +// ============================================ + +/** + * 服务器树节点 — 展示服务器名称、URL、健康状态和 session 总数。 + * 点击展开/收起下方工程列表。 + */ +function ServerTreeItem({ + node, + isExpanded, + expandedProjects, + onToggleServer, + onToggleProject, + onSelectSession, +}: { + node: ServerNode + isExpanded: boolean + expandedProjects: Set + onToggleServer: (id: string) => void + onToggleProject: (id: string) => void + onSelectSession: (session: SessionInfo, serverId: string) => void +}) { + const { t } = useTranslation(['chat', 'common']) + const isActive = node.server.id === serverStore.getActiveServer()?.id + + /** 根据健康状态返回对应的图标组件 */ + const statusIcon = (() => { + const health = node.health + if (!health || health.status === 'checking') return + if (health.status === 'online') return + if (health.status === 'unauthorized') return + return + })() + + /** 该服务器下所有工程的 session 总数 */ + const totalSessions = node.projects.reduce((sum, p) => sum + p.sessions.length, 0) + + return ( +
+ {/* Server header */} + + + {/* Server content */} + {isExpanded && ( +
+ {node.isLoading ? ( +
+ + {t('common:loading')} +
+ ) : node.error ? ( +
{node.error}
+ ) : node.projects.length === 0 ? ( +
{t('sidebar.noProjects')}
+ ) : ( + node.projects.map(project => ( + + )) + )} +
+ )} +
+ ) +} + +// ============================================ +// Project Tree Item (expandable) +// ============================================ + +/** + * 工程树节点 — 展示工程名称、session 数量和活跃 session 数。 + * 点击展开/收起下方的 session 列表。 + */ +function ProjectTreeItem({ + project, + serverId, + isExpanded, + onToggle, + onSelectSession, +}: { + project: ProjectGroup + serverId: string + isExpanded: boolean + onToggle: (id: string) => void + onSelectSession: (session: SessionInfo, serverId: string) => void +}) { + /** 该工程下的 session 总数 */ + const sessionCount = project.sessions.length + /** 处于 busy 状态的 session 数量 */ + const busyCount = project.sessions.filter(s => s.status === 'busy').length + + return ( +
+ {/* Project header */} + + + {/* Sessions */} + {isExpanded && sessionCount > 0 && ( +
+ {project.sessions.map(session => ( + + ))} +
+ )} +
+ ) +} + +// ============================================ +// Session Item +// ============================================ + +/** + * 单个 session 条目 — 展示 session 标题和状态指示点。 + * 状态点颜色:busy=绿色脉冲、idle=灰色、paused=黄色、unknown=深灰。 + */ +function SessionItem({ + session, + serverId, + onSelect, +}: { + session: SessionInfo + serverId: string + onSelect: (session: SessionInfo, serverId: string) => void +}) { + /** 根据 session 状态返回对应的 CSS 类名 */ + const statusDot = + session.status === 'busy' + ? 'bg-success-100 animate-pulse' + : session.status === 'idle' + ? 'bg-text-400' + : session.status === 'paused' + ? 'bg-warning-100' + : 'bg-text-500' + + return ( + + ) +} diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index 225c1384..275c7e56 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -6,6 +6,7 @@ import { ConfirmDialog } from '../../../components/ui/ConfirmDialog' import { ActiveSessionItem } from './ActiveSessionItem' import { NotificationItem } from './NotificationItem' import { SidebarFooter } from './SidebarFooter' +import { ServerQuickPanel } from './ServerQuickPanel' import { buildActiveSessionTree } from './activeSessionTree' import { getParentPath } from './sidebarUtils' import { @@ -23,7 +24,8 @@ import { } from '../../../components/Icons' import { useDirectory, useSessionStats, useKeybindingLabel, useGitWorkspaceCatalog, useVcsInfo } from '../../../hooks' import { useSessionContext } from '../../../contexts/useSessionContext' -import { useLayoutStore, useMessageStore, childSessionStore } from '../../../store' +import { useLayoutStore, useMessageStore, childSessionStore, messageStore } from '../../../store' +import { serverStore } from '../../../store/serverStore' import { useBusySessions, useBusyCount } from '../../../store/activeSessionStore' import { notificationStore, useNotifications, useUnreadNotificationCount } from '../../../store/notificationStore' import type { NotificationEntry } from '../../../store/notificationStore' @@ -146,6 +148,8 @@ export function SidePanel({ const [projectsExpanded, setProjectsExpanded] = useState(false) const [sidebarTab, setSidebarTab] = useState<'recents' | 'active'>('recents') const [expandedRecentProjectIds, setExpandedRecentProjectIds] = useState([]) + const [serverPanelOpen, setServerPanelOpen] = useState(false) + const serverPanelTriggerRef = useRef(null) // ---- 编辑模式状态 ---- const [isEditMode, setIsEditMode] = useState(false) @@ -745,6 +749,25 @@ export function SidePanel({ setBatchRemoveProjectConfirm(false) }, [getProjectDirectoriesToRemove, selectedProjectIds, removeDirectory]) + const handleQuickPanelSelectSession = useCallback( + (session: { id: string; title: string; directory?: string }, serverId: string) => { + const activeServer = serverStore.getActiveServer() + if (activeServer?.id !== serverId) { + messageStore.clearSession(selectedSessionId ?? '') + serverStore.setActiveServer(serverId) + } + if (session.directory) { + addDirectory(session.directory) + } + getSession(session.id, session.directory) + .then(apiSession => { + onSelectSession(apiSession) + }) + .catch(() => {}) + }, + [selectedSessionId, addDirectory, onSelectSession], + ) + const commonFolderRecentListProps = { currentDirectory, selectedSessionId, @@ -792,9 +815,13 @@ export function SidePanel({ opacity: showLabels ? 1 : 0, }} > - - {t('header.openCode')} - + {/* Toggle Button - 桌面端和移动端都显示 */} @@ -1196,6 +1223,14 @@ export function SidePanel({ onOpenSettings={onOpenSettings} /> + {serverPanelOpen && ( + setServerPanelOpen(false)} + onSelectSession={handleQuickPanelSelectSession} + /> + )} + {/* Confirm Dialog */}