Files
UHD_Media_Tool/apps/web/app/components/console/agent-launcher.tsx
T
rootandClaude Opus 5.5 9b017906cf fix(web): agent FAB slides away while scrolling down (QA U6)
Page-level scroll areas only; it returns on scroll up, near the top or at the
bottom, and when focused by keyboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

278 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
splitContextGoal,
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]} · ${splitContextGoal(latest.goal).text}` : 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<HTMLElement>(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 (
<span
className={cn(
"grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1 text-label font-semibold text-primary-foreground tabular-nums",
className,
)}
>
{count > 99 ? "99+" : count}
</span>
);
}
/** 进行中的脉冲点:减少动态效果时只留静态点 */
function PulseDot({ className }: { className?: string }) {
return (
<span aria-hidden className={cn("relative flex size-2.5", className)}>
<span className="absolute inline-flex size-full rounded-full bg-primary/60 motion-safe:animate-ping" />
<span className="relative inline-flex size-2.5 rounded-full bg-primary" />
</span>
);
}
/**
* QA U6:页面向下滚动时 FAB 滑出右侧,向上滚动、停在页底或页面很短时回来,
* 滚动途中不再盖住右对齐的开关与状态栏。只看页面级滚动区域(高度 ≥ 半屏),列表内小滚动区不算。
*/
function useHideOnScroll(): boolean {
const [hidden, setHidden] = useState(false);
useEffect(() => {
const last = new WeakMap<object, number>();
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);最小化小窗在右下角时让位给小窗。
* - 抽屉打开时变成"收起",停在抽屉左侧(窄屏抽屉占满全宽,用抽屉自带的关闭钮,FAB 隐藏)。
*/
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<HTMLElement | null>(null);
useEffect(() => setHost(document.body), []);
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
const label = open ? "收起 Agent" : count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent";
return createPortal(
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={() => toggleAgentDrawer(pathname)}
aria-label={label}
aria-keyshortcuts="Control+J Meta+J"
aria-expanded={open}
title={`${open ? "收起" : "打开"} Agent(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
style={{ bottom: `calc(${BASE_BOTTOM + lift}px + env(safe-area-inset-bottom))` }}
className={cn(
"pg-float prism-ring group pointer-events-auto fixed right-4 z-40 flex h-14 min-w-14 cursor-pointer items-center justify-center gap-3 rounded-full text-foreground outline-none select-none",
"transition-[transform,bottom,right] duration-layout ease-spring active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
"md:px-2 md:pr-5",
open && "z-[60] hidden md:right-[calc(36rem+1rem)] md:flex",
!open && scrolledAway && "translate-x-[calc(100%+2rem)] focus-visible:translate-x-0",
)}
>
<span className="relative grid size-10 shrink-0 place-items-center rounded-full pill-selected">
{open ? <ChevronsRight className="size-5" /> : <Bot className="size-5" />}
{count && !open ? <CountBadge count={count} className="absolute -top-1.5 -right-1.5 md:hidden" /> : null}
</span>
<span className="hidden min-w-0 flex-col items-start text-left md:flex">
<span className="flex items-center gap-2 text-control leading-5 font-semibold">
{open ? "收起" : "Agent"}
{count && !open ? <PulseDot /> : null}
{count && !open ? <CountBadge count={count} /> : null}
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium text-muted-foreground">
{shortcut}
</kbd>
</span>
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
{open ? "Esc 关闭" : (summary ?? "交代目标,边做边汇报")}
</span>
</span>
</button>,
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 = `Agent(${shortcut})${summary ? `\n${summary}` : ""}`;
if (collapsed) {
return (
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={activate}
title={title}
aria-label={count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent"}
aria-keyshortcuts="Control+J Meta+J"
className="prism-ring relative mb-2 grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-xl text-foreground outline-none transition-transform duration-micro active:scale-[0.95] focus-visible:ring-2 focus-visible:ring-ring"
>
<Bot className="h-[18px] w-[18px]" />
{count ? <CountBadge count={count} className="absolute -top-1.5 -right-1.5" /> : null}
</button>
);
}
return (
<button
type="button"
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={activate}
title={title}
aria-keyshortcuts="Control+J Meta+J"
className="prism-ring flex w-full cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2 text-left text-foreground outline-none transition-transform duration-micro active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="grid size-8 shrink-0 place-items-center rounded-lg pill-selected">
<Bot className="h-4 w-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 text-sm font-semibold">
Agent
{count ? <PulseDot /> : null}
</span>
<span className="truncate text-meta text-muted-foreground">
{count ? `${count} 个会话进行中` : "交代目标,边做边汇报"}
</span>
</span>
{count ? (
<CountBadge count={count} />
) : (
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 text-muted-foreground">
{shortcut}
</kbd>
)}
</button>
);
}
/** 快捷键说明(设置页"快捷键"一节用) */
export function AgentShortcutsHelp() {
const shortcut = agentShortcutLabel();
const rows: [string, string][] = [
[shortcut, "打开 / 收起 Agent,附带当前页面"],
["Esc", "关闭 Agent"],
["Enter", "发送目标;Shift+Enter 换行"],
];
return (
<section aria-labelledby="agent-shortcuts-title" className="space-y-2">
<h3 id="agent-shortcuts-title" className="flex items-center gap-2 text-sm font-semibold">
<Keyboard aria-hidden className="size-4 text-muted-foreground" />
快捷键
</h3>
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-1.5 text-caption">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt>
<kbd className="rounded-tight border border-border/70 px-1.5 py-0.5 font-sans text-label text-foreground">
{k}
</kbd>
</dt>
<dd className="text-muted-foreground">{v}</dd>
</div>
))}
</dl>
</section>
);
}