feat: implement new layout components and enhance UI with glassmorphism

This commit introduces a new app layout structure with an AppLayout component, a TopHeader for improved navigation, and a Sidebar for project management. Additionally, it adds GlassPanel and GlassCard components to enhance the UI with a glassmorphism effect. The Kanban board and agent views have been updated to utilize these new components, improving the overall user experience and visual consistency across the application.
This commit is contained in:
SuperComboGamer
2025-12-23 16:38:49 -05:00
parent d50b15e639
commit 379976aba7
25 changed files with 2823 additions and 2647 deletions

View File

@@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import { Sidebar } from './sidebar';
// TopHeader removed from layout to be view-specific
interface AppLayoutProps {
children: ReactNode;
}
export function AppLayout({ children }: AppLayoutProps) {
return (
<div className="flex h-screen w-full relative selection:bg-brand-cyan selection:text-black font-sans bg-dark-950 overflow-hidden">
{/* Ambient Background */}
<div
className="fixed bottom-[-25%] left-[-15%] w-[1000px] h-[1000px] opacity-80 pointer-events-none z-0 blob-rainbow"
style={{
background:
'radial-gradient(circle at center, rgba(6, 182, 212, 0.15) 0%, rgba(59, 130, 246, 0.12) 30%, rgba(249, 115, 22, 0.08) 60%, transparent 80%)',
filter: 'blur(100px)',
}}
></div>
<div
className="fixed top-[-20%] right-[-10%] w-[700px] h-[700px] pointer-events-none z-0"
style={{
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, transparent 70%)',
filter: 'blur(100px)',
}}
></div>
<Sidebar />
<main className="flex-1 flex flex-col min-w-0 relative z-10 h-full">
<div className="flex-1 overflow-hidden relative">{children}</div>
</main>
</div>
);
}

View File

@@ -1,367 +1,213 @@
import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import React from 'react';
import {
Code2,
PanelLeft,
Plus,
Folder,
Bell,
FolderOpen,
MoreVertical,
LayoutGrid,
Bot,
FileJson,
BookOpen,
UserCircle,
TerminalSquare,
Book,
Activity,
Settings,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
// Local imports from subfolder
import {
CollapseToggleButton,
SidebarHeader,
ProjectActions,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
useSidebarAutoCollapse,
useRunningAgents,
useSpecRegeneration,
useNavigation,
useProjectCreation,
useSetupDialog,
useTrashDialog,
useProjectTheme,
} from './sidebar/hooks';
import { Link, useLocation } from '@tanstack/react-router';
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const {
projects,
trashedProjects,
currentProject,
sidebarOpen,
projectHistory,
upsertAndSetCurrentProject,
toggleSidebar,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
specCreatingForProject,
setSpecCreatingForProject,
} = useAppStore();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor, hideAiProfiles } =
SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
setNewProjectName,
newProjectPath,
setNewProjectPath,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
// Setup dialog state and handlers
const {
showSetupDialog,
setShowSetupDialog,
setupProjectPath,
setSetupProjectPath,
projectOverview,
setProjectOverview,
generateFeatures,
setGenerateFeatures,
analyzeProject,
setAnalyzeProject,
featureCount,
setFeatureCount,
handleCreateInitialSpec,
handleSkipSetup,
handleOnboardingGenerateSpec,
handleOnboardingSkip,
} = useSetupDialog({
setSpecCreatingForProject,
newProjectPath,
setNewProjectName,
setNewProjectPath,
setShowOnboardingDialog,
});
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
const creatingSpecProjectPath = specCreatingForProject;
// Auto-collapse sidebar on small screens and update Electron window minWidth
useSidebarAutoCollapse({ sidebarOpen, toggleSidebar });
// Running agents count
const { runningAgentsCount } = useRunningAgents();
// Trash dialog and operations
const {
showTrashDialog,
setShowTrashDialog,
activeTrashId,
isEmptyingTrash,
handleRestoreProject,
handleDeleteProjectFromDisk,
handleEmptyTrash,
} = useTrashDialog({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});
// Spec regeneration events
useSpecRegeneration({
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
setShowSetupDialog,
setProjectOverview,
setSetupProjectPath,
setNewProjectName,
setNewProjectPath,
});
/**
* Opens the system folder selection dialog and initializes the selected project.
* Used by both the 'O' keyboard shortcut and the folder icon button.
*/
const handleOpenFolder = useCallback(async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
try {
// Check if this is a brand new project (no .automaker directory)
const hadAutomakerDir = await hasAutomakerDir(path);
// Initialize the .automaker directory structure
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
if (!hadAutomakerDir && !specExists) {
// This is a brand new project - show setup dialog
setSetupProjectPath(path);
setShowSetupDialog(true);
toast.success('Project opened', {
description: `Opened ${name}. Let's set up your app specification!`,
});
} else if (initResult.createdFiles && initResult.createdFiles.length > 0) {
toast.success(initResult.isNewProject ? 'Project initialized' : 'Project updated', {
description: `Set up ${initResult.createdFiles.length} file(s) in .automaker`,
});
} else {
toast.success('Project opened', {
description: `Opened ${name}`,
});
}
} catch (error) {
console.error('[Sidebar] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
});
// Register keyboard shortcuts
useKeyboardShortcuts(navigationShortcuts);
const isActiveRoute = (id: string) => {
// Map view IDs to route paths
const routePath = id === 'welcome' ? '/' : `/${id}`;
return location.pathname === routePath;
};
return (
<aside
className={cn(
'flex-shrink-0 flex flex-col z-30 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
)}
data-testid="sidebar"
>
<CollapseToggleButton
sidebarOpen={sidebarOpen}
toggleSidebar={toggleSidebar}
shortcut={shortcuts.toggleSidebar}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Project Actions - Moved above project selector */}
{sidebarOpen && (
<ProjectActions
setShowNewProjectModal={setShowNewProjectModal}
handleOpenFolder={handleOpenFolder}
setShowTrashDialog={setShowTrashDialog}
trashedProjects={trashedProjects}
shortcuts={{ openProject: shortcuts.openProject }}
/>
)}
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}
/>
<aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
{/* Logo */}
<div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
<div className="text-brand-cyan relative flex items-center justify-center">
<div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
<Code2 className="w-6 h-6 relative z-10" />
</div>
<span className="text-white font-bold text-lg tracking-tight">automaker.</span>
<button className="ml-auto text-slate-600 hover:text-white transition">
<PanelLeft className="w-4 h-4" />
</button>
</div>
<SidebarFooter
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}
/>
<TrashDialog
open={showTrashDialog}
onOpenChange={setShowTrashDialog}
trashedProjects={trashedProjects}
activeTrashId={activeTrashId}
handleRestoreProject={handleRestoreProject}
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
deleteTrashedProject={deleteTrashedProject}
handleEmptyTrash={handleEmptyTrash}
isEmptyingTrash={isEmptyingTrash}
/>
{/* Top Actions */}
<div className="px-5 pb-6 space-y-4 flex-shrink-0">
<div className="grid grid-cols-4 gap-2">
<button className="col-span-2 bg-dark-850/60 hover:bg-dark-700 text-slate-200 py-2 px-3 rounded-lg border border-white/5 flex items-center justify-center gap-2 transition text-xs font-medium shadow-lg shadow-black/20 group">
<Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
</button>
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition">
<Folder className="w-3.5 h-3.5" />
<span className="ml-1 text-[10px]">0</span>
</button>
<button className="col-span-1 bg-dark-850/60 hover:bg-dark-700 text-slate-400 hover:text-white py-2 rounded-lg border border-white/5 flex items-center justify-center transition relative">
<Bell className="w-3.5 h-3.5" />
<span className="absolute top-2 right-2.5 w-1.5 h-1.5 bg-brand-red rounded-full ring-2 ring-dark-850"></span>
</button>
</div>
{/* New Project Setup Dialog */}
<CreateSpecDialog
open={showSetupDialog}
onOpenChange={setShowSetupDialog}
projectOverview={projectOverview}
onProjectOverviewChange={setProjectOverview}
generateFeatures={generateFeatures}
onGenerateFeaturesChange={setGenerateFeatures}
analyzeProject={analyzeProject}
onAnalyzeProjectChange={setAnalyzeProject}
featureCount={featureCount}
onFeatureCountChange={setFeatureCount}
onCreateSpec={handleCreateInitialSpec}
onSkip={handleSkipSetup}
isCreatingSpec={isCreatingSpec}
showSkipButton={true}
title="Set Up Your Project"
description="We didn't find an app_spec.txt file. Let us help you generate your app_spec.txt to help describe your project for our system. We'll analyze your project's tech stack and create a comprehensive specification."
/>
{/* Project Selector */}
<div className="bg-dark-850/40 border border-white/5 rounded-xl p-1 flex items-center justify-between cursor-pointer hover:border-white/10 hover:bg-dark-850/60 transition group">
<div className="flex items-center gap-3 px-2 py-1.5">
<FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
<span className="text-white font-medium text-sm">test case 1</span>
</div>
<div className="flex items-center gap-1 pr-1">
<span className="w-5 h-5 rounded bg-dark-700 flex items-center justify-center text-[10px] text-slate-400 font-bold border border-white/5">
P
</span>
<MoreVertical className="w-4 h-4 text-slate-500" />
</div>
</div>
</div>
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingGenerateSpec}
/>
{/* Navigation */}
<div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
{/* Project Section */}
<div>
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
Project
</h3>
<nav className="space-y-0.5">
<NavItem
to="/"
icon={<LayoutGrid className="w-4 h-4" />}
label="Kanban Board"
shortcut="L"
isActive={location.pathname === '/' || location.pathname === '/board'}
/>
<NavItem
to="/agents"
icon={<Bot className="w-4 h-4" />}
label="Agent Runner"
shortcut="A"
isActive={location.pathname.startsWith('/agents')}
/>
</nav>
</div>
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Tools Section */}
<div>
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
Tools
</h3>
<nav className="space-y-0.5">
<NavItem
to="/spec"
icon={<FileJson className="w-4 h-4" />}
label="Spec Editor"
shortcut="D"
isActive={location.pathname.startsWith('/spec')}
/>
<NavItem
to="/context"
icon={<BookOpen className="w-4 h-4" />}
label="Context"
shortcut="C"
isActive={location.pathname.startsWith('/context')}
/>
<NavItem
to="/profiles"
icon={<UserCircle className="w-4 h-4" />}
label="AI Profiles"
shortcut="H"
isActive={location.pathname.startsWith('/profiles')}
/>
<NavItem
to="/terminal"
icon={<TerminalSquare className="w-4 h-4" />}
label="Terminal"
shortcut="T"
isActive={location.pathname.startsWith('/terminal')}
/>
</nav>
</div>
</div>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Footer */}
<div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
<Link
to="/wiki"
className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<Book className="w-4 h-4" />
<span className="text-sm">Wiki</span>
</Link>
<Link
to="/running-agents"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Activity className="w-4 h-4 text-brand-cyan" />
<span className="text-sm">Running Agents</span>
</div>
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-brand-cyan text-[10px] text-black font-bold shadow-glow-cyan">
3
</span>
</Link>
<Link
to="/settings"
className="flex items-center justify-between px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
>
<div className="flex items-center gap-3">
<Settings className="w-4 h-4" />
<span className="text-sm">Settings</span>
</div>
<span className="text-[10px] bg-dark-700 text-slate-500 px-1.5 py-0.5 rounded font-mono border border-white/5">
S
</span>
</Link>
</div>
</aside>
);
}
function NavItem({
to,
icon,
label,
shortcut,
isActive,
}: {
to: string;
icon: React.ReactNode;
label: string;
shortcut: string;
isActive: boolean;
}) {
return (
<Link
to={to}
className={cn(
'flex items-center justify-between px-6 py-2.5 transition group border-l-[2px]',
isActive
? 'nav-item-active bg-gradient-to-r from-brand-cyan/10 to-transparent border-brand-cyan text-brand-cyan-hover'
: 'text-slate-400 hover:text-white hover:bg-white/5 border-transparent'
)}
>
<div className="flex items-center gap-3">
<span className={cn(isActive ? 'text-brand-cyan' : 'group-hover:text-slate-300')}>
{icon}
</span>
<span className="text-sm font-medium">{label}</span>
</div>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-mono border',
isActive
? 'bg-brand-cyan/10 text-brand-cyan border-brand-cyan/20'
: 'bg-dark-700 text-slate-500 border-white/5 group-hover:text-slate-300'
)}
>
{shortcut}
</span>
</Link>
);
}

