Files
UHD_Media_Tool/apps/web/app/components/agent/agent-drawer.tsx
T
rootandClaude Opus 5.5 a87c2c19c4 fix(agent): 抽屉与整页之间的切换
- 在智能体页面按 Ctrl+J / 点智能体入口聚焦页面输入框,不再在抽屉里叠一份同一会话
- 会话页统计行右侧加「在侧边打开」:回到进智能体页面之前的页面,会话在抽屉里接着看
- 窄屏顶栏按钮与快捷键、悬浮按钮共用同一套切换
- 抽屉关闭钮放进标题栏按钮行(原来绝对定位,比其它按钮低 5px)
- 关闭抽屉后焦点回到打开前的元素(快捷键打开没有触发元素,原来落到 body)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 18:07:21 +08:00

295 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, ExternalLink, History, Maximize2, Minus, SquarePen, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
import { agentMockEnabled, isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
import { useDownloadTask } from "~/api/downloads.ts";
import { useLibraryDetail } from "~/api/library.ts";
import { useRecord } from "~/api/records.ts";
import { useSite } from "~/api/sites.ts";
import { StreamingCursor } from "~/components/agentui/streaming-response.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { errorMessage } from "~/lib/api.ts";
import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.tsx";
import { DRAFT_DRAWER_GOAL, writeDraft } from "./agent-nav.ts";
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import { AgentSessionList, drawerListMemory } from "./agent-session-list.tsx";
import {
type AgentContext,
agentShortcutLabel,
clearAgentContext,
contextPrefix,
minimizeAgentDrawer,
newAgentConversation,
noteAgentReturnPath,
restoreAgentDrawer,
setAgentDrawerOpen,
shortGoalTitle,
showAgentRunInDrawer,
takeReturnFocus,
toggleAgentDrawer,
useAgentDrawer,
} from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
import { quickGoalsFor } from "./quick-goals.ts";
import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
/**
* 上下文的显示名称:页面按钮给的 label(「作品:沙丘2」)去掉类别;按路由推断的只有 ID,按 ID 查名称。
* 查不到时退回类别名(「这部作品」),不显示 ID。
*/
export function useContextName(ctx: AgentContext | null): string | null {
const path = !!ctx?.fromPath;
const entity = useLibraryDetail(path ? ctx?.entityId : undefined);
const site = useSite(path ? ctx?.siteId : undefined);
const task = useDownloadTask(path ? ctx?.downloadTaskId : undefined);
const record = useRecord(path ? ctx?.recordId : undefined);
if (!ctx) return null;
if (!path) return ctx.label ? ctx.label.replace(/^(作品|资源|下载|整理记录|站点|任务)[::]\s*/, "") : null;
if (ctx.entityId) return entity.data?.entity.title ?? "这部作品";
if (ctx.siteId) return site.data?.name ?? ctx.siteId;
if (ctx.downloadTaskId) return task.data?.name ?? "这个下载任务";
if (ctx.recordId) return record.data?.media?.title ?? "这条整理记录";
if (ctx.taskId) return "这个后台任务";
return null;
}
/** 历史弹层:最近 20 条,等你处理置顶,子会话收在父会话下;点一条在抽屉里打开 */
function HistoryPopover({ activeId }: { activeId: string | null }) {
const [open, setOpen] = useState(false);
const mock = agentMockEnabled();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" aria-label="历史会话" title="历史会话">
<History />
历史
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="flex h-[min(32rem,70dvh)] w-[min(22rem,calc(100vw-2rem))] flex-col p-2">
<AgentSessionList
memory={drawerListMemory}
limit={20}
activeId={activeId ?? undefined}
onOpen={(id) => {
setOpen(false);
showAgentRunInDrawer(id);
}}
className="min-h-0 flex-1"
/>
<Button asChild variant="ghost" size="sm" className="mt-1 shrink-0 text-muted-foreground">
<Link to={`/console/agent${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
全部会话
</Link>
</Button>
</PopoverContent>
</Popover>
);
}
/**
* 智能体抽屉(AGUI-3):任意页面可打开,带当前页面上下文(作品 / 站点 / 下载任务等)。
* 头部只有「历史」弹层和「新对话」;没有 Tab。运行中可最小化到右下角小窗,流式输出继续。
* 挂载:在 console layout 里渲染一次 <AgentDrawer />。页面内打开:openAgentDrawer({ entityId, label, ... })。
*/
export function AgentDrawer() {
const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const { pathname, search } = useLocation();
const content = useRef<HTMLDivElement>(null);
// 记下最近的非智能体页面:会话页「在侧边打开」回到这里
useEffect(() => noteAgentReturnPath(pathname, search), [pathname, search]);
const name = useContextName(context);
const mock = agentMockEnabled();
// 目标前缀带名称和 ID:名称给人看,ID 给模型用
const prefix = contextPrefix(context && name ? { ...context, label: name } : context);
return (
<>
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent
ref={content}
side="right"
showCloseButton={false}
className="w-full gap-0 p-0 sm:max-w-xl"
// 打开即可输入:焦点直接给输入框,没有输入框时退回抽屉本身
onOpenAutoFocus={(e) => {
const ta = content.current?.querySelector<HTMLTextAreaElement>("textarea:not([disabled])");
if (!ta) return;
e.preventDefault();
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}}
// 关闭后焦点回到打开前的位置(快捷键打开没有触发元素,缺省会落到 body)
onCloseAutoFocus={(e) => {
const el = takeReturnFocus();
if (!el) return;
e.preventDefault();
el.focus();
}}
onInteractOutside={(e) => {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}}
>
<SheetHeader className="flex-row items-center gap-1 border-b border-[var(--shell-border)] py-2.5 pr-3 pl-5">
<SheetTitle className="mr-auto flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
智能体
</SheetTitle>
<SheetDescription className="sr-only">{`${agentShortcutLabel()} 打开或收起`}</SheetDescription>
<HistoryPopover activeId={runId} />
<Button variant="ghost" size="sm" onClick={newAgentConversation} disabled={!runId}>
<SquarePen />
新对话
</Button>
{runId ? (
<>
<Button asChild variant="ghost" size="icon-sm" aria-label="在页面打开" title="在页面打开">
<Link
to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`}
onClick={() => setAgentDrawerOpen(false)}
>
<ExternalLink />
</Link>
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="最小化(运行继续)"
title="最小化(运行继续)"
onClick={minimizeAgentDrawer}
>
<Minus />
</Button>
</>
) : null}
{/* 关闭钮放进标题栏这一行,与其它按钮对齐(Sheet 自带的是绝对定位,偏下 5px) */}
<SheetClose asChild>
<Button variant="ghost" size="icon-sm" aria-label="关闭" title="关闭(Esc)">
<X />
</Button>
</SheetClose>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col px-4 py-3">
{runId ? (
<AgentRunOpenContext.Provider value={showAgentRunInDrawer}>
<AgentRunPanel key={runId} runId={runId} compact className="flex-1" onCreated={showAgentRunInDrawer} />
</AgentRunOpenContext.Provider>
) : (
<div className="flex min-h-0 flex-1 flex-col justify-end">
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
context={name}
onClearContext={clearAgentContext}
defaultGoal={context?.goal ?? ""}
draftKey={DRAFT_DRAWER_GOAL}
pending={create.isPending}
onSubmit={(goal, opts) =>
create.mutate(
{ goal: `${prefix}${goal}`, ...opts },
{
onSuccess: (run) => {
writeDraft(DRAFT_DRAWER_GOAL, "");
showAgentRunInDrawer(run.id);
},
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
)}
</div>
</SheetContent>
</Sheet>
{minimized && runId ? <AgentMiniDock runId={runId} /> : null}
</>
);
}
/** 最小化后的小窗:保持事件流订阅,显示状态与最新一句输出 */
function AgentMiniDock({ runId }: { runId: string }) {
const run = useAgentRun(runId);
const active = isRunActive(run.data?.run.status);
const live = useAgentStream(runId, active || !run.data);
const last = live.items[live.items.length - 1];
const streaming = active && !live.finished;
const line =
last?.kind === "text"
? last.text.trim().split("\n").pop()
: last?.kind === "tool"
? `${isDenied(last.call) ? "被拒绝" : last.call.status === "running" ? "调用" : "完成"} · ${capabilityMeta(last.call.capabilityId)?.title ?? last.call.capabilityId}`
: last?.kind === "thinking"
? "思考中…"
: last?.kind === "end"
? last.ok
? "已完成,展开查看结果"
: last.message
: "等待输出…";
const status = run.data
? live.finished && isRunActive(run.data.run.status)
? "running"
: run.data.run.status
: null;
return (
<section
aria-label="智能体小窗"
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
>
<div className="flex items-center gap-2">
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{status ? <RunStatusBadge status={status} /> : null}
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开智能体" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="关闭小窗(运行在后台继续)"
title="关闭小窗(运行在后台继续)"
onClick={() => setAgentDrawerOpen(false)}
>
<X />
</Button>
</div>
<button
type="button"
onClick={restoreAgentDrawer}
className="mt-2 block w-full cursor-pointer rounded-lg px-1 text-left text-caption leading-5 text-foreground/90 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="line-clamp-2 break-words">
{line}
{streaming && last?.kind === "text" ? <StreamingCursor /> : null}
</span>
</button>
</section>
);
}
/** 顶栏按钮(窄屏):与快捷键、悬浮按钮同一套切换(页面内按钮用 openAgentDrawer 传更完整的上下文) */
export function AgentDrawerTrigger({ className }: { className?: string }) {
const { pathname } = useLocation();
return (
<Button
variant="ghost"
size="icon-sm"
aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`}
className={className}
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={() => toggleAgentDrawer(pathname)}
>
<Bot />
</Button>
);
}