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:
rootandClaude Opus 5.5 committed 2026-10-08 13:56:38 +08:00
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();