feat(agent): 抽屉与会话页统一成一个对话界面,子会话在父会话里实时可见
- 抽屉和会话页渲染同一套 GoalComposer / AgentRunPanel;抽屉标题即最近会话切换, 头部只有新对话、在页面打开、关闭;去掉右下角悬浮按钮和最小化小窗 - 会话列表合一:对话与巡检混排带类型小标签,搜索 + 一个状态下拉,只列顶层会话 (GET /agent/runs?roots=true,新增可选参数) - 子会话卡片:由镜像的 subagent_* 事件折成,显示任务、状态、当前步骤、用时、调用次数、费用, 可展开看过程、可打开;顶部「N 个子会话运行中」跳到卡片;subagent_completed 加可选 durationMs - 回答去内部字段:汇报规则禁止照抄键值与字段表;展示层 stripInternalFields 把字段行 / 字段表移进技术信息 - 开场建议按页面给 2 条完整问法、点了直接发;指令只留 /先给方案,/重跑 改成「重新运行」按钮 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
dd0f6e2c09
commit
3692793dff
35 files changed
+1454
-1137
No files matched your search
@@ -1,6 +1,5 @@
|
||||
import { Bot, ChevronsRight, Keyboard } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Bot, Keyboard } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
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";
|
||||
@@ -9,16 +8,16 @@ import {
|
||||
isAgentShortcut,
|
||||
shortGoalTitle,
|
||||
toggleAgentDrawer,
|
||||
useAgentDrawer,
|
||||
} from "~/components/agent/agent-store.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
// Agent 的三处醒目入口:右下角悬浮按钮(FAB)、侧栏最上方主入口、全局快捷键 Ctrl/⌘+J。
|
||||
// 三者都走 toggleAgentDrawer,行为一致:打开中 → 收起;有最小化的运行 → 恢复;否则带当前页面上下文打开。
|
||||
// 智能体抽屉的入口只有一个按钮:桌面是侧栏最上方的「智能体」,没有侧栏的窄屏是顶栏胶囊里的智能体按钮
|
||||
// (agent-drawer.tsx 的 AgentDrawerTrigger);再加全局快捷键 Ctrl/⌘+J。右下角悬浮按钮已去掉(2026-10-08)。
|
||||
// 都走 toggleAgentDrawer,行为一致:打开中 → 收起;上次的会话还在 → 接着看;否则带当前页面上下文开新对话。
|
||||
|
||||
/** 进行中的会话数与最近一条的状态摘要(列表里有进行中的会话时 5 秒轮询一次) */
|
||||
export function useActiveAgentRuns() {
|
||||
const runs = useAgentRuns();
|
||||
const runs = useAgentRuns(undefined, undefined, { roots: true });
|
||||
return useMemo(() => {
|
||||
const active = (runs.data?.pages ?? []).flatMap((p) => p.items).filter((r) => isRunActive(r.status));
|
||||
const latest = active[0];
|
||||
@@ -43,39 +42,20 @@ function useAgentHotkey() {
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
/** 页面里的底部浮动操作栏(文件页批量操作等):FAB 与它横向重叠时抬到它上面 */
|
||||
const FLOATING_BARS = '[data-floating-bar], [role="toolbar"][aria-label="批量操作"]';
|
||||
const BASE_BOTTOM = 16;
|
||||
/** 全局快捷键 Ctrl/⌘+J(没有界面,挂在 console layout 里一次) */
|
||||
export function AgentHotkey() {
|
||||
useAgentHotkey();
|
||||
return null;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
function CountBadge({ count, className }: { count: number; className?: string }) {
|
||||
@@ -92,102 +72,6 @@ function CountBadge({ count, className }: { count: number; className?: string })
|
||||
);
|
||||
}
|
||||
|
||||
/** 进行中的脉冲点:减少动态效果时只留静态点 */
|
||||
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);最小化小窗在右下角时让位给小窗。
|
||||
* - 只在没有侧栏的窄屏显示(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<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 ? "收起智能体" : count ? `打开智能体(${count} 个会话进行中)` : "打开智能体";
|
||||
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 ? "收起" : "打开"}智能体(${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",
|
||||
// 桌面(xl 起)侧栏已有入口,抽屉打开时用抽屉自己的关闭钮:这两种情况都不显示悬浮按钮
|
||||
open ? "hidden" : "xl:hidden",
|
||||
!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 ? "收起" : "智能体"}
|
||||
{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();
|
||||
|
||||
Reference in new issue
Block a user