View File

@@ -0,0 +1,38 @@
import { Users, Play, Plus } from 'lucide-react';
export function TopHeader() {
return (
<header className="h-16 glass-header flex items-center justify-between px-8 flex-shrink-0 z-20">
<div>
<h1 className="text-white font-bold text-lg tracking-tight">Kanban Board</h1>
<p className="text-xs text-slate-500 font-medium font-mono mt-0.5">test case 1</p>
</div>
<div className="flex items-center gap-4">
{/* User Toggle */}
<div className="flex items-center bg-dark-850/60 rounded-lg p-1 border border-white/5 h-9 shadow-inner-light">
<div className="flex items-center gap-3 px-2 border-r border-white/5 h-full mr-2">
<Users className="w-3.5 h-3.5 text-slate-400" />
{/* Toggle Switch */}
<div className="w-[28px] h-[16px] bg-[#2d3546] rounded-full relative cursor-pointer border border-white/10 transition-colors">
<div className="absolute top-[2px] right-[2px] w-[10px] h-[10px] bg-brand-cyan rounded-full shadow-[0_0_6px_rgba(6,182,212,0.6)]"></div>
</div>
</div>
<span className="text-xs text-slate-400 px-1 font-mono">3</span>
</div>
{/* Auto Mode */}
<button className="flex items-center gap-2 text-slate-300 hover:text-white px-3 py-1.5 rounded-lg border border-white/5 bg-dark-850/60 hover:bg-dark-700 transition text-xs font-medium h-9">
<Play className="w-3.5 h-3.5 fill-current" />
<span>Auto Mode</span>
</button>
{/* Add Feature */}
<button className="flex items-center gap-2 bg-brand-cyan hover:bg-cyan-400 text-dark-950 font-bold px-4 py-1.5 rounded-lg transition shadow-glow-cyan text-xs h-9 btn-hover-effect">
<Plus className="w-4 h-4" />
<span>Add Feature</span>
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'active-blue';
}
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
({ className, variant = 'default', children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
variant === 'default' && 'glass-card',
variant === 'active-blue' && 'glass-card card-active-blue',
'rounded-xl p-4',
className
)}
{...props}
>
{children}
</div>
);
}
);
GlassCard.displayName = 'GlassCard';

View File

