feat(web): prominent agent launcher (FAB, Ctrl/Cmd+J, sidebar entry, quick goals)

- Bottom-right FAB (56px, prism ring) with active-run count, pulse and latest
  status summary; turns into "collapse" beside the open drawer; hidden on the
  Agent page and while the mini dock shows; lifts above bottom floating bars
- Ctrl/Cmd+J toggles the drawer, Esc closes; input is focused on open
- Emphasized Agent entry at the top of the sidebar (expanded and collapsed)
  with active-run count; header button kept only below xl
- Drawer first screen offers 3 page-aware quick goals that fill the input
- Respects reduced motion; AgentShortcutsHelp exported for the settings page

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:10:28 +08:00
1 parent 16ab2a6c84
commit 39a001c099
7 files changed
+433 -8

No files matched your search

+31 -3
View File
@@ -1,4 +1,5 @@
import { Bot, ExternalLink, Maximize2, Minus, Plus, X } from "lucide-react";
import { useRef } from "react";
import { Link, useLocation } from "react-router";
import { toast } from "sonner";
import { isRunActive, useAgentRun, useCreateAgentRun } from "~/api/agent.ts";
@@ -10,6 +11,7 @@ import { capabilityMeta, isDenied, RunStatusBadge } from "./agent-conversation.t
import { AgentRunPanel, GoalComposer } from "./agent-run-panel.tsx";
import {
type AgentContext,
agentShortcutLabel,
contextFromPath,
contextPrefix,
minimizeAgentDrawer,
@@ -20,8 +22,12 @@ import {
splitContextGoal,
useAgentDrawer,
} from "./agent-store.ts";
import { quickGoalsFor } from "./quick-goals.ts";
import { useAgentStream } from "./use-agent-stream.ts";
/** 悬浮按钮等"抽屉外但属于抽屉"的控件打这个属性:点它不算点外面(否则先被关掉又被重新打开) */
export const AGENT_LAUNCHER_ATTR = "data-agent-launcher";
const CONTEXT_KEYS: [keyof AgentContext, string][] = [
["entityId", "作品"],
["releaseId", "资源"],
@@ -59,11 +65,28 @@ export function AgentDrawer() {
const { open, minimized, context, runId } = useAgentDrawer();
const create = useCreateAgentRun();
const prefix = contextPrefix(context);
const { pathname } = useLocation();
const content = useRef<HTMLDivElement>(null);
return (
<>
<Sheet open={open} onOpenChange={setAgentDrawerOpen}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
<SheetContent
ref={content}
side="right"
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);
}}
onInteractOutside={(e) => {
if ((e.target as Element | null)?.closest?.(`[${AGENT_LAUNCHER_ATTR}]`)) e.preventDefault();
}}
>
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-24">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
@@ -71,6 +94,9 @@ export function AgentDrawer() {
</SheetTitle>
<SheetDescription className="break-words">
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内边执行边汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭)
</span>
</SheetDescription>
{context ? <ContextChips context={context} /> : null}
</SheetHeader>
@@ -93,6 +119,7 @@ export function AgentDrawer() {
<GoalComposer
key={`${context?.entityId ?? ""}${context?.releaseId ?? ""}${context?.goal ?? ""}`}
compact
suggestions={context?.goal ? undefined : quickGoalsFor(pathname)}
prefix={prefix || undefined}
defaultGoal={context?.goal ?? ""}
pending={create.isPending}
@@ -200,7 +227,7 @@ function AgentMiniDock({ runId }: { runId: string }) {
}
/** 顶栏按钮:有最小化的运行时恢复它;否则按当前路由带上下文打开(页面内按钮用 openAgentDrawer 传更完整的上下文) */
export function AgentDrawerTrigger() {
export function AgentDrawerTrigger({ className }: { className?: string }) {
const { minimized, runId } = useAgentDrawer();
const { pathname } = useLocation();
return (
@@ -208,7 +235,8 @@ export function AgentDrawerTrigger() {
variant="ghost"
size="icon-sm"
aria-label="打开 Agent"
title="Agent"
title={`Agent(${agentShortcutLabel()})`}
className={className}
onClick={() => (minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname)))}
>
<Bot />
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { EventType } from "@umt/contracts";
import { FlaskConical, Play, RefreshCw, WifiOff } from "lucide-react";
import { useState } from "react";
import { FlaskConical, Play, RefreshCw, Sparkles, WifiOff } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import {
type AgentMode,
@@ -78,6 +78,7 @@ export function GoalComposer({
prefix,
defaultGoal = "",
compact = false,
suggestions,
}: {
onSubmit: (goal: string, mode: AgentMode) => void;
pending: boolean;
@@ -85,11 +86,23 @@ export function GoalComposer({
prefix?: string;
defaultGoal?: string;
compact?: boolean;
/** 快捷目标:点一下填入输入框(不直接提交),光标停在末尾 */
suggestions?: string[];
}) {
const [goal, setGoal] = useState(defaultGoal);
const [mode, setMode] = useState<AgentMode>("execute");
const box = useRef<HTMLDivElement>(null);
const fill = (text: string) => {
setGoal(text);
requestAnimationFrame(() => {
const ta = box.current?.querySelector("textarea");
if (!ta) return;
ta.focus();
ta.setSelectionRange(text.length, text.length);
});
};
return (
<div className="space-y-1.5">
<div ref={box} className="space-y-1.5">
<PromptInput
value={goal}
onValueChange={setGoal}
@@ -116,6 +129,27 @@ export function GoalComposer({
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{MODE_HELP[mode].help} Enter 发送,Shift+Enter 换行。
</p>
{suggestions?.length ? (
<div className="pt-2">
<p className="mb-1.5 flex items-center gap-1.5 px-1 text-meta text-muted-foreground">
<Sparkles aria-hidden className="size-3.5" />
试试这些
</p>
<ul aria-label="快捷目标" className="flex flex-col gap-1.5">
{suggestions.map((s) => (
<li key={s}>
<button
type="button"
onClick={() => fill(s)}
className="w-full cursor-pointer rounded-xl border border-border/70 px-3 py-2 text-left text-caption text-foreground/90 outline-none transition-colors duration-micro hover:bg-foreground/[0.04] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{s.endsWith(" ") || s.endsWith(":") ? `${s}…` : s}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
@@ -46,6 +46,29 @@ export function restoreAgentDrawer() {
emit();
}
/** 快捷键文案:Mac 显示 ⌘J,其余 Ctrl+J */
export function agentShortcutLabel(): string {
const mac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
return mac ? "⌘J" : "Ctrl+J";
}
/** Ctrl/⌘+J(不带 Shift/Alt) */
export function isAgentShortcut(
e: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
): boolean {
return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "j";
}
/**
* 快捷键 / 悬浮按钮 / 侧栏主入口共用的切换:
* 打开中 → 收起;有最小化的运行 → 恢复;否则按当前路由带上下文打开新目标。
*/
export function toggleAgentDrawer(pathname: string) {
if (state.open) setAgentDrawerOpen(false);
else if (state.minimized && state.runId) restoreAgentDrawer();
else openAgentDrawer(contextFromPath(pathname));
}
/** 按当前路由推断页面上下文(顶栏按钮用;页面内按钮直接传更完整的上下文) */
export function contextFromPath(pathname: string): AgentContext | null {
const m = pathname.match(/^\/console\/(library|sites|tasks|organize|engine)\/([^/?#]+)/);
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { quickGoalsFor } from "./quick-goals.ts";
describe("抽屉快捷目标", () => {
it("按页面给 3 个相关目标", () => {
expect(quickGoalsFor("/console/library/abc")[0]).toBe("检查这部作品缺什么版本");
expect(quickGoalsFor("/console/sites/hdsky")[0]).toBe("从这个站找 ");
expect(quickGoalsFor("/console/tasks/t1")[0]).toBe("看看为什么没进展");
for (const p of ["/console", "/console/library", "/console/files", "/nope"]) {
expect(quickGoalsFor(p)).toHaveLength(3);
}
});
});
@@ -0,0 +1,69 @@
// 抽屉首屏的快捷目标:按当前页面给 3 个最常见的问法,点一下填进输入框(不直接提交)。
// 以空格结尾的是"半句",填入后光标停在末尾等用户补完(如"从这个站找 ")。
type Rule = { match: RegExp; goals: [string, string, string] };
const RULES: Rule[] = [
{
match: /^\/console\/library\/[^/]+/,
goals: ["检查这部作品缺什么版本", "这部作品有没有更好的片源可以升级", "核对这部作品的文件和元数据是否一致"],
},
{
match: /^\/console\/library\/?$/,
goals: ["盘点媒体库里还缺 4K 版本的作品", "找出最近入库但没识别成功的作品", "列出占用空间最大的 10 部作品"],
},
{
match: /^\/console\/sites\/[^/]+/,
goals: ["从这个站找 ", "看看这个站最近有哪些新的 4K 资源", "检查这个站的登录和抓取是否正常"],
},
{
match: /^\/console\/sites\/?$/,
goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"],
},
{
match: /^\/console\/tasks\/[^/]+/,
goals: ["看看为什么没进展", "下载完成后帮我整理入库", "换一个做种更多的资源重新下载"],
},
{
match: /^\/console\/tasks\/?$/,
goals: ["看看哪些下载卡住了、为什么没进展", "汇总今天的下载和整理结果", "找出失败的任务并说明原因"],
},
{
match: /^\/console\/organize/,
goals: ["这次整理为什么有文件失败", "核对整理后的路径是否符合命名规则", "把失败的文件重新整理一遍"],
},
{
match: /^\/console\/files/,
goals: ["把下载目录里还没整理的文件整理入库", "找出重复的文件", "看看哪些文件还没被识别"],
},
{
match: /^\/console\/inbox/,
goals: ["逐条说明待处理事项的原因和建议", "先处理最紧急的一项", "哪些事项可以直接按建议批量处理"],
},
{
match: /^\/console\/identity/,
goals: ["帮我识别待识别池里的文件", "找出外部身份映射可能错误的作品", "补全缺少元数据的作品"],
},
{
match: /^\/console\/subscriptions/,
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"],
},
{
match: /^\/console\/sources/,
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"],
},
{
match: /^\/console\/engine/,
goals: ["这个任务为什么失败", "哪些工作流一直在重试", "汇总最近一小时失败的运行"],
},
];
const FALLBACK: [string, string, string] = [
"收一部电影:",
"看看今天有什么需要我处理的",
"检查下载器和媒体服务器是否正常",
];
export function quickGoalsFor(pathname: string): string[] {
return RULES.find((r) => r.match.test(pathname))?.goals ?? FALLBACK;
}