- 抽屉和会话页渲染同一套 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
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import type { SessionInfo } from "@umt/contracts";
|
|
import { Clapperboard, LoaderCircle, Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
import { Link, Outlet, useLocation, useMatches } from "react-router";
|
|
import { useSession } from "~/api/auth.ts";
|
|
import { AgentDrawer, AgentDrawerTrigger } from "~/components/agent/agent-drawer.tsx";
|
|
import { outletKey } from "~/components/agent/agent-store.ts";
|
|
import { AccountMenu } from "~/components/console/account-menu.tsx";
|
|
import { AgentHotkey, AgentNavEntry } from "~/components/console/agent-launcher.tsx";
|
|
import { ErrorState } from "~/components/console/empty-state.tsx";
|
|
import { InboxBell } from "~/components/console/inbox-bell.tsx";
|
|
import { findNavItem, NAV_GROUPS, type NavGroup, pageTitle } from "~/components/console/nav.ts";
|
|
import { LiquidSurface } from "~/components/liquid-surface.tsx";
|
|
import PrismLeak from "~/components/prism-leak.tsx";
|
|
import { CommandPalette } from "~/components/search/command-palette.tsx";
|
|
import { SearchTrigger } from "~/components/search/search-trigger.tsx";
|
|
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
|
|
import { redirectToLogin } from "~/lib/api.ts";
|
|
import { cn } from "~/lib/utils.ts";
|
|
|
|
const PORTAL_NAME = "媒体自动化";
|
|
|
|
// ─── 展开态导航:选中项由一枚玻璃滑块(seg-thumb)滑到激活链接背后 ───
|
|
function ExpandedNav({ groups, onNavigate }: { groups: NavGroup[]; onNavigate?: () => void }) {
|
|
const location = useLocation();
|
|
const activeTo = findNavItem(location.pathname)?.to;
|
|
const navRef = useRef<HTMLElement>(null);
|
|
const [pill, setPill] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
|
|
|
const measure = useCallback(() => {
|
|
const nav = navRef.current;
|
|
if (!nav) return;
|
|
const active = nav.querySelector<HTMLElement>('[aria-current="page"]');
|
|
if (!active) {
|
|
setPill(null);
|
|
return;
|
|
}
|
|
const navRect = nav.getBoundingClientRect();
|
|
const rect = active.getBoundingClientRect();
|
|
setPill({ x: rect.left - navRect.left, y: rect.top - navRect.top + nav.scrollTop, w: rect.width, h: rect.height });
|
|
}, []);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 路由变化时重新量测
|
|
useLayoutEffect(() => {
|
|
measure();
|
|
}, [measure, location.pathname]);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("resize", measure);
|
|
return () => window.removeEventListener("resize", measure);
|
|
}, [measure]);
|
|
|
|
return (
|
|
<nav ref={navRef} className="relative flex-1 space-y-4 overflow-y-auto px-2.5 py-4">
|
|
{pill ? (
|
|
<span
|
|
aria-hidden
|
|
className="tab-slider seg-thumb pointer-events-none absolute top-0 left-0 -z-[1] rounded-lg"
|
|
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">
|
|
{group.title}
|
|
</p>
|
|
<div className="space-y-0.5">
|
|
{group.items.map((item) => {
|
|
const active = item.to === activeTo;
|
|
return (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
onClick={onNavigate}
|
|
aria-current={active ? "page" : undefined}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-base",
|
|
active
|
|
? "text-foreground"
|
|
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
|
)}
|
|
>
|
|
<item.icon className={cn("h-4 w-4 shrink-0", active && "text-primary")} />
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
// ─── 折叠态:只显示图标,组间细线分隔 ───
|
|
function CollapsedNav() {
|
|
const location = useLocation();
|
|
const activeTo = findNavItem(location.pathname)?.to;
|
|
return (
|
|
<nav className="flex flex-1 flex-col items-center gap-1 overflow-y-auto py-4">
|
|
<AgentNavEntry collapsed />
|
|
{NAV_GROUPS.map((group, gi) => (
|
|
<div key={group.title} className="flex flex-col items-center gap-1">
|
|
{gi > 0 ? <span aria-hidden className="my-1 h-px w-6 bg-border" /> : null}
|
|
{group.items.map((item) => {
|
|
const active = item.to === activeTo;
|
|
return (
|
|
<Link
|
|
key={item.to}
|
|
to={item.to}
|
|
title={item.label}
|
|
aria-current={active ? "page" : undefined}
|
|
className={cn(
|
|
"relative flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-micro",
|
|
active
|
|
? "seg-thumb text-primary"
|
|
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
|
)}
|
|
>
|
|
<item.icon className="h-[18px] w-[18px]" />
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function Brand({ className }: { className?: string }) {
|
|
return (
|
|
<Link to="/console" className={cn("flex items-center gap-2 transition-opacity hover:opacity-80", className)}>
|
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg pill-selected">
|
|
<Clapperboard className="h-4 w-4" />
|
|
</span>
|
|
<p className="truncate text-xs font-bold tracking-wide text-foreground">UHD Media Tool</p>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
/** 会话探测:未登录跳登录页;探测中/失败给全屏占位 */
|
|
export default function ConsoleLayout() {
|
|
const session = useSession();
|
|
const unauthenticated = session.data && !session.data.authenticated;
|
|
|
|
useEffect(() => {
|
|
if (unauthenticated) redirectToLogin();
|
|
}, [unauthenticated]);
|
|
|
|
if (session.isError) {
|
|
return (
|
|
<div className="grid min-h-screen place-items-center bg-background px-4">
|
|
<ErrorState title="读取登录状态失败" error={session.error} onRetry={() => session.refetch()} />
|
|
</div>
|
|
);
|
|
}
|
|
if (!session.data || unauthenticated) {
|
|
return (
|
|
<div className="grid min-h-screen place-items-center bg-background" role="status" aria-label="正在确认登录状态">
|
|
<LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
return <ConsoleShell session={session.data} />;
|
|
}
|
|
|
|
function ConsoleShell({ session }: { session: SessionInfo }) {
|
|
const location = useLocation();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const activeLabel = pageTitle(location.pathname) ?? PORTAL_NAME;
|
|
// 路由 handle.layout === "wide"(文件管理):内容区不限最大宽度、不留外边距,页面自己排版
|
|
const wide = useMatches().some((m) => (m.handle as { layout?: string } | undefined)?.layout === "wide");
|
|
|
|
return (
|
|
<div className="app-scrollbars relative isolate flex min-h-screen overflow-x-clip bg-background">
|
|
<div className="pointer-events-none fixed inset-0 -z-10 bg-page-glow" />
|
|
<PrismLeak className="-z-10" />
|
|
|
|
{/* 桌面端侧栏 */}
|
|
{/* 侧栏贴边,不做浮起卡片;右边不画硬分隔线,由 pg-chrome-side 给内侧细亮边、外侧细暗线和淡投影 */}
|
|
<aside
|
|
className={cn(
|
|
"pg-chrome pg-chrome-side fixed inset-y-0 left-0 z-30 hidden flex-col transition-all duration-layout select-none xl:flex",
|
|
collapsed ? "w-[60px]" : "w-60",
|
|
)}
|
|
>
|
|
{/* 标志行 + 折叠开关:展开时开关在名称右侧;收起后只剩标志,悬停换成展开图标,点一下展开 */}
|
|
<div
|
|
className={cn(
|
|
"flex h-14 shrink-0 items-center border-b border-[var(--shell-border)] transition-all duration-layout",
|
|
collapsed ? "justify-center px-0" : "gap-2 pr-2 pl-4",
|
|
)}
|
|
>
|
|
{collapsed ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setCollapsed(false)}
|
|
title="展开侧边栏"
|
|
aria-label="展开侧边栏"
|
|
className="group/logo relative grid h-9 w-9 cursor-pointer place-items-center rounded-full transition-colors duration-base hover:bg-foreground/[0.06]"
|
|
>
|
|
<span className="grid h-7 w-7 place-items-center rounded-lg pill-selected transition-opacity duration-base group-hover/logo:opacity-0">
|
|
<Clapperboard className="h-4 w-4" />
|
|
</span>
|
|
<PanelLeftOpen className="absolute h-[18px] w-[18px] text-muted-foreground opacity-0 transition-opacity duration-base group-hover/logo:opacity-100" />
|
|
</button>
|
|
) : (
|
|
<>
|
|
<Brand className="min-w-0 flex-1" />
|
|
<button
|
|
type="button"
|
|
onClick={() => setCollapsed(true)}
|
|
title="收起侧边栏"
|
|
aria-label="收起侧边栏"
|
|
className="grid h-8 w-8 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-base hover:bg-foreground/[0.06] hover:text-foreground"
|
|
>
|
|
<PanelLeftClose className="h-[18px] w-[18px]" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{collapsed ? <CollapsedNav /> : <ExpandedNav groups={NAV_GROUPS} />}
|
|
</aside>
|
|
|
|
{/* 主内容区 */}
|
|
<div
|
|
className={cn(
|
|
"relative flex min-h-screen min-w-0 flex-1 flex-col transition-all duration-layout",
|
|
collapsed ? "xl:ml-[60px]" : "xl:ml-60",
|
|
)}
|
|
>
|
|
{/* 顶栏不铺磨砂(磨砂栏会成为背景根,里面的玻璃糊不到页面):页面底色渐隐托住标题,
|
|
右侧搜索、通知、智能体、日夜切换与账户收进一枚玻璃胶囊 */}
|
|
<header className="sticky top-0 z-20 flex h-14 items-center bg-gradient-to-b from-background/90 via-background/60 to-transparent px-4 md:px-6">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 xl:hidden" aria-label="打开导航">
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="w-64 p-0 select-none">
|
|
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4">
|
|
<SheetTitle className="text-sm">
|
|
<Brand />
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
<ExpandedNav groups={NAV_GROUPS} onNavigate={() => setSheetOpen(false)} />
|
|
</SheetContent>
|
|
</Sheet>
|
|
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<LiquidSurface tier="regular" radius={20} noRefraction style={{ width: "auto", height: "auto" }}>
|
|
<div className="flex items-center gap-0.5 p-1">
|
|
<SearchTrigger className="mr-1" />
|
|
<InboxBell bare />
|
|
{/* 桌面的入口是侧栏最上方的「智能体」;没有侧栏的窄屏用顶栏胶囊里的这颗 */}
|
|
<AgentDrawerTrigger className="xl:hidden" />
|
|
<ThemeToggle bare />
|
|
<AccountMenu session={session} bare />
|
|
</div>
|
|
</LiquidSurface>
|
|
</div>
|
|
</header>
|
|
|
|
<main className={cn("relative flex-1 overflow-x-clip", !wide && "px-4 pt-6 pb-10 md:px-6 md:pt-8")}>
|
|
<div
|
|
key={outletKey(location.pathname)}
|
|
className={cn(!wide && "mx-auto max-w-6xl", "motion-safe:animate-blur-in")}
|
|
>
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
<AgentDrawer />
|
|
<AgentHotkey />
|
|
<CommandPalette />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|