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
- 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 改为"查看产物文件",空状态增加图标和说明
152 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|