feat(agent-sidebar): 「智能体」区支持收起/展开

标题栏改为可折叠头(chevron 切换),收起后仅留标题栏并隐藏来源下拉/搜索/列表,
下方「最近的对话」占满剩余空间;收起状态持久化到 localStorage。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-19 15:04:30 +08:00
1 parent c1c23d9136
commit 1f2a1aaa76
1 file changed
+148 -106
@@ -2,6 +2,7 @@
import {
Bot,
ChevronDown,
Download,
FileJson,
FileText,
@@ -127,6 +128,18 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
return () => clearTimeout(timer);
}, [searchInput]);
// 「智能体」区可收起(持久化);收起后只留标题栏,下方「最近的对话」占满剩余空间。
const [agentsCollapsed, setAgentsCollapsed] = useState(
() =>
typeof window !== "undefined" &&
localStorage.getItem("agent-chat-sidebar:agents-collapsed") === "true",
);
const toggleAgents = () =>
setAgentsCollapsed((v) => {
localStorage.setItem("agent-chat-sidebar:agents-collapsed", String(!v));
return !v;
});
// 若所选广场被管理员删除,回退到「我的」,避免指向不存在的广场。
useEffect(() => {
if (
@@ -151,119 +164,148 @@ export function AgentChatSidebar({ activeAgentId }: { activeAgentId?: string })
return (
// 配色与 AgentModuleSidebar 一致:浅色白底,深蓝主题半透明深蓝玻璃面板。
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-white text-foreground backdrop-blur-md dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80">
{/* 顶部:作用域下拉(我的 / 各广场)+ 智能体名称模糊搜索 */}
<div className="shrink-0 space-y-2 border-b border-border/60 px-3 py-2.5">
<Select value={scope} onValueChange={setScope}>
<SelectTrigger className="h-9 w-full text-sm" title="切换智能体来源">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={MINE_SCOPE}>我的</SelectItem>
{squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索智能体名称"
className="h-9 pl-8 pr-8 text-sm"
aria-label="搜索智能体名称"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
title="清除"
aria-label="清除搜索"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
{/* 智能体列表(上半,独立滚动) */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="mb-1 px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 select-none">
智能体
</h2>
{isLoading && agents.length === 0 ? (
<div className="text-muted-foreground flex h-24 items-center justify-center gap-2 text-xs">
<Loader2 className="size-3.5 animate-spin" />
加载中…
</div>
) : agents.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">
{search ? "未找到匹配的智能体" : isMine ? "暂无智能体" : "该广场暂无智能体"}
</p>
) : (
<div
{/* 智能体(上半,可收起)。展开时:标题栏 + 来源下拉/名称搜索 + 列表;
收起时只留标题栏,下方「最近的对话」占满剩余空间。 */}
<div
className={cn(
"flex flex-col",
agentsCollapsed ? "shrink-0" : "min-h-0 flex-[3]",
)}
>
<button
type="button"
onClick={toggleAgents}
className="text-foreground/80 hover:bg-accent/40 flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2.5 text-[12px] font-semibold transition-colors select-none"
title={agentsCollapsed ? "展开智能体" : "收起智能体"}
>
<span>智能体</span>
<ChevronDown
className={cn(
"flex flex-col gap-1 transition-opacity",
isFetching && "opacity-60",
"text-muted-foreground size-4 shrink-0 transition-transform duration-200",
agentsCollapsed && "-rotate-90",
)}
>
{agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={agent.id}
href={href}
/>
</button>
{!agentsCollapsed && (
<>
{/* 来源下拉(我的 / 各广场)+ 智能体名称模糊搜索 */}
<div className="shrink-0 space-y-2 border-b border-border/60 px-3 py-2.5">
<Select value={scope} onValueChange={setScope}>
<SelectTrigger className="h-9 w-full text-sm" title="切换智能体来源">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value={MINE_SCOPE}>我的</SelectItem>
{squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索智能体名称"
className="h-9 pl-8 pr-8 text-sm"
aria-label="搜索智能体名称"
/>
{searchInput && (
<button
type="button"
onClick={() => setSearchInput("")}
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
title="清除"
aria-label="清除搜索"
>
<X className="size-3.5" />
</button>
)}
</div>
</div>
{/* 智能体列表(独立滚动) */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
{isLoading && agents.length === 0 ? (
<div className="text-muted-foreground flex h-24 items-center justify-center gap-2 text-xs">
<Loader2 className="size-3.5 animate-spin" />
加载中…
</div>
) : agents.length === 0 ? (
<p className="px-3 py-1.5 text-xs text-muted-foreground/70">
{search
? "未找到匹配的智能体"
: isMine
? "暂无智能体"
: "该广场暂无智能体"}
</p>
) : (
<div
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
"flex flex-col gap-1 transition-opacity",
isFetching && "opacity-60",
)}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
{agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
const { from, to } = agentColor(agent.id, isDark);
return (
<Link
key={agent.id}
href={href}
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{
backgroundImage: `linear-gradient(135deg, ${from}, ${to})`,
}}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description
? desensitize(agent.description)
: "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
);
})}
{/* 超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */}
{total > agents.length && (
<Link
href={`${routeBase}/agents`}
className="text-muted-foreground hover:text-foreground hover:bg-accent/50 mt-0.5 rounded-lg px-3 py-1.5 text-center text-xs transition-colors"
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description
? desensitize(agent.description)
: "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
共 {total} 个,查看全部 →
</Link>
)}
</Link>
);
})}
{/* 超出一页时引导到完整的智能体管理页(不在侧边栏静默截断) */}
{total > agents.length && (
<Link
href={`${routeBase}/agents`}
className="text-muted-foreground hover:text-foreground mt-0.5 rounded-lg px-3 py-1.5 text-center text-xs transition-colors hover:bg-accent/50"
>
共 {total} 个,查看全部 →
</Link>
)}
</div>
</div>
)}
</div>
</>
)}
</div>