重构项目详情页面
This commit is contained in:
@@ -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 { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
Hash,
|
Hash,
|
||||||
Command,
|
Command,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
PanelRightOpen
|
PanelRightOpen,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -41,7 +43,13 @@ import { FileEditorEnhanced } from "./FileEditorEnhanced";
|
|||||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { useTrackEvent, useComponentMetrics, useWorkflowTracking, useLayoutManager } from "@/hooks";
|
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 {
|
interface ClaudeCodeSessionProps {
|
||||||
/**
|
/**
|
||||||
@@ -94,8 +102,11 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
toggleTimeline,
|
toggleTimeline,
|
||||||
setPanelWidth,
|
setPanelWidth,
|
||||||
setSplitPosition: setLayoutSplitPosition,
|
setSplitPosition: setLayoutSplitPosition,
|
||||||
getGridTemplateColumns,
|
getResponsiveClasses,
|
||||||
getResponsiveClasses
|
openFileEditor,
|
||||||
|
closeFileEditor,
|
||||||
|
openPreview: openLayoutPreview,
|
||||||
|
closePreview: closeLayoutPreview
|
||||||
} = layoutManager;
|
} = layoutManager;
|
||||||
|
|
||||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||||
@@ -118,17 +129,30 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
// Queued prompts state
|
// Queued prompts state
|
||||||
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
|
const [queuedPrompts, setQueuedPrompts] = useState<Array<{ id: string; prompt: string; model: "sonnet" | "opus" }>>([]);
|
||||||
|
|
||||||
// New state for preview feature
|
// 使用布局管理器的预览功能
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const handleOpenPreview = useCallback((url: string) => {
|
||||||
const [previewUrl, setPreviewUrl] = useState("");
|
openLayoutPreview(url);
|
||||||
|
setShowPreviewPrompt(false);
|
||||||
|
}, [openLayoutPreview]);
|
||||||
|
|
||||||
|
const handleClosePreview = useCallback(() => {
|
||||||
|
closeLayoutPreview();
|
||||||
|
setIsPreviewMaximized(false);
|
||||||
|
}, [closeLayoutPreview]);
|
||||||
|
|
||||||
|
// 添加临时状态用于预览提示
|
||||||
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
|
const [showPreviewPrompt, setShowPreviewPrompt] = useState(false);
|
||||||
const [isPreviewMaximized, setIsPreviewMaximized] = 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
|
// Add collapsed state for queued prompts
|
||||||
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
|
const [queuedPromptsCollapsed, setQueuedPromptsCollapsed] = useState(false);
|
||||||
|
|
||||||
// File editor state
|
// 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 parentRef = useRef<HTMLDivElement>(null);
|
||||||
const unlistenRefs = useRef<UnlistenFn[]>([]);
|
const unlistenRefs = useRef<UnlistenFn[]>([]);
|
||||||
@@ -287,13 +311,30 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStreamingChange?.(isLoading, claudeSessionId);
|
onStreamingChange?.(isLoading, claudeSessionId);
|
||||||
}, [isLoading, claudeSessionId, onStreamingChange]);
|
}, [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
|
// Auto-scroll to bottom when new messages arrive
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (displayableMessages.length > 0) {
|
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
|
// Calculate total tokens from messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1067,32 +1108,51 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle URL detection from terminal output
|
// 处理URL检测
|
||||||
const handleLinkDetected = (url: string) => {
|
const handleLinkDetected = (url: string) => {
|
||||||
if (!showPreview && !showPreviewPrompt) {
|
if (!layout.previewUrl && !showPreviewPrompt) {
|
||||||
setPreviewUrl(url);
|
openLayoutPreview(url);
|
||||||
setShowPreviewPrompt(true);
|
setShowPreviewPrompt(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClosePreview = () => {
|
// 监听滚动位置
|
||||||
setShowPreview(false);
|
useEffect(() => {
|
||||||
setIsPreviewMaximized(false);
|
const scrollContainer = parentRef.current;
|
||||||
// Keep the previewUrl so it can be restored when reopening
|
if (!scrollContainer) return;
|
||||||
};
|
|
||||||
|
const handleScroll = () => {
|
||||||
const handlePreviewUrlChange = (url: string) => {
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
||||||
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
setIsAtTop(scrollTop < 10);
|
||||||
setPreviewUrl(url);
|
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 = () => {
|
const handleTogglePreviewMaximize = () => {
|
||||||
setIsPreviewMaximized(!isPreviewMaximized);
|
setIsPreviewMaximized(!isPreviewMaximized);
|
||||||
// Reset split position when toggling maximize
|
// 重置分割位置
|
||||||
if (isPreviewMaximized) {
|
if (isPreviewMaximized) {
|
||||||
setLayoutSplitPosition(50);
|
setLayoutSplitPosition(50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePreviewUrlChange = (url: string) => {
|
||||||
|
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
||||||
|
openLayoutPreview(url);
|
||||||
|
};
|
||||||
|
|
||||||
// Cleanup event listeners and track mount state
|
// Cleanup event listeners and track mount state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1150,20 +1210,23 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
const messagesList = (
|
const messagesList = (
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="flex-1 overflow-y-auto relative pb-24"
|
className="h-full overflow-y-auto relative pb-2"
|
||||||
style={{
|
|
||||||
contain: 'strict',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
height: `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
|
height: displayableMessages.length === 0 ? '100%' : `${Math.max(rowVirtualizer.getTotalSize(), 100)}px`,
|
||||||
minHeight: '100px',
|
minHeight: '100px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<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];
|
const message = displayableMessages[virtualItem.index];
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1174,7 +1237,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -20 }}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="absolute inset-x-4 pb-4"
|
className="absolute inset-x-4 pb-3"
|
||||||
style={{
|
style={{
|
||||||
top: virtualItem.start,
|
top: virtualItem.start,
|
||||||
}}
|
}}
|
||||||
@@ -1186,7 +1249,8 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1195,7 +1259,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
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" />
|
<div className="rotating-symbol text-primary" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1206,11 +1270,62 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
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}
|
{error}
|
||||||
</motion.div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1246,7 +1361,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// If preview is maximized, render only the WebviewPreview in full screen
|
// If preview is maximized, render only the WebviewPreview in full screen
|
||||||
if (showPreview && isPreviewMaximized) {
|
if (layout.activeView === 'preview' && layout.previewUrl && isPreviewMaximized) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1257,7 +1372,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<WebviewPreview
|
<WebviewPreview
|
||||||
initialUrl={previewUrl}
|
initialUrl={layout.previewUrl || ''}
|
||||||
onClose={handleClosePreview}
|
onClose={handleClosePreview}
|
||||||
isMaximized={isPreviewMaximized}
|
isMaximized={isPreviewMaximized}
|
||||||
onToggleMaximize={handleTogglePreviewMaximize}
|
onToggleMaximize={handleTogglePreviewMaximize}
|
||||||
@@ -1300,6 +1415,15 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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 */}
|
{/* File Explorer Toggle */}
|
||||||
{projectPath && (
|
{projectPath && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -1460,319 +1584,194 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Main Content Area with Grid Layout */}
|
{/* 使用新的 FlexLayoutContainer 替代 GridLayoutContainer */}
|
||||||
<GridLayoutContainer
|
<FlexLayoutContainer
|
||||||
className="flex-1 overflow-hidden"
|
className="flex-1 overflow-hidden"
|
||||||
gridTemplateColumns={getGridTemplateColumns()}
|
mainContentId="main-content"
|
||||||
isMobile={breakpoints.isMobile}
|
panels={[
|
||||||
isTablet={breakpoints.isTablet}
|
// 文件浏览器面板
|
||||||
showFileExplorer={layout.showFileExplorer}
|
{
|
||||||
showGitPanel={layout.showGitPanel}
|
id: 'file-explorer',
|
||||||
showTimeline={layout.showTimeline}
|
position: 'left',
|
||||||
>
|
visible: layout.showFileExplorer,
|
||||||
{/* File Explorer Panel */}
|
defaultWidth: layout.fileExplorerWidth,
|
||||||
{layout.showFileExplorer && (
|
minWidth: 200,
|
||||||
<ResponsivePanel
|
maxWidth: 500,
|
||||||
isVisible={layout.showFileExplorer}
|
resizable: !breakpoints.isMobile,
|
||||||
position="left"
|
content: (
|
||||||
width={layout.fileExplorerWidth}
|
<FileExplorerPanelEnhanced
|
||||||
isMobile={breakpoints.isMobile}
|
projectPath={projectPath}
|
||||||
onClose={toggleFileExplorer}
|
isVisible={true}
|
||||||
resizable={!breakpoints.isMobile}
|
onFileSelect={(path) => {
|
||||||
onResize={(width) => setPanelWidth('fileExplorer', width)}
|
floatingPromptRef.current?.addImage(path);
|
||||||
minWidth={200}
|
}}
|
||||||
maxWidth={500}
|
onFileOpen={(path) => {
|
||||||
>
|
openFileEditor(path);
|
||||||
<FileExplorerPanelEnhanced
|
}}
|
||||||
projectPath={projectPath}
|
onToggle={toggleFileExplorer}
|
||||||
isVisible={true}
|
/>
|
||||||
onFileSelect={(path) => {
|
)
|
||||||
floatingPromptRef.current?.addImage(path);
|
},
|
||||||
}}
|
// 主内容区域
|
||||||
onFileOpen={(path) => {
|
{
|
||||||
setEditingFile(path);
|
id: 'main-content',
|
||||||
}}
|
position: 'center',
|
||||||
onToggle={toggleFileExplorer}
|
visible: true,
|
||||||
/>
|
content: (
|
||||||
</ResponsivePanel>
|
<MainContentArea isEditing={layout.activeView === 'editor'}>
|
||||||
)}
|
{layout.activeView === 'editor' && layout.editingFile ? (
|
||||||
|
// 文件编辑器视图
|
||||||
{/* Main Content */}
|
<FileEditorEnhanced
|
||||||
<div className="flex-1 relative flex flex-col overflow-hidden">
|
filePath={layout.editingFile}
|
||||||
{showPreview ? (
|
onClose={closeFileEditor}
|
||||||
// Split pane layout when preview is active
|
className="h-full"
|
||||||
<div className="h-full">
|
/>
|
||||||
<SplitPane
|
) : layout.activeView === 'preview' && layout.previewUrl ? (
|
||||||
left={
|
// 预览视图
|
||||||
<div className="h-full flex flex-col">
|
<SplitPane
|
||||||
<div className="flex-1 flex flex-col mx-auto w-full px-4">
|
left={
|
||||||
{projectPathInput}
|
<ChatView
|
||||||
{messagesList}
|
projectPathInput={projectPathInput}
|
||||||
</div>
|
messagesList={messagesList}
|
||||||
{/* Floating Input for preview mode */}
|
floatingInput={
|
||||||
<div className="mx-auto w-full relative px-4">
|
<div className="w-full max-w-5xl mx-auto px-4">
|
||||||
<FloatingPromptInput
|
<FloatingPromptInput
|
||||||
ref={floatingPromptRef}
|
ref={floatingPromptRef}
|
||||||
onSend={handleSendPrompt}
|
onSend={handleSendPrompt}
|
||||||
onCancel={handleCancelExecution}
|
onCancel={handleCancelExecution}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={!projectPath}
|
disabled={!projectPath}
|
||||||
projectPath={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>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
}
|
||||||
variant="ghost"
|
/>
|
||||||
size="icon"
|
}
|
||||||
className="h-6 w-6 flex-shrink-0"
|
right={
|
||||||
onClick={() => setQueuedPrompts(prev => prev.filter(p => p.id !== queuedPrompt.id))}
|
<WebviewPreview
|
||||||
>
|
initialUrl={layout.previewUrl}
|
||||||
<X className="h-3 w-3" />
|
onClose={handleClosePreview}
|
||||||
</Button>
|
isMaximized={isPreviewMaximized}
|
||||||
</motion.div>
|
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>
|
||||||
</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>
|
</MainContentArea>
|
||||||
|
)
|
||||||
{/* Navigation Arrows */}
|
},
|
||||||
{displayableMessages.length > 5 && (
|
// Git 面板
|
||||||
<motion.div
|
{
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
id: 'git-panel',
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
position: 'right',
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
visible: layout.showGitPanel,
|
||||||
transition={{ delay: 0.5 }}
|
defaultWidth: layout.gitPanelWidth,
|
||||||
className="absolute bottom-32 right-6 z-50"
|
minWidth: 200,
|
||||||
>
|
maxWidth: 500,
|
||||||
<div className="flex items-center bg-background/95 backdrop-blur-md border rounded-full shadow-lg overflow-hidden">
|
resizable: !breakpoints.isMobile,
|
||||||
<Button
|
content: (
|
||||||
variant="ghost"
|
<GitPanelEnhanced
|
||||||
size="sm"
|
projectPath={projectPath}
|
||||||
onClick={() => {
|
isVisible={true}
|
||||||
if (displayableMessages.length > 0) {
|
onToggle={toggleGitPanel}
|
||||||
parentRef.current?.scrollTo({
|
/>
|
||||||
top: 0,
|
)
|
||||||
behavior: 'smooth'
|
},
|
||||||
});
|
// 时间线面板(仅桌面端)
|
||||||
|
...(layout.showTimeline && effectiveSession && !breakpoints.isMobile ? [{
|
||||||
setTimeout(() => {
|
id: 'timeline',
|
||||||
if (parentRef.current) {
|
position: 'right' as const,
|
||||||
parentRef.current.scrollTop = 1;
|
visible: true,
|
||||||
requestAnimationFrame(() => {
|
defaultWidth: layout.timelineWidth,
|
||||||
if (parentRef.current) {
|
minWidth: 320,
|
||||||
parentRef.current.scrollTop = 0;
|
maxWidth: 600,
|
||||||
}
|
resizable: true,
|
||||||
});
|
content: (
|
||||||
}
|
<SidePanel
|
||||||
}, 500);
|
title={t('app.sessionTimeline')}
|
||||||
}
|
onClose={toggleTimeline}
|
||||||
}}
|
position="right"
|
||||||
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">
|
|
||||||
<TimelineNavigator
|
<TimelineNavigator
|
||||||
sessionId={effectiveSession.id}
|
sessionId={effectiveSession.id}
|
||||||
projectId={effectiveSession.project_id}
|
projectId={effectiveSession.project_id}
|
||||||
@@ -1783,11 +1782,25 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
onCheckpointCreated={handleCheckpointCreated}
|
onCheckpointCreated={handleCheckpointCreated}
|
||||||
refreshVersion={timelineVersion}
|
refreshVersion={timelineVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SidePanel>
|
||||||
</div>
|
)
|
||||||
</ResponsivePanel>
|
}] : [])
|
||||||
)}
|
]}
|
||||||
</GridLayoutContainer>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Fork Dialog */}
|
{/* Fork Dialog */}
|
||||||
|
@@ -667,39 +667,17 @@ export const FileExplorerPanelEnhanced: React.FC<FileExplorerPanelEnhancedProps>
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 如果不可见,返回null
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<div className="flex flex-col h-full border-r border-border">
|
||||||
{isVisible && (
|
{/* Header */}
|
||||||
<motion.div
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
ref={panelRef}
|
<div className="flex items-center gap-2">
|
||||||
initial={{ x: -300, opacity: 0 }}
|
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||||
animate={{ x: 0, opacity: 1 }}
|
<h3 className="font-medium text-sm">{t("app.fileExplorer")}</h3>
|
||||||
exit={{ x: -300, opacity: 0 }}
|
</div>
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
style={{ width: `${width}px` }}
|
|
||||||
className={cn(
|
|
||||||
"fixed left-0 top-[172px] bottom-0 bg-background border-r shadow-lg z-40",
|
|
||||||
"flex flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 拖拽手柄 */}
|
|
||||||
<div
|
|
||||||
ref={resizeHandleRef}
|
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 hover:w-2 bg-transparent hover:bg-primary/20 cursor-col-resize transition-all"
|
|
||||||
onMouseDown={() => setIsResizing(true)}
|
|
||||||
>
|
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
|
||||||
<GripVertical className="h-6 w-6 text-muted-foreground/50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-3 border-b">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<h3 className="font-medium text-sm">{t("app.fileExplorer")}</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* 展开/收起按钮 */}
|
{/* 展开/收起按钮 */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -819,9 +797,7 @@ export const FileExplorerPanelEnhanced: React.FC<FileExplorerPanelEnhancedProps>
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -872,7 +872,7 @@ const FloatingPromptInputInner = (
|
|||||||
{/* Fixed Position Input Bar */}
|
{/* Fixed Position Input Bar */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute bottom-0 left-0 right-0 z-40 bg-background/98 backdrop-blur-sm border-t border-border/40",
|
"w-full bg-background/98 backdrop-blur-sm border-t border-border/40",
|
||||||
dragActive && "ring-2 ring-primary ring-offset-2",
|
dragActive && "ring-2 ring-primary ring-offset-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -881,7 +881,7 @@ const FloatingPromptInputInner = (
|
|||||||
onDragOver={handleDrag}
|
onDragOver={handleDrag}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="w-full">
|
||||||
{/* Image previews */}
|
{/* Image previews */}
|
||||||
{embeddedImages.length > 0 && (
|
{embeddedImages.length > 0 && (
|
||||||
<ImagePreview
|
<ImagePreview
|
||||||
@@ -891,7 +891,7 @@ const FloatingPromptInputInner = (
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-3">
|
<div className="p-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Model Picker */}
|
{/* Model Picker */}
|
||||||
<Popover
|
<Popover
|
||||||
@@ -900,7 +900,7 @@ const FloatingPromptInputInner = (
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="gap-2 min-w-[160px] h-10 justify-start"
|
className="gap-2 min-w-[140px] h-8 justify-start"
|
||||||
>
|
>
|
||||||
{selectedModelData.icon}
|
{selectedModelData.icon}
|
||||||
<span className="flex-1 text-left">{selectedModelData.name}</span>
|
<span className="flex-1 text-left">{selectedModelData.name}</span>
|
||||||
@@ -949,7 +949,7 @@ const FloatingPromptInputInner = (
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="gap-2 h-10"
|
className="gap-2 h-8"
|
||||||
>
|
>
|
||||||
<Brain className="h-4 w-4" />
|
<Brain className="h-4 w-4" />
|
||||||
<ThinkingModeIndicator
|
<ThinkingModeIndicator
|
||||||
@@ -1010,7 +1010,7 @@ const FloatingPromptInputInner = (
|
|||||||
placeholder={dragActive ? t('messages.dropImagesHere') : t('messages.askClaudeAnything')}
|
placeholder={dragActive ? t('messages.dropImagesHere') : t('messages.askClaudeAnything')}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-[40px] max-h-[120px] resize-none pr-10 py-2",
|
"min-h-[32px] max-h-[100px] resize-none pr-10 py-1",
|
||||||
dragActive && "border-primary"
|
dragActive && "border-primary"
|
||||||
)}
|
)}
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -1021,7 +1021,7 @@ const FloatingPromptInputInner = (
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setIsExpanded(true)}
|
onClick={() => setIsExpanded(true)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="absolute right-1 bottom-1 h-8 w-8"
|
className="absolute right-1 bottom-0 h-6 w-6"
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-4 w-4" />
|
<Maximize2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1057,7 +1057,7 @@ const FloatingPromptInputInner = (
|
|||||||
disabled={isLoading ? false : (!prompt.trim() || disabled)}
|
disabled={isLoading ? false : (!prompt.trim() || disabled)}
|
||||||
variant={isLoading ? "destructive" : "default"}
|
variant={isLoading ? "destructive" : "default"}
|
||||||
size="default"
|
size="default"
|
||||||
className="min-w-[60px] h-10"
|
className="min-w-[56px] h-8"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
@@ -1069,10 +1069,6 @@ const FloatingPromptInputInner = (
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{t('input.pressEnterToSend')}{projectPath?.trim() && t('input.withFileAndCommandSupport')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -639,30 +639,14 @@ export const GitPanelEnhanced: React.FC<GitPanelEnhancedProps> = ({
|
|||||||
<>
|
<>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
initial={{ x: 300, opacity: 0 }}
|
|
||||||
animate={{ x: 0, opacity: 1 }}
|
|
||||||
exit={{ x: 300, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
style={{ width: `${width}px` }}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-0 top-[172px] bottom-0 bg-background border-l shadow-lg z-40",
|
"h-full bg-background border-l border-border",
|
||||||
"flex flex-col",
|
"flex flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 拖拽手柄 */}
|
|
||||||
<div
|
|
||||||
ref={resizeHandleRef}
|
|
||||||
className="absolute left-0 top-0 bottom-0 w-1 hover:w-2 bg-transparent hover:bg-primary/20 cursor-col-resize transition-all"
|
|
||||||
onMouseDown={() => setIsResizing(true)}
|
|
||||||
>
|
|
||||||
<div className="absolute left-0 top-1/2 -translate-y-1/2">
|
|
||||||
<GripVertical className="h-6 w-6 text-muted-foreground/50" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-3 border-b">
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -823,7 +807,7 @@ export const GitPanelEnhanced: React.FC<GitPanelEnhancedProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
50
src/components/layout/ChatView.tsx
Normal file
50
src/components/layout/ChatView.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ChatViewProps {
|
||||||
|
projectPathInput?: React.ReactNode;
|
||||||
|
messagesList: React.ReactNode;
|
||||||
|
floatingInput?: React.ReactNode;
|
||||||
|
floatingElements?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatView: React.FC<ChatViewProps> = ({
|
||||||
|
projectPathInput,
|
||||||
|
messagesList,
|
||||||
|
floatingInput,
|
||||||
|
floatingElements,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn('h-full w-full flex flex-col relative', className)}>
|
||||||
|
{/* 项目路径输入(如果提供) */}
|
||||||
|
{projectPathInput && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{projectPathInput}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 消息列表区域 - 占据大部分空间 */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{messagesList}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浮动输入框 - 最小化高度 */}
|
||||||
|
{floatingInput && (
|
||||||
|
<div className="shrink-0 relative">
|
||||||
|
{floatingInput}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 其他浮动元素(如队列提示、Token计数器等) */}
|
||||||
|
{floatingElements && (
|
||||||
|
<div className="absolute inset-0 pointer-events-none z-30">
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{floatingElements}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
144
src/components/layout/FlexLayoutContainer.tsx
Normal file
144
src/components/layout/FlexLayoutContainer.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { ReactNode, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface LayoutPanel {
|
||||||
|
id: string;
|
||||||
|
content: ReactNode;
|
||||||
|
defaultWidth?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
resizable?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
position?: 'left' | 'center' | 'right';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlexLayoutContainerProps {
|
||||||
|
panels: LayoutPanel[];
|
||||||
|
className?: string;
|
||||||
|
mainContentId: string;
|
||||||
|
onPanelResize?: (panelId: string, width: number) => void;
|
||||||
|
savedWidths?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlexLayoutContainer: React.FC<FlexLayoutContainerProps> = ({
|
||||||
|
panels,
|
||||||
|
className,
|
||||||
|
mainContentId,
|
||||||
|
onPanelResize,
|
||||||
|
savedWidths = {}
|
||||||
|
}) => {
|
||||||
|
const [panelWidths, setPanelWidths] = useState<Record<string, number>>({});
|
||||||
|
const [isDragging, setIsDragging] = useState<string | null>(null);
|
||||||
|
const [dragStartX, setDragStartX] = useState(0);
|
||||||
|
const [dragStartWidth, setDragStartWidth] = useState(0);
|
||||||
|
|
||||||
|
// 初始化面板宽度
|
||||||
|
useEffect(() => {
|
||||||
|
const initialWidths: Record<string, number> = {};
|
||||||
|
panels.forEach(panel => {
|
||||||
|
if (panel.visible !== false && panel.id !== mainContentId) {
|
||||||
|
initialWidths[panel.id] = savedWidths[panel.id] || panel.defaultWidth || 280;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setPanelWidths(initialWidths);
|
||||||
|
}, [panels, mainContentId, savedWidths]);
|
||||||
|
|
||||||
|
// 处理拖拽开始
|
||||||
|
const handleDragStart = useCallback((e: React.MouseEvent, panelId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(panelId);
|
||||||
|
setDragStartX(e.clientX);
|
||||||
|
setDragStartWidth(panelWidths[panelId] || 280);
|
||||||
|
}, [panelWidths]);
|
||||||
|
|
||||||
|
// 处理拖拽移动
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const panel = panels.find(p => p.id === isDragging);
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const delta = panel.position === 'left'
|
||||||
|
? e.clientX - dragStartX
|
||||||
|
: dragStartX - e.clientX;
|
||||||
|
|
||||||
|
const newWidth = Math.max(
|
||||||
|
panel.minWidth || 200,
|
||||||
|
Math.min(panel.maxWidth || 600, dragStartWidth + delta)
|
||||||
|
);
|
||||||
|
|
||||||
|
setPanelWidths(prev => ({
|
||||||
|
...prev,
|
||||||
|
[isDragging]: newWidth
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (onPanelResize) {
|
||||||
|
onPanelResize(isDragging, newWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, dragStartX, dragStartWidth, panels, onPanelResize]);
|
||||||
|
|
||||||
|
// 渲染面板
|
||||||
|
const renderPanel = (panel: LayoutPanel) => {
|
||||||
|
if (panel.visible === false) return null;
|
||||||
|
|
||||||
|
const isMain = panel.id === mainContentId;
|
||||||
|
const width = isMain ? 'flex-1' : `${panelWidths[panel.id] || panel.defaultWidth || 280}px`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={panel.id}
|
||||||
|
className={cn(
|
||||||
|
'relative h-full',
|
||||||
|
isMain ? 'flex-1 min-w-0' : 'overflow-hidden',
|
||||||
|
panel.className
|
||||||
|
)}
|
||||||
|
style={!isMain ? { width, flexShrink: 0 } : undefined}
|
||||||
|
>
|
||||||
|
{panel.content}
|
||||||
|
|
||||||
|
{/* 调整手柄 */}
|
||||||
|
{!isMain && panel.resizable !== false && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors z-50',
|
||||||
|
panel.position === 'left' ? 'right-0' : 'left-0',
|
||||||
|
isDragging === panel.id && 'bg-primary/40'
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => handleDragStart(e, panel.id)}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 w-4 -left-1.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按位置排序面板
|
||||||
|
const sortedPanels = [...panels].sort((a, b) => {
|
||||||
|
const positionOrder = { left: 0, center: 1, right: 2 };
|
||||||
|
const aPos = a.position || (a.id === mainContentId ? 'center' : 'left');
|
||||||
|
const bPos = b.position || (b.id === mainContentId ? 'center' : 'left');
|
||||||
|
return positionOrder[aPos] - positionOrder[bPos];
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full w-full', className)}>
|
||||||
|
{sortedPanels.map(renderPanel)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
25
src/components/layout/MainContentArea.tsx
Normal file
25
src/components/layout/MainContentArea.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MainContentAreaProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainContentArea: React.FC<MainContentAreaProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
isEditing = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'h-full w-full flex flex-col',
|
||||||
|
'bg-background',
|
||||||
|
isEditing && 'relative',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
49
src/components/layout/SidePanel.tsx
Normal file
49
src/components/layout/SidePanel.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SidePanelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
title?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
position?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidePanel: React.FC<SidePanelProps> = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
position = 'left'
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col h-full',
|
||||||
|
'bg-background',
|
||||||
|
position === 'left' ? 'border-r' : 'border-l',
|
||||||
|
'border-border',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between p-3 border-b border-border">
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
{onClose && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -9,6 +9,9 @@ interface LayoutState {
|
|||||||
showTimeline: boolean;
|
showTimeline: boolean;
|
||||||
splitPosition: number;
|
splitPosition: number;
|
||||||
isCompactMode: boolean;
|
isCompactMode: boolean;
|
||||||
|
activeView: 'chat' | 'editor' | 'preview'; // 新增:当前活动视图
|
||||||
|
editingFile: string | null; // 新增:正在编辑的文件
|
||||||
|
previewUrl: string | null; // 新增:预览URL
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutBreakpoints {
|
interface LayoutBreakpoints {
|
||||||
@@ -29,6 +32,9 @@ const DEFAULT_LAYOUT: LayoutState = {
|
|||||||
showTimeline: false,
|
showTimeline: false,
|
||||||
splitPosition: 50,
|
splitPosition: 50,
|
||||||
isCompactMode: false,
|
isCompactMode: false,
|
||||||
|
activeView: 'chat', // 默认显示聊天视图
|
||||||
|
editingFile: null,
|
||||||
|
previewUrl: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = 'claudia_layout_preferences';
|
const STORAGE_KEY = 'claudia_layout_preferences';
|
||||||
@@ -250,6 +256,49 @@ export function useLayoutManager(projectPath?: string) {
|
|||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
}, [breakpoints, layout.isCompactMode]);
|
}, [breakpoints, layout.isCompactMode]);
|
||||||
|
|
||||||
|
// 打开文件编辑器
|
||||||
|
const openFileEditor = useCallback((filePath: string) => {
|
||||||
|
saveLayout({
|
||||||
|
activeView: 'editor',
|
||||||
|
editingFile: filePath,
|
||||||
|
previewUrl: null, // 关闭预览
|
||||||
|
});
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
// 关闭文件编辑器
|
||||||
|
const closeFileEditor = useCallback(() => {
|
||||||
|
saveLayout({
|
||||||
|
activeView: 'chat',
|
||||||
|
editingFile: null,
|
||||||
|
});
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
// 打开预览
|
||||||
|
const openPreview = useCallback((url: string) => {
|
||||||
|
saveLayout({
|
||||||
|
activeView: 'preview',
|
||||||
|
previewUrl: url,
|
||||||
|
editingFile: null, // 关闭编辑器
|
||||||
|
});
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
// 关闭预览
|
||||||
|
const closePreview = useCallback(() => {
|
||||||
|
saveLayout({
|
||||||
|
activeView: 'chat',
|
||||||
|
previewUrl: null,
|
||||||
|
});
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
|
// 切换到聊天视图
|
||||||
|
const switchToChatView = useCallback(() => {
|
||||||
|
saveLayout({
|
||||||
|
activeView: 'chat',
|
||||||
|
editingFile: null,
|
||||||
|
previewUrl: null,
|
||||||
|
});
|
||||||
|
}, [saveLayout]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout,
|
layout,
|
||||||
breakpoints,
|
breakpoints,
|
||||||
@@ -264,5 +313,11 @@ export function useLayoutManager(projectPath?: string) {
|
|||||||
getGridTemplateColumns,
|
getGridTemplateColumns,
|
||||||
getResponsiveClasses,
|
getResponsiveClasses,
|
||||||
saveLayout,
|
saveLayout,
|
||||||
|
// 新增的方法
|
||||||
|
openFileEditor,
|
||||||
|
closeFileEditor,
|
||||||
|
openPreview,
|
||||||
|
closePreview,
|
||||||
|
switchToChatView,
|
||||||
};
|
};
|
||||||
}
|
}
|
Reference in New Issue
Block a user