refactor(ui): improve agent execution layout with sticky header and configuration
Restructure AgentExecution component layout to enhance user experience: - Make header and configuration sections sticky to remain visible during scrolling - Optimize scrollable output display area with proper overflow handling - Improve container structure for better layout management and responsiveness - Maintain existing functionality while providing better visual organization
This commit is contained in:
@@ -442,291 +442,300 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||||
<div className="w-full max-w-5xl mx-auto h-full flex flex-col">
|
{/* Fixed container that takes full height */}
|
||||||
{/* Header */}
|
<div className="h-full flex flex-col">
|
||||||
<motion.div
|
{/* Sticky Header */}
|
||||||
initial={{ opacity: 0, y: -20 }}
|
<div className="sticky top-0 z-10 bg-background border-b border-border">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="w-full max-w-5xl mx-auto">
|
||||||
transition={{ duration: 0.3 }}
|
<motion.div
|
||||||
className="flex items-center justify-between p-4 border-b border-border"
|
initial={{ opacity: 0, y: -20 }}
|
||||||
>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="flex items-center space-x-3">
|
transition={{ duration: 0.3 }}
|
||||||
<Button
|
className="flex items-center justify-between p-4"
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleBackWithConfirmation}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<div className="flex items-center space-x-3">
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{renderIcon()}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="text-lg font-semibold">{agent.name}</h2>
|
|
||||||
{isRunning && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-xs text-green-600 font-medium">Running</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{isRunning ? "Click back to return to main menu - view in CC Agents > Running Sessions" : "Execute CC Agent"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{messages.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => setIsFullscreenModalOpen(true)}
|
onClick={handleBackWithConfirmation}
|
||||||
className="flex items-center gap-2"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Fullscreen
|
|
||||||
</Button>
|
</Button>
|
||||||
<Popover
|
<div className="flex items-center gap-2">
|
||||||
trigger={
|
{renderIcon()}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{agent.name}</h2>
|
||||||
|
{isRunning && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span className="text-xs text-green-600 font-medium">Running</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isRunning ? "Click back to return to main menu - view in CC Agents > Running Sessions" : "Execute CC Agent"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
onClick={() => setIsFullscreenModalOpen(true)}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Maximize2 className="h-4 w-4" />
|
||||||
Copy Output
|
Fullscreen
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
<Popover
|
||||||
content={
|
trigger={
|
||||||
<div className="w-44 p-1">
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
className="flex items-center gap-2"
|
||||||
className="w-full justify-start"
|
>
|
||||||
onClick={handleCopyAsJsonl}
|
<Copy className="h-4 w-4" />
|
||||||
>
|
Copy Output
|
||||||
Copy as JSONL
|
<ChevronDown className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
}
|
||||||
variant="ghost"
|
content={
|
||||||
size="sm"
|
<div className="w-44 p-1">
|
||||||
className="w-full justify-start"
|
<Button
|
||||||
onClick={handleCopyAsMarkdown}
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
Copy as Markdown
|
className="w-full justify-start"
|
||||||
</Button>
|
onClick={handleCopyAsJsonl}
|
||||||
</div>
|
>
|
||||||
}
|
Copy as JSONL
|
||||||
open={copyPopoverOpen}
|
</Button>
|
||||||
onOpenChange={setCopyPopoverOpen}
|
<Button
|
||||||
align="end"
|
variant="ghost"
|
||||||
/>
|
size="sm"
|
||||||
</>
|
className="w-full justify-start"
|
||||||
)}
|
onClick={handleCopyAsMarkdown}
|
||||||
</div>
|
>
|
||||||
</motion.div>
|
Copy as Markdown
|
||||||
|
</Button>
|
||||||
{/* Configuration */}
|
</div>
|
||||||
<div className="p-4 border-b border-border space-y-4">
|
}
|
||||||
{/* Error display */}
|
open={copyPopoverOpen}
|
||||||
{error && (
|
onOpenChange={setCopyPopoverOpen}
|
||||||
<motion.div
|
align="end"
|
||||||
initial={{ opacity: 0 }}
|
/>
|
||||||
animate={{ opacity: 1 }}
|
</>
|
||||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive flex items-center gap-2"
|
)}
|
||||||
>
|
</div>
|
||||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
||||||
{error}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Project Path */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Project Path</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={projectPath}
|
|
||||||
onChange={(e) => setProjectPath(e.target.value)}
|
|
||||||
placeholder="Select or enter project path"
|
|
||||||
disabled={isRunning}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleSelectPath}
|
|
||||||
disabled={isRunning}
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Model Selection */}
|
{/* Sticky Configuration */}
|
||||||
<div className="space-y-2">
|
<div className="sticky top-[73px] z-10 bg-background border-b border-border">
|
||||||
<Label>Model</Label>
|
<div className="w-full max-w-5xl mx-auto p-4 space-y-4">
|
||||||
<div className="flex gap-3">
|
{/* Error display */}
|
||||||
<button
|
{error && (
|
||||||
type="button"
|
<motion.div
|
||||||
onClick={() => !isRunning && setModel("sonnet")}
|
initial={{ opacity: 0 }}
|
||||||
className={cn(
|
animate={{ opacity: 1 }}
|
||||||
"flex-1 px-3.5 py-2 rounded-full border-2 font-medium transition-all text-sm",
|
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive flex items-center gap-2"
|
||||||
!isRunning && "hover:scale-[1.02] active:scale-[0.98]",
|
|
||||||
isRunning && "opacity-50 cursor-not-allowed",
|
|
||||||
model === "sonnet"
|
|
||||||
? "border-primary bg-primary text-primary-foreground shadow-lg"
|
|
||||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
|
||||||
)}
|
|
||||||
disabled={isRunning}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
<div className={cn(
|
{error}
|
||||||
"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
</motion.div>
|
||||||
model === "sonnet" ? "border-primary-foreground" : "border-current"
|
)}
|
||||||
)}>
|
|
||||||
{model === "sonnet" && (
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span>Claude 4 Sonnet</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
{/* Project Path */}
|
||||||
type="button"
|
<div className="space-y-2">
|
||||||
onClick={() => !isRunning && setModel("opus")}
|
<Label>Project Path</Label>
|
||||||
className={cn(
|
<div className="flex gap-2">
|
||||||
"flex-1 px-3.5 py-2 rounded-full border-2 font-medium transition-all text-sm",
|
<Input
|
||||||
!isRunning && "hover:scale-[1.02] active:scale-[0.98]",
|
value={projectPath}
|
||||||
isRunning && "opacity-50 cursor-not-allowed",
|
onChange={(e) => setProjectPath(e.target.value)}
|
||||||
model === "opus"
|
placeholder="Select or enter project path"
|
||||||
? "border-primary bg-primary text-primary-foreground shadow-lg"
|
disabled={isRunning}
|
||||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
className="flex-1"
|
||||||
)}
|
/>
|
||||||
disabled={isRunning}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<div className="flex items-center justify-center gap-2">
|
size="icon"
|
||||||
<div className={cn(
|
onClick={handleSelectPath}
|
||||||
"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
disabled={isRunning}
|
||||||
model === "opus" ? "border-primary-foreground" : "border-current"
|
>
|
||||||
)}>
|
<FolderOpen className="h-4 w-4" />
|
||||||
{model === "opus" && (
|
</Button>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span>Claude 4 Opus</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Input */}
|
{/* Model Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Task</Label>
|
<Label>Model</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<Input
|
<button
|
||||||
value={task}
|
type="button"
|
||||||
onChange={(e) => setTask(e.target.value)}
|
onClick={() => !isRunning && setModel("sonnet")}
|
||||||
placeholder={agent.default_task || "Enter the task for the agent"}
|
className={cn(
|
||||||
disabled={isRunning}
|
"flex-1 px-3.5 py-2 rounded-full border-2 font-medium transition-all text-sm",
|
||||||
className="flex-1"
|
!isRunning && "hover:scale-[1.02] active:scale-[0.98]",
|
||||||
onKeyPress={(e) => {
|
isRunning && "opacity-50 cursor-not-allowed",
|
||||||
if (e.key === "Enter" && !isRunning && projectPath && task.trim()) {
|
model === "sonnet"
|
||||||
handleExecute();
|
? "border-primary bg-primary text-primary-foreground shadow-lg"
|
||||||
}
|
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
}}
|
)}
|
||||||
/>
|
disabled={isRunning}
|
||||||
<Button
|
>
|
||||||
onClick={isRunning ? handleStop : handleExecute}
|
<div className="flex items-center justify-center gap-2">
|
||||||
disabled={!projectPath || !task.trim()}
|
<div className={cn(
|
||||||
variant={isRunning ? "destructive" : "default"}
|
"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||||
>
|
model === "sonnet" ? "border-primary-foreground" : "border-current"
|
||||||
{isRunning ? (
|
)}>
|
||||||
<>
|
{model === "sonnet" && (
|
||||||
<StopCircle className="mr-2 h-4 w-4" />
|
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||||
Stop
|
)}
|
||||||
</>
|
</div>
|
||||||
) : (
|
<span>Claude 4 Sonnet</span>
|
||||||
<>
|
</div>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
</button>
|
||||||
Execute
|
|
||||||
</>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</Button>
|
onClick={() => !isRunning && setModel("opus")}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3.5 py-2 rounded-full border-2 font-medium transition-all text-sm",
|
||||||
|
!isRunning && "hover:scale-[1.02] active:scale-[0.98]",
|
||||||
|
isRunning && "opacity-50 cursor-not-allowed",
|
||||||
|
model === "opus"
|
||||||
|
? "border-primary bg-primary text-primary-foreground shadow-lg"
|
||||||
|
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||||
|
)}
|
||||||
|
disabled={isRunning}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||||
|
model === "opus" ? "border-primary-foreground" : "border-current"
|
||||||
|
)}>
|
||||||
|
{model === "opus" && (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>Claude 4 Opus</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Task</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={task}
|
||||||
|
onChange={(e) => setTask(e.target.value)}
|
||||||
|
placeholder={agent.default_task || "Enter the task for the agent"}
|
||||||
|
disabled={isRunning}
|
||||||
|
className="flex-1"
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === "Enter" && !isRunning && projectPath && task.trim()) {
|
||||||
|
handleExecute();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={isRunning ? handleStop : handleExecute}
|
||||||
|
disabled={!projectPath || !task.trim()}
|
||||||
|
variant={isRunning ? "destructive" : "default"}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<StopCircle className="mr-2 h-4 w-4" />
|
||||||
|
Stop
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Execute
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Output Display */}
|
{/* Scrollable Output Display */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div
|
<div className="w-full max-w-5xl mx-auto h-full">
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="flex-1 w-full overflow-y-auto p-6 space-y-8"
|
|
||||||
onScroll={() => {
|
|
||||||
// Mark that user has scrolled manually
|
|
||||||
if (!hasUserScrolled) {
|
|
||||||
setHasUserScrolled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user scrolls back to bottom, re-enable auto-scroll
|
|
||||||
if (isAtBottom()) {
|
|
||||||
setHasUserScrolled(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div ref={messagesContainerRef}>
|
|
||||||
{messages.length === 0 && !isRunning && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
|
||||||
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
|
|
||||||
<h3 className="text-lg font-medium mb-2">Ready to Execute</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Select a project path and enter a task to run the agent
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isRunning && messages.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
<span className="text-sm text-muted-foreground">Initializing agent...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative w-full max-w-5xl mx-auto"
|
ref={scrollContainerRef}
|
||||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
className="h-full overflow-y-auto p-6 space-y-8"
|
||||||
>
|
onScroll={() => {
|
||||||
<AnimatePresence>
|
// Mark that user has scrolled manually
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
if (!hasUserScrolled) {
|
||||||
const message = displayableMessages[virtualItem.index];
|
setHasUserScrolled(true);
|
||||||
return (
|
}
|
||||||
<motion.div
|
|
||||||
key={virtualItem.key}
|
|
||||||
data-index={virtualItem.index}
|
|
||||||
ref={(el) => el && rowVirtualizer.measureElement(el)}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="absolute inset-x-4 pb-4"
|
|
||||||
style={{ top: virtualItem.start }}
|
|
||||||
>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<StreamMessage message={message} streamMessages={messages} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
// If user scrolls back to bottom, re-enable auto-scroll
|
||||||
|
if (isAtBottom()) {
|
||||||
|
setHasUserScrolled(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={messagesContainerRef}>
|
||||||
|
{messages.length === 0 && !isRunning && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium mb-2">Ready to Execute</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a project path and enter a task to run the agent
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && messages.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">Initializing agent...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
|
const message = displayableMessages[virtualItem.index];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={virtualItem.key}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
ref={(el) => el && rowVirtualizer.measureElement(el)}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="absolute inset-x-4 pb-4"
|
||||||
|
style={{ top: virtualItem.start }}
|
||||||
|
>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<StreamMessage message={message} streamMessages={messages} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user