重构项目详情页面

This commit is contained in:
2025-08-13 00:23:37 +08:00
parent ef0c895f1e
commit 4943e48254
9 changed files with 712 additions and 420 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
@@ -14,7 +14,9 @@ import {
Hash,
Command,
PanelLeftOpen,
PanelRightOpen
PanelRightOpen,
ArrowUp,
ArrowDown
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -41,7 +43,13 @@ import { FileEditorEnhanced } from "./FileEditorEnhanced";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks";
import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout";
// import { GridLayoutContainer, ResponsivePanel } from "@/components/ui/grid-layout";
// 新增布局组件导入
import { FlexLayoutContainer } from "@/components/layout/FlexLayoutContainer";
import { MainContentArea } from "@/components/layout/MainContentArea";
import { SidePanel } from "@/components/layout/SidePanel";
import { ChatView } from "@/components/layout/ChatView";
interface ClaudeCodeSessionProps {
/**
@@ -94,8 +102,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
toggleTimeline,
setPanelWidth,
setSplitPosition: setLayoutSplitPosition,
getGridTemplateColumns,
getResponsiveClasses
getResponsiveClasses,
openFileEditor,
closeFileEditor,
openPreview: openLayoutPreview,
closePreview: closeLayoutPreview
} = layoutManager;
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
@@ -118,17 +129,30 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
// Queued prompts state
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
// New state for preview feature
const [showPreview, setShowPreview] = useState(false);
const [previewUrl, setPreviewUrl] = useState("");
// 使用布局管理器的预览功能
const handleOpenPreview = useCallback((url: string) => {
openLayoutPreview(url);
setShowPreviewPrompt(false);
}, [openLayoutPreview]);
const handleClosePreview = useCallback(() => {
closeLayoutPreview();
setIsPreviewMaximized(false);
}, [closeLayoutPreview]);
// 添加临时状态用于预览提示
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
const [isPreviewMaximized, setIsPreviewMaximized] = useState(false);
const [showScrollButtons, setShowScrollButtons] = useState(false);
const [isAtTop, setIsAtTop] = useState(true);
const [isAtBottom, setIsAtBottom] = useState(true);
// Add collapsed state for queued prompts
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
// File editor state
const [editingFile, setEditingFile] = useState<string | null>(null);
// 移除重复的状态,使用 layout 中的状态
// const [editingFile, setEditingFile] = useState<string | null>(null); // 移除,使用 layout.editingFile
const parentRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
@@ -287,13 +311,30 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
useEffect(() => {
onStreamingChange?.(isLoading, claudeSessionId);
}, [isLoading, claudeSessionId, onStreamingChange]);
// 滚动到顶部
const scrollToTop = useCallback(() => {
if (parentRef.current) {
parentRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
}, []);
// 滚动到底部
const scrollToBottom = useCallback(() => {
if (parentRef.current) {
parentRef.current.scrollTo({ top: parentRef.current.scrollHeight, behavior: 'smooth' });
}
}, []);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (displayableMessages.length > 0) {
rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' });
// 使用setTimeout确保DOM更新后再滚动
setTimeout(() => {
scrollToBottom();
}, 100);
}
}, [displayableMessages.length, rowVirtualizer]);
}, [displayableMessages.length, scrollToBottom]);
// Calculate total tokens from messages
useEffect(() => {
@@ -1067,32 +1108,51 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}
};
// Handle URL detection from terminal output
// 处理URL检测
const handleLinkDetected = (url: string) => {
if (!showPreview && !showPreviewPrompt) {
setPreviewUrl(url);
if (!layout.previewUrl && !showPreviewPrompt) {
openLayoutPreview(url);
setShowPreviewPrompt(true);
}
};
const handleClosePreview = () => {
setShowPreview(false);
setIsPreviewMaximized(false);
// Keep the previewUrl so it can be restored when reopening
};
const handlePreviewUrlChange = (url: string) => {
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
setPreviewUrl(url);
};
// 监听滚动位置
useEffect(() => {
const scrollContainer = parentRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
setIsAtTop(scrollTop < 10);
setIsAtBottom(scrollTop + clientHeight >= scrollHeight - 10);
setShowScrollButtons(scrollHeight > clientHeight);
};
handleScroll(); // 初始检查
scrollContainer.addEventListener('scroll', handleScroll);
// 监听内容变化
const observer = new ResizeObserver(handleScroll);
observer.observe(scrollContainer);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
observer.disconnect();
};
}, []);
const handleTogglePreviewMaximize = () => {
setIsPreviewMaximized(!isPreviewMaximized);
// Reset split position when toggling maximize
// 重置分割位置
if (isPreviewMaximized) {
setLayoutSplitPosition(50);
}
};
const handlePreviewUrlChange = (url: string) => {
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
openLayoutPreview(url);
};
// Cleanup event listeners and track mount state
useEffect(() => {
@@ -1150,20 +1210,23 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
const messagesList = (
<div
ref={parentRef}
className="flex-1 overflow-y-auto relative pb-24"
style={{
contain: 'strict',
}}
className="h-full overflow-y-auto relative pb-2"
>
<div
className="relative w-full max-w-5xl mx-auto px-4 pt-8 pb-4"
className="relative w-full max-w-5xl mx-auto px-4 pt-3 pb-2"
style={{
height: `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
height: displayableMessages.length === 0 ? '100%' : `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
minHeight: '100px',
}}
>
<AnimatePresence>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
{displayableMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-muted-foreground">
<Terminal className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">...</p>
</div>
) : (
rowVirtualizer.getVirtualItems().map((virtualItem) => {
const message = displayableMessages[virtualItem.index];
return (
<motion.div
@@ -1174,7 +1237,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="absolute inset-x-4 pb-4"
className="absolute inset-x-4 pb-3"
style={{
top: virtualItem.start,
}}
@@ -1186,7 +1249,8 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
/>
</motion.div>
);
})}
})
)}
</AnimatePresence>
</div>
@@ -1195,7 +1259,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center py-4 mb-40"
className="flex items-center justify-center py-2 mb-4"
>
<div className="rotating-symbol text-primary" />
</motion.div>
@@ -1206,11 +1270,62 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive mb-40 w-full max-w-5xl mx-auto"
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive w-full max-w-5xl mx-auto mb-4"
>
{error}
</motion.div>
)}
{/* 滚动按钮 */}
<AnimatePresence>
{showScrollButtons && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="fixed bottom-20 right-6 z-40 flex flex-col gap-2"
>
{!isAtTop && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={scrollToTop}
className="h-9 w-9 rounded-full shadow-lg bg-background/95 backdrop-blur"
>
<ArrowUp className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!isAtBottom && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={scrollToBottom}
className="h-9 w-9 rounded-full shadow-lg bg-background/95 backdrop-blur"
>
<ArrowDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
@@ -1246,7 +1361,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
);
// If preview is maximized, render only the WebviewPreview in full screen
if (showPreview && isPreviewMaximized) {
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
return (
<AnimatePresence>
<motion.div
@@ -1257,7 +1372,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
transition={{ duration: 0.2 }}
>
<WebviewPreview
initialUrl={previewUrl}
initialUrl={layout.previewUrl || ''}
onClose={handleClosePreview}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
@@ -1300,6 +1415,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
<div className="flex items-center gap-2">
{/* Token计数器 */}
{totalTokens > 0 && (
<div className="flex items-center gap-1.5 text-xs bg-muted/50 rounded-full px-2.5 py-1">
<Hash className="h-3 w-3 text-muted-foreground" />
<span className="font-mono">{totalTokens.toLocaleString()}</span>
<span className="text-muted-foreground">tokens</span>
</div>
)}
{/* File Explorer Toggle */}
{projectPath && (
<TooltipProvider>
@@ -1460,319 +1584,194 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
</motion.div>
{/* Main Content Area with Grid Layout */}
<GridLayoutContainer
{/* 使用新的 FlexLayoutContainer 替代 GridLayoutContainer */}
<FlexLayoutContainer
className="flex-1 overflow-hidden"
gridTemplateColumns={getGridTemplateColumns()}
isMobile={breakpoints.isMobile}
isTablet={breakpoints.isTablet}
showFileExplorer={layout.showFileExplorer}
showGitPanel={layout.showGitPanel}
showTimeline={layout.showTimeline}
>
{/* File Explorer Panel */}
{layout.showFileExplorer && (
<ResponsivePanel
isVisible={layout.showFileExplorer}
position="left"
width={layout.fileExplorerWidth}
isMobile={breakpoints.isMobile}
onClose={toggleFileExplorer}
resizable={!breakpoints.isMobile}
onResize={(width) => setPanelWidth('fileExplorer', width)}
minWidth={200}
maxWidth={500}
>
<FileExplorerPanelEnhanced
projectPath={projectPath}
isVisible={true}
onFileSelect={(path) => {
floatingPromptRef.current?.addImage(path);
}}
onFileOpen={(path) => {
setEditingFile(path);
}}
onToggle={toggleFileExplorer}
/>
</ResponsivePanel>
)}
{/* Main Content */}
<div className="flex-1 relative flex flex-col overflow-hidden">
{showPreview ? (
// Split pane layout when preview is active
<div className="h-full">
<SplitPane
left={
<div className="h-full flex flex-col">
<div className="flex-1 flex flex-col mx-auto w-full px-4">
{projectPathInput}
{messagesList}
</div>
{/* Floating Input for preview mode */}
<div className="mx-auto w-full relative px-4">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
</div>
}
right={
<WebviewPreview
initialUrl={previewUrl}
onClose={handleClosePreview}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
onUrlChange={handlePreviewUrlChange}
/>
}
initialSplit={layout.splitPosition}
onSplitChange={(position) => {
setLayoutSplitPosition(position);
}}
minLeftWidth={400}
minRightWidth={400}
className="h-full"
/>
</div>
) : editingFile ? (
// File Editor layout with enhanced features
<div className="h-full flex flex-col relative">
<FileEditorEnhanced
filePath={editingFile}
onClose={() => setEditingFile(null)}
className="flex-1"
/>
</div>
) : (
// Original layout when no preview or editor
<div className="h-full flex flex-col relative">
{/* Main content area with messages */}
<div className="flex-1 flex flex-col mx-auto w-full px-4">
{projectPathInput}
{messagesList}
{isLoading && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<div className="rotating-symbol text-primary" />
<span className="text-sm text-muted-foreground">
{session ? "Loading session history..." : "Initializing Claude Code..."}
</span>
</div>
</div>
)}
</div>
{/* Floating elements container - same width as main content */}
<div className="mx-auto w-full relative px-4">
<ErrorBoundary>
{/* Queued Prompts Display */}
<AnimatePresence>
{queuedPrompts.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="absolute bottom-24 left-0 right-0 z-30"
>
<div className="mx-4">
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground mb-1">
Queued Prompts ({queuedPrompts.length})
</div>
<Button variant="ghost" size="icon" onClick={() => setQueuedPromptsCollapsed(prev => !prev)}>
{queuedPromptsCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</Button>
</div>
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
<motion.div
key={queuedPrompt.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
className="flex items-start gap-2 bg-muted/50 rounded-md p-2"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground">#{index + 1}</span>
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
</span>
</div>
<p className="text-sm line-clamp-2 break-words">{queuedPrompt.prompt}</p>
mainContentId="main-content"
panels={[
// 文件浏览器面板
{
id: 'file-explorer',
position: 'left',
visible: layout.showFileExplorer,
defaultWidth: layout.fileExplorerWidth,
minWidth: 200,
maxWidth: 500,
resizable: !breakpoints.isMobile,
content: (
<FileExplorerPanelEnhanced
projectPath={projectPath}
isVisible={true}
onFileSelect={(path) => {
floatingPromptRef.current?.addImage(path);
}}
onFileOpen={(path) => {
openFileEditor(path);
}}
onToggle={toggleFileExplorer}
/>
)
},
// 主内容区域
{
id: 'main-content',
position: 'center',
visible: true,
content: (
<MainContentArea isEditing={layout.activeView === 'editor'}>
{layout.activeView === 'editor' && layout.editingFile ? (
// 文件编辑器视图
<FileEditorEnhanced
filePath={layout.editingFile}
onClose={closeFileEditor}
className="h-full"
/>
) : layout.activeView === 'preview' && layout.previewUrl ? (
// 预览视图
<SplitPane
left={
<ChatView
projectPathInput={projectPathInput}
messagesList={messagesList}
floatingInput={
<div className="w-full max-w-5xl mx-auto px-4">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
}
/>
}
right={
<WebviewPreview
initialUrl={layout.previewUrl}
onClose={handleClosePreview}
isMaximized={isPreviewMaximized}
onToggleMaximize={handleTogglePreviewMaximize}
onUrlChange={handlePreviewUrlChange}
/>
}
initialSplit={layout.splitPosition}
onSplitChange={(position) => {
setLayoutSplitPosition(position);
}}
minLeftWidth={400}
minRightWidth={400}
className="h-full"
/>
) : (
// 默认聊天视图
<ChatView
projectPathInput={projectPathInput}
messagesList={messagesList}
floatingInput={
<div className="w-full max-w-5xl mx-auto px-4">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
</div>
</motion.div>
}
floatingElements={
<>
{/* 排队提示显示 */}
<AnimatePresence>
{queuedPrompts.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="absolute bottom-20 left-0 right-0 z-30 pointer-events-auto px-4"
>
<div className="bg-background/95 backdrop-blur-md border rounded-lg shadow-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground mb-1">
Queued Prompts ({queuedPrompts.length})
</div>
<Button variant="ghost" size="icon" onClick={() => setQueuedPromptsCollapsed(prev => !prev)}>
{queuedPromptsCollapsed ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</Button>
</div>
{!queuedPromptsCollapsed && queuedPrompts.map((queuedPrompt, index) => (
<motion.div
key={queuedPrompt.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.05 }}
className="flex items-start gap-2 bg-muted/50 rounded-md p-2"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-muted-foreground">#{index + 1}</span>
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{queuedPrompt.model === "opus" ? "Opus" : "Sonnet"}
</span>
</div>
<p className="text-sm line-clamp-2 break-words">{queuedPrompt.prompt}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
>
<X className="h-3 w-3" />
</Button>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</>
}
/>
)}
</AnimatePresence>
{/* Navigation Arrows */}
{displayableMessages.length > 5 && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ delay: 0.5 }}
className="absolute bottom-32 right-6 z-50"
>
<div className="flex items-center bg-background/95 backdrop-blur-md border rounded-full shadow-lg overflow-hidden">
<Button
variant="ghost"
size="sm"
onClick={() => {
if (displayableMessages.length > 0) {
parentRef.current?.scrollTo({
top: 0,
behavior: 'smooth'
});
setTimeout(() => {
if (parentRef.current) {
parentRef.current.scrollTop = 1;
requestAnimationFrame(() => {
if (parentRef.current) {
parentRef.current.scrollTop = 0;
}
});
}
}, 500);
}
}}
className="px-3 py-2 hover:bg-accent rounded-none"
title="Scroll to top"
>
<ChevronUp className="h-4 w-4" />
</Button>
<div className="w-px h-4 bg-border" />
<Button
variant="ghost"
size="sm"
onClick={() => {
if (displayableMessages.length > 0) {
const scrollElement = parentRef.current;
if (scrollElement) {
scrollElement.scrollTo({
top: scrollElement.scrollHeight,
behavior: 'smooth'
});
}
}
}}
className="px-3 py-2 hover:bg-accent rounded-none"
title="Scroll to bottom"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</motion.div>
)}
{/* Floating Prompt Input - Now properly aligned with main content */}
<div className="relative">
<FloatingPromptInput
ref={floatingPromptRef}
onSend={handleSendPrompt}
onCancel={handleCancelExecution}
isLoading={isLoading}
disabled={!projectPath}
projectPath={projectPath}
/>
</div>
{/* Token Counter */}
{totalTokens > 0 && (
<div className="absolute bottom-0 right-0 z-30 pointer-events-none">
<div className="w-full">
<div className="flex justify-end px-4 pb-2">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="bg-background/95 backdrop-blur-md border rounded-full px-3 py-1 shadow-lg pointer-events-auto"
>
<div className="flex items-center gap-1.5 text-xs">
<Hash className="h-3 w-3 text-muted-foreground" />
<span className="font-mono">{totalTokens.toLocaleString()}</span>
<span className="text-muted-foreground">tokens</span>
</div>
</motion.div>
</div>
</div>
</div>
)}
</ErrorBoundary>
</div>
</div>
)}
</div>
{/* Git Panel */}
{layout.showGitPanel && (
<ResponsivePanel
isVisible={layout.showGitPanel}
position="right"
width={layout.gitPanelWidth}
isMobile={breakpoints.isMobile}
onClose={toggleGitPanel}
resizable={!breakpoints.isMobile}
onResize={(width) => setPanelWidth('gitPanel', width)}
minWidth={200}
maxWidth={500}
>
<GitPanelEnhanced
projectPath={projectPath}
isVisible={true}
onToggle={toggleGitPanel}
/>
</ResponsivePanel>
)}
{/* Timeline Panel - Only on desktop */}
{layout.showTimeline && effectiveSession && !breakpoints.isMobile && (
<ResponsivePanel
isVisible={layout.showTimeline}
position="right"
width={layout.timelineWidth}
isMobile={false}
onClose={toggleTimeline}
resizable={true}
onResize={(width) => setPanelWidth('timeline', width)}
minWidth={320}
maxWidth={600}
className="border-l"
>
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="text-lg font-semibold">{t('app.sessionTimeline')}</h3>
<Button
variant="ghost"
size="icon"
onClick={toggleTimeline}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
</MainContentArea>
)
},
// Git 面板
{
id: 'git-panel',
position: 'right',
visible: layout.showGitPanel,
defaultWidth: layout.gitPanelWidth,
minWidth: 200,
maxWidth: 500,
resizable: !breakpoints.isMobile,
content: (
<GitPanelEnhanced
projectPath={projectPath}
isVisible={true}
onToggle={toggleGitPanel}
/>
)
},
// 时间线面板(仅桌面端)
...(layout.showTimeline && effectiveSession && !breakpoints.isMobile ? [{
id: 'timeline',
position: 'right' as const,
visible: true,
defaultWidth: layout.timelineWidth,
minWidth: 320,
maxWidth: 600,
resizable: true,
content: (
<SidePanel
title={t('app.sessionTimeline')}
onClose={toggleTimeline}
position="right"
>
<TimelineNavigator
sessionId={effectiveSession.id}
projectId={effectiveSession.project_id}
@@ -1783,11 +1782,25 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
onCheckpointCreated={handleCheckpointCreated}
refreshVersion={timelineVersion}
/>
</div>
</div>
</ResponsivePanel>
)}
</GridLayoutContainer>
</SidePanel>
)
}] : [])
]}
onPanelResize={(panelId, width) => {
if (panelId === 'file-explorer') {
setPanelWidth('fileExplorer', width);
} else if (panelId === 'git-panel') {
setPanelWidth('gitPanel', width);
} else if (panelId === 'timeline') {
setPanelWidth('timeline', width);
}
}}
savedWidths={{
'file-explorer': layout.fileExplorerWidth,
'git-panel': layout.gitPanelWidth,
'timeline': layout.timelineWidth,
}}
/>
</div>
{/* Fork Dialog */}