import { Bot, ChevronsRight, Keyboard } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useLocation } from "react-router"; import { isRunActive, RUN_STATUS_LABEL, useAgentRuns } from "~/api/agent.ts"; import { AGENT_LAUNCHER_ATTR } from "~/components/agent/agent-drawer.tsx"; import { agentShortcutLabel, isAgentShortcut, shortGoalTitle, toggleAgentDrawer, useAgentDrawer, } from "~/components/agent/agent-store.ts"; import { cn } from "~/lib/utils.ts"; // Agent 的三处醒目入口:右下角悬浮按钮(FAB)、侧栏最上方主入口、全局快捷键 Ctrl/⌘+J。 // 三者都走 toggleAgentDrawer,行为一致:打开中 → 收起;有最小化的运行 → 恢复;否则带当前页面上下文打开。 /** 进行中的会话数与最近一条的状态摘要(列表里有进行中的会话时 5 秒轮询一次) */ export function useActiveAgentRuns() { const runs = useAgentRuns(); return useMemo(() => { const active = (runs.data?.pages ?? []).flatMap((p) => p.items).filter((r) => isRunActive(r.status)); const latest = active[0]; return { count: active.length, summary: latest ? `${RUN_STATUS_LABEL[latest.status]} · ${shortGoalTitle(latest.goal)}` : null, }; }, [runs.data]); } /** 全局快捷键:Ctrl/⌘+J 切换抽屉(Esc 关闭由抽屉自身处理) */ function useAgentHotkey() { const { pathname } = useLocation(); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.repeat || !isAgentShortcut(e)) return; e.preventDefault(); toggleAgentDrawer(pathname); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [pathname]); } /** 页面里的底部浮动操作栏(文件页批量操作等):FAB 与它横向重叠时抬到它上面 */ const FLOATING_BARS = '[data-floating-bar], [role="toolbar"][aria-label="批量操作"]'; const BASE_BOTTOM = 16; function useFloatingBarLift(): number { const [lift, setLift] = useState(0); useEffect(() => { let frame = 0; const measure = () => { frame = 0; let next = 0; for (const bar of document.querySelectorAll(FLOATING_BARS)) { const r = bar.getBoundingClientRect(); // 只看贴底、且伸到屏幕右侧 FAB 所在区域(右 30%)的栏 if (r.height === 0 || r.bottom < window.innerHeight - 160 || r.right < window.innerWidth * 0.7) continue; next = Math.max(next, window.innerHeight - r.top + 12 - BASE_BOTTOM); } setLift((prev) => (prev === next ? prev : next)); }; const schedule = () => { if (!frame) frame = requestAnimationFrame(measure); }; const mo = new MutationObserver(schedule); mo.observe(document.body, { childList: true, subtree: true }); window.addEventListener("resize", schedule); measure(); return () => { mo.disconnect(); window.removeEventListener("resize", schedule); if (frame) cancelAnimationFrame(frame); }; }, []); return lift; } function CountBadge({ count, className }: { count: number; className?: string }) { if (!count) return null; return ( {count > 99 ? "99+" : count} ); } /** 进行中的脉冲点:减少动态效果时只留静态点 */ function PulseDot({ className }: { className?: string }) { return ( ); } /** * QA U6:页面向下滚动时 FAB 滑出右侧,向上滚动、停在页底或页面很短时回来, * 滚动途中不再盖住右对齐的开关与状态栏。只看页面级滚动区域(高度 ≥ 半屏),列表内小滚动区不算。 */ function useHideOnScroll(): boolean { const [hidden, setHidden] = useState(false); useEffect(() => { const last = new WeakMap(); const onScroll = (e: Event) => { const t = e.target === document ? document.scrollingElement : e.target; if (!(t instanceof HTMLElement) || t.clientHeight < window.innerHeight / 2) return; const top = t.scrollTop; const prev = last.get(t) ?? top; last.set(t, top); const atBottom = top + t.clientHeight >= t.scrollHeight - 4; if (atBottom || top < 80 || top - prev < -8) setHidden(false); else if (top - prev > 8) setHidden(true); }; document.addEventListener("scroll", onScroll, { capture: true, passive: true }); return () => document.removeEventListener("scroll", onScroll, { capture: true }); }, []); return hidden; } /** * 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。 * - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。 * - 只在没有侧栏的窄屏显示(xl 起侧栏顶部就是入口);抽屉打开时隐藏,用抽屉自带的关闭钮。 */ export function AgentLauncher() { useAgentHotkey(); const { pathname } = useLocation(); const { open, minimized, runId } = useAgentDrawer(); const { count, summary } = useActiveAgentRuns(); const lift = useFloatingBarLift(); const scrolledAway = useHideOnScroll(); const shortcut = agentShortcutLabel(); // 挂到 body:外壳是 isolate 的层叠上下文,留在里面的话抽屉打开后"收起"会被遮罩盖住 const [host, setHost] = useState(null); useEffect(() => setHost(document.body), []); const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname); if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null; const label = open ? "收起智能体" : count ? `打开智能体(${count} 个会话进行中)` : "打开智能体"; return createPortal( , host, ); } /** 侧栏最上方的 Agent 主入口:强调样式(棱镜环),显示进行中会话数 */ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) { const { pathname } = useLocation(); const { count, summary } = useActiveAgentRuns(); const shortcut = agentShortcutLabel(); const activate = () => { onNavigate?.(); toggleAgentDrawer(pathname); }; const title = `智能体(${shortcut})${summary ? `\n${summary}` : ""}`; if (collapsed) { return ( ); } return ( ); } /** 快捷键说明(设置页"快捷键"一节用) */ export function AgentShortcutsHelp() { const shortcut = agentShortcutLabel(); const rows: [string, string][] = [ [shortcut, "打开 / 收起智能体,附带当前页面"], ["Esc", "关闭智能体"], ["Enter", "发送目标;Shift+Enter 换行"], ]; return (

快捷键

{rows.map(([k, v]) => (
{k}
{v}
))}
); }