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:
1 parent
16ab2a6c84
commit
39a001c099
7 files changed
+433
-8
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右下角悬浮按钮 + 全局快捷键。挂在 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 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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
||||
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
||||
import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
|
||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||
import { CONSOLE_BASE, findNavItem, NAV_GROUPS, NAV_ITEMS, type NavGroup } from "~/components/console/nav.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
@@ -54,6 +55,7 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?:
|
||||
style={{ transform: `translate(${pill.x}px, ${pill.y}px)`, width: pill.w, height: pill.h }}
|
||||
/>
|
||||
) : null}
|
||||
<AgentNavEntry onNavigate={onNavigate} />
|
||||
{groups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground/75 uppercase">
|
||||
@@ -94,6 +96,7 @@ function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?:
|
||||
function CollapsedNav() {
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col items-center gap-1 overflow-y-auto py-4">
|
||||
<AgentNavEntry collapsed />
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
@@ -223,18 +226,21 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AgentDrawerTrigger />
|
||||
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
|
||||
<AgentDrawerTrigger className="xl:hidden" />
|
||||
<ThemeToggle />
|
||||
<AccountMenu session={session} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative flex-1 overflow-x-hidden px-4 py-6 md:px-6 md:py-8">
|
||||
{/* 底部留出悬浮按钮的高度,页面末尾的内容能滚到它上面 */}
|
||||
<main className="relative flex-1 overflow-x-hidden px-4 pt-6 pb-24 md:px-6 md:pt-8">
|
||||
<div key={location.pathname} className="mx-auto max-w-6xl motion-safe:animate-blur-in">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<AgentDrawer />
|
||||
<AgentLauncher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user