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 (
|
||||
<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">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBackWithConfirmation}
|
||||
className="h-8 w-8"
|
||||
{/* Fixed container that takes full height */}
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Sticky Header */}
|
||||
<div className="sticky top-0 z-10 bg-background border-b border-border">
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</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 && (
|
||||
<>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreenModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
size="icon"
|
||||
onClick={handleBackWithConfirmation}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
trigger={
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreenModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<div className="w-44 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleCopyAsJsonl}
|
||||
>
|
||||
Copy as JSONL
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
Copy as Markdown
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={copyPopoverOpen}
|
||||
onOpenChange={setCopyPopoverOpen}
|
||||
align="end"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="p-4 border-b border-border space-y-4">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<div className="w-44 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleCopyAsJsonl}
|
||||
>
|
||||
Copy as JSONL
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={handleCopyAsMarkdown}
|
||||
>
|
||||
Copy as Markdown
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={copyPopoverOpen}
|
||||
onOpenChange={setCopyPopoverOpen}
|
||||
align="end"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Sticky Configuration */}
|
||||
<div className="sticky top-[73px] z-10 bg-background border-b border-border">
|
||||
<div className="w-full max-w-5xl mx-auto p-4 space-y-4">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isRunning && setModel("sonnet")}
|
||||
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 === "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">
|
||||
<div className={cn(
|
||||
"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"
|
||||
)}>
|
||||
{model === "sonnet" && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>Claude 4 Sonnet</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isRunning && setModel("sonnet")}
|
||||
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 === "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">
|
||||
<div className={cn(
|
||||
"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"
|
||||
)}>
|
||||
{model === "sonnet" && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>Claude 4 Sonnet</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="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>
|
||||
|
||||
{/* Output Display */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
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
|
||||
className="relative w-full max-w-5xl mx-auto"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
{/* Scrollable Output Display */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="w-full max-w-5xl mx-auto h-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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 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>
|
||||
|
Reference in New Issue
Block a user