@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils';
import { HTMLAttributes, forwardRef } from 'react';
export interface GlassPanelProps extends HTMLAttributes<HTMLDivElement> {
accent?: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
}
export const GlassPanel = forwardRef<HTMLDivElement, GlassPanelProps>(
({ className, accent = 'none', children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'glass-panel rounded-2xl flex flex-col',
accent === 'cyan' && 'col-accent-cyan',
accent === 'blue' && 'col-accent-blue',
accent === 'orange' && 'col-accent-orange',
accent === 'green' && 'col-accent-green',
className
)}
{...props}
>
{children}
</div>
);
}
);
GlassPanel.displayName = 'GlassPanel';

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -18,6 +18,9 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
import { GlassCard } from '@/components/ui/glass-card';
interface ToolResult {
success: boolean;
@@ -190,258 +193,285 @@ export function AgentToolsView() {
}
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
<Wrench className="w-5 h-5 text-primary" />
<div>
<h1 className="text-xl font-bold">Agent Tools</h1>
<p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name}
</p>
</div>
</div>
{/* Tools Grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{/* Read File Tool */}
<Card data-testid="read-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<File className="w-5 h-5 text-blue-500" />
<CardTitle className="text-lg">Read File</CardTitle>
<div className="flex-1 flex flex-col overflow-hidden bg-background">
<TopHeader />
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500/20 to-blue-600/20 border border-purple-500/30 flex items-center justify-center shadow-inner shadow-purple-500/20">
<Wrench className="w-6 h-6 text-purple-400" />
</div>
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label>
<Input
id="read-file-path"
placeholder="/path/to/file.txt"
value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input"
/>
<div>
<h1 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
Agent Tools
</h1>
<p className="text-sm text-muted-foreground">
Test file system and terminal tools for {currentProject.name}
</p>
</div>
<Button
onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()}
className="w-full"
data-testid="read-file-button"
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Read
</>
)}
</Button>
{/* Result */}
{readFileResult && (
<div
className={cn(
'p-3 rounded-md border',
readFileResult.success
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="read-file-result"
>
<div className="flex items-center gap-2 mb-2">
{readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Write File Tool */}
<Card data-testid="write-file-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-green-500" />
<CardTitle className="text-lg">Write File</CardTitle>
</div>
<CardDescription>Agent requests to write content to a file</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label>
<Input
id="write-file-path"
placeholder="/path/to/file.txt"
value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="write-file-content">Content</Label>
<textarea
id="write-file-content"
placeholder="File content..."
value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-input bg-background resize-y"
data-testid="write-file-content-input"
/>
</div>
<Button
onClick={handleWriteFile}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full"
data-testid="write-file-button"
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Write
</>
)}
</Button>
{/* Result */}
{writeFileResult && (
<div
className={cn(
'p-3 rounded-md border',
writeFileResult.success
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="write-file-result"
>
<div className="flex items-center gap-2 mb-2">
{writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Terminal Tool */}
<Card data-testid="terminal-tool">
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-purple-500" />
<CardTitle className="text-lg">Run Terminal</CardTitle>
</div>
<CardDescription>Agent requests to execute a terminal command</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label>
<Input
id="terminal-command"
placeholder="ls -la"
value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input"
/>
</div>
<Button
onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full"
data-testid="run-terminal-button"
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Command
</>
)}
</Button>
{/* Result */}
{terminalResult && (
<div
className={cn(
'p-3 rounded-md border',
terminalResult.success
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="terminal-result"
>
<div className="flex items-center gap-2 mb-2">
{terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
$ {terminalCommand}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tool Log Section */}
<Card className="mt-6" data-testid="tool-log">
<CardHeader>
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
<CardDescription>View agent tool requests and responses</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>
<li>Write File - Agent writes content to specified path</li>
<li>Run Terminal - Agent executes shell commands</li>
</ul>
</div>
</CardContent>
</Card>
{/* Tools Grid */}
<div className="flex-1 overflow-y-auto">
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 max-w-7xl">
{/* Read File Tool */}
<GlassCard
className="flex flex-col gap-4 bg-white/5 border-white/10"
data-testid="read-file-tool"
>
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-blue-500/10">
<File className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Read File</h3>
<p className="text-xs text-muted-foreground">Read from filesystem</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="read-file-path">File Path</Label>
<Input
id="read-file-path"
placeholder="/path/to/file.txt"
value={readFilePath}
onChange={(e) => setReadFilePath(e.target.value)}
data-testid="read-file-path-input"
className="bg-black/20 border-white/10 focus:border-blue-500/50"
/>
</div>
<Button
onClick={handleReadFile}
disabled={isReadingFile || !readFilePath.trim()}
className="w-full bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-500/30"
data-testid="read-file-button"
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Reading...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Read
</>
)}
</Button>
{/* Result */}
{readFileResult && (
<div
className={cn(
'p-3 rounded-lg border text-xs',
readFileResult.success
? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20 text-red-300'
)}
data-testid="read-file-result"
>
<div className="flex items-center gap-2 mb-2 font-medium">
{readFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span>{readFileResult.success ? 'Success' : 'Failed'}</span>
</div>
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
</div>
</GlassCard>
{/* Write File Tool */}
<GlassCard
className="flex flex-col gap-4 bg-white/5 border-white/10"
data-testid="write-file-tool"
>
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-green-500/10">
<Pencil className="w-5 h-5 text-green-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Write File</h3>
<p className="text-xs text-muted-foreground">Write to filesystem</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="write-file-path">File Path</Label>
<Input
id="write-file-path"
placeholder="/path/to/file.txt"
value={writeFilePath}
onChange={(e) => setWriteFilePath(e.target.value)}
data-testid="write-file-path-input"
className="bg-black/20 border-white/10 focus:border-green-500/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="write-file-content">Content</Label>
<textarea
id="write-file-content"
placeholder="File content..."
value={writeFileContent}
onChange={(e) => setWriteFileContent(e.target.value)}
className="w-full min-h-[100px] px-3 py-2 text-sm rounded-md border border-white/10 bg-black/20 resize-y focus:outline-none focus:ring-1 focus:ring-green-500/50"
data-testid="write-file-content-input"
/>
</div>
<Button
onClick={handleWriteFile}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full bg-green-600/20 hover:bg-green-600/30 text-green-400 border border-green-500/30"
data-testid="write-file-button"
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Writing...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Write
</>
)}
</Button>
{/* Result */}
{writeFileResult && (
<div
className={cn(
'p-3 rounded-lg border text-xs',
writeFileResult.success
? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20 text-red-300'
)}
data-testid="write-file-result"
>
<div className="flex items-center gap-2 mb-2 font-medium">
{writeFileResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span>{writeFileResult.success ? 'Success' : 'Failed'}</span>
</div>
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
</div>
</GlassCard>
{/* Terminal Tool */}
<GlassCard
className="flex flex-col gap-4 bg-white/5 border-white/10"
data-testid="terminal-tool"
>
<div className="flex items-center gap-3 pb-2 border-b border-white/5">
<div className="p-2 rounded-lg bg-purple-500/10">
<Terminal className="w-5 h-5 text-purple-400" />
</div>
<div>
<h3 className="font-semibold text-foreground">Run Terminal</h3>
<p className="text-xs text-muted-foreground">Execute shell commands</p>
</div>
</div>
<div className="space-y-4 flex-1">
<div className="space-y-2">
<Label htmlFor="terminal-command">Command</Label>
<Input
id="terminal-command"
placeholder="ls -la"
value={terminalCommand}
onChange={(e) => setTerminalCommand(e.target.value)}
data-testid="terminal-command-input"
className="bg-black/20 border-white/10 focus:border-purple-500/50 font-mono text-sm"
/>
</div>
<Button
onClick={handleRunCommand}
disabled={isRunningCommand || !terminalCommand.trim()}
className="w-full bg-purple-600/20 hover:bg-purple-600/30 text-purple-400 border border-purple-500/30"
data-testid="run-terminal-button"
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Execute Command
</>
)}
</Button>
{/* Result */}
{terminalResult && (
<div
className={cn(
'p-3 rounded-lg border text-xs',
terminalResult.success
? 'bg-green-500/10 border-green-500/20 text-green-300'
: 'bg-red-500/10 border-red-500/20 text-red-300'
)}
data-testid="terminal-result"
>
<div className="flex items-center gap-2 mb-2 font-medium">
{terminalResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : (
<XCircle className="w-4 h-4 text-red-400" />
)}
<span>{terminalResult.success ? 'Success' : 'Failed'}</span>
</div>
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
$ {terminalCommand}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
</div>
</GlassCard>
</div>
{/* Tool Log Section */}
<GlassCard className="mt-6 bg-white/5 border-white/10" data-testid="tool-log">
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-foreground">Tool Execution Log</h3>
<p className="text-sm text-muted-foreground">
View agent tool requests and responses
</p>
<div className="mt-4 space-y-2 text-sm bg-black/20 p-4 rounded-lg border border-white/5">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>
<li>Write File - Agent writes content to specified path</li>
<li>Run Terminal - Agent executes shell commands</li>
</ul>
</div>
</div>
</GlassCard>
</div>
</div>
</GlassPanel>
</div>
</div>
);

View File

@@ -50,6 +50,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
@@ -491,468 +493,501 @@ export function AgentView() {
: messages;
return (
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
projectPath={currentProject.path}
isCurrentSessionThinking={isProcessing}
onQuickCreateRef={quickCreateSessionRef}
/>
</div>
)}
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="agent-view">
<TopHeader />
{/* Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => setShowSessionManager(!showSessionManager)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
<div className="flex-1 flex overflow-hidden p-4 pt-0 gap-4">
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 flex-shrink-0">
<GlassPanel className="h-full flex flex-col overflow-hidden bg-black/40 backdrop-blur-xl border-white/5">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}
projectPath={currentProject.path}
isCurrentSessionThinking={isProcessing}
onQuickCreateRef={quickCreateSessionRef}
/>
</GlassPanel>
</div>
)}
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Chat Area */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<GlassPanel className="h-full flex flex-col overflow-hidden shadow-2xl relative">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 bg-white/5 backdrop-blur-md z-20">
<div className="flex items-center gap-3">
<Button
variant="outline"
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs font-medium"
disabled={isProcessing}
data-testid="model-selector"
onClick={() => setShowSessionManager(!showSessionManager)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-white/10"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && (
<span className="text-xs text-destructive font-medium">{agentError}</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
)}
</div>
</div>
{/* Messages */}
{!currentSessionId ? (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="no-session-placeholder"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
<Button
onClick={() => setShowSessionManager(true)}
variant="outline"
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
) : (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={handleScroll}
>
{displayMessages.map((message) => (
<div
key={message.id}
className={cn(
'flex gap-4 max-w-4xl',
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
)}
>
{/* Avatar */}
<div
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
)}
>
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
)}
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-cyan-500/20 to-blue-600/20 border border-cyan-500/30 flex items-center justify-center shadow-inner shadow-cyan-500/20">
<Bot className="w-4 h-4 text-cyan-400" />
</div>
<div>
<h1 className="text-sm font-semibold text-foreground flex items-center gap-2">
AI Agent
{currentSessionId && !isConnected && (
<span className="text-[10px] bg-yellow-500/20 text-yellow-500 px-1.5 py-0.5 rounded-full animate-pulse">
Connecting...
</span>
)}
</h1>
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
{currentProject.name}
</p>
</div>
</div>
{/* Message Bubble */}
<div
className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{message.role === 'assistant' ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{/* Status indicators & actions */}
<div className="flex items-center gap-2">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs font-medium bg-black/20 border-white/10 hover:bg-white/5 hover:text-cyan-400"
disabled={isProcessing}
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 bg-zinc-950/95 border-white/10 backdrop-blur-xl"
>
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
'cursor-pointer focus:bg-white/10',
selectedModel === model.id && 'bg-cyan-500/10 text-cyan-400'
)}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col gap-0.5">
<span className="font-medium text-xs">{model.label}</span>
<span className="text-[10px] text-muted-foreground">
{model.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Display attached images for user messages */}
{message.role === 'user' && message.images && message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? 's' : ''} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith('data:')
? image.data
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={image.filename || `Attached image ${index + 1}`}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
{currentTool && (
<div className="flex items-center gap-1.5 text-[10px] text-cyan-400 bg-cyan-950/40 px-2.5 py-1 rounded-full border border-cyan-500/30 shadow-sm shadow-cyan-900/20 animate-in fade-in zoom-in-95 duration-300">
<Wrench className="w-3 h-3" />
<span className="font-medium">{currentTool}</span>
</div>
)}
{agentError && (
<span className="text-xs text-red-400 font-medium bg-red-950/30 px-2 py-0.5 rounded border border-red-500/30">
{agentError}
</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
variant="ghost"
size="icon"
onClick={handleClearChat}
disabled={isProcessing}
className="h-7 w-7 text-muted-foreground hover:text-red-400 hover:bg-red-500/10"
title="Clear Chat"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
<p
{/* Messages */}
{!currentSessionId ? (
<div
className="flex-1 flex items-center justify-center bg-background"
data-testid="no-session-placeholder"
>
<div className="text-center max-w-md">
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">
No Session Selected
</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
<Button
onClick={() => setShowSessionManager(true)}
variant="outline"
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
) : (
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6 scroll-smooth"
data-testid="message-list"
onScroll={handleScroll}
>
{displayMessages.map((message) => (
<div
key={message.id}
className={cn(
'text-[11px] mt-2 font-medium',
message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
'flex gap-4 max-w-4xl',
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
))}
{/* Thinking Indicator */}
{isProcessing && (
<div className="flex gap-4 max-w-4xl">
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4 text-primary" />
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Input Area */}
{currentSessionId && (
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={handleImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={isProcessing || !isConnected}
/>
)}
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
</p>
<button
onClick={() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
{/* Avatar */}
<div
key={image.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
)}
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground">
{formatFileSize(image.size)}
</p>
)}
</div>
{/* Remove button */}
{image.id && (
<button
onClick={() => removeImage(image.id!)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
{/* Message Bubble */}
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
{message.role === 'assistant' ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeTextFile(file.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
)}
{/* Display attached images for user messages */}
{message.role === 'user' && message.images && message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? 's' : ''} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith('data:')
? image.data
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={image.filename || `Attached image ${index + 1}`}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
<p
className={cn(
'text-[11px] mt-2 font-medium',
message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
<X className="h-3 w-3" />
</button>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
))}
</div>
</div>
))}
{/* Thinking Indicator */}
{isProcessing && (
<div className="flex gap-4 max-w-4xl">
<div className="w-9 h-9 rounded-xl bg-primary/10 ring-1 ring-primary/20 flex items-center justify-center shrink-0 shadow-sm">
<Bot className="w-4 h-4 text-primary" />
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex-1 relative">
<Input
ref={inputRef}
placeholder={
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
onPaste={handlePaste}
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
{/* Input Area */}
{currentSessionId && (
<div className="border-t border-border p-4 bg-card/50 backdrop-blur-sm">
{/* Image Drop Zone (when visible) */}
{showImageDropZone && (
<ImageDropZone
onImagesSelected={handleImagesSelected}
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={isProcessing || !isConnected}
/>
)}
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
!showImageDropZone && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
</p>
<button
onClick={() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded-md overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground">
{formatFileSize(image.size)}
</p>
)}
</div>
{/* Remove button */}
{image.id && (
<button
onClick={() => removeImage(image.id!)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeTextFile(file.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
/>
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex-1 relative">
<Input
ref={inputRef}
placeholder={
isDragOver
? 'Drop your files here...'
: 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
onPaste={handlePaste}
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
</div>
)}
{isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
<Paperclip className="w-3 h-3" />
Drop here
</div>
)}
</div>
)}
{isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-xs text-primary font-medium">
<Paperclip className="w-3 h-3" />
Drop here
</div>
)}
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Send / Stop Button */}
{isProcessing ? (
<Button
onClick={stopExecution}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
Enter
</kbd>{' '}
to send
</p>
</div>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Send / Stop Button */}
{isProcessing ? (
<Button
onClick={stopExecution}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
) : (
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
!isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
>
<Send className="w-4 h-4" />
</Button>
)}
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send
</p>
</div>
)}
)}
</GlassPanel>
</div>
</div>
</div>
);

View File

@@ -17,7 +17,8 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from './board-view/board-header';
import { TopHeader } from '@/components/layout/top-header';
// BoardHeader removed
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
@@ -263,9 +264,9 @@ export function BoardView() {
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
(counts: Record<string, number>, feature) => {
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
const branch = (feature.branchName as string) ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
@@ -918,27 +919,8 @@ export function BoardView() {
data-testid="board-view"
>
{/* Header */}
<BoardHeader
projectName={currentProject.name}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
} else {
autoMode.stop();
}
}}
onAddFeature={() => setShowAddDialog(true)}
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: 'Add new feature',
}}
isMounted={isMounted}
/>
{/* Top Header */}
<TopHeader />
{/* Worktree Panel */}
<WorktreePanel

View File

@@ -31,6 +31,7 @@ interface CardHeaderProps {
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
hideActions?: boolean;
}
export function CardHeaderSection({
@@ -40,6 +41,7 @@ export function CardHeaderSection({
onEdit,
onDelete,
onViewOutput,
hideActions,
}: CardHeaderProps) {
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@@ -2,7 +2,7 @@ import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { GlassCard } from '@/components/ui/glass-card';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
@@ -56,10 +56,6 @@ export const KanbanCard = memo(function KanbanCard({
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const { useWorktrees } = useAppStore();
@@ -68,6 +64,7 @@ export const KanbanCard = memo(function KanbanCard({
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
@@ -79,36 +76,15 @@ export const KanbanCard = memo(function KanbanCard({
opacity: isDragging ? 0.5 : undefined,
};
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = '0px';
(borderStyle as Record<string, string>).borderColor = 'transparent';
} else if (cardBorderOpacity !== 100) {
(borderStyle as Record<string, string>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
const cardElement = (
<Card
<GlassCard
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
variant={isCurrentAutoTask ? 'active-blue' : 'default'}
style={style}
className={cn(
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
'shadow-sm hover:shadow-md hover:shadow-black/10',
// Subtle lift on hover
'hover:-translate-y-0.5',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity === 100 && 'border-border/50',
!isCurrentAutoTask && cardBorderEnabled && cardBorderOpacity !== 100 && 'border',
!isDragging && 'bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
'group relative min-h-[140px] flex flex-col',
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
feature.error && 'border-brand-red border-2 shadow-glow-red',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
@@ -116,51 +92,79 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(isDraggable ? listeners : {})}
>
{/* Background overlay with opacity */}
{!isDragging && (
<div
className={cn(
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
)}
{/* Status Badges Row */}
<CardBadges feature={feature} />
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
{/* Top Row: Empty space + Delete (on hover) */}
<div className="flex justify-between items-start mb-2 h-5">
<div className="flex flex-wrap gap-1">
<CardBadges feature={feature} />
</div>
{/* Delete/Actions on hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-slate-600 hover:text-brand-red transition"
>
<i data-lucide="trash" className="w-3.5 h-3.5"></i>
{/* Fallback to SVG if i tag fails */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-trash w-3.5 h-3.5"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Priority and Manual Verification badges */}
<PriorityBadges feature={feature} />
{/* Description */}
<div className="mb-4">
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
hideActions={true} // We handle actions via hover/bottom bar
/>
</div>
{/* Card Header */}
<CardHeaderSection
feature={feature}
isDraggable={isDraggable}
isCurrentAutoTask={!!isCurrentAutoTask}
onEdit={onEdit}
onDelete={onDelete}
onViewOutput={onViewOutput}
/>
{/* Middle Grid: Priority, etc */}
<div className="flex items-center justify-between mb-4">
<PriorityBadges feature={feature} />
<div className="flex items-center gap-2">
{/* Category / Model info */}
<span className="text-[10px] text-brand-cyan font-mono">
{feature.model || 'Opus 4.2'}
</span>
</div>
</div>
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
{/* Content & Agent Info */}
<div className="mb-2">
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
{/* Agent Info Panel */}
<AgentInfoPanel
feature={feature}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
/>
</div>
{/* Actions */}
{/* Buttons Grid */}
<div className="mt-auto pt-2">
<CardActions
feature={feature}
isCurrentAutoTask={!!isCurrentAutoTask}
@@ -178,14 +182,9 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan}
/>
</CardContent>
</Card>
</div>
</GlassCard>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
});

View File

@@ -2,31 +2,30 @@ import { memo } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import type { ReactNode } from 'react';
import { GlassPanel } from '@/components/ui/glass-panel';
interface KanbanColumnProps {
id: string;
title: string;
colorClass: string;
accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
count: number;
children: ReactNode;
headerAction?: ReactNode;
width?: number;
// Legacy props ignored or used for compatibility
colorClass?: string;
opacity?: number;
showBorder?: boolean;
hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
}
export const KanbanColumn = memo(function KanbanColumn({
id,
title,
colorClass,
accent,
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
width,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -35,60 +34,63 @@ export const KanbanColumn = memo(function KanbanColumn({
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
return (
<div
<GlassPanel
ref={setNodeRef}
accent={accent}
className={cn(
'relative flex flex-col h-full rounded-xl',
// Only transition ring/shadow for drag-over effect, not width
'transition-[box-shadow,ring] duration-200',
!width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
'relative flex flex-col h-full min-w-[300px] transition-[box-shadow,ring] duration-200',
!width && 'w-72',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
)}
style={widthStyle}
data-testid={`kanban-column-${id}`}
>
{/* Background layer with opacity */}
<div
className={cn(
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
isOver ? 'bg-accent/80' : 'bg-card/80'
)}
style={{ opacity: opacity / 100 }}
/>
{/* Subtle Glow Top (Only for Blue/Orange/Green to match design, could make generic) */}
{(accent === 'blue' || accent === 'orange' || accent === 'green') && (
<div
className={cn(
'absolute top-0 left-0 w-full h-32 bg-gradient-to-b pointer-events-none rounded-t-2xl',
accent === 'blue' && 'from-brand-blue/10 to-transparent',
accent === 'orange' && 'from-brand-orange/10 to-transparent',
accent === 'green' && 'from-brand-green/10 to-transparent'
)}
></div>
)}
{/* Column Header */}
<div
className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && 'border-b border-border/40'
)}
>
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
<div className="flex items-center justify-between p-4 border-b border-white/5 relative z-10">
<div className="flex items-center gap-2">
{/* Status Dot */}
<div
className={cn(
'w-2 h-2 rounded-full',
accent === 'cyan' && 'bg-slate-400', // Backlog is neutral in design
accent === 'blue' && 'bg-brand-orange shadow-glow-orange', // In Progress has orange dot in design
accent === 'orange' && 'bg-brand-orange shadow-glow-orange',
accent === 'green' && 'bg-brand-green shadow-glow-green'
)}
></div>
<span className="font-bold text-slate-200 text-sm">{title}</span>
{/* Action container (like "Make") */}
{headerAction}
</div>
{/* Count Badge */}
<span className="text-[10px] bg-dark-700 text-slate-400 px-2 py-0.5 rounded border border-white/5 font-medium">
{count}
</span>
</div>
{/* Column Content */}
<div
className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5',
hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]',
// Smooth scrolling
'scroll-smooth'
)}
>
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
{children}
</div>
{/* Drop zone indicator when dragging over */}
{isOver && (
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
<div className="absolute inset-0 rounded-2xl bg-white/5 pointer-events-none z-20 border-2 border-dashed border-white/10" />
)}
</div>
</GlassPanel>
);
});

View File

@@ -2,21 +2,29 @@ import { Feature } from '@/store/app-store';
export type ColumnId = Feature['status'];
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
export const COLUMNS: {
id: ColumnId;
title: string;
colorClass: string;
accent: 'cyan' | 'blue' | 'orange' | 'green';
}[] = [
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]', accent: 'cyan' },
{
id: 'in_progress',
title: 'In Progress',
colorClass: 'bg-[var(--status-in-progress)]',
accent: 'blue',
},
{
id: 'waiting_approval',
title: 'Waiting Approval',
colorClass: 'bg-[var(--status-waiting)]',
accent: 'orange',
},
{
id: 'verified',
title: 'Verified',
colorClass: 'bg-[var(--status-success)]',
accent: 'green',
},
];

View File

@@ -109,7 +109,9 @@ export function useBoardColumnFeatures({
// This ensures features appear in dependency order (dependencies before dependents)
// Within the same dependency level, features are sorted by priority
if (map.backlog.length > 0) {
const { orderedFeatures } = resolveDependencies(map.backlog);
const { orderedFeatures } = resolveDependencies(map.backlog as any) as {
orderedFeatures: Feature[];
};
// Get all features to check blocking dependencies against
const allFeatures = features;
@@ -122,7 +124,7 @@ export function useBoardColumnFeatures({
const blocked: Feature[] = [];
for (const f of orderedFeatures) {
if (getBlockingDependencies(f, allFeatures).length > 0) {
if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
blocked.push(f);
} else {
unblocked.push(f);

View File

@@ -101,6 +101,7 @@ export function KanbanBoard({
key={column.id}
id={column.id}
title={column.title}
accent={column.accent}
colorClass={column.colorClass}
count={columnFeatures.length}
width={columnWidth}

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -87,7 +88,7 @@ export function WorktreeTab({
onStopDevServer,
onOpenDevServerUrl,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
let prBadge: React.ReactNode | null = null;
if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
const prStateClasses = (() => {

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-react';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -103,102 +105,121 @@ export function RunningAgentsView() {
}
return (
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-brand-500/10">
<Activity className="h-6 w-6 text-brand-500" />
</div>
<div>
<h1 className="text-2xl font-bold">Running Agents</h1>
<p className="text-sm text-muted-foreground">
{runningAgents.length === 0
? 'No agents currently running'
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
Refresh
</Button>
</div>
<div className="flex-1 flex flex-col overflow-hidden bg-background">
<TopHeader />
{/* Content */}
{runningAgents.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Bot className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
<p className="text-muted-foreground max-w-md">
Agents will appear here when they are actively working on features. Start an agent from
the Kanban board by dragging a feature to "In Progress".
</p>
</div>
) : (
<div className="flex-1 overflow-auto">
<div className="space-y-3">
{runningAgents.map((agent) => (
<div
key={`${agent.projectPath}-${agent.featureId}`}
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-4 min-w-0">
{/* Status indicator */}
<div className="relative">
<Bot className="h-8 w-8 text-brand-500" />
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
</div>
{/* Agent info */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{agent.featureId}</span>
{agent.isAutoMode && (
<span className="px-2 py-0.5 text-[10px] font-medium rounded-full bg-brand-500/10 text-brand-500 border border-brand-500/30">
AUTO
</span>
)}
</div>
<button
onClick={() => handleNavigateToProject(agent)}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Folder className="h-3.5 w-3.5" />
<span className="truncate">{agent.projectName}</span>
</button>
</div>
<div className="flex-1 flex flex-col overflow-hidden p-6 pt-0">
<GlassPanel className="flex-1 flex flex-col overflow-hidden relative shadow-2xl bg-black/40 backdrop-blur-xl border-white/5">
<div className="flex-1 flex flex-col overflow-hidden p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500/20 to-blue-600/20 border border-brand-500/30 flex items-center justify-center shadow-inner shadow-brand-500/20">
<Activity className="h-5 w-5 text-brand-400" />
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigateToProject(agent)}
className="text-muted-foreground hover:text-foreground"
>
View Project
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleStopAgent(agent.featureId)}
>
<Square className="h-3.5 w-3.5 mr-1.5" />
Stop
</Button>
<div>
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
Running Agents
</h1>
<p className="text-xs text-muted-foreground">
{runningAgents.length === 0
? 'No agents currently running'
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
</p>
</div>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="bg-white/5 border-white/10 hover:bg-white/10 text-xs gap-2"
>
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
Refresh
</Button>
</div>
{/* Content */}
{runningAgents.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-6">
<Bot className="h-8 w-8 text-muted-foreground/50" />
</div>
<h2 className="text-lg font-medium mb-2 text-foreground">No Running Agents</h2>
<p className="text-muted-foreground max-w-sm text-sm">
Agents will appear here when they are actively working on features. Start an agent
from the Kanban board.
</p>
</div>
) : (
<div className="flex-1 overflow-auto pr-2">
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
{runningAgents.map((agent) => (
<div
key={`${agent.projectPath}-${agent.featureId}`}
className="group relative flex flex-col p-4 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20 transition-all duration-300"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{/* Status indicator */}
<div className="relative">
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Bot className="h-5 w-5 text-brand-400" />
</div>
<span className="absolute -top-1 -right-1 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-semibold text-sm truncate text-foreground">
{agent.featureId}
</span>
{agent.isAutoMode && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-brand-500/20 text-brand-400 border border-brand-500/20">
AUTO
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Folder className="h-3 w-3" />
<span className="truncate max-w-[120px]">{agent.projectName}</span>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="mt-auto pt-3 flex items-center gap-2 border-t border-white/5">
<Button
variant="ghost"
size="sm"
onClick={() => handleNavigateToProject(agent)}
className="flex-1 h-8 text-xs hover:bg-white/10"
>
View Project
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleStopAgent(agent.featureId)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-400 hover:bg-red-500/10"
title="Stop Agent"
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</GlassPanel>
</div>
</div>
);
}

View File

@@ -110,7 +110,7 @@ export function SettingsView() {
<AppearanceSection
effectiveTheme={effectiveTheme}
currentProject={settingsProject}
onThemeChange={handleSetTheme}
onThemeChange={handleSetTheme as (theme: Theme) => void}
/>
);
case 'terminal':

View File

@@ -46,6 +46,8 @@ import {
defaultDropAnimationSideEffects,
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { TopHeader } from '@/components/layout/top-header';
import { GlassPanel } from '@/components/ui/glass-panel';
interface TerminalStatus {
enabled: boolean;
@@ -1414,252 +1416,210 @@ export function TerminalView() {
// Terminal view with tabs
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-card border-b border-border px-2">
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
{terminalState.tabs.map((tab) => (
<TerminalTabButton
key={tab.id}
tab={tab}
isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => killTerminalTab(tab.id)}
onRename={(newName) => renameTerminalTab(tab.id, newName)}
isDropTarget={activeDragId !== null || activeDragTabId !== null}
isDraggingTab={activeDragTabId !== null}
/>
))}
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="terminal-view">
<TopHeader />
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
{/* Main Content Area - Glass Panel */}
<div className="flex-1 min-h-0 p-4 pt-0">
<GlassPanel className="h-full flex flex-col overflow-hidden shadow-2xl">
{/* Header / Tabs */}
<div className="flex items-center gap-2 p-2 border-b border-white/10 bg-white/5 backdrop-blur-md select-none">
{/* Terminal Icon */}
<div className="flex items-center justify-center w-8 h-8 rounded-md bg-white/5 border border-white/5">
<TerminalIcon className="w-4 h-4 text-cyan-400" />
</div>
{/* New tab button */}
<button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Tabs List */}
<div className="flex flex-1 items-center gap-1 overflow-x-auto no-scrollbar mask-gradient-right">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{terminalState.tabs.map((tab) => (
<TerminalTabButton
key={tab.id}
tab={tab}
isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => removeTerminalTab(tab.id)}
onRename={(name) => renameTerminalTab(tab.id, name)}
isDropTarget={activeDragId !== null}
isDraggingTab={activeDragTabId !== null}
/>
))}
{/* Toolbar buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-border">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
</Button>
{/* Global Terminal Settings */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
title="Terminal Settings"
{/* Add New Tab Button */}
<button
onClick={() => addTerminalTab()}
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-white/10 text-muted-foreground hover:text-foreground transition-colors ml-1"
title="New Tab"
>
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium text-sm">Terminal Settings</h4>
<p className="text-xs text-muted-foreground">
Configure global terminal appearance
</p>
</div>
<Plus className="w-4 h-4" />
</button>
{/* Default Font Size */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Default Font Size</Label>
<span className="text-sm text-muted-foreground">
{terminalState.defaultFontSize}px
</span>
{activeDragId && <NewTabDropZone isDropTarget={true} />}
<DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
{activeDragTabId ? (
<div className="px-3 py-1.5 text-sm bg-background border-2 border-brand-500 rounded-md shadow-xl opacity-90 cursor-grabbing">
<div className="flex items-center gap-2">
<TerminalIcon className="h-3 w-3" />
<span>
{terminalState.tabs.find((t) => t.id === activeDragTabId)?.name || 'Tab'}
</span>
</div>
</div>
<Slider
value={[terminalState.defaultFontSize]}
min={8}
max={24}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
onValueCommit={() => {
toast.info('Font size changed', {
description: 'New terminals will use this size',
});
}}
/>
</div>
{/* Font Family */}
<div className="space-y-2">
<Label className="text-sm">Font Family</Label>
<select
value={terminalState.fontFamily}
onChange={(e) => {
setTerminalFontFamily(e.target.value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className={cn(
'w-full px-2 py-1.5 rounded-md text-sm',
'bg-accent/50 border border-border',
'text-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
>
{TERMINAL_FONT_OPTIONS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</div>
{/* Line Height */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">Line Height</Label>
<span className="text-sm text-muted-foreground">
{terminalState.lineHeight.toFixed(1)}
</span>
) : activeDragId ? (
<div className="p-4 bg-background border-2 border-brand-500 rounded-lg shadow-xl opacity-90 w-64 h-48 flex items-center justify-center cursor-grabbing">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<TerminalIcon className="h-8 w-8" />
<span>Moving Terminal...</span>
</div>
</div>
<Slider
value={[terminalState.lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => setTerminalLineHeight(value)}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
/>
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
{/* Default Run Script */}
<div className="space-y-2">
<Label className="text-sm">Default Run Script</Label>
<Input
value={terminalState.defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, npm run dev"
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground">
Command to run when opening new terminals
</p>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Right Actions Toolbar */}
<div className="flex items-center gap-1 pl-2 border-l border-white/10 ml-2">
{/* Layout Controls */}
<div className="flex items-center border border-border/40 rounded-md overflow-hidden mr-2">
<button
onClick={() => {
if (terminalState.activeSessionId) {
createTerminal('horizontal', terminalState.activeSessionId);
}
}}
className="p-1.5 hover:bg-white/10 text-muted-foreground hover:text-foreground border-r border-border/40"
title="Split Horizontal"
>
<SplitSquareHorizontal className="w-4 h-4" />
</button>
<button
onClick={() => {
if (terminalState.activeSessionId) {
createTerminal('vertical', terminalState.activeSessionId);
}
}}
className="p-1.5 hover:bg-white/10 text-muted-foreground hover:text-foreground"
title="Split Vertical"
>
<SplitSquareVertical className="w-4 h-4" />
</button>
</div>
{/* Active tab content */}
<div className="flex-1 overflow-hidden bg-background">
{terminalState.maximizedSessionId ? (
// When a terminal is maximized, render only that terminal
<TerminalErrorBoundary
key={`boundary-maximized-${terminalState.maximizedSessionId}`}
sessionId={terminalState.maximizedSessionId}
onRestart={() => {
const sessionId = terminalState.maximizedSessionId!;
toggleTerminalMaximized(sessionId);
killTerminal(sessionId);
createTerminal();
}}
>
<TerminalPanel
key={`maximized-${terminalState.maximizedSessionId}`}
sessionId={terminalState.maximizedSessionId}
authToken={terminalState.authToken}
isActive={true}
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
onSplitHorizontal={() =>
createTerminal('horizontal', terminalState.maximizedSessionId!)
}
onSplitVertical={() =>
createTerminal('vertical', terminalState.maximizedSessionId!)
}
onNewTab={createTerminalInNewTab}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;
console.log(
`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`
);
killTerminal(sessionId);
{/* Lock/Unlock Toggle */}
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 gap-2 border border-transparent',
!terminalState.isUnlocked &&
'text-amber-500 bg-amber-500/10 border-amber-500/30 hover:bg-amber-500/20'
)}
onClick={() => {
if (terminalState.isUnlocked) setTerminalUnlocked(false);
else {
// Trigger lock logic (input password)
// Ideally show dialog, but for now just toggle for UI demo
}
}}
isDragging={false}
isDropTarget={false}
fontSize={findTerminalFontSize(terminalState.maximizedSessionId)}
onFontSizeChange={(size) =>
setTerminalPanelFontSize(terminalState.maximizedSessionId!, size)
}
isMaximized={true}
onToggleMaximize={() => toggleTerminalMaximized(terminalState.maximizedSessionId!)}
/>
</TerminalErrorBoundary>
) : activeTab?.layout ? (
renderPanelContent(activeTab.layout)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
>
{terminalState.isUnlocked ? (
<Unlock className="w-4 h-4" />
) : (
<Lock className="w-4 h-4" />
)}
{terminalState.isUnlocked ? 'Unlocked' : 'Locked'}
</Button>
</div>
)}
</div>
</div>
{/* Drag overlay */}
<DragOverlay
dropAnimation={{
sideEffects: defaultDropAnimationSideEffects({
styles: { active: { opacity: '0.5' } },
}),
}}
zIndex={1000}
>
{activeDragId ? (
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
{/* Terminal Content Area */}
<div className="flex-1 relative bg-black/40 backdrop-blur-sm">
{loading ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
<p>Connecting to terminal server...</p>
</div>
) : error ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 text-destructive">
<AlertCircle className="w-10 h-10" />
<p className="text-lg font-medium">{error}</p>
<Button variant="outline" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Connection
</Button>
</div>
) : !terminalState.isUnlocked ? (
/* Password Prompt */
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10">
<div className="w-full max-w-sm p-6 space-y-4 bg-card border border-border rounded-lg shadow-xl">
<div className="flex flex-col items-center gap-2 text-center">
<Lock className="w-10 h-10 text-primary mb-2" />
<h3 className="text-lg font-semibold">Terminal Locked</h3>
<p className="text-sm text-muted-foreground">
Enter your password to access terminal sessions.
</p>
</div>
<form onSubmit={handleAuth} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password..."
autoFocus
/>
</div>
{authError && <p className="text-sm text-destructive">{authError}</p>}
<Button type="submit" className="w-full" disabled={authLoading}>
{authLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Unlock Terminal
</Button>
</form>
</div>
</div>
) : activeTab ? (
activeTab.layout ? (
renderPanelContent(activeTab.layout)
) : (
/* Empty State */
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground opacity-50">
<TerminalIcon className="w-16 h-16 mb-4 opacity-20" />
<p>No active terminals</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
createTerminal();
}}
>
<Plus className="w-4 h-4 mr-2" />
New Terminal
</Button>
</div>
)
) : (
/* No Tabs State */
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground">
<p>No tabs open.</p>
<Button variant="outline" className="mt-4" onClick={() => addTerminalTab()}>
<Plus className="w-4 h-4 mr-2" />
Create Tab
</Button>
</div>
)}
</div>
</GlassPanel>
</div>
</div>
);
}

View File

@@ -543,6 +543,7 @@ export function TerminalPanel({
allowProposedApi: true,
screenReaderMode: screenReaderEnabled,
scrollback: terminalScrollback,
allowTransparency: true,
});
// Create fit addon

View File

@@ -61,7 +61,7 @@ export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
// Dark theme (default)
const darkTheme: TerminalTheme = {
background: '#0a0a0a',
background: 'transparent', // Transparent for glassmorphism
foreground: '#d4d4d4',
cursor: '#d4d4d4',
cursorAccent: '#0a0a0a',

View File

@@ -1,6 +1,6 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
import { Sidebar } from '@/components/layout/sidebar';
import { AppLayout } from '@/components/layout/app-layout';
import {
FileBrowserProvider,
useFileBrowser,
@@ -159,10 +159,9 @@ function RootLayoutContent() {
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<AppLayout>
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
className="h-full flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
>
<Outlet />
@@ -170,12 +169,12 @@ function RootLayoutContent() {
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 z-50 ${
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
}`}
/>
<Toaster richColors position="bottom-right" />
</main>
</AppLayout>
);
}

View File

@@ -7,10 +7,21 @@ import type {
AgentModel,
PlanningMode,
AIProfile,
ThinkingLevel,
ModelProvider,
FeatureTextFilePath,
} from '@automaker/types';
// Re-export ThemeMode for convenience
export type { ThemeMode };
export type {
AgentModel,
ThinkingLevel,
ModelProvider,
AIProfile,
PlanningMode,
FeatureTextFilePath,
};
// ThemeMode is defined below, no need to re-export here
export type ViewMode =
| 'welcome'
@@ -262,13 +273,24 @@ export interface Feature extends Omit<
titleGenerating?: boolean;
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
steps?: string[] | undefined; // Optional in UI
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
prUrl?: string; // UI-specific: Pull request URL
planSpec?: PlanSpec; // Spec/Plan data
planningMode?: PlanningMode; // Planning mode used
priority?: number; // Priority (1 is highest)
branchName?: string; // Branch associated with feature
model?: AgentModel;
thinkingLevel?: ThinkingLevel;
skipTests?: boolean;
requirePlanApproval?: boolean;
summary?: string;
dependencies?: string[];
startedAt?: string;
}
// Parsed task from spec (for spec and full planning modes)
@@ -881,6 +903,9 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
];
const initialState: AppState = {
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
projects: [],
currentProject: null,
trashedProjects: [],

File diff suppressed because it is too large Load Diff

1065
index (28).html Normal file

File diff suppressed because it is too large Load Diff

88
package-lock.json generated
View File

@@ -422,7 +422,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1006,7 +1005,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1049,7 +1047,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1870,6 +1867,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1891,6 +1889,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1907,6 +1906,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1921,6 +1921,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -2676,6 +2677,7 @@
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2800,6 +2802,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -2816,6 +2819,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -2832,6 +2836,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -2940,6 +2945,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -2962,6 +2968,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -2984,6 +2991,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3069,6 +3077,7 @@
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
@@ -3091,6 +3100,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3110,6 +3120,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3448,7 +3459,8 @@
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10",
@@ -3462,6 +3474,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3478,6 +3491,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3494,6 +3508,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3510,6 +3525,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3526,6 +3542,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3542,6 +3559,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3558,6 +3576,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3574,6 +3593,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3664,7 +3684,6 @@
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.57.0"
},
@@ -5075,6 +5094,7 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -5408,7 +5428,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0",
@@ -5960,7 +5979,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -5971,7 +5989,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6077,7 +6094,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -6571,8 +6587,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
@@ -6670,7 +6685,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6731,7 +6745,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7291,7 +7304,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7823,7 +7835,8 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/cliui": {
"version": "8.0.1",
@@ -8109,7 +8122,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8206,7 +8220,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8508,7 +8521,6 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.0.12",
"builder-util": "26.0.11",
@@ -8835,6 +8847,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8855,6 +8868,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9105,7 +9119,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -11011,6 +11024,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11072,6 +11086,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -13490,6 +13505,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -13506,6 +13522,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13523,6 +13540,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13711,7 +13729,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13721,7 +13738,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14071,6 +14087,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -14259,7 +14276,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -14308,6 +14324,7 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -14358,6 +14375,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14380,6 +14398,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14402,6 +14421,7 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14418,6 +14438,7 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14434,6 +14455,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14450,6 +14472,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14466,6 +14489,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14482,6 +14506,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14498,6 +14523,7 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14514,6 +14540,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14536,6 +14563,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14558,6 +14586,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14580,6 +14609,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14602,6 +14632,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14624,6 +14655,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15092,6 +15124,7 @@
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"client-only": "0.0.1"
},
@@ -15261,6 +15294,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15324,6 +15358,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -15421,7 +15456,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15626,7 +15660,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15998,7 +16031,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16088,8 +16120,7 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
@@ -16115,7 +16146,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16158,7 +16188,6 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -16416,7 +16445,6 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},