Files
zclaw_openfang/desktop/src/components/ai/ResizableChatLayout.tsx
iven a0d1392371
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(ui): 5 项 E2E 测试 Bug 修复 — Agent 502 / 错误持久化 / 模型标记 / 侧面板 / 记忆页
- BUG-01: createFromTemplate 在 saas-relay 模式下 try-catch 跳过本地 Kernel
- BUG-02: upsertActiveConversation 持久化前剥离 error/streaming/optimistic 字段
- BUG-04: ModelSelector 添加 available 标记,ChatArea 追踪失败模型 ID
- BUG-05: VikingPanel 移除 status?.available 门控,不可用时 disabled + 重连按钮
- BUG-06: 侧面板 tooltip 改为"查看产物文件",空状态增加图标和说明
2026-04-16 19:12:21 +08:00

152 lines
4.7 KiB
TypeScript

import { useCallback, type ReactNode } from 'react';
import { Group, Panel, Separator } from 'react-resizable-panels';
import { X, PanelRightOpen, PanelRightClose } from 'lucide-react';
/**
* Resizable dual-panel layout for chat + artifact/detail panel.
*
* Uses react-resizable-panels v4 API:
* - Left panel: Chat area (always visible)
* - Right panel: Artifact/detail viewer (collapsible)
* - Draggable resize handle between panels
* - Persisted panel sizes via localStorage
*
* Side-panel toggle is injected into the chat header via `headerAction`
* so it sits cleanly in the top-right corner (Trae Solo style).
*/
interface ResizableChatLayoutProps {
chatPanel: ReactNode;
rightPanel?: ReactNode;
rightPanelTitle?: string;
rightPanelOpen?: boolean;
onRightPanelToggle?: (open: boolean) => void;
}
const STORAGE_KEY = 'zclaw-layout-panels';
const LEFT_PANEL_ID = 'chat-panel';
const RIGHT_PANEL_ID = 'detail-panel';
function loadPanelSizes(): { left: string; right: string } {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.left && parsed.right) {
return { left: parsed.left, right: parsed.right };
}
}
} catch { /* ignore */ }
return { left: '65%', right: '35%' };
}
function savePanelSizes(layout: Record<string, number>) {
try {
const left = layout[LEFT_PANEL_ID];
const right = layout[RIGHT_PANEL_ID];
if (left !== undefined && right !== undefined) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ left, right }));
}
} catch { /* ignore */ }
}
export function ResizableChatLayout({
chatPanel,
rightPanel,
rightPanelTitle = '详情',
rightPanelOpen = false,
onRightPanelToggle,
}: ResizableChatLayoutProps) {
const sizes = loadPanelSizes();
const handleToggle = useCallback(() => {
onRightPanelToggle?.(!rightPanelOpen);
}, [rightPanelOpen, onRightPanelToggle]);
if (!rightPanelOpen || !rightPanel) {
// Panel closed: just render chat panel, no floating button
return (
<div className="h-full flex flex-col overflow-hidden">
{chatPanel}
</div>
);
}
return (
<div className="h-full flex flex-col overflow-hidden">
<Group
orientation="horizontal"
onLayoutChanged={(layout) => savePanelSizes(layout)}
>
{/* Left panel: Chat */}
<Panel
id={LEFT_PANEL_ID}
defaultSize={sizes.left}
minSize="40%"
>
<div className="h-full flex flex-col">
{chatPanel}
</div>
</Panel>
{/* Resize handle */}
<Separator className="w-1.5 flex items-center justify-center group cursor-col-resize hover:bg-orange-100 dark:hover:bg-orange-900/20 transition-colors">
<div className="w-0.5 h-8 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-orange-400 dark:group-hover:bg-orange-500 transition-colors" />
</Separator>
{/* Right panel: Artifact/Detail */}
<Panel
id={RIGHT_PANEL_ID}
defaultSize={sizes.right}
minSize="25%"
>
<div className="h-full flex flex-col bg-gray-50 dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800">
{/* Panel header */}
<div className="h-12 flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 flex-shrink-0">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
{rightPanelTitle}
</span>
<button
onClick={handleToggle}
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
title="关闭面板"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Panel content */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{rightPanel}
</div>
</div>
</Panel>
</Group>
</div>
);
}
/**
* Toggle button for embedding in chat header.
* Renders PanelRightOpen/PanelRightClose icon — Trae Solo style.
*/
export function PanelToggleButton({
panelOpen,
onToggle,
}: {
panelOpen: boolean;
onToggle: () => void;
}) {
return (
<button
onClick={onToggle}
className="p-1.5 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={panelOpen ? '关闭侧面板' : '查看产物文件'}
>
{panelOpen
? <PanelRightClose className="w-4 h-4" />
: <PanelRightOpen className="w-4 h-4" />
}
</button>
);
}