mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 22:32:04 +00:00
Compare commits
1 Commits
new-ui-bas
...
weird-side
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eb92a0402 |
@@ -61,7 +61,7 @@ Traditional development tools help you write code. Automaker helps you **orchest
|
||||
|
||||
### Powered by Claude Code
|
||||
|
||||
Automaker leverages the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
@@ -106,7 +106,7 @@ https://discord.gg/jjem7aEDKU
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
|
||||
### Quick Start
|
||||
|
||||
|
||||
@@ -111,19 +111,6 @@ export async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a git repository has at least one commit (i.e., HEAD exists)
|
||||
* Returns false for freshly initialized repos with no commits
|
||||
*/
|
||||
export async function hasCommits(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --verify HEAD', { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||
* These are expected in test environments with mock paths
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import { Router } from 'express';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
||||
import { createInfoHandler } from './routes/info.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
@@ -39,42 +38,17 @@ export function createWorktreeRoutes(): Router {
|
||||
router.post('/list', createListHandler());
|
||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||
router.post(
|
||||
'/merge',
|
||||
validatePathParams('projectPath'),
|
||||
requireValidProject,
|
||||
createMergeHandler()
|
||||
);
|
||||
router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||
router.post('/create-pr', createCreatePRHandler());
|
||||
router.post('/pr-info', createPRInfoHandler());
|
||||
router.post(
|
||||
'/commit',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createCommitHandler()
|
||||
);
|
||||
router.post(
|
||||
'/push',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createPushHandler()
|
||||
);
|
||||
router.post(
|
||||
'/pull',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createPullHandler()
|
||||
);
|
||||
router.post('/checkout-branch', requireValidWorktree, createCheckoutBranchHandler());
|
||||
router.post(
|
||||
'/list-branches',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createListBranchesHandler()
|
||||
);
|
||||
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
|
||||
router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
|
||||
router.post('/push', validatePathParams('worktreePath'), createPushHandler());
|
||||
router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
|
||||
router.post('/checkout-branch', createCheckoutBranchHandler());
|
||||
router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
|
||||
router.post('/switch-branch', createSwitchBranchHandler());
|
||||
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Middleware for worktree route validation
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { isGitRepo, hasCommits } from './common.js';
|
||||
|
||||
interface ValidationOptions {
|
||||
/** Check if the path is a git repository (default: true) */
|
||||
requireGitRepo?: boolean;
|
||||
/** Check if the repository has at least one commit (default: true) */
|
||||
requireCommits?: boolean;
|
||||
/** The name of the request body field containing the path (default: 'worktreePath') */
|
||||
pathField?: 'worktreePath' | 'projectPath';
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware factory to validate that a path is a valid git repository with commits.
|
||||
* This reduces code duplication across route handlers.
|
||||
*
|
||||
* @param options - Validation options
|
||||
* @returns Express middleware function
|
||||
*/
|
||||
export function requireValidGitRepo(options: ValidationOptions = {}) {
|
||||
const { requireGitRepo = true, requireCommits = true, pathField = 'worktreePath' } = options;
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const repoPath = req.body[pathField] as string | undefined;
|
||||
|
||||
if (!repoPath) {
|
||||
// Let the route handler deal with missing path validation
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireGitRepo && !(await isGitRepo(repoPath))) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Not a git repository',
|
||||
code: 'NOT_GIT_REPO',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requireCommits && !(await hasCommits(repoPath))) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Repository has no commits yet',
|
||||
code: 'NO_COMMITS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate git repo for worktreePath field
|
||||
*/
|
||||
export const requireValidWorktree = requireValidGitRepo({ pathField: 'worktreePath' });
|
||||
|
||||
/**
|
||||
* Middleware to validate git repo for projectPath field
|
||||
*/
|
||||
export const requireValidProject = requireValidGitRepo({ pathField: 'projectPath' });
|
||||
|
||||
/**
|
||||
* Middleware to validate git repo without requiring commits (for commit route)
|
||||
*/
|
||||
export const requireGitRepoOnly = requireValidGitRepo({
|
||||
pathField: 'worktreePath',
|
||||
requireCommits: false,
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /commit endpoint - Commit changes in a worktree
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo) is handled by
|
||||
* the requireGitRepoOnly middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /list-branches endpoint - List all local branches
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidProject middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* POST /push endpoint - Push a worktree branch to remote
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
* Simple branch switching.
|
||||
* If there are uncommitted changes, the switch will fail and
|
||||
* the user should commit first.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"framer-motion": "^12.23.26",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "19.2.3",
|
||||
@@ -76,9 +77,9 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useSettingsMigration } from './hooks/use-settings-migration';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
|
||||
import { Shell } from './components/layout/shell';
|
||||
|
||||
export default function App() {
|
||||
const [showSplash, setShowSplash] = useState(() => {
|
||||
// Only show splash once per session
|
||||
@@ -27,9 +29,9 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Shell>
|
||||
<RouterProvider router={router} />
|
||||
{showSplash && <SplashScreen onComplete={handleSplashComplete} />}
|
||||
</>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,21 +245,18 @@ export function NewProjectModal({
|
||||
{/* Workspace Directory Display */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm',
|
||||
'flex items-center gap-2 text-sm',
|
||||
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Folder className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<Folder className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{isLoadingWorkspace ? (
|
||||
'Loading workspace...'
|
||||
) : workspaceDir ? (
|
||||
<>
|
||||
<span>Will be created at:</span>
|
||||
<code
|
||||
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
|
||||
title={projectPath || workspaceDir}
|
||||
>
|
||||
Will be created at:{' '}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
||||
{projectPath || workspaceDir}
|
||||
</code>
|
||||
</>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
118
apps/ui/src/components/layout/floating-dock.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Bot,
|
||||
FileText,
|
||||
Database,
|
||||
Terminal,
|
||||
Settings,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
export function FloatingDock() {
|
||||
const mouseX = useMotionValue(Infinity);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
const navItems = [
|
||||
{ id: 'board', icon: LayoutDashboard, label: 'Board', path: '/board' },
|
||||
{ id: 'agent', icon: Bot, label: 'Agent', path: '/agent' },
|
||||
{ id: 'spec', icon: FileText, label: 'Spec', path: '/spec' },
|
||||
{ id: 'context', icon: Database, label: 'Context', path: '/context' },
|
||||
{ id: 'profiles', icon: Users, label: 'Profiles', path: '/profiles' },
|
||||
{ id: 'terminal', icon: Terminal, label: 'Terminal', path: '/terminal' },
|
||||
{ id: 'settings', icon: Settings, label: 'Settings', path: '/settings' },
|
||||
];
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
|
||||
<motion.div
|
||||
onMouseMove={(e) => mouseX.set(e.pageX)}
|
||||
onMouseLeave={() => mouseX.set(Infinity)}
|
||||
className={cn(
|
||||
'flex h-16 items-end gap-4 rounded-2xl px-4 pb-3',
|
||||
'bg-white/5 backdrop-blur-2xl border border-white/10 shadow-2xl'
|
||||
)}
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<DockIcon
|
||||
key={item.id}
|
||||
mouseX={mouseX}
|
||||
icon={item.icon}
|
||||
path={item.path}
|
||||
label={item.label}
|
||||
isActive={location.pathname.startsWith(item.path)}
|
||||
onClick={() => navigate({ to: item.path })}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DockIcon({
|
||||
mouseX,
|
||||
icon: Icon,
|
||||
path,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
mouseX: any;
|
||||
icon: LucideIcon;
|
||||
path: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const distance = useTransform(mouseX, (val: number) => {
|
||||
const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
|
||||
return val - bounds.x - bounds.width / 2;
|
||||
});
|
||||
|
||||
const widthSync = useTransform(distance, [-150, 0, 150], [40, 80, 40]);
|
||||
const width = useSpring(widthSync, { mass: 0.1, stiffness: 150, damping: 12 });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
style={{ width }}
|
||||
className="aspect-square cursor-pointer group relative"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute -top-10 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity text-xs font-mono bg-black/80 text-white px-2 py-1 rounded backdrop-blur-md border border-white/10 pointer-events-none whitespace-nowrap">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full transition-colors',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground shadow-[0_0_20px_rgba(34,211,238,0.3)]'
|
||||
: 'bg-white/5 text-muted-foreground hover:bg-white/10'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-[40%] w-[40%]" />
|
||||
</div>
|
||||
|
||||
{/* Active Dot */}
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="activeDockDot"
|
||||
className="absolute -bottom-2 left-1/2 w-1 h-1 bg-primary rounded-full -translate-x-1/2"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
70
apps/ui/src/components/layout/hud.tsx
Normal file
70
apps/ui/src/components/layout/hud.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ChevronDown, Command, Folder } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface HudProps {
|
||||
onOpenProjectPicker: () => void;
|
||||
onOpenFolder: () => void;
|
||||
}
|
||||
|
||||
export function Hud({ onOpenProjectPicker, onOpenFolder }: HudProps) {
|
||||
const { currentProject, projects, setCurrentProject } = useAppStore();
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-4 z-50 flex items-center gap-3">
|
||||
{/* Project Pill */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-3 px-4 py-2 rounded-full cursor-pointer',
|
||||
'bg-white/5 backdrop-blur-md border border-white/10',
|
||||
'hover:bg-white/10 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.4)] animate-pulse" />
|
||||
<span className="font-mono text-sm font-medium tracking-tight">
|
||||
{currentProject.name}
|
||||
</span>
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 glass border-white/10" align="start">
|
||||
<DropdownMenuLabel>Switch Project</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{projects.slice(0, 5).map((p) => (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => setCurrentProject(p)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{p.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onOpenProjectPicker}>
|
||||
<Command className="mr-2 w-3 h-3" />
|
||||
All Projects...
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenFolder}>
|
||||
<Folder className="mr-2 w-3 h-3" />
|
||||
Open Local Folder...
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Dynamic Status / Breadcrumbs could go here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
17
apps/ui/src/components/layout/noise-overlay.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export function NoiseOverlay() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 pointer-events-none opacity-[0.015] mix-blend-overlay">
|
||||
<svg className="w-full h-full">
|
||||
<filter id="noiseFilter">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.80"
|
||||
numOctaves="3"
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#noiseFilter)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
30
apps/ui/src/components/layout/page-shell.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface PageShellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function PageShell({ children, className, fullWidth = false }: PageShellProps) {
|
||||
return (
|
||||
<div className="relative w-full h-full pt-16 pb-24 px-6 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: [0.2, 0, 0, 1] }}
|
||||
className={cn(
|
||||
'w-full h-full rounded-3xl overflow-hidden',
|
||||
'bg-black/20 backdrop-blur-2xl border border-white/5 shadow-2xl',
|
||||
'flex flex-col',
|
||||
!fullWidth && 'max-w-7xl mx-auto',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
69
apps/ui/src/components/layout/prism-field.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function PrismField() {
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMousePosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[#0b101a]">
|
||||
{/* Deep Space Base */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,1)_0%,rgba(11,16,26,1)_100%)]" />
|
||||
|
||||
{/* Animated Orbs */}
|
||||
<motion.div
|
||||
animate={{
|
||||
x: mousePosition.x * 0.02,
|
||||
y: mousePosition.y * 0.02,
|
||||
}}
|
||||
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
|
||||
className="absolute top-[-20%] left-[-10%] w-[70vw] h-[70vw] rounded-full bg-cyan-500/5 blur-[120px] mix-blend-screen"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
x: mousePosition.x * -0.03,
|
||||
y: mousePosition.y * -0.03,
|
||||
}}
|
||||
transition={{ type: 'spring', damping: 50, stiffness: 400 }}
|
||||
className="absolute bottom-[-20%] right-[-10%] w-[60vw] h-[60vw] rounded-full bg-violet-600/5 blur-[120px] mix-blend-screen"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
opacity: [0.3, 0.5, 0.3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 8,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
className="absolute top-[30%] left-[50%] transform -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] rounded-full bg-blue-500/5 blur-[100px] mix-blend-screen"
|
||||
/>
|
||||
|
||||
{/* Grid Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 z-10 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)`,
|
||||
backgroundSize: '50px 50px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Vignette */}
|
||||
<div className="absolute inset-0 z-20 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(11,16,26,0.8)_100%)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/ui/src/components/layout/shell.tsx
Normal file
32
apps/ui/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { PrismField } from './prism-field';
|
||||
import { NoiseOverlay } from './noise-overlay';
|
||||
|
||||
interface ShellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
showBackgroundElements?: boolean;
|
||||
}
|
||||
|
||||
export function Shell({ children, className, showBackgroundElements = true }: ShellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-screen w-full overflow-hidden bg-background text-foreground transition-colors duration-500',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated Background Layers */}
|
||||
{showBackgroundElements && (
|
||||
<>
|
||||
<PrismField />
|
||||
<NoiseOverlay />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content wrapper */}
|
||||
<div className="relative z-10 flex h-screen flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +1,328 @@
|
||||
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 { useState, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
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,
|
||||
} from './sidebar/components';
|
||||
import { Hud } from './hud';
|
||||
import { FloatingDock } from './floating-dock';
|
||||
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';
|
||||
|
||||
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="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>
|
||||
<>
|
||||
{/* Heads-Up Display (Top Bar) */}
|
||||
<Hud
|
||||
onOpenProjectPicker={() => setIsProjectPickerOpen(true)}
|
||||
onOpenFolder={handleOpenFolder}
|
||||
/>
|
||||
|
||||
{/* Floating Navigation Dock */}
|
||||
<FloatingDock />
|
||||
|
||||
{/* Project Selector Dialog (Hidden logic, controlled by state) */}
|
||||
<div className="hidden">
|
||||
<ProjectSelectorWithOptions
|
||||
sidebarOpen={true}
|
||||
isProjectPickerOpen={isProjectPickerOpen}
|
||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Dialogs & Modals - Preservation of Logic */}
|
||||
<TrashDialog
|
||||
open={showTrashDialog}
|
||||
onOpenChange={setShowTrashDialog}
|
||||
trashedProjects={trashedProjects}
|
||||
activeTrashId={activeTrashId}
|
||||
handleRestoreProject={handleRestoreProject}
|
||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
||||
deleteTrashedProject={deleteTrashedProject}
|
||||
handleEmptyTrash={handleEmptyTrash}
|
||||
isEmptyingTrash={isEmptyingTrash}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<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."
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<OnboardingDialog
|
||||
open={showOnboardingDialog}
|
||||
onOpenChange={setShowOnboardingDialog}
|
||||
newProjectName={newProjectName}
|
||||
onSkip={handleOnboardingSkip}
|
||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteProjectDialog}
|
||||
onOpenChange={setShowDeleteProjectDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<NewProjectModal
|
||||
open={showNewProjectModal}
|
||||
onOpenChange={setShowNewProjectModal}
|
||||
onCreateBlankProject={handleCreateBlankProject}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||
isCreating={isCreatingProject}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function ProjectSelectorWithOptions({
|
||||
setIsProjectPickerOpen,
|
||||
setShowDeleteProjectDialog,
|
||||
}: ProjectSelectorWithOptionsProps) {
|
||||
// Get data from store
|
||||
const {
|
||||
projects,
|
||||
currentProject,
|
||||
@@ -58,24 +59,25 @@ export function ProjectSelectorWithOptions({
|
||||
clearProjectHistory,
|
||||
} = useAppStore();
|
||||
|
||||
// Get keyboard shortcuts
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const {
|
||||
projectSearchQuery,
|
||||
setProjectSearchQuery,
|
||||
selectedProjectIndex,
|
||||
projectSearchInputRef,
|
||||
scrollContainerRef,
|
||||
filteredProjects,
|
||||
} = useProjectPicker({
|
||||
projects,
|
||||
currentProject,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setCurrentProject,
|
||||
});
|
||||
|
||||
// Drag-and-drop handlers
|
||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||
|
||||
// Theme management
|
||||
const {
|
||||
globalTheme,
|
||||
setTheme,
|
||||
@@ -104,6 +106,7 @@ export function ProjectSelectorWithOptions({
|
||||
'shadow-sm shadow-black/5',
|
||||
'text-foreground titlebar-no-drag min-w-0',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.01] active:scale-[0.99]',
|
||||
isProjectPickerOpen &&
|
||||
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
|
||||
)}
|
||||
@@ -136,7 +139,7 @@ export function ProjectSelectorWithOptions({
|
||||
align="start"
|
||||
data-testid="project-picker-dropdown"
|
||||
>
|
||||
{/* Search input */}
|
||||
{/* Search input for type-ahead filtering */}
|
||||
<div className="px-1 pb-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -147,10 +150,10 @@ export function ProjectSelectorWithOptions({
|
||||
value={projectSearchQuery}
|
||||
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
'w-full h-8 pl-8 pr-3 text-sm rounded-lg',
|
||||
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
|
||||
'border border-border bg-background/50',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-1 focus:ring-brand-500/30 focus:border-brand-500/50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
data-testid="project-search-input"
|
||||
@@ -172,10 +175,7 @@ export function ProjectSelectorWithOptions({
|
||||
items={filteredProjects.map((p) => p.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth scrollbar-styled"
|
||||
>
|
||||
<div className="space-y-0.5 max-h-64 overflow-y-auto">
|
||||
{filteredProjects.map((project, index) => (
|
||||
<SortableProjectItem
|
||||
key={project.id}
|
||||
@@ -196,9 +196,9 @@ export function ProjectSelectorWithOptions({
|
||||
{/* Keyboard hint */}
|
||||
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
|
||||
<span className="text-foreground/60">↑↓</span> navigate{' '}
|
||||
<span className="text-foreground/60">arrow</span> navigate{' '}
|
||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||
<span className="text-foreground/60">↵</span> select{' '}
|
||||
<span className="text-foreground/60">enter</span> select{' '}
|
||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||
<span className="text-foreground/60">esc</span> close
|
||||
</p>
|
||||
@@ -206,7 +206,7 @@ export function ProjectSelectorWithOptions({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Project Options Menu */}
|
||||
{/* Project Options Menu - theme and history */}
|
||||
{currentProject && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
@@ -223,7 +223,8 @@ export function ProjectSelectorWithOptions({
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'bg-transparent hover:bg-accent/60',
|
||||
'border border-border/50 hover:border-border',
|
||||
'transition-all duration-200 ease-out titlebar-no-drag'
|
||||
'transition-all duration-200 ease-out titlebar-no-drag',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
title="Project options"
|
||||
data-testid="project-options-menu"
|
||||
@@ -251,6 +252,7 @@ export function ProjectSelectorWithOptions({
|
||||
setPreviewTheme(null);
|
||||
}}
|
||||
>
|
||||
{/* Use Global Option */}
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentProject.theme || ''}
|
||||
onValueChange={(value) => {
|
||||
@@ -326,7 +328,7 @@ export function ProjectSelectorWithOptions({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Project History Section */}
|
||||
{/* Project History Section - only show when there's history */}
|
||||
{projectHistory.length > 1 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -31,7 +31,6 @@ export function SortableProjectItem({
|
||||
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
|
||||
)}
|
||||
data-testid={`project-option-${project.id}`}
|
||||
onClick={() => onSelect(project)}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
<button
|
||||
@@ -44,14 +43,9 @@ export function SortableProjectItem({
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</button>
|
||||
|
||||
{/* Project content */}
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<Folder
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
currentProjectId === project.id ? 'text-brand-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
{/* Project content - clickable area */}
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
|
||||
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
|
||||
</div>
|
||||
|
||||
@@ -37,11 +37,11 @@ export function OnboardingDialog({
|
||||
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20 shrink-0">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
|
||||
<Rocket className="w-6 h-6 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="text-2xl truncate">Welcome to {newProjectName}!</DialogTitle>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground mt-1">
|
||||
Your new project is ready. Let's get you started.
|
||||
</DialogDescription>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Project } from '@/lib/electron';
|
||||
|
||||
interface UseProjectPickerProps {
|
||||
projects: Project[];
|
||||
currentProject: Project | null;
|
||||
isProjectPickerOpen: boolean;
|
||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setCurrentProject: (project: Project) => void;
|
||||
@@ -11,7 +10,6 @@ interface UseProjectPickerProps {
|
||||
|
||||
export function useProjectPicker({
|
||||
projects,
|
||||
currentProject,
|
||||
isProjectPickerOpen,
|
||||
setIsProjectPickerOpen,
|
||||
setCurrentProject,
|
||||
@@ -19,7 +17,6 @@ export function useProjectPicker({
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
||||
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filtered projects based on search query
|
||||
const filteredProjects = useMemo(() => {
|
||||
@@ -30,66 +27,28 @@ export function useProjectPicker({
|
||||
return projects.filter((project) => project.name.toLowerCase().includes(query));
|
||||
}, [projects, projectSearchQuery]);
|
||||
|
||||
// Helper function to scroll to a specific project
|
||||
const scrollToProject = useCallback((projectId: string) => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const element = scrollContainerRef.current.querySelector(
|
||||
`[data-testid="project-option-${projectId}"]`
|
||||
);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// On open/close, handle search query reset and focus
|
||||
// Reset selection when filtered results change
|
||||
useEffect(() => {
|
||||
if (isProjectPickerOpen) {
|
||||
// Focus search input after DOM renders
|
||||
requestAnimationFrame(() => {
|
||||
projectSearchInputRef.current?.focus();
|
||||
});
|
||||
} else {
|
||||
// Reset search when closing
|
||||
setSelectedProjectIndex(0);
|
||||
}, [filteredProjects.length, projectSearchQuery]);
|
||||
|
||||
// Reset search query when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!isProjectPickerOpen) {
|
||||
setProjectSearchQuery('');
|
||||
setSelectedProjectIndex(0);
|
||||
}
|
||||
}, [isProjectPickerOpen]);
|
||||
|
||||
// Update selection when search query changes (while picker is open)
|
||||
// Focus the search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (!isProjectPickerOpen) {
|
||||
setSelectedProjectIndex(0);
|
||||
return;
|
||||
if (isProjectPickerOpen) {
|
||||
// Small delay to ensure the dropdown is rendered
|
||||
setTimeout(() => {
|
||||
projectSearchInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (projectSearchQuery.trim()) {
|
||||
// When searching, reset to first result
|
||||
setSelectedProjectIndex(0);
|
||||
} else {
|
||||
// When not searching (e.g., on open or search cleared), find and select the current project
|
||||
const currentIndex = currentProject
|
||||
? filteredProjects.findIndex((p) => p.id === currentProject.id)
|
||||
: -1;
|
||||
setSelectedProjectIndex(currentIndex !== -1 ? currentIndex : 0);
|
||||
}
|
||||
}, [isProjectPickerOpen, projectSearchQuery, filteredProjects, currentProject]);
|
||||
|
||||
// Scroll to highlighted item when selection changes
|
||||
useEffect(() => {
|
||||
if (!isProjectPickerOpen) return;
|
||||
|
||||
const targetProject = filteredProjects[selectedProjectIndex];
|
||||
if (targetProject) {
|
||||
// Use requestAnimationFrame to ensure DOM is rendered before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
scrollToProject(targetProject.id);
|
||||
});
|
||||
}
|
||||
}, [selectedProjectIndex, isProjectPickerOpen, filteredProjects, scrollToProject]);
|
||||
}, [isProjectPickerOpen]);
|
||||
|
||||
// Handle selecting the currently highlighted project
|
||||
const selectHighlightedProject = useCallback(() => {
|
||||
@@ -140,7 +99,6 @@ export function useProjectPicker({
|
||||
selectedProjectIndex,
|
||||
setSelectedProjectIndex,
|
||||
projectSearchInputRef,
|
||||
scrollContainerRef,
|
||||
filteredProjects,
|
||||
selectHighlightedProject,
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,13 @@ const badgeVariants = cva(
|
||||
// Muted variants for subtle indication
|
||||
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
|
||||
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
|
||||
// Prism variants
|
||||
prism:
|
||||
'border-cyan-500/30 bg-cyan-500/10 text-cyan-400 hover:bg-cyan-500/20 font-mono tracking-wide rounded-md',
|
||||
'prism-orange':
|
||||
'border-amber-500/30 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 font-mono tracking-wide rounded-md',
|
||||
'prism-green':
|
||||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 font-mono tracking-wide rounded-md',
|
||||
},
|
||||
size: {
|
||||
default: 'px-2.5 py-0.5 text-xs',
|
||||
|
||||
@@ -6,25 +6,32 @@ import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-300 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25',
|
||||
'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:bg-primary/90 hover:shadow-primary/40 hover:-translate-y-0.5',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
'border border-border/50 bg-background/50 backdrop-blur-sm shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-white/5 dark:hover:bg-white/10 hover:border-accent',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 hover:shadow-md',
|
||||
ghost: 'hover:bg-accent/50 hover:text-accent-foreground hover:backdrop-blur-sm',
|
||||
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
|
||||
glass:
|
||||
'border border-white/10 bg-white/5 text-foreground shadow-sm drop-shadow-sm backdrop-blur-md hover:bg-white/10 hover:border-white/20 hover:shadow-md transition-all duration-300',
|
||||
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
|
||||
'prism-primary':
|
||||
'bg-cyan-400 text-slate-950 font-extrabold shadow-lg shadow-cyan-400/20 hover:brightness-110 hover:shadow-cyan-400/40 transition-all duration-200 tracking-wide',
|
||||
'prism-glass':
|
||||
'glass hover:bg-white/10 text-xs font-bold rounded-xl transition-all duration-200',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs',
|
||||
lg: 'h-11 rounded-md px-8 has-[>svg]:px-5 text-base',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
|
||||
@@ -11,9 +11,9 @@ function Card({ className, gradient = false, ...props }: CardProps) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6',
|
||||
// Premium layered shadow
|
||||
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]',
|
||||
'bg-white/5 text-card-foreground flex flex-col gap-1 rounded-[1.5rem] border border-white/10 backdrop-blur-xl py-6 transition-all duration-300',
|
||||
// Prism hover effect
|
||||
'hover:-translate-y-1 hover:bg-white/[0.06] hover:border-white/15',
|
||||
// Gradient border option
|
||||
gradient &&
|
||||
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
|
||||
|
||||
@@ -66,10 +66,10 @@ function DialogOverlay({
|
||||
<DialogOverlayPrimitive
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'fixed inset-0 z-50 bg-black/40 backdrop-blur-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'duration-200',
|
||||
'duration-300',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -99,15 +99,15 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
|
||||
'bg-card border border-border rounded-xl shadow-2xl',
|
||||
'bg-card/90 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-xl',
|
||||
// Premium shadow
|
||||
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
|
||||
'shadow-[0_40px_80px_-12px_rgba(0,0,0,0.5)]',
|
||||
// Animations - smoother with scale
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
|
||||
'duration-200',
|
||||
'duration-300 ease-out',
|
||||
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -157,7 +157,8 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/10 bg-popover/80 p-1 text-popover-foreground shadow-xl backdrop-blur-xl',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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';
|
||||
@@ -1,28 +0,0 @@
|
||||
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';
|
||||
@@ -15,17 +15,21 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
// Inner shadow for depth
|
||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
||||
// Animated focus ring
|
||||
'transition-[color,box-shadow,border-color] duration-200 ease-out',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'file:text-foreground placeholder:text-muted-foreground/50 selection:bg-cyan-500/30 selection:text-cyan-100',
|
||||
'bg-white/5 border-white/10 h-9 w-full min-w-0 rounded-xl border px-3 py-1 text-sm shadow-sm outline-none transition-all duration-200',
|
||||
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'backdrop-blur-sm',
|
||||
// Hover state
|
||||
'hover:bg-white/10 hover:border-white/20',
|
||||
// Focus state with ring
|
||||
'focus:bg-white/10 focus:border-cyan-500/50',
|
||||
'focus-visible:border-cyan-500/50 focus-visible:ring-cyan-500/20 focus-visible:ring-[4px]',
|
||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||
// Adjust padding for addons
|
||||
startAddon && 'pl-0',
|
||||
endAddon && 'pr-0',
|
||||
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
|
||||
hasAddons && 'border-0 shadow-none focus-visible:ring-0 bg-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,10 +43,10 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
|
||||
'flex items-center h-9 w-full rounded-lg border border-input/50 bg-input/50 shadow-xs backdrop-blur-sm transition-all duration-300',
|
||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
||||
'transition-[box-shadow,border-color] duration-200 ease-out',
|
||||
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
|
||||
'focus-within:bg-input/80 focus-within:border-ring/50',
|
||||
'focus-within:border-ring focus-within:ring-ring/20 focus-within:ring-[4px]',
|
||||
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
|
||||
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
|
||||
)}
|
||||
|
||||
@@ -50,10 +50,10 @@ const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...p
|
||||
className={cn('relative flex w-full touch-none select-none items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
||||
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
|
||||
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10 cursor-pointer">
|
||||
<SliderRangePrimitive className="slider-range absolute h-full bg-cyan-400" />
|
||||
</SliderTrackPrimitive>
|
||||
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
||||
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-cyan-400/50 bg-background shadow-none transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400 disabled:pointer-events-none disabled:opacity-50 hover:bg-cyan-950/30 hover:border-cyan-400" />
|
||||
</SliderRootPrimitive>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-cyan-500 data-[state=unchecked]:bg-white/10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -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,9 +18,6 @@ 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;
|
||||
@@ -193,285 +190,258 @@ export function AgentToolsView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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"
|
||||
{/* 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-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 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>
|
||||
</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'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>
|
||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
||||
{readFileResult.success ? readFileResult.output : readFileResult.error}
|
||||
</pre>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
</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'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>
|
||||
</GlassPanel>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ImageIcon,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||
@@ -50,8 +49,6 @@ 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();
|
||||
@@ -86,7 +83,6 @@ export function AgentView() {
|
||||
isConnected,
|
||||
sendMessage,
|
||||
clearHistory,
|
||||
stopExecution,
|
||||
error: agentError,
|
||||
} = useElectronAgent({
|
||||
sessionId: currentSessionId || '',
|
||||
@@ -493,501 +489,456 @@ export function AgentView() {
|
||||
: messages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="agent-view">
|
||||
<TopHeader />
|
||||
<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-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>
|
||||
{/* 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>
|
||||
|
||||
{/* Status indicators & actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Model Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs font-medium"
|
||||
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-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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-white/10"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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'
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
{/* 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={() => {
|
||||
setSelectedImages([]);
|
||||
setSelectedTextFiles([]);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
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}
|
||||
>
|
||||
Clear all
|
||||
<X className="h-3 w-3" />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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"
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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 Button */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={
|
||||
(!input.trim() &&
|
||||
selectedImages.length === 0 &&
|
||||
selectedTextFiles.length === 0) ||
|
||||
isProcessing ||
|
||||
!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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,9 +16,9 @@ import { RefreshCw } from 'lucide-react';
|
||||
import { useAutoMode } from '@/hooks/use-auto-mode';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useWindowState } from '@/hooks/use-window-state';
|
||||
import { PageShell } from '@/components/layout/page-shell';
|
||||
// Board-view specific imports
|
||||
import { TopHeader } from '@/components/layout/top-header';
|
||||
// BoardHeader removed
|
||||
import { BoardHeader } from './board-view/board-header';
|
||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
||||
import { BoardControls } from './board-view/board-controls';
|
||||
import { KanbanBoard } from './board-view/kanban-board';
|
||||
@@ -264,9 +264,9 @@ export function BoardView() {
|
||||
// Calculate unarchived card counts per branch
|
||||
const branchCardCounts = useMemo(() => {
|
||||
return hookFeatures.reduce(
|
||||
(counts: Record<string, number>, feature) => {
|
||||
(counts, feature) => {
|
||||
if (feature.status !== 'completed') {
|
||||
const branch = (feature.branchName as string) ?? 'main';
|
||||
const branch = feature.branchName ?? 'main';
|
||||
counts[branch] = (counts[branch] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -919,8 +919,27 @@ export function BoardView() {
|
||||
data-testid="board-view"
|
||||
>
|
||||
{/* Header */}
|
||||
{/* Top Header */}
|
||||
<TopHeader />
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Worktree Panel */}
|
||||
<WorktreePanel
|
||||
@@ -1018,13 +1037,8 @@ export function BoardView() {
|
||||
currentWorktreePath={currentWorktreePath}
|
||||
currentWorktreeBranch={currentWorktreeBranch}
|
||||
projectPath={currentProject?.path || null}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
onEditFeature={(feature) => setEditingFeature(feature)}
|
||||
onViewOutput={handleViewOutput}
|
||||
onStartTask={handleStartImplementation}
|
||||
onStopTask={handleForceStopFeature}
|
||||
onResumeTask={handleResumeFeature}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -39,23 +40,20 @@ export function BoardHeader({
|
||||
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<header className="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectName}</p>
|
||||
<h2 className="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
||||
<p className="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
||||
{projectName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Usage Popover - only show for CLI users (not API key users) */}
|
||||
{isMounted && showUsageTracking && <ClaudeUsagePopover />}
|
||||
|
||||
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Concurrency/Agent Control - Styled as Toggle for visual matching, but keeps slider logic if needed or simplified */}
|
||||
{isMounted && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border"
|
||||
data-testid="concurrency-slider-container"
|
||||
>
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agents</span>
|
||||
<div className="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3">
|
||||
<Bot className="w-4 h-4 text-slate-500" />
|
||||
{/* We keep the slider for functionality, but could style it to look like the toggle or just use the slider cleanly */}
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
@@ -63,43 +61,43 @@ export function BoardHeader({
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-20"
|
||||
data-testid="concurrency-slider"
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground min-w-[5ch] text-center"
|
||||
data-testid="concurrency-value"
|
||||
>
|
||||
<span className="mono text-xs font-bold text-slate-400">
|
||||
{runningAgentsCount} / {maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{/* Auto Mode Button */}
|
||||
{isMounted && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Auto Mode
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
data-testid="auto-mode-toggle"
|
||||
<button
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-5 py-2 rounded-xl text-xs font-bold transition',
|
||||
isAutoModeRunning
|
||||
? 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/20'
|
||||
: 'glass hover:bg-white/10'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
isAutoModeRunning ? 'bg-cyan-400 animate-pulse' : 'bg-slate-500'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
Auto Mode
|
||||
</button>
|
||||
)}
|
||||
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
{/* Add Feature Button */}
|
||||
<button
|
||||
onClick={onAddFeature}
|
||||
hotkey={addFeatureShortcut}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-feature-button"
|
||||
className="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Feature
|
||||
</HotkeyButton>
|
||||
<Plus className="w-4 h-4 stroke-[3.5px]" />
|
||||
ADD FEATURE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ interface CardHeaderProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewOutput?: () => void;
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export function CardHeaderSection({
|
||||
@@ -41,7 +40,6 @@ export function CardHeaderSection({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewOutput,
|
||||
hideActions,
|
||||
}: CardHeaderProps) {
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@@ -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 { GlassCard } from '@/components/ui/glass-card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { CardBadges, PriorityBadges } from './card-badges';
|
||||
import { CardHeaderSection } from './card-header';
|
||||
@@ -56,6 +56,10 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
shortcutKey,
|
||||
contextContent,
|
||||
summary,
|
||||
opacity = 100,
|
||||
glassmorphism = true,
|
||||
cardBorderEnabled = true,
|
||||
cardBorderOpacity = 100,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees } = useAppStore();
|
||||
|
||||
@@ -64,7 +68,6 @@ 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,
|
||||
@@ -76,15 +79,36 @@ 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 = (
|
||||
<GlassCard
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
variant={isCurrentAutoTask ? 'active-blue' : 'default'}
|
||||
style={style}
|
||||
style={isCurrentAutoTask ? style : borderStyle}
|
||||
className={cn(
|
||||
'group relative min-h-[140px] flex flex-col',
|
||||
'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]!',
|
||||
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||
feature.error && 'border-brand-red border-2 shadow-glow-red',
|
||||
// Error state - using CSS variable
|
||||
feature.error &&
|
||||
!isCurrentAutoTask &&
|
||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
!isDraggable && 'cursor-default'
|
||||
)}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
@@ -92,79 +116,51 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
{...attributes}
|
||||
{...(isDraggable ? listeners : {})}
|
||||
>
|
||||
{/* 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>
|
||||
|
||||
{/* 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
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Priority and Manual Verification badges */}
|
||||
<PriorityBadges feature={feature} />
|
||||
|
||||
{/* Content & Agent Info */}
|
||||
<div className="mb-2">
|
||||
{/* Card Header */}
|
||||
<CardHeaderSection
|
||||
feature={feature}
|
||||
isDraggable={isDraggable}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onViewOutput={onViewOutput}
|
||||
/>
|
||||
|
||||
<CardContent className="px-3 pt-0 pb-0">
|
||||
{/* Content Sections */}
|
||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
||||
|
||||
{/* Agent Info Panel */}
|
||||
<AgentInfoPanel
|
||||
feature={feature}
|
||||
contextContent={contextContent}
|
||||
summary={summary}
|
||||
isCurrentAutoTask={isCurrentAutoTask}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons Grid */}
|
||||
<div className="mt-auto pt-2">
|
||||
{/* Actions */}
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||
@@ -182,9 +178,14 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onViewPlan={onViewPlan}
|
||||
onApprovePlan={onApprovePlan}
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// Wrap with animated border when in progress
|
||||
if (isCurrentAutoTask) {
|
||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||
}
|
||||
|
||||
return cardElement;
|
||||
});
|
||||
|
||||
@@ -2,30 +2,33 @@ 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;
|
||||
accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
|
||||
colorClass: string;
|
||||
columnClass?: string;
|
||||
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,
|
||||
accent,
|
||||
colorClass,
|
||||
columnClass,
|
||||
count,
|
||||
children,
|
||||
headerAction,
|
||||
opacity = 100,
|
||||
showBorder = true,
|
||||
hideScrollbar = false,
|
||||
width,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
@@ -34,63 +37,61 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||
|
||||
return (
|
||||
<GlassPanel
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
accent={accent}
|
||||
className={cn(
|
||||
'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'
|
||||
'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',
|
||||
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background',
|
||||
columnClass
|
||||
)}
|
||||
style={widthStyle}
|
||||
data-testid={`kanban-column-${id}`}
|
||||
>
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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 }}
|
||||
/>
|
||||
|
||||
{/* Column Header */}
|
||||
<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">
|
||||
<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">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Drop zone indicator when dragging over */}
|
||||
{isOver && (
|
||||
<div className="absolute inset-0 rounded-2xl bg-white/5 pointer-events-none z-20 border-2 border-dashed border-white/10" />
|
||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
||||
)}
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,29 +2,25 @@ import { Feature } from '@/store/app-store';
|
||||
|
||||
export type ColumnId = Feature['status'];
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string; columnClass?: string }[] =
|
||||
[
|
||||
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-white/20', columnClass: '' },
|
||||
{
|
||||
id: 'in_progress',
|
||||
title: 'In Progress',
|
||||
colorClass: 'bg-cyan-400',
|
||||
columnClass: 'col-in-progress',
|
||||
},
|
||||
{
|
||||
id: 'waiting_approval',
|
||||
title: 'Waiting Approval',
|
||||
colorClass: 'bg-amber-500',
|
||||
columnClass: 'col-waiting',
|
||||
},
|
||||
{
|
||||
id: 'verified',
|
||||
title: 'Verified',
|
||||
colorClass: 'bg-emerald-500',
|
||||
columnClass: 'col-verified',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -109,9 +109,7 @@ 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 as any) as {
|
||||
orderedFeatures: Feature[];
|
||||
};
|
||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
||||
|
||||
// Get all features to check blocking dependencies against
|
||||
const allFeatures = features;
|
||||
@@ -124,7 +122,7 @@ export function useBoardColumnFeatures({
|
||||
const blocked: Feature[] = [];
|
||||
|
||||
for (const f of orderedFeatures) {
|
||||
if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
|
||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
||||
blocked.push(f);
|
||||
} else {
|
||||
unblocked.push(f);
|
||||
|
||||
@@ -101,8 +101,8 @@ export function KanbanBoard({
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
accent={column.accent}
|
||||
colorClass={column.colorClass}
|
||||
columnClass={column.columnClass}
|
||||
count={columnFeatures.length}
|
||||
width={columnWidth}
|
||||
opacity={backgroundSettings.columnOpacity}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface TooltipWrapperProps {
|
||||
/** The element to wrap with a tooltip */
|
||||
children: ReactElement;
|
||||
/** The content to display in the tooltip */
|
||||
tooltipContent: ReactNode;
|
||||
/** Whether to show the tooltip (if false, renders children without tooltip) */
|
||||
showTooltip: boolean;
|
||||
/** The side where the tooltip should appear */
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable wrapper that conditionally adds a tooltip to its children.
|
||||
* When showTooltip is false, it renders the children directly without any tooltip.
|
||||
* This is useful for adding tooltips to disabled elements that need to show
|
||||
* a reason for being disabled.
|
||||
*/
|
||||
export function TooltipWrapper({
|
||||
children,
|
||||
tooltipContent,
|
||||
showTooltip,
|
||||
side = 'left',
|
||||
}: TooltipWrapperProps) {
|
||||
if (!showTooltip) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* The div wrapper is necessary for tooltips to work on disabled elements */}
|
||||
<div>{children}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>
|
||||
<p>{tooltipContent}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,9 @@ import {
|
||||
Globe,
|
||||
MessageSquare,
|
||||
GitMerge,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -37,7 +35,6 @@ interface WorktreeActionsDropdownProps {
|
||||
isStartingDevServer: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
@@ -63,7 +60,6 @@ export function WorktreeActionsDropdown({
|
||||
isStartingDevServer,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
gitRepoStatus,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
@@ -80,14 +76,6 @@ export function WorktreeActionsDropdown({
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
// Check git operations availability
|
||||
const canPerformGitOps = gitRepoStatus.isGitRepo && gitRepoStatus.hasCommits;
|
||||
const gitOpsDisabledReason = !gitRepoStatus.isGitRepo
|
||||
? 'Not a git repository'
|
||||
: !gitRepoStatus.hasCommits
|
||||
? 'Repository has no commits yet'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -104,16 +92,6 @@ export function WorktreeActionsDropdown({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{/* Warning label when git operations are not available */}
|
||||
{!canPerformGitOps && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{gitOpsDisabledReason}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{isDevServerRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
@@ -146,58 +124,36 @@ export function WorktreeActionsDropdown({
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onPull(worktree)}
|
||||
disabled={isPulling || !canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||
{isPulling ? 'Pulling...' : 'Pull'}
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
{canPerformGitOps && behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
|
||||
{canPerformGitOps && aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuItem onClick={() => onPull(worktree)} disabled={isPulling} className="text-xs">
|
||||
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||
{isPulling ? 'Pulling...' : 'Pull'}
|
||||
{behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||
{isPushing ? 'Pushing...' : 'Push'}
|
||||
{aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{!worktree.isMain && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onResolveConflicts(worktree)}
|
||||
className="text-xs text-purple-500 focus:text-purple-600"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn(
|
||||
'text-xs text-purple-500 focus:text-purple-600',
|
||||
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Pull & Resolve Conflicts
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||
Pull & Resolve Conflicts
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
||||
@@ -206,41 +162,17 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!gitRepoStatus.isGitRepo}
|
||||
tooltipContent="Not a git repository"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => gitRepoStatus.isGitRepo && onCommit(worktree)}
|
||||
disabled={!gitRepoStatus.isGitRepo}
|
||||
className={cn('text-xs', !gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||
Commit Changes
|
||||
{!gitRepoStatus.isGitRepo && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||
Commit Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
||||
<TooltipWrapper
|
||||
showTooltip={!!gitOpsDisabledReason}
|
||||
tooltipContent={gitOpsDisabledReason}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => canPerformGitOps && onCreatePR(worktree)}
|
||||
disabled={!canPerformGitOps}
|
||||
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||
>
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
{!canPerformGitOps && (
|
||||
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</TooltipWrapper>
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR info and Address Comments button if PR exists */}
|
||||
{!worktree.isMain && hasPR && worktree.pr && (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
|
||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||
|
||||
@@ -28,7 +27,6 @@ interface WorktreeTabProps {
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
gitRepoStatus: GitRepoStatus;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
@@ -69,7 +67,6 @@ export function WorktreeTab({
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
gitRepoStatus,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
@@ -88,7 +85,7 @@ export function WorktreeTab({
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
let prBadge: React.ReactNode | null = null;
|
||||
let prBadge: JSX.Element | null = null;
|
||||
if (worktree.pr) {
|
||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||
const prStateClasses = (() => {
|
||||
@@ -323,7 +320,6 @@ export function WorktreeTab({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { BranchInfo, GitRepoStatus } from '../types';
|
||||
import type { BranchInfo } from '../types';
|
||||
|
||||
export function useBranches() {
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
@@ -8,57 +8,27 @@ export function useBranches() {
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState('');
|
||||
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
|
||||
isGitRepo: true,
|
||||
hasCommits: true,
|
||||
});
|
||||
|
||||
/** Helper to reset branch state to initial values */
|
||||
const resetBranchState = useCallback(() => {
|
||||
setBranches([]);
|
||||
setAheadCount(0);
|
||||
setBehindCount(0);
|
||||
}, []);
|
||||
|
||||
const fetchBranches = useCallback(
|
||||
async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
console.warn('List branches API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
||||
} else if (result.code === 'NOT_GIT_REPO') {
|
||||
// Not a git repository - clear branches silently without logging an error
|
||||
resetBranchState();
|
||||
setGitRepoStatus({ isGitRepo: false, hasCommits: false });
|
||||
} else if (result.code === 'NO_COMMITS') {
|
||||
// Git repo but no commits yet - clear branches silently without logging an error
|
||||
resetBranchState();
|
||||
setGitRepoStatus({ isGitRepo: true, hasCommits: false });
|
||||
} else if (!result.success) {
|
||||
// Other errors - log them
|
||||
console.warn('Failed to fetch branches:', result.error);
|
||||
resetBranchState();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch branches:', error);
|
||||
resetBranchState();
|
||||
// Reset git status to unknown state on network/API errors
|
||||
setGitRepoStatus({ isGitRepo: true, hasCommits: true });
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
console.warn('List branches API not available');
|
||||
return;
|
||||
}
|
||||
},
|
||||
[resetBranchState]
|
||||
);
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch branches:', error);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetBranchFilter = useCallback(() => {
|
||||
setBranchFilter('');
|
||||
@@ -78,6 +48,5 @@ export function useBranches() {
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
gitRepoStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,12 +114,13 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
|
||||
const handleOpenDevServerUrl = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
|
||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
||||
const serverInfo = runningDevServers.get(targetPath);
|
||||
if (serverInfo) {
|
||||
window.open(serverInfo.url, '_blank');
|
||||
}
|
||||
},
|
||||
[runningDevServers, getWorktreeKey]
|
||||
[projectPath, runningDevServers]
|
||||
);
|
||||
|
||||
const isDevServerRunning = useCallback(
|
||||
|
||||
@@ -3,29 +3,6 @@ import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
// Error codes that need special user-friendly handling
|
||||
const GIT_STATUS_ERROR_CODES = ['NOT_GIT_REPO', 'NO_COMMITS'] as const;
|
||||
type GitStatusErrorCode = (typeof GIT_STATUS_ERROR_CODES)[number];
|
||||
|
||||
// User-friendly messages for git status errors
|
||||
const GIT_STATUS_ERROR_MESSAGES: Record<GitStatusErrorCode, string> = {
|
||||
NOT_GIT_REPO: 'This directory is not a git repository',
|
||||
NO_COMMITS: 'Repository has no commits yet. Create an initial commit first.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to handle git status errors with user-friendly messages.
|
||||
* @returns true if the error was a git status error and was handled, false otherwise.
|
||||
*/
|
||||
function handleGitStatusError(result: { code?: string; error?: string }): boolean {
|
||||
const errorCode = result.code as GitStatusErrorCode | undefined;
|
||||
if (errorCode && GIT_STATUS_ERROR_CODES.includes(errorCode)) {
|
||||
toast.info(GIT_STATUS_ERROR_MESSAGES[errorCode] || result.error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface UseWorktreeActionsOptions {
|
||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||
@@ -52,7 +29,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
if (handleGitStatusError(result)) return;
|
||||
toast.error(result.error || 'Failed to switch branch');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -80,7 +56,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
toast.success(result.result.message);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
if (handleGitStatusError(result)) return;
|
||||
toast.error(result.error || 'Failed to pull latest changes');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,7 +84,6 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
||||
fetchBranches(worktree.path);
|
||||
fetchWorktrees();
|
||||
} else {
|
||||
if (handleGitStatusError(result)) return;
|
||||
toast.error(result.error || 'Failed to push changes');
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,11 +23,6 @@ export interface BranchInfo {
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
export interface GitRepoStatus {
|
||||
isGitRepo: boolean;
|
||||
hasCommits: boolean;
|
||||
}
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
|
||||
@@ -61,7 +61,6 @@ export function WorktreePanel({
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
gitRepoStatus,
|
||||
} = useBranches();
|
||||
|
||||
const {
|
||||
@@ -211,7 +210,6 @@ export function WorktreePanel({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||
@@ -266,7 +264,6 @@ export function WorktreePanel({
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
|
||||
@@ -7,8 +7,6 @@ import { Feature } from '@/store/app-store';
|
||||
export interface DependencyEdgeData {
|
||||
sourceStatus: Feature['status'];
|
||||
targetStatus: Feature['status'];
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
}
|
||||
|
||||
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
|
||||
@@ -54,17 +52,11 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
curvature: 0.25,
|
||||
});
|
||||
|
||||
const isHighlighted = edgeData?.isHighlighted ?? false;
|
||||
const isDimmed = edgeData?.isDimmed ?? false;
|
||||
const edgeColor = edgeData
|
||||
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||
: 'var(--border)';
|
||||
|
||||
const edgeColor = isHighlighted
|
||||
? 'var(--brand-500)'
|
||||
: edgeData
|
||||
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||
: 'var(--border)';
|
||||
|
||||
const isCompleted =
|
||||
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||
|
||||
return (
|
||||
@@ -74,9 +66,8 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
id={`${id}-bg`}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeWidth: isHighlighted ? 6 : 4,
|
||||
strokeWidth: 4,
|
||||
stroke: 'var(--background)',
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -87,20 +78,13 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
animated && 'animated-edge',
|
||||
isInProgress && 'edge-flowing',
|
||||
isHighlighted && 'graph-edge-highlighted',
|
||||
isDimmed && 'graph-edge-dimmed'
|
||||
isInProgress && 'edge-flowing'
|
||||
)}
|
||||
style={{
|
||||
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
|
||||
strokeWidth: selected ? 3 : 2,
|
||||
stroke: edgeColor,
|
||||
strokeDasharray: isCompleted ? 'none' : '5 5',
|
||||
filter: isHighlighted
|
||||
? 'drop-shadow(0 0 6px var(--brand-500))'
|
||||
: selected
|
||||
? 'drop-shadow(0 0 3px var(--brand-500))'
|
||||
: 'none',
|
||||
opacity: isDimmed ? 0.2 : 1,
|
||||
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function GraphControls({
|
||||
return (
|
||||
<Panel position="bottom-left" className="flex flex-col gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg">
|
||||
{/* Zoom controls */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -120,13 +120,22 @@ export function GraphControls({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn('h-8 w-8 p-0', isLocked && 'bg-brand-500/20 text-brand-500')}
|
||||
className={cn(
|
||||
'h-8 w-8 p-0',
|
||||
isLocked && 'bg-brand-500/20 text-brand-500'
|
||||
)}
|
||||
onClick={onToggleLock}
|
||||
>
|
||||
{isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
|
||||
{isLocked ? (
|
||||
<Lock className="w-4 h-4" />
|
||||
) : (
|
||||
<Unlock className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
|
||||
<TooltipContent side="right">
|
||||
{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Filter,
|
||||
X,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Pause,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
GraphFilterState,
|
||||
STATUS_FILTER_OPTIONS,
|
||||
StatusFilterValue,
|
||||
} from '../hooks/use-graph-filter';
|
||||
|
||||
// Status display configuration
|
||||
const statusDisplayConfig: Record<
|
||||
StatusFilterValue,
|
||||
{ label: string; icon: typeof Play; colorClass: string }
|
||||
> = {
|
||||
running: { label: 'Running', icon: Play, colorClass: 'text-[var(--status-in-progress)]' },
|
||||
paused: { label: 'Paused', icon: Pause, colorClass: 'text-[var(--status-warning)]' },
|
||||
backlog: { label: 'Backlog', icon: Clock, colorClass: 'text-muted-foreground' },
|
||||
waiting_approval: {
|
||||
label: 'Waiting Approval',
|
||||
icon: CircleDot,
|
||||
colorClass: 'text-[var(--status-waiting)]',
|
||||
},
|
||||
verified: { label: 'Verified', icon: CheckCircle2, colorClass: 'text-[var(--status-success)]' },
|
||||
};
|
||||
|
||||
interface GraphFilterControlsProps {
|
||||
filterState: GraphFilterState;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
onCategoriesChange: (categories: string[]) => void;
|
||||
onStatusesChange: (statuses: string[]) => void;
|
||||
onNegativeFilterChange: (isNegative: boolean) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
export function GraphFilterControls({
|
||||
filterState,
|
||||
availableCategories,
|
||||
hasActiveFilter,
|
||||
onCategoriesChange,
|
||||
onStatusesChange,
|
||||
onNegativeFilterChange,
|
||||
onClearFilters,
|
||||
}: GraphFilterControlsProps) {
|
||||
const { selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
|
||||
|
||||
const handleCategoryToggle = (category: string) => {
|
||||
if (selectedCategories.includes(category)) {
|
||||
onCategoriesChange(selectedCategories.filter((c) => c !== category));
|
||||
} else {
|
||||
onCategoriesChange([...selectedCategories, category]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllCategories = () => {
|
||||
if (selectedCategories.length === availableCategories.length) {
|
||||
onCategoriesChange([]);
|
||||
} else {
|
||||
onCategoriesChange([...availableCategories]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusToggle = (status: string) => {
|
||||
if (selectedStatuses.includes(status)) {
|
||||
onStatusesChange(selectedStatuses.filter((s) => s !== status));
|
||||
} else {
|
||||
onStatusesChange([...selectedStatuses, status]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllStatuses = () => {
|
||||
if (selectedStatuses.length === STATUS_FILTER_OPTIONS.length) {
|
||||
onStatusesChange([]);
|
||||
} else {
|
||||
onStatusesChange([...STATUS_FILTER_OPTIONS]);
|
||||
}
|
||||
};
|
||||
|
||||
const categoryButtonLabel =
|
||||
selectedCategories.length === 0
|
||||
? 'All Categories'
|
||||
: selectedCategories.length === 1
|
||||
? selectedCategories[0]
|
||||
: `${selectedCategories.length} Categories`;
|
||||
|
||||
const statusButtonLabel =
|
||||
selectedStatuses.length === 0
|
||||
? 'All Statuses'
|
||||
: selectedStatuses.length === 1
|
||||
? statusDisplayConfig[selectedStatuses[0] as StatusFilterValue]?.label ||
|
||||
selectedStatuses[0]
|
||||
: `${selectedStatuses.length} Statuses`;
|
||||
|
||||
return (
|
||||
<Panel position="top-left" className="flex items-center gap-2">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
{/* Category Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 px-2 gap-1.5',
|
||||
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Filter by Category</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
|
||||
Categories
|
||||
</div>
|
||||
|
||||
{/* Select All option */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||
onClick={handleSelectAllCategories}
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedCategories.length === availableCategories.length &&
|
||||
availableCategories.length > 0
|
||||
}
|
||||
onCheckedChange={handleSelectAllCategories}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedCategories.length === availableCategories.length
|
||||
? 'Deselect All'
|
||||
: 'Select All'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Category list */}
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{availableCategories.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-2 py-2">
|
||||
No categories available
|
||||
</div>
|
||||
) : (
|
||||
availableCategories.map((category) => (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleCategoryToggle(category)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedCategories.includes(category)}
|
||||
onCheckedChange={() => handleCategoryToggle(category)}
|
||||
/>
|
||||
<span className="text-sm truncate">{category}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Status Filter Dropdown */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-8 px-2 gap-1.5',
|
||||
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
|
||||
)}
|
||||
>
|
||||
<CircleDot className="w-4 h-4" />
|
||||
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Filter by Status</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="start" className="w-56 p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Status</div>
|
||||
|
||||
{/* Select All option */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||
onClick={handleSelectAllStatuses}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
|
||||
onCheckedChange={handleSelectAllStatuses}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
|
||||
? 'Deselect All'
|
||||
: 'Select All'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
{/* Status list */}
|
||||
<div className="space-y-0.5">
|
||||
{STATUS_FILTER_OPTIONS.map((status) => {
|
||||
const config = statusDisplayConfig[status];
|
||||
const StatusIcon = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
|
||||
onClick={() => handleStatusToggle(status)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedStatuses.includes(status)}
|
||||
onCheckedChange={() => handleStatusToggle(status)}
|
||||
/>
|
||||
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
|
||||
<span className="text-sm">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-6 w-px bg-border" />
|
||||
|
||||
{/* Positive/Negative Filter Toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
|
||||
aria-label={
|
||||
isNegativeFilter
|
||||
? 'Switch to show matching nodes'
|
||||
: 'Switch to hide matching nodes'
|
||||
}
|
||||
aria-pressed={isNegativeFilter}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
|
||||
isNegativeFilter
|
||||
? 'bg-orange-500/20 text-orange-500'
|
||||
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{isNegativeFilter ? (
|
||||
<>
|
||||
<EyeOff className="w-3.5 h-3.5" />
|
||||
<span>Hide</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>Show</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Switch
|
||||
checked={isNegativeFilter}
|
||||
onCheckedChange={onNegativeFilterChange}
|
||||
aria-label="Toggle between show and hide filter modes"
|
||||
className="h-5 w-9 data-[state=checked]:bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isNegativeFilter
|
||||
? 'Negative filter: Highlighting non-matching nodes'
|
||||
: 'Positive filter: Highlighting matching nodes'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Clear Filters Button - only show when filters are active */}
|
||||
{hasActiveFilter && (
|
||||
<>
|
||||
<div className="h-6 w-px bg-border" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onClearFilters}
|
||||
aria-label="Clear all filters"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Clear All Filters</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Panel } from '@xyflow/react';
|
||||
import { Clock, Play, Pause, CheckCircle2, Lock, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Clock,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle2,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const legendItems = [
|
||||
@@ -44,7 +51,7 @@ const legendItems = [
|
||||
export function GraphLegend() {
|
||||
return (
|
||||
<Panel position="bottom-right" className="pointer-events-none">
|
||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
|
||||
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto">
|
||||
{legendItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
|
||||
@@ -2,4 +2,3 @@ export { TaskNode } from './task-node';
|
||||
export { DependencyEdge } from './dependency-edge';
|
||||
export { GraphControls } from './graph-controls';
|
||||
export { GraphLegend } from './graph-legend';
|
||||
export { GraphFilterControls } from './graph-filter-controls';
|
||||
|
||||
@@ -10,10 +10,8 @@ import {
|
||||
Play,
|
||||
Pause,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
MoreHorizontal,
|
||||
GitBranch,
|
||||
Terminal,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +19,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -73,19 +72,14 @@ const priorityConfig = {
|
||||
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
||||
};
|
||||
|
||||
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
|
||||
export const TaskNode = memo(function TaskNode({
|
||||
data,
|
||||
selected,
|
||||
}: TaskNodeProps) {
|
||||
const config = statusConfig[data.status] || statusConfig.backlog;
|
||||
const StatusIcon = config.icon;
|
||||
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
|
||||
|
||||
// Filter highlight states
|
||||
const isMatched = data.isMatched ?? false;
|
||||
const isHighlighted = data.isHighlighted ?? false;
|
||||
const isDimmed = data.isDimmed ?? false;
|
||||
|
||||
// Task is stopped if it's in_progress but not actively running
|
||||
const isStopped = data.status === 'in_progress' && !data.isRunning;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Target handle (left side - receives dependencies) */}
|
||||
@@ -95,46 +89,39 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
className={cn(
|
||||
'w-3 h-3 !bg-border border-2 border-background',
|
||||
'transition-colors duration-200',
|
||||
'hover:!bg-brand-500',
|
||||
isDimmed && 'opacity-30'
|
||||
'hover:!bg-brand-500'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
||||
'transition-all duration-300',
|
||||
'transition-all duration-200',
|
||||
config.borderClass,
|
||||
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||
data.isRunning && 'animate-pulse-subtle',
|
||||
data.error && 'border-[var(--status-error)]',
|
||||
// Filter highlight states
|
||||
isMatched && 'graph-node-matched',
|
||||
isHighlighted && !isMatched && 'graph-node-highlighted',
|
||||
isDimmed && 'graph-node-dimmed'
|
||||
data.error && 'border-[var(--status-error)]'
|
||||
)}
|
||||
>
|
||||
{/* Header with status and actions */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||
config.bgClass
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
|
||||
config.bgClass
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
|
||||
<span className={cn('text-xs font-medium', config.colorClass)}>{config.label}</span>
|
||||
<span className={cn('text-xs font-medium', config.colorClass)}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Priority badge */}
|
||||
{priorityConf && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded',
|
||||
priorityConf.colorClass
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-[10px] font-bold px-1.5 py-0.5 rounded',
|
||||
priorityConf.colorClass
|
||||
)}>
|
||||
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
|
||||
</span>
|
||||
)}
|
||||
@@ -171,101 +158,39 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Stopped indicator - task is in_progress but not actively running */}
|
||||
{isStopped && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="p-1 rounded bg-[var(--status-warning-bg)]">
|
||||
<Pause className="w-3 h-3 text-[var(--status-warning)]" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs max-w-[200px]">
|
||||
<p>Task paused - click menu to resume</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 w-7 p-0 rounded-md',
|
||||
'bg-background/60 hover:bg-background',
|
||||
'border border-border/50 hover:border-border',
|
||||
'shadow-sm',
|
||||
'transition-all duration-150'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-6 w-6 p-0 hover:bg-background/50"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-foreground" />
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onViewLogs?.();
|
||||
}}
|
||||
>
|
||||
<Terminal className="w-3 h-3 mr-2" />
|
||||
View Agent Logs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onViewDetails?.();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem className="text-xs">
|
||||
<Eye className="w-3 h-3 mr-2" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
{data.status === 'backlog' && !data.isBlocked && (
|
||||
<DropdownMenuItem
|
||||
className="text-xs cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onStartTask?.();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem className="text-xs">
|
||||
<Play className="w-3 h-3 mr-2" />
|
||||
Start Task
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{data.isRunning && (
|
||||
<DropdownMenuItem
|
||||
className="text-xs text-[var(--status-error)] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onStopTask?.();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem className="text-xs text-[var(--status-error)]">
|
||||
<Pause className="w-3 h-3 mr-2" />
|
||||
Stop Task
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isStopped && (
|
||||
<DropdownMenuItem
|
||||
className="text-xs text-[var(--status-success)] cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
data.onResumeTask?.();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 mr-2" />
|
||||
Resume Task
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-xs">
|
||||
<GitBranch className="w-3 h-3 mr-2" />
|
||||
View Branch
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -279,40 +204,22 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
</span>
|
||||
|
||||
{/* Title */}
|
||||
{data.title && (
|
||||
<h3 className="text-sm font-medium mt-1 line-clamp-1 text-foreground">{data.title}</h3>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground line-clamp-2',
|
||||
data.title ? 'mt-1' : 'mt-1 font-medium text-foreground text-sm'
|
||||
)}
|
||||
>
|
||||
<h3 className="text-sm font-medium mt-1 line-clamp-2 text-foreground">
|
||||
{data.description}
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
{/* Progress indicator for in-progress tasks */}
|
||||
{data.isRunning && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate" />
|
||||
<div
|
||||
className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">Running...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paused indicator for stopped tasks */}
|
||||
{isStopped && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full w-1/2 bg-[var(--status-warning)] rounded-full" />
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--status-warning)] font-medium">Paused</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch name if assigned */}
|
||||
{data.branchName && (
|
||||
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
@@ -333,8 +240,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
|
||||
'hover:!bg-brand-500',
|
||||
data.status === 'completed' || data.status === 'verified'
|
||||
? '!bg-[var(--status-success)]'
|
||||
: '',
|
||||
isDimmed && 'opacity-30'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
ReactFlowProvider,
|
||||
@@ -15,25 +14,9 @@ import {
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { Feature } from '@/store/app-store';
|
||||
import {
|
||||
TaskNode,
|
||||
DependencyEdge,
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphFilterControls,
|
||||
} from './components';
|
||||
import {
|
||||
useGraphNodes,
|
||||
useGraphLayout,
|
||||
useGraphFilter,
|
||||
type TaskNodeData,
|
||||
type GraphFilterState,
|
||||
type NodeActionCallbacks,
|
||||
} from './hooks';
|
||||
import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
|
||||
import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDebounceValue } from 'usehooks-ts';
|
||||
import { SearchX } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Define custom node and edge types - using any to avoid React Flow's strict typing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -49,10 +32,8 @@ const edgeTypes: any = {
|
||||
interface GraphCanvasProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onNodeClick?: (featureId: string) => void;
|
||||
onNodeDoubleClick?: (featureId: string) => void;
|
||||
nodeActionCallbacks?: NodeActionCallbacks;
|
||||
backgroundStyle?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
@@ -60,41 +41,18 @@ interface GraphCanvasProps {
|
||||
function GraphCanvasInner({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
nodeActionCallbacks,
|
||||
backgroundStyle,
|
||||
className,
|
||||
}: GraphCanvasProps) {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
|
||||
|
||||
// Filter state (category, status, and negative toggle are local to graph view)
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
|
||||
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
|
||||
|
||||
// Debounce search query for performance with large graphs
|
||||
const [debouncedSearchQuery] = useDebounceValue(searchQuery, 200);
|
||||
|
||||
// Combined filter state
|
||||
const filterState: GraphFilterState = {
|
||||
searchQuery: debouncedSearchQuery,
|
||||
selectedCategories,
|
||||
selectedStatuses,
|
||||
isNegativeFilter,
|
||||
};
|
||||
|
||||
// Calculate filter results
|
||||
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
|
||||
|
||||
// Transform features to nodes and edges with filter results
|
||||
// Transform features to nodes and edges
|
||||
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
filterResult,
|
||||
actionCallbacks: nodeActionCallbacks,
|
||||
});
|
||||
|
||||
// Apply layout
|
||||
@@ -122,13 +80,13 @@ function GraphCanvasInner({
|
||||
[runLayout]
|
||||
);
|
||||
|
||||
// Handle clear all filters
|
||||
const handleClearFilters = useCallback(() => {
|
||||
onSearchQueryChange('');
|
||||
setSelectedCategories([]);
|
||||
setSelectedStatuses([]);
|
||||
setIsNegativeFilter(false);
|
||||
}, [onSearchQueryChange]);
|
||||
// Handle node click
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
|
||||
onNodeClick?.(node.id);
|
||||
},
|
||||
[onNodeClick]
|
||||
);
|
||||
|
||||
// Handle node double click
|
||||
const handleNodeDoubleClick = useCallback(
|
||||
@@ -164,6 +122,7 @@ function GraphCanvasInner({
|
||||
edges={edges}
|
||||
onNodesChange={isLocked ? undefined : onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
@@ -199,35 +158,7 @@ function GraphCanvasInner({
|
||||
layoutDirection={layoutDirection}
|
||||
/>
|
||||
|
||||
<GraphFilterControls
|
||||
filterState={filterState}
|
||||
availableCategories={filterResult.availableCategories}
|
||||
hasActiveFilter={filterResult.hasActiveFilter}
|
||||
onCategoriesChange={setSelectedCategories}
|
||||
onStatusesChange={setSelectedStatuses}
|
||||
onNegativeFilterChange={setIsNegativeFilter}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
|
||||
<GraphLegend />
|
||||
|
||||
{/* Empty state when all nodes are filtered out */}
|
||||
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
|
||||
<Panel position="top-center" className="mt-20">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
|
||||
<SearchX className="w-10 h-10 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">No matching tasks</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Try adjusting your filters or search query
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleClearFilters} className="mt-1">
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo, useCallback } from 'react';
|
||||
import { Feature, useAppStore } from '@/store/app-store';
|
||||
import { GraphCanvas } from './graph-canvas';
|
||||
import { useBoardBackground } from '../board-view/hooks';
|
||||
import { NodeActionCallbacks } from './hooks';
|
||||
|
||||
interface GraphViewProps {
|
||||
features: Feature[];
|
||||
@@ -10,13 +9,8 @@ interface GraphViewProps {
|
||||
currentWorktreePath: string | null;
|
||||
currentWorktreeBranch: string | null;
|
||||
projectPath: string | null;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
onEditFeature: (feature: Feature) => void;
|
||||
onViewOutput: (feature: Feature) => void;
|
||||
onStartTask?: (feature: Feature) => void;
|
||||
onStopTask?: (feature: Feature) => void;
|
||||
onResumeTask?: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export function GraphView({
|
||||
@@ -25,13 +19,8 @@ export function GraphView({
|
||||
currentWorktreePath,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
onEditFeature,
|
||||
onViewOutput,
|
||||
onStartTask,
|
||||
onStopTask,
|
||||
onResumeTask,
|
||||
}: GraphViewProps) {
|
||||
const { currentProject } = useAppStore();
|
||||
|
||||
@@ -46,7 +35,7 @@ export function GraphView({
|
||||
// Skip completed features (they're in archive)
|
||||
if (f.status === 'completed') return false;
|
||||
|
||||
const featureBranch = f.branchName as string | undefined;
|
||||
const featureBranch = f.branchName;
|
||||
|
||||
if (!featureBranch) {
|
||||
// No branch assigned - show only on primary worktree
|
||||
@@ -63,6 +52,17 @@ export function GraphView({
|
||||
});
|
||||
}, [features, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||
|
||||
// Handle node click - view details
|
||||
const handleNodeClick = useCallback(
|
||||
(featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (feature) {
|
||||
onViewOutput(feature);
|
||||
}
|
||||
},
|
||||
[features, onViewOutput]
|
||||
);
|
||||
|
||||
// Handle node double click - edit
|
||||
const handleNodeDoubleClick = useCallback(
|
||||
(featureId: string) => {
|
||||
@@ -74,52 +74,13 @@ export function GraphView({
|
||||
[features, onEditFeature]
|
||||
);
|
||||
|
||||
// Node action callbacks for dropdown menu
|
||||
const nodeActionCallbacks: NodeActionCallbacks = useMemo(
|
||||
() => ({
|
||||
onViewLogs: (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (feature) {
|
||||
onViewOutput(feature);
|
||||
}
|
||||
},
|
||||
onViewDetails: (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (feature) {
|
||||
onEditFeature(feature);
|
||||
}
|
||||
},
|
||||
onStartTask: (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (feature) {
|
||||
onStartTask?.(feature);
|
||||
}
|
||||
},
|
||||
onStopTask: (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (feature) {
|
||||
onStopTask?.(feature);
|
||||
}
|
||||
},
|
||||
onResumeTask: (featureId: string) => {
|
||||
const feature = features.find((f) => f.id === featureId);
|
||||
if (feature) {
|
||||
onResumeTask?.(feature);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[features, onViewOutput, onEditFeature, onStartTask, onStopTask, onResumeTask]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<GraphCanvas
|
||||
features={filteredFeatures}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={onSearchQueryChange}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
nodeActionCallbacks={nodeActionCallbacks}
|
||||
backgroundStyle={backgroundImageStyle}
|
||||
className="h-full"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
export {
|
||||
useGraphNodes,
|
||||
type TaskNode,
|
||||
type DependencyEdge,
|
||||
type TaskNodeData,
|
||||
type NodeActionCallbacks,
|
||||
} from './use-graph-nodes';
|
||||
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
|
||||
export { useGraphLayout } from './use-graph-layout';
|
||||
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
|
||||
export interface GraphFilterState {
|
||||
searchQuery: string;
|
||||
selectedCategories: string[];
|
||||
selectedStatuses: string[];
|
||||
isNegativeFilter: boolean;
|
||||
}
|
||||
|
||||
// Available status filter values
|
||||
export const STATUS_FILTER_OPTIONS = [
|
||||
'running',
|
||||
'paused',
|
||||
'backlog',
|
||||
'waiting_approval',
|
||||
'verified',
|
||||
] as const;
|
||||
|
||||
export type StatusFilterValue = (typeof STATUS_FILTER_OPTIONS)[number];
|
||||
|
||||
export interface GraphFilterResult {
|
||||
matchedNodeIds: Set<string>;
|
||||
highlightedNodeIds: Set<string>;
|
||||
highlightedEdgeIds: Set<string>;
|
||||
availableCategories: string[];
|
||||
hasActiveFilter: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses up the dependency tree to find all ancestors of a node
|
||||
*/
|
||||
function getAncestors(
|
||||
featureId: string,
|
||||
featureMap: Map<string, Feature>,
|
||||
visited: Set<string>
|
||||
): void {
|
||||
if (visited.has(featureId)) return;
|
||||
visited.add(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (!feature?.dependencies) return;
|
||||
|
||||
const deps = feature.dependencies as string[] | undefined;
|
||||
if (!deps) return;
|
||||
|
||||
for (const depId of deps) {
|
||||
if (featureMap.has(depId)) {
|
||||
getAncestors(depId, featureMap, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses down to find all descendants (features that depend on this one)
|
||||
*/
|
||||
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
|
||||
if (visited.has(featureId)) return;
|
||||
visited.add(featureId);
|
||||
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies as string[] | undefined;
|
||||
if (deps?.includes(featureId)) {
|
||||
getDescendants(feature.id, features, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all edges in the highlighted path
|
||||
*/
|
||||
function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[]): Set<string> {
|
||||
const edges = new Set<string>();
|
||||
|
||||
for (const feature of features) {
|
||||
if (!highlightedNodeIds.has(feature.id)) continue;
|
||||
const deps = feature.dependencies as string[] | undefined;
|
||||
if (!deps) continue;
|
||||
|
||||
for (const depId of deps) {
|
||||
if (highlightedNodeIds.has(depId)) {
|
||||
edges.add(`${depId}->${feature.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective status of a feature (accounting for running state)
|
||||
*/
|
||||
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
|
||||
if (feature.status === 'in_progress') {
|
||||
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
|
||||
}
|
||||
return feature.status as StatusFilterValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate graph filter results based on search query, categories, statuses, and filter mode
|
||||
*/
|
||||
export function useGraphFilter(
|
||||
features: Feature[],
|
||||
filterState: GraphFilterState,
|
||||
runningAutoTasks: string[] = []
|
||||
): GraphFilterResult {
|
||||
const { searchQuery, selectedCategories, selectedStatuses, isNegativeFilter } = filterState;
|
||||
|
||||
return useMemo(() => {
|
||||
// Extract all unique categories
|
||||
const availableCategories = Array.from(
|
||||
new Set(features.map((f) => f.category).filter(Boolean))
|
||||
).sort();
|
||||
|
||||
const normalizedQuery = searchQuery.toLowerCase().trim();
|
||||
const hasSearchQuery = normalizedQuery.length > 0;
|
||||
const hasCategoryFilter = selectedCategories.length > 0;
|
||||
const hasStatusFilter = selectedStatuses.length > 0;
|
||||
const hasActiveFilter =
|
||||
hasSearchQuery || hasCategoryFilter || hasStatusFilter || isNegativeFilter;
|
||||
|
||||
// If no filters active, return empty sets (show all nodes normally)
|
||||
if (!hasActiveFilter) {
|
||||
return {
|
||||
matchedNodeIds: new Set<string>(),
|
||||
highlightedNodeIds: new Set<string>(),
|
||||
highlightedEdgeIds: new Set<string>(),
|
||||
availableCategories,
|
||||
hasActiveFilter: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Find directly matched nodes
|
||||
const matchedNodeIds = new Set<string>();
|
||||
const featureMap = new Map(features.map((f) => [f.id, f]));
|
||||
|
||||
for (const feature of features) {
|
||||
let matchesSearch = true;
|
||||
let matchesCategory = true;
|
||||
let matchesStatus = true;
|
||||
|
||||
// Check search query match (title or description)
|
||||
if (hasSearchQuery) {
|
||||
const titleMatch = feature.title?.toLowerCase().includes(normalizedQuery);
|
||||
const descMatch = feature.description?.toLowerCase().includes(normalizedQuery);
|
||||
matchesSearch = titleMatch || descMatch;
|
||||
}
|
||||
|
||||
// Check category match
|
||||
if (hasCategoryFilter) {
|
||||
matchesCategory = selectedCategories.includes(feature.category);
|
||||
}
|
||||
|
||||
// Check status match
|
||||
if (hasStatusFilter) {
|
||||
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
|
||||
matchesStatus = selectedStatuses.includes(effectiveStatus);
|
||||
}
|
||||
|
||||
// All conditions must be true for a match
|
||||
if (matchesSearch && matchesCategory && matchesStatus) {
|
||||
matchedNodeIds.add(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply negative filter if enabled (invert the matched set)
|
||||
let effectiveMatchedIds: Set<string>;
|
||||
if (isNegativeFilter) {
|
||||
effectiveMatchedIds = new Set(
|
||||
features.filter((f) => !matchedNodeIds.has(f.id)).map((f) => f.id)
|
||||
);
|
||||
} else {
|
||||
effectiveMatchedIds = matchedNodeIds;
|
||||
}
|
||||
|
||||
// Calculate full path (ancestors + descendants) for highlighted nodes
|
||||
const highlightedNodeIds = new Set<string>();
|
||||
|
||||
for (const id of effectiveMatchedIds) {
|
||||
// Add the matched node itself
|
||||
highlightedNodeIds.add(id);
|
||||
|
||||
// Add all ancestors (dependencies)
|
||||
getAncestors(id, featureMap, highlightedNodeIds);
|
||||
|
||||
// Add all descendants (dependents)
|
||||
getDescendants(id, features, highlightedNodeIds);
|
||||
}
|
||||
|
||||
// Get edges in the highlighted path
|
||||
const highlightedEdgeIds = getHighlightedEdges(highlightedNodeIds, features);
|
||||
|
||||
return {
|
||||
matchedNodeIds: effectiveMatchedIds,
|
||||
highlightedNodeIds,
|
||||
highlightedEdgeIds,
|
||||
availableCategories,
|
||||
hasActiveFilter: true,
|
||||
};
|
||||
}, [
|
||||
features,
|
||||
searchQuery,
|
||||
selectedCategories,
|
||||
selectedStatuses,
|
||||
isNegativeFilter,
|
||||
runningAutoTasks,
|
||||
]);
|
||||
}
|
||||
@@ -2,63 +2,26 @@ import { useMemo } from 'react';
|
||||
import { Node, Edge } from '@xyflow/react';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { GraphFilterResult } from './use-graph-filter';
|
||||
|
||||
export interface TaskNodeData extends Feature {
|
||||
// Re-declare properties from BaseFeature that have index signature issues
|
||||
priority?: number;
|
||||
error?: string;
|
||||
branchName?: string;
|
||||
dependencies?: string[];
|
||||
// Task node specific properties
|
||||
isBlocked: boolean;
|
||||
isRunning: boolean;
|
||||
blockingDependencies: string[];
|
||||
// Filter highlight states
|
||||
isMatched?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
// Action callbacks
|
||||
onViewLogs?: () => void;
|
||||
onViewDetails?: () => void;
|
||||
onStartTask?: () => void;
|
||||
onStopTask?: () => void;
|
||||
onResumeTask?: () => void;
|
||||
}
|
||||
|
||||
export type TaskNode = Node<TaskNodeData, 'task'>;
|
||||
export type DependencyEdge = Edge<{
|
||||
sourceStatus: Feature['status'];
|
||||
targetStatus: Feature['status'];
|
||||
isHighlighted?: boolean;
|
||||
isDimmed?: boolean;
|
||||
}>;
|
||||
|
||||
export interface NodeActionCallbacks {
|
||||
onViewLogs?: (featureId: string) => void;
|
||||
onViewDetails?: (featureId: string) => void;
|
||||
onStartTask?: (featureId: string) => void;
|
||||
onStopTask?: (featureId: string) => void;
|
||||
onResumeTask?: (featureId: string) => void;
|
||||
}
|
||||
export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>;
|
||||
|
||||
interface UseGraphNodesProps {
|
||||
features: Feature[];
|
||||
runningAutoTasks: string[];
|
||||
filterResult?: GraphFilterResult;
|
||||
actionCallbacks?: NodeActionCallbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms features into React Flow nodes and edges
|
||||
* Creates dependency edges based on feature.dependencies array
|
||||
*/
|
||||
export function useGraphNodes({
|
||||
features,
|
||||
runningAutoTasks,
|
||||
filterResult,
|
||||
actionCallbacks,
|
||||
}: UseGraphNodesProps) {
|
||||
export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) {
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
const nodeList: TaskNode[] = [];
|
||||
const edgeList: DependencyEdge[] = [];
|
||||
@@ -67,22 +30,11 @@ export function useGraphNodes({
|
||||
// Create feature map for quick lookups
|
||||
features.forEach((f) => featureMap.set(f.id, f));
|
||||
|
||||
// Extract filter state
|
||||
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
|
||||
const matchedNodeIds = filterResult?.matchedNodeIds ?? new Set<string>();
|
||||
const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set<string>();
|
||||
const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set<string>();
|
||||
|
||||
// Create nodes
|
||||
features.forEach((feature) => {
|
||||
const isRunning = runningAutoTasks.includes(feature.id);
|
||||
const blockingDeps = getBlockingDependencies(feature, features);
|
||||
|
||||
// Calculate filter highlight states
|
||||
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
|
||||
const isHighlighted = hasActiveFilter && highlightedNodeIds.has(feature.id);
|
||||
const isDimmed = hasActiveFilter && !highlightedNodeIds.has(feature.id);
|
||||
|
||||
const node: TaskNode = {
|
||||
id: feature.id,
|
||||
type: 'task',
|
||||
@@ -92,46 +44,19 @@ export function useGraphNodes({
|
||||
isBlocked: blockingDeps.length > 0,
|
||||
isRunning,
|
||||
blockingDependencies: blockingDeps,
|
||||
// Filter states
|
||||
isMatched,
|
||||
isHighlighted,
|
||||
isDimmed,
|
||||
// Action callbacks (bound to this feature's ID)
|
||||
onViewLogs: actionCallbacks?.onViewLogs
|
||||
? () => actionCallbacks.onViewLogs!(feature.id)
|
||||
: undefined,
|
||||
onViewDetails: actionCallbacks?.onViewDetails
|
||||
? () => actionCallbacks.onViewDetails!(feature.id)
|
||||
: undefined,
|
||||
onStartTask: actionCallbacks?.onStartTask
|
||||
? () => actionCallbacks.onStartTask!(feature.id)
|
||||
: undefined,
|
||||
onStopTask: actionCallbacks?.onStopTask
|
||||
? () => actionCallbacks.onStopTask!(feature.id)
|
||||
: undefined,
|
||||
onResumeTask: actionCallbacks?.onResumeTask
|
||||
? () => actionCallbacks.onResumeTask!(feature.id)
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
nodeList.push(node);
|
||||
|
||||
// Create edges for dependencies
|
||||
const deps = feature.dependencies as string[] | undefined;
|
||||
if (deps && deps.length > 0) {
|
||||
deps.forEach((depId: string) => {
|
||||
if (feature.dependencies && feature.dependencies.length > 0) {
|
||||
feature.dependencies.forEach((depId: string) => {
|
||||
// Only create edge if the dependency exists in current view
|
||||
if (featureMap.has(depId)) {
|
||||
const sourceFeature = featureMap.get(depId)!;
|
||||
const edgeId = `${depId}->${feature.id}`;
|
||||
|
||||
// Calculate edge highlight states
|
||||
const edgeIsHighlighted = hasActiveFilter && highlightedEdgeIds.has(edgeId);
|
||||
const edgeIsDimmed = hasActiveFilter && !highlightedEdgeIds.has(edgeId);
|
||||
|
||||
const edge: DependencyEdge = {
|
||||
id: edgeId,
|
||||
id: `${depId}->${feature.id}`,
|
||||
source: depId,
|
||||
target: feature.id,
|
||||
type: 'dependency',
|
||||
@@ -139,8 +64,6 @@ export function useGraphNodes({
|
||||
data: {
|
||||
sourceStatus: sourceFeature.status,
|
||||
targetStatus: feature.status,
|
||||
isHighlighted: edgeIsHighlighted,
|
||||
isDimmed: edgeIsDimmed,
|
||||
},
|
||||
};
|
||||
edgeList.push(edge);
|
||||
@@ -150,7 +73,7 @@ export function useGraphNodes({
|
||||
});
|
||||
|
||||
return { nodes: nodeList, edges: edgeList };
|
||||
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
|
||||
}, [features, runningAutoTasks]);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export { GraphView } from './graph-view';
|
||||
export { GraphCanvas } from './graph-canvas';
|
||||
export { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
|
||||
export {
|
||||
useGraphNodes,
|
||||
useGraphLayout,
|
||||
type TaskNode as TaskNodeType,
|
||||
type DependencyEdge as DependencyEdgeType,
|
||||
} from './hooks';
|
||||
export { useGraphNodes, useGraphLayout, type TaskNode as TaskNodeType, type DependencyEdge as DependencyEdgeType } from './hooks';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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';
|
||||
@@ -105,121 +103,102 @@ export function RunningAgentsView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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 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>
|
||||
<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 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>
|
||||
</GlassPanel>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { PageShell } from '@/components/layout/page-shell';
|
||||
|
||||
import { useCliStatus, useSettingsView } from './settings-view/hooks';
|
||||
import { NAV_ITEMS } from './settings-view/config/navigation';
|
||||
@@ -110,7 +111,7 @@ export function SettingsView() {
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
currentProject={settingsProject}
|
||||
onThemeChange={handleSetTheme as (theme: Theme) => void}
|
||||
onThemeChange={handleSetTheme}
|
||||
/>
|
||||
);
|
||||
case 'terminal':
|
||||
@@ -156,36 +157,38 @@ export function SettingsView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
<PageShell>
|
||||
<div className="flex-1 flex flex-col overflow-hidden h-full" data-testid="settings-view">
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
||||
<SettingsNavigation
|
||||
navItems={NAV_ITEMS}
|
||||
activeSection={activeView}
|
||||
currentProject={currentProject}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
||||
<SettingsNavigation
|
||||
navItems={NAV_ITEMS}
|
||||
activeSection={activeView}
|
||||
currentProject={currentProject}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
|
||||
{/* Content Panel - Shows only the active section */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||
{/* Content Panel - Shows only the active section */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog open={showKeyboardMapDialog} onOpenChange={setShowKeyboardMapDialog} />
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,7 @@ export function SettingsHeader({
|
||||
description = 'Configure your API keys and preferences',
|
||||
}: SettingsHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
'border-b border-border/50',
|
||||
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
|
||||
)}
|
||||
>
|
||||
<div className={cn('shrink-0', 'border-b border-white/10', 'bg-white/5 backdrop-blur-xl')}>
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
|
||||
@@ -115,7 +115,7 @@ export function SpecView() {
|
||||
|
||||
// Main view - spec exists
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
|
||||
<div className="flex-1 flex flex-col overflow-hidden" data-testid="spec-view">
|
||||
<SpecHeader
|
||||
projectPath={currentProject.path}
|
||||
isRegenerating={isRegenerating}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function SpecHeader({
|
||||
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5 backdrop-blur-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
|
||||
@@ -46,8 +46,7 @@ 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';
|
||||
import { PageShell } from '@/components/layout/page-shell';
|
||||
|
||||
interface TerminalStatus {
|
||||
enabled: boolean;
|
||||
@@ -143,11 +142,11 @@ function TerminalTabButton({
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-grab active:cursor-grabbing transition-colors select-none',
|
||||
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-grab active:cursor-grabbing transition-all select-none',
|
||||
isActive
|
||||
? 'bg-background border-brand-500 text-foreground'
|
||||
: 'bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
isOver && isDropTarget && isDraggingTab && 'ring-2 ring-blue-500 bg-blue-500/10',
|
||||
? 'bg-white/10 border-cyan-500 text-cyan-100 shadow-[0_-1px_10px_rgba(6,182,212,0.1)]'
|
||||
: 'bg-white/5 border-transparent text-muted-foreground hover:text-cyan-50 hover:bg-white/10',
|
||||
isOver && isDropTarget && isDraggingTab && 'ring-2 ring-cyan-500/50 bg-cyan-500/10',
|
||||
isDragging && 'opacity-50'
|
||||
)}
|
||||
onClick={onClick}
|
||||
@@ -194,8 +193,8 @@ function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
|
||||
className={cn(
|
||||
'flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all',
|
||||
isOver && isDropTarget
|
||||
? 'border-green-500 bg-green-500/10 text-green-500'
|
||||
: 'border-transparent text-muted-foreground hover:border-border'
|
||||
? 'border-cyan-500/50 bg-cyan-500/10 text-cyan-400'
|
||||
: 'border-transparent text-muted-foreground hover:border-white/10'
|
||||
)}
|
||||
>
|
||||
<SquarePlus className="h-4 w-4" />
|
||||
@@ -1416,210 +1415,256 @@ export function TerminalView() {
|
||||
|
||||
// Terminal view with tabs
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="terminal-view">
|
||||
<TopHeader />
|
||||
<PageShell fullWidth>
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
|
||||
|
||||
{/* 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}
|
||||
{/* 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"
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{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>
|
||||
) : 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>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* Lock/Unlock Toggle */}
|
||||
{/* Toolbar buttons */}
|
||||
<div className="flex items-center gap-1 pl-2 border-l border-border">
|
||||
<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
|
||||
}
|
||||
}}
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => createTerminal('horizontal')}
|
||||
title="Split Right"
|
||||
>
|
||||
{terminalState.isUnlocked ? (
|
||||
<Unlock className="w-4 h-4" />
|
||||
) : (
|
||||
<Lock className="w-4 h-4" />
|
||||
)}
|
||||
{terminalState.isUnlocked ? 'Unlocked' : 'Locked'}
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
{/* 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);
|
||||
}}
|
||||
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)
|
||||
) : (
|
||||
/* 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
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</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>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -543,7 +543,6 @@ export function TerminalPanel({
|
||||
allowProposedApi: true,
|
||||
screenReaderMode: screenReaderEnabled,
|
||||
scrollback: terminalScrollback,
|
||||
allowTransparency: true,
|
||||
});
|
||||
|
||||
// Create fit addon
|
||||
|
||||
@@ -61,7 +61,7 @@ export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
|
||||
|
||||
// Dark theme (default)
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: 'transparent', // Transparent for glassmorphism
|
||||
background: '#0a0a0a',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#d4d4d4',
|
||||
cursorAccent: '#0a0a0a',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
@@ -159,9 +159,10 @@ function RootLayoutContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<Sidebar />
|
||||
<div
|
||||
className="h-full flex flex-col overflow-hidden transition-all duration-300"
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
||||
>
|
||||
<Outlet />
|
||||
@@ -169,12 +170,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 z-50 ${
|
||||
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
||||
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
/>
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</AppLayout>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,25 +4,12 @@ import type { Project, TrashedProject } from '@/lib/electron';
|
||||
import type {
|
||||
Feature as BaseFeature,
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath, // Import missing type
|
||||
AgentModel,
|
||||
PlanningMode,
|
||||
AIProfile,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
FeatureTextFilePath,
|
||||
} from '@automaker/types';
|
||||
|
||||
export type {
|
||||
AgentModel,
|
||||
ThinkingLevel,
|
||||
ModelProvider,
|
||||
AIProfile,
|
||||
PlanningMode,
|
||||
FeatureTextFilePath,
|
||||
};
|
||||
|
||||
// ThemeMode is defined below, no need to re-export here
|
||||
|
||||
export type ViewMode =
|
||||
| 'welcome'
|
||||
| 'setup'
|
||||
@@ -54,7 +41,25 @@ export type ThemeMode =
|
||||
| 'red'
|
||||
| 'cream'
|
||||
| 'sunset'
|
||||
| 'gray';
|
||||
| 'gray'
|
||||
| 'forest'
|
||||
| 'ocean'
|
||||
| 'light'
|
||||
| 'cream'
|
||||
| 'solarizedlight'
|
||||
| 'github'
|
||||
| 'paper'
|
||||
| 'rose'
|
||||
| 'mint'
|
||||
| 'lavender'
|
||||
| 'sand'
|
||||
| 'sky'
|
||||
| 'peach'
|
||||
| 'snow'
|
||||
| 'sepia'
|
||||
| 'gruvboxlight'
|
||||
| 'nordlight'
|
||||
| 'blossom';
|
||||
|
||||
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
|
||||
|
||||
@@ -273,24 +278,13 @@ export interface Feature extends Omit<
|
||||
titleGenerating?: boolean;
|
||||
category: string;
|
||||
description: string;
|
||||
steps?: string[] | undefined; // Optional in UI
|
||||
steps: string[]; // Required in UI (not optional)
|
||||
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)
|
||||
@@ -903,9 +897,6 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
||||
];
|
||||
|
||||
const initialState: AppState = {
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
claudeUsageLastUpdated: null,
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
trashedProjects: [],
|
||||
@@ -928,19 +919,19 @@ const initialState: AppState = {
|
||||
chatHistoryOpen: false,
|
||||
autoModeByProject: {},
|
||||
autoModeActivityLog: [],
|
||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
||||
boardViewMode: 'kanban', // Default to kanban view
|
||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||
maxConcurrency: 3,
|
||||
kanbanCardDetailLevel: 'standard',
|
||||
boardViewMode: 'kanban',
|
||||
defaultSkipTests: false,
|
||||
enableDependencyBlocking: true,
|
||||
useWorktrees: false,
|
||||
currentWorktreeByProject: {},
|
||||
worktreesByProject: {},
|
||||
showProfilesOnly: false, // Default to showing all options (not profiles only)
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
|
||||
muteDoneSound: false, // Default to sound enabled (not muted)
|
||||
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
|
||||
aiProfiles: DEFAULT_AI_PROFILES,
|
||||
showProfilesOnly: false,
|
||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
muteDoneSound: false,
|
||||
enhancementModel: 'sonnet',
|
||||
projectAnalysis: null,
|
||||
isAnalyzing: false,
|
||||
boardBackgroundByProject: {},
|
||||
@@ -953,19 +944,23 @@ const initialState: AppState = {
|
||||
activeSessionId: null,
|
||||
maximizedSessionId: null,
|
||||
defaultFontSize: 14,
|
||||
defaultRunScript: '',
|
||||
defaultRunScript: '', // Empty string = standard shell
|
||||
screenReaderMode: false,
|
||||
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
||||
scrollbackLines: 5000,
|
||||
lineHeight: 1.0,
|
||||
maxSessions: 100,
|
||||
fontFamily: 'monospace',
|
||||
scrollbackLines: 1000,
|
||||
lineHeight: 1.2,
|
||||
maxSessions: 20,
|
||||
},
|
||||
terminalLayoutByProject: {},
|
||||
specCreatingForProject: null,
|
||||
defaultPlanningMode: 'skip' as PlanningMode,
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultPlanningMode: 'lite',
|
||||
defaultRequirePlanApproval: true,
|
||||
defaultAIProfileId: null,
|
||||
pendingPlanApproval: null,
|
||||
// Claude Usage Defaults
|
||||
claudeRefreshInterval: 60,
|
||||
claudeUsage: null,
|
||||
claudeUsageLastUpdated: null,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
5
apps/ui/src/types/electron.d.ts
vendored
5
apps/ui/src/types/electron.d.ts
vendored
@@ -733,7 +733,6 @@ export interface WorktreeAPI {
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||
}>;
|
||||
|
||||
// Create a pull request from a worktree
|
||||
@@ -784,7 +783,6 @@ export interface WorktreeAPI {
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||
}>;
|
||||
|
||||
// Create and checkout a new branch
|
||||
@@ -799,7 +797,6 @@ export interface WorktreeAPI {
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||
}>;
|
||||
|
||||
// List all local branches
|
||||
@@ -816,7 +813,6 @@ export interface WorktreeAPI {
|
||||
behindCount: number;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues
|
||||
}>;
|
||||
|
||||
// Switch to an existing branch
|
||||
@@ -831,7 +827,6 @@ export interface WorktreeAPI {
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
|
||||
}>;
|
||||
|
||||
// Open a worktree directory in the editor
|
||||
|
||||
723
index (25).html
Normal file
723
index (25).html
Normal file
@@ -0,0 +1,723 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>automaker. | Kanban Board</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg-deep: #0b101a;
|
||||
--sidebar-bg: rgba(13, 17, 26, 0.7);
|
||||
--accent-cyan: #22d3ee;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-green: #10b981;
|
||||
--accent-red: #ef4444;
|
||||
--glass-bg: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.07);
|
||||
--card-radius: 1.5rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-deep);
|
||||
color: #e2e8f0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Rainbow Prism Background Effect */
|
||||
.prism-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(circle at 10% 20%, rgba(34, 211, 238, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 80%, rgba(139, 92, 246, 0.08) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 50%, rgba(245, 158, 11, 0.04) 0%, transparent 60%),
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 0, 0, 0.02) 0%,
|
||||
rgba(0, 255, 255, 0.02) 50%,
|
||||
rgba(0, 0, 255, 0.02) 100%
|
||||
);
|
||||
filter: blur(80px);
|
||||
}
|
||||
|
||||
/* Glassmorphism */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.sidebar-glass {
|
||||
background: var(--sidebar-bg);
|
||||
backdrop-filter: blur(40px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Nav Active State */
|
||||
.nav-active {
|
||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.12) 0%, transparent 100%);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Column Specific Visuals */
|
||||
.col-in-progress {
|
||||
border: 1px solid rgba(34, 211, 238, 0.25);
|
||||
background: rgba(34, 211, 238, 0.02);
|
||||
box-shadow: inset 0 0 40px rgba(34, 211, 238, 0.03);
|
||||
}
|
||||
.col-waiting {
|
||||
border-top: 2px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.col-verified {
|
||||
border-top: 2px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Shortcut Badge */
|
||||
.shortcut-badge {
|
||||
font-size: 9px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-track {
|
||||
width: 42px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.kanban-card {
|
||||
border-radius: var(--card-radius);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-3px);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-cyan {
|
||||
background: var(--accent-cyan);
|
||||
color: #0b101a;
|
||||
font-weight: 800;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-cyan:hover {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 0 15px rgba(34, 211, 238, 0.4);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.glow-cyan {
|
||||
box-shadow: 0 0 10px var(--accent-cyan);
|
||||
}
|
||||
.glow-orange {
|
||||
box-shadow: 0 0 10px var(--accent-orange);
|
||||
}
|
||||
.glow-green {
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex h-screen w-screen overflow-hidden">
|
||||
<div class="prism-bg"></div>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="w-64 h-full sidebar-glass flex flex-col z-30 shrink-0">
|
||||
<!-- Brand -->
|
||||
<div class="p-6 flex items-center gap-2.5">
|
||||
<div class="bg-cyan-500/10 p-1.5 rounded-lg border border-cyan-500/30">
|
||||
<i data-lucide="code-2" class="w-5 h-5 text-cyan-400 stroke-[2.5px]"></i>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold tracking-tighter text-white">
|
||||
automaker<span class="text-cyan-400">.</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Top Actions -->
|
||||
<div class="px-4 space-y-3 mb-8">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 glass py-2 rounded-xl text-[11px] font-bold hover:bg-white/10 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<i data-lucide="plus" class="w-3.5 h-3.5"></i> New
|
||||
</button>
|
||||
<button
|
||||
class="w-10 glass rounded-xl flex items-center justify-center hover:bg-white/10 transition relative"
|
||||
>
|
||||
<i data-lucide="file-text" class="w-4 h-4 opacity-40"></i>
|
||||
<span class="mono text-[8px] absolute top-1 right-1.5 opacity-40 font-bold">0</span>
|
||||
</button>
|
||||
<button
|
||||
class="w-10 glass rounded-xl flex items-center justify-center hover:bg-white/10 transition relative"
|
||||
>
|
||||
<i data-lucide="layers" class="w-4 h-4 opacity-40"></i>
|
||||
<span
|
||||
class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-rose-500 rounded-full border border-[#0b101a]"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Project Dropdown -->
|
||||
<div
|
||||
class="glass p-3.5 rounded-2xl flex items-center justify-between cursor-pointer hover:bg-white/5 transition group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="folder-kanban" class="w-4 h-4 text-cyan-400"></i>
|
||||
<span class="text-xs font-bold text-slate-200">test case 1</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shortcut-badge">P</span>
|
||||
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-500 group-hover:text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-0 space-y-8 overflow-y-auto custom-scrollbar">
|
||||
<div>
|
||||
<p class="px-6 text-[10px] font-black text-slate-600 uppercase tracking-[0.2em] mb-3">
|
||||
Project
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
<a href="#" class="nav-active flex items-center justify-between px-6 py-3 text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="layout-grid" class="w-4 h-4"></i
|
||||
><span class="font-medium">Kanban Board</span>
|
||||
</div>
|
||||
<span class="shortcut-badge">E</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-between px-6 py-3 text-sm text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="zap" class="w-4 h-4"></i
|
||||
><span class="font-medium">Agent Runner</span>
|
||||
</div>
|
||||
<span class="shortcut-badge">A</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="px-6 text-[10px] font-black text-slate-600 uppercase tracking-[0.2em] mb-3">
|
||||
Tools
|
||||
</p>
|
||||
<div class="space-y-0.5">
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-between px-6 py-3 text-sm text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="file-edit" class="w-4 h-4"></i><span>Spec Editor</span>
|
||||
</div>
|
||||
<span class="shortcut-badge">D</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-between px-6 py-3 text-sm text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="database" class="w-4 h-4"></i><span>Context</span>
|
||||
</div>
|
||||
<span class="shortcut-badge">C</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-between px-6 py-3 text-sm text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="user-circle" class="w-4 h-4"></i><span>AI Profiles</span>
|
||||
</div>
|
||||
<span class="shortcut-badge">H</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center justify-between px-6 py-3 text-sm text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="terminal" class="w-4 h-4"></i><span>Terminal</span>
|
||||
</div>
|
||||
<span class="shortcut-badge">T</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-white/5 space-y-1 mt-auto bg-black/10">
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white transition"
|
||||
>
|
||||
<i data-lucide="book-open" class="w-4 h-4"></i> Wiki
|
||||
</a>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 text-sm text-slate-400 hover:text-white cursor-pointer group transition"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<i data-lucide="activity" class="w-4 h-4 text-cyan-400"></i> Running Agents
|
||||
</div>
|
||||
<span class="bg-cyan-500 text-slate-950 font-black text-[10px] px-2 py-0.5 rounded-full"
|
||||
>3</span
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white transition"
|
||||
>
|
||||
<i data-lucide="settings" class="w-4 h-4"></i> Settings
|
||||
<span class="ml-auto shortcut-badge">S</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="h-16 flex items-center justify-between px-8 border-b border-white/5 bg-[#0b101a]/40 backdrop-blur-md z-20 shrink-0"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white tracking-tight">Kanban Board</h2>
|
||||
<p class="text-[10px] text-slate-500 uppercase tracking-[0.2em] font-bold mono">
|
||||
test case 1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-5">
|
||||
<div
|
||||
class="flex items-center bg-white/5 border border-white/10 rounded-full px-4 py-1.5 gap-3"
|
||||
>
|
||||
<i data-lucide="users" class="w-4 h-4 text-slate-500"></i>
|
||||
<div class="toggle-track">
|
||||
<div class="toggle-thumb"></div>
|
||||
</div>
|
||||
<span class="mono text-xs font-bold text-slate-400">3</span>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 glass px-5 py-2 rounded-xl text-xs font-bold hover:bg-white/10 transition"
|
||||
>
|
||||
<i data-lucide="play" class="w-3.5 h-3.5 text-cyan-400 fill-cyan-400"></i> Auto Mode
|
||||
</button>
|
||||
<button
|
||||
class="btn-cyan px-6 py-2 rounded-xl text-xs font-black flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||
>
|
||||
<i data-lucide="plus" class="w-4 h-4 stroke-[3.5px]"></i> ADD FEATURE
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="px-8 py-4 flex items-center justify-between shrink-0">
|
||||
<div class="relative flex-1 max-w-2xl group">
|
||||
<i
|
||||
data-lucide="search"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-400 transition-colors"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search features by keyword..."
|
||||
class="w-full bg-white/5 border border-white/10 rounded-2xl py-2.5 pl-12 pr-12 text-sm focus:outline-none focus:border-cyan-500/50 transition-all mono"
|
||||
/>
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<span class="shortcut-badge">/</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-6">
|
||||
<button class="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition">
|
||||
<i data-lucide="history" class="w-4.5 h-4.5"></i>
|
||||
</button>
|
||||
<button class="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition">
|
||||
<i data-lucide="trash-2" class="w-4.5 h-4.5"></i>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-white/10 mx-1"></div>
|
||||
<button class="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition">
|
||||
<i data-lucide="maximize-2" class="w-4.5 h-4.5"></i>
|
||||
</button>
|
||||
<button class="p-2.5 glass rounded-xl text-slate-500 hover:text-white transition">
|
||||
<i data-lucide="layout" class="w-4.5 h-4.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Grid -->
|
||||
<div class="flex-1 overflow-x-auto custom-scrollbar px-8 pb-8">
|
||||
<div class="flex gap-6 h-full min-w-max items-start">
|
||||
<!-- COLUMN: BACKLOG -->
|
||||
<div class="w-80 flex flex-col gap-5 h-full">
|
||||
<div class="flex items-center justify-between px-2 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-dot bg-slate-600"></span>
|
||||
<h3 class="text-[11px] font-black text-slate-400 uppercase tracking-widest">
|
||||
Backlog
|
||||
</h3>
|
||||
<div class="flex items-center gap-1.5 opacity-40">
|
||||
<i data-lucide="lightbulb" class="w-3.5 h-3.5 text-yellow-500"></i>
|
||||
<i data-lucide="git-branch" class="w-3.5 h-3.5 text-cyan-400"></i>
|
||||
<span class="mono text-[9px] text-cyan-400 font-bold">Mabe 6</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="mono text-[10px] bg-white/5 px-2.5 py-0.5 rounded-full text-slate-500 border border-white/5"
|
||||
>47</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-4 pr-2">
|
||||
<div class="glass kanban-card flex flex-col gap-4 group relative">
|
||||
<div
|
||||
class="absolute top-5 right-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<i
|
||||
data-lucide="trash-2"
|
||||
class="w-4 h-4 text-slate-600 hover:text-red-400 cursor-pointer"
|
||||
></i>
|
||||
</div>
|
||||
<p class="text-[13px] text-slate-300 leading-relaxed font-medium line-clamp-3">
|
||||
Create a bouncing animation using CSS keyframes that simulates elastic motion...
|
||||
</p>
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] text-slate-500 -mt-1 cursor-pointer hover:text-slate-300"
|
||||
>
|
||||
<i data-lucide="chevron-down" class="w-3 h-3"></i> More
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] font-bold text-cyan-400/80 mono flex items-center gap-1.5 uppercase tracking-tight"
|
||||
>
|
||||
<i data-lucide="layers" class="w-3.5 h-3.5"></i> Opus 4.2
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 glass py-2.5 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:bg-white/10 transition"
|
||||
>
|
||||
<i data-lucide="edit-3" class="w-3.5 h-3.5"></i> Edit
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/20 py-2.5 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<i data-lucide="target" class="w-3.5 h-3.5"></i> Make
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass kanban-card flex flex-col gap-4">
|
||||
<p class="text-[13px] text-slate-300 leading-relaxed font-medium line-clamp-3">
|
||||
Implement CSS reset rules and establish base styling for the page including
|
||||
typography, spacing...
|
||||
</p>
|
||||
<div
|
||||
class="text-[10px] font-bold text-cyan-400/80 mono flex items-center gap-1.5 uppercase"
|
||||
>
|
||||
<i data-lucide="layers" class="w-3.5 h-3.5"></i> Opus 4.3
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="flex-1 glass py-2.5 rounded-xl text-[11px] font-bold">Edit</button>
|
||||
<button
|
||||
class="flex-1 bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 py-2.5 rounded-xl text-[11px] font-bold"
|
||||
>
|
||||
Make
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLUMN: IN PROGRESS -->
|
||||
<div class="w-80 flex flex-col gap-5 col-in-progress rounded-[2.5rem] p-3 h-full">
|
||||
<div class="flex items-center justify-between px-2 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-dot bg-cyan-400 glow-cyan"></span>
|
||||
<h3 class="text-[11px] font-black text-slate-200 uppercase tracking-widest">
|
||||
In Progress
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
class="mono text-[10px] bg-cyan-500/10 px-2.5 py-0.5 rounded-full text-cyan-400 border border-cyan-500/20"
|
||||
>3</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-4 pr-1">
|
||||
<!-- Active Card -->
|
||||
<div
|
||||
class="glass kanban-card border-cyan-500/40 bg-cyan-500/[0.08] flex flex-col gap-4"
|
||||
>
|
||||
<div class="flex justify-end items-center gap-2">
|
||||
<div
|
||||
class="bg-orange-500/15 text-orange-400 text-[9px] px-2.5 py-1 rounded-lg border border-orange-500/20 flex items-center gap-1.5 font-black mono"
|
||||
>
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3"></i> Opus 6.5
|
||||
</div>
|
||||
<div
|
||||
class="bg-slate-900/50 text-slate-500 text-[9px] px-2 py-1 rounded-lg border border-white/5 font-mono"
|
||||
>
|
||||
00:04
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[13px] text-white leading-relaxed font-semibold">
|
||||
Configure the application for deployment to a web hosting platform. Set up
|
||||
necessary build processes...
|
||||
</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="flex-[4] btn-cyan py-3 rounded-xl text-[11px] font-black flex items-center justify-center gap-2 tracking-widest"
|
||||
>
|
||||
<i data-lucide="terminal" class="w-4 h-4 stroke-[2.5px]"></i> LOGS
|
||||
<span class="bg-black/10 px-1.5 rounded ml-1">8</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-rose-500 hover:bg-rose-600 text-white py-3 rounded-xl flex items-center justify-center transition shadow-lg shadow-rose-500/20"
|
||||
>
|
||||
<i data-lucide="square" class="w-4 h-4 fill-current"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card 2 -->
|
||||
<div class="glass kanban-card flex flex-col gap-4">
|
||||
<div class="flex justify-end gap-2">
|
||||
<div
|
||||
class="bg-orange-500/10 text-orange-400 text-[9px] px-2.5 py-1 rounded-lg border border-orange-500/10 flex items-center gap-1.5 font-bold mono"
|
||||
>
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3"></i> Opus 4.5
|
||||
</div>
|
||||
<div
|
||||
class="bg-slate-900/50 text-slate-500 text-[9px] px-2 py-1 rounded-lg border border-white/5 font-mono"
|
||||
>
|
||||
00:07
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[13px] text-slate-300 leading-relaxed font-medium">
|
||||
Create helper functions for selecting and querying DOM elements. Provide reusable
|
||||
utilities for element...
|
||||
</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="flex-[4] bg-cyan-500/15 text-cyan-400 border border-cyan-500/20 py-3 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2"
|
||||
>
|
||||
<i data-lucide="terminal" class="w-4 h-4"></i> LOGS
|
||||
<span class="bg-cyan-500/10 px-1.5 rounded ml-1">2</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-rose-500/20 text-rose-500/50 border border-rose-500/20 py-3 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<i data-lucide="square" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLUMN: WAITING APPROVAL -->
|
||||
<div class="w-80 flex flex-col gap-5 col-waiting rounded-[2.5rem] p-3 h-full">
|
||||
<div class="flex items-center justify-between px-2 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-dot bg-orange-500 glow-orange"></span>
|
||||
<h3 class="text-[11px] font-black text-slate-300 uppercase tracking-widest">
|
||||
Waiting Approval
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
class="mono text-[10px] bg-white/5 px-2.5 py-0.5 rounded-full text-slate-500 border border-white/5"
|
||||
>2</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-4 pr-1">
|
||||
<div class="glass kanban-card flex flex-col gap-4 group">
|
||||
<div
|
||||
class="flex justify-end gap-3.5 opacity-30 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<i
|
||||
data-lucide="edit-3"
|
||||
class="w-4 h-4 cursor-pointer hover:text-white transition"
|
||||
></i>
|
||||
<i
|
||||
data-lucide="copy"
|
||||
class="w-4 h-4 cursor-pointer hover:text-white transition"
|
||||
></i>
|
||||
<i
|
||||
data-lucide="trash-2"
|
||||
class="w-4 h-4 cursor-pointer hover:text-rose-400 transition"
|
||||
></i>
|
||||
</div>
|
||||
<p class="text-[13px] text-slate-300 leading-relaxed font-medium italic">
|
||||
Add descriptive labels and titles for each button animation style to identify them
|
||||
visually. Help users...
|
||||
</p>
|
||||
<div class="flex gap-2.5 mt-2">
|
||||
<button
|
||||
class="flex-1 glass border-white/10 py-3 rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:bg-white/10 transition"
|
||||
>
|
||||
<i data-lucide="wand-2" class="w-4 h-4"></i> Refine
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 btn-cyan py-3 rounded-xl text-[11px] font-black flex items-center justify-center gap-2 tracking-widest"
|
||||
>
|
||||
<i data-lucide="git-commit" class="w-4 h-4 stroke-[2.5px]"></i> COMMIT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLUMN: VERIFIED -->
|
||||
<div class="w-80 flex flex-col gap-5 col-verified rounded-[2.5rem] p-3 h-full">
|
||||
<div class="flex items-center justify-between px-2 shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-dot bg-emerald-500 glow-green"></span>
|
||||
<h3 class="text-[11px] font-black text-slate-300 uppercase tracking-widest">
|
||||
Verified
|
||||
</h3>
|
||||
<button
|
||||
class="ml-2 text-[10px] text-rose-500 flex items-center gap-1 hover:underline font-black transition"
|
||||
>
|
||||
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete All
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="mono text-[10px] bg-emerald-500/10 px-2.5 py-0.5 rounded-full text-emerald-500 border border-emerald-500/20"
|
||||
>4</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-4 pr-1">
|
||||
<div
|
||||
class="glass kanban-card opacity-60 hover:opacity-100 flex flex-col gap-4 transition-all"
|
||||
>
|
||||
<div class="flex justify-end gap-3.5 opacity-20">
|
||||
<i data-lucide="edit-3" class="w-4 h-4"></i>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<p
|
||||
class="text-[13px] text-slate-400 leading-relaxed line-through decoration-slate-600 font-medium"
|
||||
>
|
||||
Define foundational button styles with padding, borders, radius, and default
|
||||
colors. Create...
|
||||
</p>
|
||||
<div class="flex gap-2.5 mt-2">
|
||||
<button
|
||||
class="px-7 glass border-white/10 py-3 rounded-xl text-[11px] font-bold text-slate-500 hover:text-slate-300 transition"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 bg-emerald-500/15 text-emerald-400 border border-emerald-500/20 py-3 rounded-xl text-[11px] font-black flex items-center justify-center gap-2 tracking-widest"
|
||||
>
|
||||
<i data-lucide="check-circle" class="w-4 h-4 stroke-[2.5px]"></i> COMPLETE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Floating UI -->
|
||||
<div class="fixed bottom-8 right-8 flex flex-col gap-4 z-50">
|
||||
<button
|
||||
class="w-12 h-12 glass rounded-2xl flex items-center justify-center text-slate-400 hover:text-white transition shadow-2xl"
|
||||
>
|
||||
<i data-lucide="history" class="w-5 h-5"></i>
|
||||
</button>
|
||||
<button
|
||||
class="w-14 h-14 bg-cyan-500 rounded-2xl flex items-center justify-center text-slate-950 shadow-2xl shadow-cyan-500/40 hover:scale-110 active:scale-95 transition-all"
|
||||
>
|
||||
<i data-lucide="message-square" class="w-7 h-7 stroke-[2.5px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize Lucide Icons
|
||||
lucide.createIcons();
|
||||
|
||||
// Smooth horizontal scroll for the board
|
||||
const board = document.querySelector('.overflow-x-auto');
|
||||
board.addEventListener(
|
||||
'wheel',
|
||||
(evt) => {
|
||||
if (evt.deltaY !== 0) {
|
||||
evt.preventDefault();
|
||||
board.scrollLeft += evt.deltaY * 1.5;
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Keyboard shortcut for search
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/') {
|
||||
const searchInput = document.querySelector('input[type="text"]');
|
||||
if (document.activeElement !== searchInput) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1065
index (28).html
1065
index (28).html
File diff suppressed because it is too large
Load Diff
@@ -24,24 +24,6 @@ function isBinaryFile(filePath: string): boolean {
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a synthetic diff for a new file with the given content lines
|
||||
* This helper reduces duplication in diff generation logic
|
||||
*/
|
||||
function createNewFileDiff(relativePath: string, mode: string, contentLines: string[]): string {
|
||||
const lineCount = contentLines.length;
|
||||
const addedLines = contentLines.map((line) => `+${line}`).join('\n');
|
||||
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode ${mode}
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +${lineCount === 1 ? '1' : `1,${lineCount}`} @@
|
||||
${addedLines}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a synthetic unified diff for an untracked (new) file
|
||||
* This is needed because `git diff HEAD` doesn't include untracked files
|
||||
@@ -62,19 +44,18 @@ Binary file ${relativePath} added
|
||||
`;
|
||||
}
|
||||
|
||||
// Get file stats to check size and type
|
||||
// Get file stats to check size
|
||||
const stats = await secureFs.stat(fullPath);
|
||||
|
||||
// Check if it's a directory (can happen with untracked directories from git status)
|
||||
if (stats.isDirectory()) {
|
||||
return createNewFileDiff(relativePath, '040000', ['[Directory]']);
|
||||
}
|
||||
|
||||
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
|
||||
const sizeKB = Math.round(stats.size / 1024);
|
||||
return createNewFileDiff(relativePath, '100644', [
|
||||
`[File too large to display: ${sizeKB}KB]`,
|
||||
]);
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1 @@
|
||||
+[File too large to display: ${sizeKB}KB]
|
||||
`;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
@@ -109,7 +90,14 @@ ${addedLines}`;
|
||||
// Log the error for debugging
|
||||
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
|
||||
// Return a placeholder diff
|
||||
return createNewFileDiff(relativePath, '100644', ['[Unable to read file content]']);
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1 @@
|
||||
+[Unable to read file content]
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,18 +117,6 @@ describe('diff.ts', () => {
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('[Unable to read file content]');
|
||||
});
|
||||
|
||||
it('should handle directory path gracefully', async () => {
|
||||
const dirName = 'some-directory';
|
||||
const dirPath = path.join(tempDir, dirName);
|
||||
await fs.mkdir(dirPath);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, dirName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${dirName} b/${dirName}`);
|
||||
expect(diff).toContain('new file mode 040000');
|
||||
expect(diff).toContain('[Directory]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendUntrackedFileDiffs', () => {
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -106,6 +106,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"framer-motion": "^12.23.26",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "19.2.3",
|
||||
@@ -113,9 +114,9 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1213,7 +1214,7 @@
|
||||
},
|
||||
"node_modules/@electron/node-gyp": {
|
||||
"version": "10.2.0-electron.1",
|
||||
"resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
|
||||
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -9713,6 +9714,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
|
||||
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
@@ -11462,12 +11490,6 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -12751,6 +12773,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -15929,21 +15966,6 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/usehooks-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/utf8-byte-length": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||
|
||||
Reference in New Issue
Block a user