mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
Compare commits
46 Commits
v0.6.0
...
new-ui-bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
379976aba7 | ||
|
|
d50b15e639 | ||
|
|
172f1a7a3f | ||
|
|
5edb38691c | ||
|
|
f1f149c6c0 | ||
|
|
e0c5f55fe7 | ||
|
|
4958ee1dda | ||
|
|
3d00f40ea0 | ||
|
|
c9e0957dfe | ||
|
|
9d4f912c93 | ||
|
|
4898a1307e | ||
|
|
629b7e7433 | ||
|
|
190f18ecae | ||
|
|
e6eb5ad97e | ||
|
|
e95912f931 | ||
|
|
eb1875f558 | ||
|
|
c761ce8120 | ||
|
|
ee9cb4deec | ||
|
|
17ed2be918 | ||
|
|
5a5165818e | ||
|
|
9a7d21438b | ||
|
|
d4d4b8fb3d | ||
|
|
48955e9a71 | ||
|
|
870df88cd1 | ||
|
|
7618a75d85 | ||
|
|
51281095ea | ||
|
|
50a595a8da | ||
|
|
a398367f00 | ||
|
|
fe6faf9aae | ||
|
|
a1331ed514 | ||
|
|
38f2e0beea | ||
|
|
ef4035a462 | ||
|
|
cb07206dae | ||
|
|
cc0405cf27 | ||
|
|
4dd00a98e4 | ||
|
|
b3c321ce02 | ||
|
|
12a796bcbb | ||
|
|
ffcdbf7d75 | ||
|
|
e70c3b7722 | ||
|
|
524a9736b4 | ||
|
|
036a7d9d26 | ||
|
|
c4df2c141a | ||
|
|
7c75c24b5c | ||
|
|
2588ecaafa | ||
|
|
a071097c0d | ||
|
|
b930091c42 |
@@ -61,7 +61,7 @@ Traditional development tools help you write code. Automaker helps you **orchest
|
|||||||
|
|
||||||
### Powered by Claude Code
|
### Powered by Claude Code
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Why This Matters
|
### Why This Matters
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ https://discord.gg/jjem7aEDKU
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm
|
- npm
|
||||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
- [Claude Code CLI](https://code.claude.com/docs/en/overview) installed and authenticated
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ 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)
|
* Check if an error is ENOENT (file/path not found or spawn failed)
|
||||||
* These are expected in test environments with mock paths
|
* These are expected in test environments with mock paths
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||||
|
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
|
||||||
import { createInfoHandler } from './routes/info.js';
|
import { createInfoHandler } from './routes/info.js';
|
||||||
import { createStatusHandler } from './routes/status.js';
|
import { createStatusHandler } from './routes/status.js';
|
||||||
import { createListHandler } from './routes/list.js';
|
import { createListHandler } from './routes/list.js';
|
||||||
@@ -38,17 +39,42 @@ export function createWorktreeRoutes(): Router {
|
|||||||
router.post('/list', createListHandler());
|
router.post('/list', createListHandler());
|
||||||
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
|
||||||
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
|
||||||
router.post('/merge', validatePathParams('projectPath'), createMergeHandler());
|
router.post(
|
||||||
|
'/merge',
|
||||||
|
validatePathParams('projectPath'),
|
||||||
|
requireValidProject,
|
||||||
|
createMergeHandler()
|
||||||
|
);
|
||||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
router.post('/create', validatePathParams('projectPath'), createCreateHandler());
|
||||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||||
router.post('/create-pr', createCreatePRHandler());
|
router.post('/create-pr', createCreatePRHandler());
|
||||||
router.post('/pr-info', createPRInfoHandler());
|
router.post('/pr-info', createPRInfoHandler());
|
||||||
router.post('/commit', validatePathParams('worktreePath'), createCommitHandler());
|
router.post(
|
||||||
router.post('/push', validatePathParams('worktreePath'), createPushHandler());
|
'/commit',
|
||||||
router.post('/pull', validatePathParams('worktreePath'), createPullHandler());
|
validatePathParams('worktreePath'),
|
||||||
router.post('/checkout-branch', createCheckoutBranchHandler());
|
requireGitRepoOnly,
|
||||||
router.post('/list-branches', validatePathParams('worktreePath'), createListBranchesHandler());
|
createCommitHandler()
|
||||||
router.post('/switch-branch', createSwitchBranchHandler());
|
);
|
||||||
|
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('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
|
||||||
router.get('/default-editor', createGetDefaultEditorHandler());
|
router.get('/default-editor', createGetDefaultEditorHandler());
|
||||||
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler());
|
||||||
|
|||||||
74
apps/server/src/routes/worktree/middleware.ts
Normal file
74
apps/server/src/routes/worktree/middleware.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 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,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /checkout-branch endpoint - Create and checkout a new branch
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /commit endpoint - Commit changes in a worktree
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /list-branches endpoint - List all local branches
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /pull endpoint - Pull latest changes for a worktree/branch
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* POST /push endpoint - Push a worktree branch to remote
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
* Simple branch switching.
|
* Simple branch switching.
|
||||||
* If there are uncommitted changes, the switch will fail and
|
* If there are uncommitted changes, the switch will fail and
|
||||||
* the user should commit first.
|
* 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';
|
import type { Request, Response } from 'express';
|
||||||
|
|||||||
@@ -63,19 +63,22 @@
|
|||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"usehooks-ts": "^3.1.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
@@ -95,6 +98,7 @@
|
|||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -245,18 +245,21 @@ export function NewProjectModal({
|
|||||||
{/* Workspace Directory Display */}
|
{/* Workspace Directory Display */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 text-sm',
|
'flex items-start gap-2 text-sm',
|
||||||
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="w-4 h-4 shrink-0" />
|
<Folder className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
<span className="flex-1 min-w-0">
|
<span className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
{isLoadingWorkspace ? (
|
{isLoadingWorkspace ? (
|
||||||
'Loading workspace...'
|
'Loading workspace...'
|
||||||
) : workspaceDir ? (
|
) : workspaceDir ? (
|
||||||
<>
|
<>
|
||||||
Will be created at:{' '}
|
<span>Will be created at:</span>
|
||||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
<code
|
||||||
|
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
|
||||||
|
title={projectPath || workspaceDir}
|
||||||
|
>
|
||||||
{projectPath || workspaceDir}
|
{projectPath || workspaceDir}
|
||||||
</code>
|
</code>
|
||||||
</>
|
</>
|
||||||
|
|||||||
36
apps/ui/src/components/layout/app-layout.tsx
Normal file
36
apps/ui/src/components/layout/app-layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Sidebar } from './sidebar';
|
||||||
|
// TopHeader removed from layout to be view-specific
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full relative selection:bg-brand-cyan selection:text-black font-sans bg-dark-950 overflow-hidden">
|
||||||
|
{/* Ambient Background */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-[-25%] left-[-15%] w-[1000px] h-[1000px] opacity-80 pointer-events-none z-0 blob-rainbow"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at center, rgba(6, 182, 212, 0.15) 0%, rgba(59, 130, 246, 0.12) 30%, rgba(249, 115, 22, 0.08) 60%, transparent 80%)',
|
||||||
|
filter: 'blur(100px)',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="fixed top-[-20%] right-[-10%] w-[700px] h-[700px] pointer-events-none z-0"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, rgba(16, 185, 129, 0.05) 0%, transparent 70%)',
|
||||||
|
filter: 'blur(100px)',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 relative z-10 h-full">
|
||||||
|
<div className="flex-1 overflow-hidden relative">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,367 +1,213 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import React from 'react';
|
||||||
import { useNavigate, useLocation } from '@tanstack/react-router';
|
import {
|
||||||
|
Code2,
|
||||||
|
PanelLeft,
|
||||||
|
Plus,
|
||||||
|
Folder,
|
||||||
|
Bell,
|
||||||
|
FolderOpen,
|
||||||
|
MoreVertical,
|
||||||
|
LayoutGrid,
|
||||||
|
Bot,
|
||||||
|
FileJson,
|
||||||
|
BookOpen,
|
||||||
|
UserCircle,
|
||||||
|
TerminalSquare,
|
||||||
|
Book,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
import { Link, useLocation } from '@tanstack/react-router';
|
||||||
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
|
||||||
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
|
|
||||||
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
|
||||||
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
|
|
||||||
|
|
||||||
// Local imports from subfolder
|
|
||||||
import {
|
|
||||||
CollapseToggleButton,
|
|
||||||
SidebarHeader,
|
|
||||||
ProjectActions,
|
|
||||||
SidebarNavigation,
|
|
||||||
ProjectSelectorWithOptions,
|
|
||||||
SidebarFooter,
|
|
||||||
} from './sidebar/components';
|
|
||||||
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
|
|
||||||
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
|
|
||||||
import {
|
|
||||||
useSidebarAutoCollapse,
|
|
||||||
useRunningAgents,
|
|
||||||
useSpecRegeneration,
|
|
||||||
useNavigation,
|
|
||||||
useProjectCreation,
|
|
||||||
useSetupDialog,
|
|
||||||
useTrashDialog,
|
|
||||||
useProjectTheme,
|
|
||||||
} from './sidebar/hooks';
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
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 (
|
return (
|
||||||
<aside
|
<aside className="w-[260px] flex-shrink-0 flex flex-col glass-sidebar z-30 relative h-full">
|
||||||
className={cn(
|
{/* Logo */}
|
||||||
'flex-shrink-0 flex flex-col z-30 relative',
|
<div className="h-16 flex items-center px-6 gap-3 flex-shrink-0">
|
||||||
// Glass morphism background with gradient
|
<div className="text-brand-cyan relative flex items-center justify-center">
|
||||||
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
|
<div className="absolute inset-0 bg-brand-cyan blur-md opacity-30"></div>
|
||||||
// Premium border with subtle glow
|
<Code2 className="w-6 h-6 relative z-10" />
|
||||||
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
|
</div>
|
||||||
// Smooth width transition
|
<span className="text-white font-bold text-lg tracking-tight">automaker.</span>
|
||||||
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
<button className="ml-auto text-slate-600 hover:text-white transition">
|
||||||
sidebarOpen ? 'w-16 lg:w-72' : 'w-16'
|
<PanelLeft className="w-4 h-4" />
|
||||||
)}
|
</button>
|
||||||
data-testid="sidebar"
|
|
||||||
>
|
|
||||||
<CollapseToggleButton
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
toggleSidebar={toggleSidebar}
|
|
||||||
shortcut={shortcuts.toggleSidebar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
|
|
||||||
|
|
||||||
{/* Project Actions - Moved above project selector */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<ProjectActions
|
|
||||||
setShowNewProjectModal={setShowNewProjectModal}
|
|
||||||
handleOpenFolder={handleOpenFolder}
|
|
||||||
setShowTrashDialog={setShowTrashDialog}
|
|
||||||
trashedProjects={trashedProjects}
|
|
||||||
shortcuts={{ openProject: shortcuts.openProject }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ProjectSelectorWithOptions
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
isProjectPickerOpen={isProjectPickerOpen}
|
|
||||||
setIsProjectPickerOpen={setIsProjectPickerOpen}
|
|
||||||
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SidebarNavigation
|
|
||||||
currentProject={currentProject}
|
|
||||||
sidebarOpen={sidebarOpen}
|
|
||||||
navSections={navSections}
|
|
||||||
isActiveRoute={isActiveRoute}
|
|
||||||
navigate={navigate}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarFooter
|
{/* Top Actions */}
|
||||||
sidebarOpen={sidebarOpen}
|
<div className="px-5 pb-6 space-y-4 flex-shrink-0">
|
||||||
isActiveRoute={isActiveRoute}
|
<div className="grid grid-cols-4 gap-2">
|
||||||
navigate={navigate}
|
<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">
|
||||||
hideWiki={hideWiki}
|
<Plus className="w-3.5 h-3.5 group-hover:text-brand-cyan transition-colors" /> New
|
||||||
hideRunningAgents={hideRunningAgents}
|
</button>
|
||||||
runningAgentsCount={runningAgentsCount}
|
<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">
|
||||||
shortcuts={{ settings: shortcuts.settings }}
|
<Folder className="w-3.5 h-3.5" />
|
||||||
/>
|
<span className="ml-1 text-[10px]">0</span>
|
||||||
<TrashDialog
|
</button>
|
||||||
open={showTrashDialog}
|
<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">
|
||||||
onOpenChange={setShowTrashDialog}
|
<Bell className="w-3.5 h-3.5" />
|
||||||
trashedProjects={trashedProjects}
|
<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>
|
||||||
activeTrashId={activeTrashId}
|
</button>
|
||||||
handleRestoreProject={handleRestoreProject}
|
</div>
|
||||||
handleDeleteProjectFromDisk={handleDeleteProjectFromDisk}
|
|
||||||
deleteTrashedProject={deleteTrashedProject}
|
|
||||||
handleEmptyTrash={handleEmptyTrash}
|
|
||||||
isEmptyingTrash={isEmptyingTrash}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* New Project Setup Dialog */}
|
{/* Project Selector */}
|
||||||
<CreateSpecDialog
|
<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">
|
||||||
open={showSetupDialog}
|
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||||
onOpenChange={setShowSetupDialog}
|
<FolderOpen className="w-4 h-4 text-brand-cyan group-hover:text-cyan-300 transition" />
|
||||||
projectOverview={projectOverview}
|
<span className="text-white font-medium text-sm">test case 1</span>
|
||||||
onProjectOverviewChange={setProjectOverview}
|
</div>
|
||||||
generateFeatures={generateFeatures}
|
<div className="flex items-center gap-1 pr-1">
|
||||||
onGenerateFeaturesChange={setGenerateFeatures}
|
<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">
|
||||||
analyzeProject={analyzeProject}
|
P
|
||||||
onAnalyzeProjectChange={setAnalyzeProject}
|
</span>
|
||||||
featureCount={featureCount}
|
<MoreVertical className="w-4 h-4 text-slate-500" />
|
||||||
onFeatureCountChange={setFeatureCount}
|
</div>
|
||||||
onCreateSpec={handleCreateInitialSpec}
|
</div>
|
||||||
onSkip={handleSkipSetup}
|
</div>
|
||||||
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."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OnboardingDialog
|
{/* Navigation */}
|
||||||
open={showOnboardingDialog}
|
<div className="flex-1 overflow-y-auto px-0 space-y-6 custom-scrollbar">
|
||||||
onOpenChange={setShowOnboardingDialog}
|
{/* Project Section */}
|
||||||
newProjectName={newProjectName}
|
<div>
|
||||||
onSkip={handleOnboardingSkip}
|
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
|
||||||
onGenerateSpec={handleOnboardingGenerateSpec}
|
Project
|
||||||
|
</h3>
|
||||||
|
<nav className="space-y-0.5">
|
||||||
|
<NavItem
|
||||||
|
to="/"
|
||||||
|
icon={<LayoutGrid className="w-4 h-4" />}
|
||||||
|
label="Kanban Board"
|
||||||
|
shortcut="L"
|
||||||
|
isActive={location.pathname === '/' || location.pathname === '/board'}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/agents"
|
||||||
|
icon={<Bot className="w-4 h-4" />}
|
||||||
|
label="Agent Runner"
|
||||||
|
shortcut="A"
|
||||||
|
isActive={location.pathname.startsWith('/agents')}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Delete Project Confirmation Dialog */}
|
{/* Tools Section */}
|
||||||
<DeleteProjectDialog
|
<div>
|
||||||
open={showDeleteProjectDialog}
|
<h3 className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 px-6 font-mono">
|
||||||
onOpenChange={setShowDeleteProjectDialog}
|
Tools
|
||||||
project={currentProject}
|
</h3>
|
||||||
onConfirm={moveProjectToTrash}
|
<nav className="space-y-0.5">
|
||||||
|
<NavItem
|
||||||
|
to="/spec"
|
||||||
|
icon={<FileJson className="w-4 h-4" />}
|
||||||
|
label="Spec Editor"
|
||||||
|
shortcut="D"
|
||||||
|
isActive={location.pathname.startsWith('/spec')}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/context"
|
||||||
|
icon={<BookOpen className="w-4 h-4" />}
|
||||||
|
label="Context"
|
||||||
|
shortcut="C"
|
||||||
|
isActive={location.pathname.startsWith('/context')}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/profiles"
|
||||||
|
icon={<UserCircle className="w-4 h-4" />}
|
||||||
|
label="AI Profiles"
|
||||||
|
shortcut="H"
|
||||||
|
isActive={location.pathname.startsWith('/profiles')}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
to="/terminal"
|
||||||
|
icon={<TerminalSquare className="w-4 h-4" />}
|
||||||
|
label="Terminal"
|
||||||
|
shortcut="T"
|
||||||
|
isActive={location.pathname.startsWith('/terminal')}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* New Project Modal */}
|
{/* Footer */}
|
||||||
<NewProjectModal
|
<div className="p-4 border-t border-white/5 space-y-1 bg-dark-900/30 flex-shrink-0 backdrop-blur-sm">
|
||||||
open={showNewProjectModal}
|
<Link
|
||||||
onOpenChange={setShowNewProjectModal}
|
to="/wiki"
|
||||||
onCreateBlankProject={handleCreateBlankProject}
|
className="flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-white/5 transition"
|
||||||
onCreateFromTemplate={handleCreateFromTemplate}
|
>
|
||||||
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
<Book className="w-4 h-4" />
|
||||||
isCreating={isCreatingProject}
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export function ProjectSelectorWithOptions({
|
|||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setShowDeleteProjectDialog,
|
setShowDeleteProjectDialog,
|
||||||
}: ProjectSelectorWithOptionsProps) {
|
}: ProjectSelectorWithOptionsProps) {
|
||||||
// Get data from store
|
|
||||||
const {
|
const {
|
||||||
projects,
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
@@ -59,25 +58,24 @@ export function ProjectSelectorWithOptions({
|
|||||||
clearProjectHistory,
|
clearProjectHistory,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Get keyboard shortcuts
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const {
|
const {
|
||||||
projectSearchQuery,
|
projectSearchQuery,
|
||||||
setProjectSearchQuery,
|
setProjectSearchQuery,
|
||||||
selectedProjectIndex,
|
selectedProjectIndex,
|
||||||
projectSearchInputRef,
|
projectSearchInputRef,
|
||||||
|
scrollContainerRef,
|
||||||
filteredProjects,
|
filteredProjects,
|
||||||
} = useProjectPicker({
|
} = useProjectPicker({
|
||||||
projects,
|
projects,
|
||||||
|
currentProject,
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag-and-drop handlers
|
|
||||||
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
|
||||||
|
|
||||||
// Theme management
|
|
||||||
const {
|
const {
|
||||||
globalTheme,
|
globalTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
@@ -106,7 +104,6 @@ export function ProjectSelectorWithOptions({
|
|||||||
'shadow-sm shadow-black/5',
|
'shadow-sm shadow-black/5',
|
||||||
'text-foreground titlebar-no-drag min-w-0',
|
'text-foreground titlebar-no-drag min-w-0',
|
||||||
'transition-all duration-200 ease-out',
|
'transition-all duration-200 ease-out',
|
||||||
'hover:scale-[1.01] active:scale-[0.99]',
|
|
||||||
isProjectPickerOpen &&
|
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'
|
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
|
||||||
)}
|
)}
|
||||||
@@ -139,7 +136,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
align="start"
|
align="start"
|
||||||
data-testid="project-picker-dropdown"
|
data-testid="project-picker-dropdown"
|
||||||
>
|
>
|
||||||
{/* Search input for type-ahead filtering */}
|
{/* Search input */}
|
||||||
<div className="px-1 pb-2">
|
<div className="px-1 pb-2">
|
||||||
<div className="relative">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
@@ -150,10 +147,10 @@ export function ProjectSelectorWithOptions({
|
|||||||
value={projectSearchQuery}
|
value={projectSearchQuery}
|
||||||
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
onChange={(e) => setProjectSearchQuery(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
|
'w-full h-8 pl-8 pr-3 text-sm rounded-lg',
|
||||||
'border border-border bg-background/50',
|
'border border-border bg-background/50',
|
||||||
'text-foreground placeholder:text-muted-foreground',
|
'text-foreground placeholder:text-muted-foreground',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
|
'focus:outline-none focus:ring-1 focus:ring-brand-500/30 focus:border-brand-500/50',
|
||||||
'transition-all duration-200'
|
'transition-all duration-200'
|
||||||
)}
|
)}
|
||||||
data-testid="project-search-input"
|
data-testid="project-search-input"
|
||||||
@@ -175,7 +172,10 @@ export function ProjectSelectorWithOptions({
|
|||||||
items={filteredProjects.map((p) => p.id)}
|
items={filteredProjects.map((p) => p.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.5 max-h-64 overflow-y-auto">
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className="space-y-0.5 max-h-64 overflow-y-auto overflow-x-hidden scroll-smooth scrollbar-styled"
|
||||||
|
>
|
||||||
{filteredProjects.map((project, index) => (
|
{filteredProjects.map((project, index) => (
|
||||||
<SortableProjectItem
|
<SortableProjectItem
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -196,9 +196,9 @@ export function ProjectSelectorWithOptions({
|
|||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
|
<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">
|
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
|
||||||
<span className="text-foreground/60">arrow</span> navigate{' '}
|
<span className="text-foreground/60">↑↓</span> navigate{' '}
|
||||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||||
<span className="text-foreground/60">enter</span> select{' '}
|
<span className="text-foreground/60">↵</span> select{' '}
|
||||||
<span className="mx-1 text-foreground/30">|</span>{' '}
|
<span className="mx-1 text-foreground/30">|</span>{' '}
|
||||||
<span className="text-foreground/60">esc</span> close
|
<span className="text-foreground/60">esc</span> close
|
||||||
</p>
|
</p>
|
||||||
@@ -206,7 +206,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Project Options Menu - theme and history */}
|
{/* Project Options Menu */}
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@@ -223,8 +223,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
'bg-transparent hover:bg-accent/60',
|
'bg-transparent hover:bg-accent/60',
|
||||||
'border border-border/50 hover:border-border',
|
'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"
|
title="Project options"
|
||||||
data-testid="project-options-menu"
|
data-testid="project-options-menu"
|
||||||
@@ -252,7 +251,6 @@ export function ProjectSelectorWithOptions({
|
|||||||
setPreviewTheme(null);
|
setPreviewTheme(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Use Global Option */}
|
|
||||||
<DropdownMenuRadioGroup
|
<DropdownMenuRadioGroup
|
||||||
value={currentProject.theme || ''}
|
value={currentProject.theme || ''}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -328,7 +326,7 @@ export function ProjectSelectorWithOptions({
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
|
||||||
{/* Project History Section - only show when there's history */}
|
{/* Project History Section */}
|
||||||
{projectHistory.length > 1 && (
|
{projectHistory.length > 1 && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function SortableProjectItem({
|
|||||||
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
|
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
|
||||||
)}
|
)}
|
||||||
data-testid={`project-option-${project.id}`}
|
data-testid={`project-option-${project.id}`}
|
||||||
|
onClick={() => onSelect(project)}
|
||||||
>
|
>
|
||||||
{/* Drag Handle */}
|
{/* Drag Handle */}
|
||||||
<button
|
<button
|
||||||
@@ -43,9 +44,14 @@ export function SortableProjectItem({
|
|||||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Project content - clickable area */}
|
{/* Project content */}
|
||||||
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
|
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Folder
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
currentProjectId === project.id ? 'text-brand-500' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
|
<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" />}
|
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export function OnboardingDialog({
|
|||||||
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
|
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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">
|
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20 shrink-0">
|
||||||
<Rocket className="w-6 h-6 text-brand-500" />
|
<Rocket className="w-6 h-6 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
|
<DialogTitle className="text-2xl truncate">Welcome to {newProjectName}!</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground mt-1">
|
<DialogDescription className="text-muted-foreground mt-1">
|
||||||
Your new project is ready. Let's get you started.
|
Your new project is ready. Let's get you started.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Project } from '@/lib/electron';
|
|||||||
|
|
||||||
interface UseProjectPickerProps {
|
interface UseProjectPickerProps {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
|
currentProject: Project | null;
|
||||||
isProjectPickerOpen: boolean;
|
isProjectPickerOpen: boolean;
|
||||||
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
setCurrentProject: (project: Project) => void;
|
setCurrentProject: (project: Project) => void;
|
||||||
@@ -10,6 +11,7 @@ interface UseProjectPickerProps {
|
|||||||
|
|
||||||
export function useProjectPicker({
|
export function useProjectPicker({
|
||||||
projects,
|
projects,
|
||||||
|
currentProject,
|
||||||
isProjectPickerOpen,
|
isProjectPickerOpen,
|
||||||
setIsProjectPickerOpen,
|
setIsProjectPickerOpen,
|
||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
@@ -17,6 +19,7 @@ export function useProjectPicker({
|
|||||||
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
const [projectSearchQuery, setProjectSearchQuery] = useState('');
|
||||||
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
|
||||||
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
const projectSearchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Filtered projects based on search query
|
// Filtered projects based on search query
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
@@ -27,29 +30,67 @@ export function useProjectPicker({
|
|||||||
return projects.filter((project) => project.name.toLowerCase().includes(query));
|
return projects.filter((project) => project.name.toLowerCase().includes(query));
|
||||||
}, [projects, projectSearchQuery]);
|
}, [projects, projectSearchQuery]);
|
||||||
|
|
||||||
// Reset selection when filtered results change
|
// Helper function to scroll to a specific project
|
||||||
useEffect(() => {
|
const scrollToProject = useCallback((projectId: string) => {
|
||||||
setSelectedProjectIndex(0);
|
if (!scrollContainerRef.current) return;
|
||||||
}, [filteredProjects.length, projectSearchQuery]);
|
|
||||||
|
|
||||||
// Reset search query when dropdown closes
|
const element = scrollContainerRef.current.querySelector(
|
||||||
useEffect(() => {
|
`[data-testid="project-option-${projectId}"]`
|
||||||
if (!isProjectPickerOpen) {
|
);
|
||||||
setProjectSearchQuery('');
|
|
||||||
setSelectedProjectIndex(0);
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [isProjectPickerOpen]);
|
}, []);
|
||||||
|
|
||||||
// Focus the search input when dropdown opens
|
// On open/close, handle search query reset and focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isProjectPickerOpen) {
|
if (isProjectPickerOpen) {
|
||||||
// Small delay to ensure the dropdown is rendered
|
// Focus search input after DOM renders
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
projectSearchInputRef.current?.focus();
|
projectSearchInputRef.current?.focus();
|
||||||
}, 0);
|
});
|
||||||
|
} else {
|
||||||
|
// Reset search when closing
|
||||||
|
setProjectSearchQuery('');
|
||||||
}
|
}
|
||||||
}, [isProjectPickerOpen]);
|
}, [isProjectPickerOpen]);
|
||||||
|
|
||||||
|
// Update selection when search query changes (while picker is open)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isProjectPickerOpen) {
|
||||||
|
setSelectedProjectIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
// Handle selecting the currently highlighted project
|
// Handle selecting the currently highlighted project
|
||||||
const selectHighlightedProject = useCallback(() => {
|
const selectHighlightedProject = useCallback(() => {
|
||||||
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
|
||||||
@@ -99,6 +140,7 @@ export function useProjectPicker({
|
|||||||
selectedProjectIndex,
|
selectedProjectIndex,
|
||||||
setSelectedProjectIndex,
|
setSelectedProjectIndex,
|
||||||
projectSearchInputRef,
|
projectSearchInputRef,
|
||||||
|
scrollContainerRef,
|
||||||
filteredProjects,
|
filteredProjects,
|
||||||
selectHighlightedProject,
|
selectHighlightedProject,
|
||||||
};
|
};
|
||||||
|
|||||||
38
apps/ui/src/components/layout/top-header.tsx
Normal file
38
apps/ui/src/components/layout/top-header.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Users, Play, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
export function TopHeader() {
|
||||||
|
return (
|
||||||
|
<header className="h-16 glass-header flex items-center justify-between px-8 flex-shrink-0 z-20">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white font-bold text-lg tracking-tight">Kanban Board</h1>
|
||||||
|
<p className="text-xs text-slate-500 font-medium font-mono mt-0.5">test case 1</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* User Toggle */}
|
||||||
|
<div className="flex items-center bg-dark-850/60 rounded-lg p-1 border border-white/5 h-9 shadow-inner-light">
|
||||||
|
<div className="flex items-center gap-3 px-2 border-r border-white/5 h-full mr-2">
|
||||||
|
<Users className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
{/* Toggle Switch */}
|
||||||
|
<div className="w-[28px] h-[16px] bg-[#2d3546] rounded-full relative cursor-pointer border border-white/10 transition-colors">
|
||||||
|
<div className="absolute top-[2px] right-[2px] w-[10px] h-[10px] bg-brand-cyan rounded-full shadow-[0_0_6px_rgba(6,182,212,0.6)]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 px-1 font-mono">3</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Mode */}
|
||||||
|
<button className="flex items-center gap-2 text-slate-300 hover:text-white px-3 py-1.5 rounded-lg border border-white/5 bg-dark-850/60 hover:bg-dark-700 transition text-xs font-medium h-9">
|
||||||
|
<Play className="w-3.5 h-3.5 fill-current" />
|
||||||
|
<span>Auto Mode</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Add Feature */}
|
||||||
|
<button className="flex items-center gap-2 bg-brand-cyan hover:bg-cyan-400 text-dark-950 font-bold px-4 py-1.5 rounded-lg transition shadow-glow-cyan text-xs h-9 btn-hover-effect">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Add Feature</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/ui/src/components/ui/glass-card.tsx
Normal file
26
apps/ui/src/components/ui/glass-card.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
|
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'default' | 'active-blue';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
|
||||||
|
({ className, variant = 'default', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
variant === 'default' && 'glass-card',
|
||||||
|
variant === 'active-blue' && 'glass-card card-active-blue',
|
||||||
|
'rounded-xl p-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
GlassCard.displayName = 'GlassCard';
|
||||||
28
apps/ui/src/components/ui/glass-panel.tsx
Normal file
28
apps/ui/src/components/ui/glass-panel.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
|
||||||
|
export interface GlassPanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
accent?: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GlassPanel = forwardRef<HTMLDivElement, GlassPanelProps>(
|
||||||
|
({ className, accent = 'none', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'glass-panel rounded-2xl flex flex-col',
|
||||||
|
accent === 'cyan' && 'col-accent-cyan',
|
||||||
|
accent === 'blue' && 'col-accent-blue',
|
||||||
|
accent === 'orange' && 'col-accent-orange',
|
||||||
|
accent === 'green' && 'col-accent-green',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
GlassPanel.displayName = 'GlassPanel';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
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 {
|
interface ToolResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -190,12 +193,20 @@ export function AgentToolsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="agent-tools-view">
|
<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 */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
<Wrench className="w-5 h-5 text-primary" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Agent Tools</h1>
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Test file system and terminal tools for {currentProject.name}
|
Test file system and terminal tools for {currentProject.name}
|
||||||
</p>
|
</p>
|
||||||
@@ -203,18 +214,24 @@ export function AgentToolsView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tools Grid */}
|
{/* Tools Grid */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 max-w-7xl">
|
||||||
{/* Read File Tool */}
|
{/* Read File Tool */}
|
||||||
<Card data-testid="read-file-tool">
|
<GlassCard
|
||||||
<CardHeader>
|
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="read-file-tool"
|
||||||
<File className="w-5 h-5 text-blue-500" />
|
>
|
||||||
<CardTitle className="text-lg">Read File</CardTitle>
|
<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>
|
||||||
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
|
<div>
|
||||||
</CardHeader>
|
<h3 className="font-semibold text-foreground">Read File</h3>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="read-file-path">File Path</Label>
|
<Label htmlFor="read-file-path">File Path</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -223,12 +240,13 @@ export function AgentToolsView() {
|
|||||||
value={readFilePath}
|
value={readFilePath}
|
||||||
onChange={(e) => setReadFilePath(e.target.value)}
|
onChange={(e) => setReadFilePath(e.target.value)}
|
||||||
data-testid="read-file-path-input"
|
data-testid="read-file-path-input"
|
||||||
|
className="bg-black/20 border-white/10 focus:border-blue-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleReadFile}
|
onClick={handleReadFile}
|
||||||
disabled={isReadingFile || !readFilePath.trim()}
|
disabled={isReadingFile || !readFilePath.trim()}
|
||||||
className="w-full"
|
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"
|
data-testid="read-file-button"
|
||||||
>
|
>
|
||||||
{isReadingFile ? (
|
{isReadingFile ? (
|
||||||
@@ -248,41 +266,45 @@ export function AgentToolsView() {
|
|||||||
{readFileResult && (
|
{readFileResult && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-3 rounded-md border',
|
'p-3 rounded-lg border text-xs',
|
||||||
readFileResult.success
|
readFileResult.success
|
||||||
? 'bg-green-500/10 border-green-500/20'
|
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||||
: 'bg-red-500/10 border-red-500/20'
|
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||||
)}
|
)}
|
||||||
data-testid="read-file-result"
|
data-testid="read-file-result"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||||
{readFileResult.success ? (
|
{readFileResult.success ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span>{readFileResult.success ? 'Success' : 'Failed'}</span>
|
||||||
{readFileResult.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
<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}
|
{readFileResult.success ? readFileResult.output : readFileResult.error}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
|
|
||||||
{/* Write File Tool */}
|
{/* Write File Tool */}
|
||||||
<Card data-testid="write-file-tool">
|
<GlassCard
|
||||||
<CardHeader>
|
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="write-file-tool"
|
||||||
<Pencil className="w-5 h-5 text-green-500" />
|
>
|
||||||
<CardTitle className="text-lg">Write File</CardTitle>
|
<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>
|
||||||
<CardDescription>Agent requests to write content to a file</CardDescription>
|
<div>
|
||||||
</CardHeader>
|
<h3 className="font-semibold text-foreground">Write File</h3>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="write-file-path">File Path</Label>
|
<Label htmlFor="write-file-path">File Path</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -291,6 +313,7 @@ export function AgentToolsView() {
|
|||||||
value={writeFilePath}
|
value={writeFilePath}
|
||||||
onChange={(e) => setWriteFilePath(e.target.value)}
|
onChange={(e) => setWriteFilePath(e.target.value)}
|
||||||
data-testid="write-file-path-input"
|
data-testid="write-file-path-input"
|
||||||
|
className="bg-black/20 border-white/10 focus:border-green-500/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -300,14 +323,14 @@ export function AgentToolsView() {
|
|||||||
placeholder="File content..."
|
placeholder="File content..."
|
||||||
value={writeFileContent}
|
value={writeFileContent}
|
||||||
onChange={(e) => setWriteFileContent(e.target.value)}
|
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"
|
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"
|
data-testid="write-file-content-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleWriteFile}
|
onClick={handleWriteFile}
|
||||||
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
|
||||||
className="w-full"
|
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"
|
data-testid="write-file-button"
|
||||||
>
|
>
|
||||||
{isWritingFile ? (
|
{isWritingFile ? (
|
||||||
@@ -327,41 +350,45 @@ export function AgentToolsView() {
|
|||||||
{writeFileResult && (
|
{writeFileResult && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-3 rounded-md border',
|
'p-3 rounded-lg border text-xs',
|
||||||
writeFileResult.success
|
writeFileResult.success
|
||||||
? 'bg-green-500/10 border-green-500/20'
|
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||||
: 'bg-red-500/10 border-red-500/20'
|
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||||
)}
|
)}
|
||||||
data-testid="write-file-result"
|
data-testid="write-file-result"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||||
{writeFileResult.success ? (
|
{writeFileResult.success ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span>{writeFileResult.success ? 'Success' : 'Failed'}</span>
|
||||||
{writeFileResult.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
|
<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}
|
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
|
|
||||||
{/* Terminal Tool */}
|
{/* Terminal Tool */}
|
||||||
<Card data-testid="terminal-tool">
|
<GlassCard
|
||||||
<CardHeader>
|
className="flex flex-col gap-4 bg-white/5 border-white/10"
|
||||||
<div className="flex items-center gap-2">
|
data-testid="terminal-tool"
|
||||||
<Terminal className="w-5 h-5 text-purple-500" />
|
>
|
||||||
<CardTitle className="text-lg">Run Terminal</CardTitle>
|
<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>
|
||||||
<CardDescription>Agent requests to execute a terminal command</CardDescription>
|
<div>
|
||||||
</CardHeader>
|
<h3 className="font-semibold text-foreground">Run Terminal</h3>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="terminal-command">Command</Label>
|
<Label htmlFor="terminal-command">Command</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -370,12 +397,13 @@ export function AgentToolsView() {
|
|||||||
value={terminalCommand}
|
value={terminalCommand}
|
||||||
onChange={(e) => setTerminalCommand(e.target.value)}
|
onChange={(e) => setTerminalCommand(e.target.value)}
|
||||||
data-testid="terminal-command-input"
|
data-testid="terminal-command-input"
|
||||||
|
className="bg-black/20 border-white/10 focus:border-purple-500/50 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRunCommand}
|
onClick={handleRunCommand}
|
||||||
disabled={isRunningCommand || !terminalCommand.trim()}
|
disabled={isRunningCommand || !terminalCommand.trim()}
|
||||||
className="w-full"
|
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"
|
data-testid="run-terminal-button"
|
||||||
>
|
>
|
||||||
{isRunningCommand ? (
|
{isRunningCommand ? (
|
||||||
@@ -395,42 +423,41 @@ export function AgentToolsView() {
|
|||||||
{terminalResult && (
|
{terminalResult && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-3 rounded-md border',
|
'p-3 rounded-lg border text-xs',
|
||||||
terminalResult.success
|
terminalResult.success
|
||||||
? 'bg-green-500/10 border-green-500/20'
|
? 'bg-green-500/10 border-green-500/20 text-green-300'
|
||||||
: 'bg-red-500/10 border-red-500/20'
|
: 'bg-red-500/10 border-red-500/20 text-red-300'
|
||||||
)}
|
)}
|
||||||
data-testid="terminal-result"
|
data-testid="terminal-result"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2 font-medium">
|
||||||
{terminalResult.success ? (
|
{terminalResult.success ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="w-4 h-4 text-red-500" />
|
<XCircle className="w-4 h-4 text-red-400" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span>{terminalResult.success ? 'Success' : 'Failed'}</span>
|
||||||
{terminalResult.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
|
<pre className="overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/30 p-2 rounded border border-white/5">
|
||||||
$ {terminalCommand}
|
$ {terminalCommand}
|
||||||
{'\n'}
|
{'\n'}
|
||||||
{terminalResult.success ? terminalResult.output : terminalResult.error}
|
{terminalResult.success ? terminalResult.output : terminalResult.error}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool Log Section */}
|
{/* Tool Log Section */}
|
||||||
<Card className="mt-6" data-testid="tool-log">
|
<GlassCard className="mt-6 bg-white/5 border-white/10" data-testid="tool-log">
|
||||||
<CardHeader>
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
|
<h3 className="font-semibold text-foreground">Tool Execution Log</h3>
|
||||||
<CardDescription>View agent tool requests and responses</CardDescription>
|
<p className="text-sm text-muted-foreground">
|
||||||
</CardHeader>
|
View agent tool requests and responses
|
||||||
<CardContent>
|
</p>
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
|
<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">
|
<p className="text-muted-foreground">
|
||||||
Open your browser's developer console to see detailed agent tool logs.
|
Open your browser's developer console to see detailed agent tool logs.
|
||||||
</p>
|
</p>
|
||||||
@@ -440,8 +467,11 @@ export function AgentToolsView() {
|
|||||||
<li>Run Terminal - Agent executes shell commands</li>
|
<li>Run Terminal - Agent executes shell commands</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileText,
|
FileText,
|
||||||
|
Square,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||||
@@ -49,6 +50,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
|
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() {
|
export function AgentView() {
|
||||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
@@ -83,6 +86,7 @@ export function AgentView() {
|
|||||||
isConnected,
|
isConnected,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
|
stopExecution,
|
||||||
error: agentError,
|
error: agentError,
|
||||||
} = useElectronAgent({
|
} = useElectronAgent({
|
||||||
sessionId: currentSessionId || '',
|
sessionId: currentSessionId || '',
|
||||||
@@ -489,10 +493,14 @@ export function AgentView() {
|
|||||||
: messages;
|
: messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="agent-view">
|
||||||
|
<TopHeader />
|
||||||
|
|
||||||
|
<div className="flex-1 flex overflow-hidden p-4 pt-0 gap-4">
|
||||||
{/* Session Manager Sidebar */}
|
{/* Session Manager Sidebar */}
|
||||||
{showSessionManager && currentProject && (
|
{showSessionManager && currentProject && (
|
||||||
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
|
<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
|
<SessionManager
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
@@ -500,19 +508,21 @@ export function AgentView() {
|
|||||||
isCurrentSessionThinking={isProcessing}
|
isCurrentSessionThinking={isProcessing}
|
||||||
onQuickCreateRef={quickCreateSessionRef}
|
onQuickCreateRef={quickCreateSessionRef}
|
||||||
/>
|
/>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chat Area */}
|
{/* Chat Area */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<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 */}
|
{/* 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 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-4">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
onClick={() => setShowSessionManager(!showSessionManager)}
|
||||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{showSessionManager ? (
|
{showSessionManager ? (
|
||||||
<PanelLeftClose className="w-4 h-4" />
|
<PanelLeftClose className="w-4 h-4" />
|
||||||
@@ -520,27 +530,33 @@ export function AgentView() {
|
|||||||
<PanelLeft className="w-4 h-4" />
|
<PanelLeft className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
<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-5 h-5 text-primary" />
|
<Bot className="w-4 h-4 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
<h1 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
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}
|
{currentProject.name}
|
||||||
{currentSessionId && !isConnected && ' - Connecting...'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status indicators & actions */}
|
{/* Status indicators & actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
{/* Model Selector */}
|
{/* Model Selector */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 text-xs font-medium"
|
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}
|
disabled={isProcessing}
|
||||||
data-testid="model-selector"
|
data-testid="model-selector"
|
||||||
>
|
>
|
||||||
@@ -552,17 +568,25 @@ export function AgentView() {
|
|||||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-56 bg-zinc-950/95 border-white/10 backdrop-blur-xl"
|
||||||
|
>
|
||||||
{CLAUDE_MODELS.map((model) => (
|
{CLAUDE_MODELS.map((model) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
onClick={() => setSelectedModel(model.id)}
|
onClick={() => setSelectedModel(model.id)}
|
||||||
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
|
className={cn(
|
||||||
|
'cursor-pointer focus:bg-white/10',
|
||||||
|
selectedModel === model.id && 'bg-cyan-500/10 text-cyan-400'
|
||||||
|
)}
|
||||||
data-testid={`model-option-${model.id}`}
|
data-testid={`model-option-${model.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="font-medium">{model.label}</span>
|
<span className="font-medium text-xs">{model.label}</span>
|
||||||
<span className="text-xs text-muted-foreground">{model.description}</span>
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{model.description}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -570,24 +594,26 @@ export function AgentView() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{currentTool && (
|
{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">
|
<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 text-primary" />
|
<Wrench className="w-3 h-3" />
|
||||||
<span className="font-medium">{currentTool}</span>
|
<span className="font-medium">{currentTool}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agentError && (
|
{agentError && (
|
||||||
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
<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 && (
|
{currentSessionId && messages.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={handleClearChat}
|
onClick={handleClearChat}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
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 mr-2" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Clear
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -603,7 +629,9 @@ export function AgentView() {
|
|||||||
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
|
<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" />
|
<Bot className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
|
<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">
|
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||||
Create or select a session to start chatting with the AI agent
|
Create or select a session to start chatting with the AI agent
|
||||||
</p>
|
</p>
|
||||||
@@ -662,7 +690,9 @@ export function AgentView() {
|
|||||||
{message.content}
|
{message.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Display attached images for user messages */}
|
{/* Display attached images for user messages */}
|
||||||
@@ -763,7 +793,8 @@ export function AgentView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
|
||||||
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
|
{(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
|
||||||
|
!showImageDropZone && (
|
||||||
<div className="mb-4 space-y-2">
|
<div className="mb-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
@@ -867,7 +898,9 @@ export function AgentView() {
|
|||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
|
isDragOver
|
||||||
|
? 'Drop your files here...'
|
||||||
|
: 'Describe what you want to build...'
|
||||||
}
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -914,14 +947,25 @@ export function AgentView() {
|
|||||||
<Paperclip className="w-4 h-4" />
|
<Paperclip className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Send Button */}
|
{/* Send / Stop Button */}
|
||||||
|
{isProcessing ? (
|
||||||
|
<Button
|
||||||
|
onClick={stopExecution}
|
||||||
|
disabled={!isConnected}
|
||||||
|
className="h-11 px-4 rounded-xl"
|
||||||
|
variant="destructive"
|
||||||
|
data-testid="stop-agent"
|
||||||
|
title="Stop generation"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 fill-current" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={
|
disabled={
|
||||||
(!input.trim() &&
|
(!input.trim() &&
|
||||||
selectedImages.length === 0 &&
|
selectedImages.length === 0 &&
|
||||||
selectedTextFiles.length === 0) ||
|
selectedTextFiles.length === 0) ||
|
||||||
isProcessing ||
|
|
||||||
!isConnected
|
!isConnected
|
||||||
}
|
}
|
||||||
className="h-11 px-4 rounded-xl"
|
className="h-11 px-4 rounded-xl"
|
||||||
@@ -929,16 +973,21 @@ export function AgentView() {
|
|||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||||
Press{' '}
|
Press{' '}
|
||||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
||||||
send
|
Enter
|
||||||
|
</kbd>{' '}
|
||||||
|
to send
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import { useAutoMode } from '@/hooks/use-auto-mode';
|
|||||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { useWindowState } from '@/hooks/use-window-state';
|
import { useWindowState } from '@/hooks/use-window-state';
|
||||||
// Board-view specific imports
|
// Board-view specific imports
|
||||||
import { BoardHeader } from './board-view/board-header';
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
// BoardHeader removed
|
||||||
import { BoardSearchBar } from './board-view/board-search-bar';
|
import { BoardSearchBar } from './board-view/board-search-bar';
|
||||||
import { BoardControls } from './board-view/board-controls';
|
import { BoardControls } from './board-view/board-controls';
|
||||||
import { KanbanBoard } from './board-view/kanban-board';
|
import { KanbanBoard } from './board-view/kanban-board';
|
||||||
|
import { GraphView } from './graph-view';
|
||||||
import {
|
import {
|
||||||
AddFeatureDialog,
|
AddFeatureDialog,
|
||||||
AgentOutputModal,
|
AgentOutputModal,
|
||||||
@@ -69,6 +71,8 @@ export function BoardView() {
|
|||||||
aiProfiles,
|
aiProfiles,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
setKanbanCardDetailLevel,
|
setKanbanCardDetailLevel,
|
||||||
|
boardViewMode,
|
||||||
|
setBoardViewMode,
|
||||||
specCreatingForProject,
|
specCreatingForProject,
|
||||||
setSpecCreatingForProject,
|
setSpecCreatingForProject,
|
||||||
pendingPlanApproval,
|
pendingPlanApproval,
|
||||||
@@ -260,9 +264,9 @@ export function BoardView() {
|
|||||||
// Calculate unarchived card counts per branch
|
// Calculate unarchived card counts per branch
|
||||||
const branchCardCounts = useMemo(() => {
|
const branchCardCounts = useMemo(() => {
|
||||||
return hookFeatures.reduce(
|
return hookFeatures.reduce(
|
||||||
(counts, feature) => {
|
(counts: Record<string, number>, feature) => {
|
||||||
if (feature.status !== 'completed') {
|
if (feature.status !== 'completed') {
|
||||||
const branch = feature.branchName ?? 'main';
|
const branch = (feature.branchName as string) ?? 'main';
|
||||||
counts[branch] = (counts[branch] || 0) + 1;
|
counts[branch] = (counts[branch] || 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
@@ -915,27 +919,8 @@ export function BoardView() {
|
|||||||
data-testid="board-view"
|
data-testid="board-view"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<BoardHeader
|
{/* Top Header */}
|
||||||
projectName={currentProject.name}
|
<TopHeader />
|
||||||
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 */}
|
{/* Worktree Panel */}
|
||||||
<WorktreePanel
|
<WorktreePanel
|
||||||
@@ -989,9 +974,12 @@ export function BoardView() {
|
|||||||
completedCount={completedFeatures.length}
|
completedCount={completedFeatures.length}
|
||||||
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
kanbanCardDetailLevel={kanbanCardDetailLevel}
|
||||||
onDetailLevelChange={setKanbanCardDetailLevel}
|
onDetailLevelChange={setKanbanCardDetailLevel}
|
||||||
|
boardViewMode={boardViewMode}
|
||||||
|
onBoardViewModeChange={setBoardViewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Kanban Columns */}
|
{/* View Content - Kanban or Graph */}
|
||||||
|
{boardViewMode === 'kanban' ? (
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetectionStrategy={collisionDetectionStrategy}
|
collisionDetectionStrategy={collisionDetectionStrategy}
|
||||||
@@ -1023,6 +1011,22 @@ export function BoardView() {
|
|||||||
suggestionsCount={suggestionsCount}
|
suggestionsCount={suggestionsCount}
|
||||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<GraphView
|
||||||
|
features={hookFeatures}
|
||||||
|
runningAutoTasks={runningAutoTasks}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Board Background Modal */}
|
{/* Board Background Modal */}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
|
import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { BoardViewMode } from '@/store/app-store';
|
||||||
|
|
||||||
interface BoardControlsProps {
|
interface BoardControlsProps {
|
||||||
isMounted: boolean;
|
isMounted: boolean;
|
||||||
@@ -10,6 +11,8 @@ interface BoardControlsProps {
|
|||||||
completedCount: number;
|
completedCount: number;
|
||||||
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
|
||||||
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
|
||||||
|
boardViewMode: BoardViewMode;
|
||||||
|
onBoardViewModeChange: (mode: BoardViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardControls({
|
export function BoardControls({
|
||||||
@@ -19,12 +22,59 @@ export function BoardControls({
|
|||||||
completedCount,
|
completedCount,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
onDetailLevelChange,
|
onDetailLevelChange,
|
||||||
|
boardViewMode,
|
||||||
|
onBoardViewModeChange,
|
||||||
}: BoardControlsProps) {
|
}: BoardControlsProps) {
|
||||||
if (!isMounted) return null;
|
if (!isMounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{/* View Mode Toggle - Kanban / Graph */}
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="view-mode-toggle"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onBoardViewModeChange('kanban')}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-l-lg transition-colors',
|
||||||
|
boardViewMode === 'kanban'
|
||||||
|
? 'bg-brand-500/20 text-brand-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-mode-kanban"
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Kanban Board View</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onBoardViewModeChange('graph')}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-r-lg transition-colors',
|
||||||
|
boardViewMode === 'graph'
|
||||||
|
? 'bg-brand-500/20 text-brand-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
|
)}
|
||||||
|
data-testid="view-mode-graph"
|
||||||
|
>
|
||||||
|
<Network className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Dependency Graph View</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Board Background Button */}
|
{/* Board Background Button */}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface CardHeaderProps {
|
|||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onViewOutput?: () => void;
|
onViewOutput?: () => void;
|
||||||
|
hideActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardHeaderSection({
|
export function CardHeaderSection({
|
||||||
@@ -40,6 +41,7 @@ export function CardHeaderSection({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onViewOutput,
|
onViewOutput,
|
||||||
|
hideActions,
|
||||||
}: CardHeaderProps) {
|
}: CardHeaderProps) {
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { memo } from 'react';
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { GlassCard } from '@/components/ui/glass-card';
|
||||||
import { Feature, useAppStore } from '@/store/app-store';
|
import { Feature, useAppStore } from '@/store/app-store';
|
||||||
import { CardBadges, PriorityBadges } from './card-badges';
|
import { CardBadges, PriorityBadges } from './card-badges';
|
||||||
import { CardHeaderSection } from './card-header';
|
import { CardHeaderSection } from './card-header';
|
||||||
@@ -56,10 +56,6 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
shortcutKey,
|
shortcutKey,
|
||||||
contextContent,
|
contextContent,
|
||||||
summary,
|
summary,
|
||||||
opacity = 100,
|
|
||||||
glassmorphism = true,
|
|
||||||
cardBorderEnabled = true,
|
|
||||||
cardBorderOpacity = 100,
|
|
||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const { useWorktrees } = useAppStore();
|
const { useWorktrees } = useAppStore();
|
||||||
|
|
||||||
@@ -68,6 +64,7 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
feature.status === 'waiting_approval' ||
|
feature.status === 'waiting_approval' ||
|
||||||
feature.status === 'verified' ||
|
feature.status === 'verified' ||
|
||||||
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
(feature.status === 'in_progress' && !isCurrentAutoTask);
|
||||||
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: feature.id,
|
id: feature.id,
|
||||||
disabled: !isDraggable,
|
disabled: !isDraggable,
|
||||||
@@ -79,36 +76,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
opacity: isDragging ? 0.5 : undefined,
|
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 = (
|
const cardElement = (
|
||||||
<Card
|
<GlassCard
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={isCurrentAutoTask ? style : borderStyle}
|
variant={isCurrentAutoTask ? 'active-blue' : 'default'}
|
||||||
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
|
'group relative min-h-[140px] flex flex-col',
|
||||||
'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',
|
isDragging && 'scale-105 shadow-xl shadow-black/20 rotate-1',
|
||||||
// Error state - using CSS variable
|
feature.error && 'border-brand-red border-2 shadow-glow-red',
|
||||||
feature.error &&
|
|
||||||
!isCurrentAutoTask &&
|
|
||||||
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
|
||||||
!isDraggable && 'cursor-default'
|
!isDraggable && 'cursor-default'
|
||||||
)}
|
)}
|
||||||
data-testid={`kanban-card-${feature.id}`}
|
data-testid={`kanban-card-${feature.id}`}
|
||||||
@@ -116,29 +92,44 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isDraggable ? listeners : {})}
|
{...(isDraggable ? listeners : {})}
|
||||||
>
|
>
|
||||||
{/* Background overlay with opacity */}
|
{/* Top Row: Empty space + Delete (on hover) */}
|
||||||
{!isDragging && (
|
<div className="flex justify-between items-start mb-2 h-5">
|
||||||
<div
|
<div className="flex flex-wrap gap-1">
|
||||||
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} />
|
<CardBadges feature={feature} />
|
||||||
|
</div>
|
||||||
{/* Category row */}
|
{/* Delete/Actions on hover */}
|
||||||
<div className="px-3 pt-4">
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-2">
|
||||||
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Priority and Manual Verification badges */}
|
{/* Description */}
|
||||||
<PriorityBadges feature={feature} />
|
<div className="mb-4">
|
||||||
|
|
||||||
{/* Card Header */}
|
|
||||||
<CardHeaderSection
|
<CardHeaderSection
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isDraggable={isDraggable}
|
isDraggable={isDraggable}
|
||||||
@@ -146,21 +137,34 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onViewOutput={onViewOutput}
|
onViewOutput={onViewOutput}
|
||||||
|
hideActions={true} // We handle actions via hover/bottom bar
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardContent className="px-3 pt-0 pb-0">
|
{/* Middle Grid: Priority, etc */}
|
||||||
{/* Content Sections */}
|
<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>
|
||||||
|
|
||||||
|
{/* Content & Agent Info */}
|
||||||
|
<div className="mb-2">
|
||||||
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
<CardContentSections feature={feature} useWorktrees={useWorktrees} />
|
||||||
|
|
||||||
{/* Agent Info Panel */}
|
|
||||||
<AgentInfoPanel
|
<AgentInfoPanel
|
||||||
feature={feature}
|
feature={feature}
|
||||||
contextContent={contextContent}
|
contextContent={contextContent}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
isCurrentAutoTask={isCurrentAutoTask}
|
isCurrentAutoTask={isCurrentAutoTask}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Buttons Grid */}
|
||||||
|
<div className="mt-auto pt-2">
|
||||||
<CardActions
|
<CardActions
|
||||||
feature={feature}
|
feature={feature}
|
||||||
isCurrentAutoTask={!!isCurrentAutoTask}
|
isCurrentAutoTask={!!isCurrentAutoTask}
|
||||||
@@ -178,14 +182,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
onViewPlan={onViewPlan}
|
onViewPlan={onViewPlan}
|
||||||
onApprovePlan={onApprovePlan}
|
onApprovePlan={onApprovePlan}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap with animated border when in progress
|
|
||||||
if (isCurrentAutoTask) {
|
|
||||||
return <div className="animated-border-wrapper">{cardElement}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardElement;
|
return cardElement;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,31 +2,30 @@ import { memo } from 'react';
|
|||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
colorClass: string;
|
accent: 'cyan' | 'blue' | 'orange' | 'green' | 'none';
|
||||||
count: number;
|
count: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerAction?: ReactNode;
|
headerAction?: ReactNode;
|
||||||
|
width?: number;
|
||||||
|
// Legacy props ignored or used for compatibility
|
||||||
|
colorClass?: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
hideScrollbar?: boolean;
|
hideScrollbar?: boolean;
|
||||||
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
|
|
||||||
width?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
colorClass,
|
accent,
|
||||||
count,
|
count,
|
||||||
children,
|
children,
|
||||||
headerAction,
|
headerAction,
|
||||||
opacity = 100,
|
|
||||||
showBorder = true,
|
|
||||||
hideScrollbar = false,
|
|
||||||
width,
|
width,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
@@ -35,60 +34,63 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<GlassPanel
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
accent={accent}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex flex-col h-full rounded-xl',
|
'relative flex flex-col h-full min-w-[300px] transition-[box-shadow,ring] duration-200',
|
||||||
// Only transition ring/shadow for drag-over effect, not width
|
!width && 'w-72',
|
||||||
'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'
|
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
|
||||||
)}
|
)}
|
||||||
style={widthStyle}
|
style={widthStyle}
|
||||||
data-testid={`kanban-column-${id}`}
|
data-testid={`kanban-column-${id}`}
|
||||||
>
|
>
|
||||||
{/* Background layer with opacity */}
|
{/* Subtle Glow Top (Only for Blue/Orange/Green to match design, could make generic) */}
|
||||||
|
{(accent === 'blue' || accent === 'orange' || accent === 'green') && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200',
|
'absolute top-0 left-0 w-full h-32 bg-gradient-to-b pointer-events-none rounded-t-2xl',
|
||||||
isOver ? 'bg-accent/80' : 'bg-card/80'
|
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>
|
||||||
)}
|
)}
|
||||||
style={{ opacity: opacity / 100 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Column Header */}
|
{/* 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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
'w-2 h-2 rounded-full',
|
||||||
showBorder && 'border-b border-border/40'
|
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>
|
||||||
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} />
|
<span className="font-bold text-slate-200 text-sm">{title}</span>
|
||||||
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
|
|
||||||
|
{/* Action container (like "Make") */}
|
||||||
{headerAction}
|
{headerAction}
|
||||||
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
|
</div>
|
||||||
|
|
||||||
|
{/* Count Badge */}
|
||||||
|
<span className="text-[10px] bg-dark-700 text-slate-400 px-2 py-0.5 rounded border border-white/5 font-medium">
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Content */}
|
{/* Column Content */}
|
||||||
<div
|
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar relative z-10">
|
||||||
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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop zone indicator when dragging over */}
|
{/* Drop zone indicator when dragging over */}
|
||||||
{isOver && (
|
{isOver && (
|
||||||
<div className="absolute inset-0 rounded-xl bg-primary/5 pointer-events-none z-5 border-2 border-dashed border-primary/20" />
|
<div className="absolute inset-0 rounded-2xl bg-white/5 pointer-events-none z-20 border-2 border-dashed border-white/10" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,21 +2,29 @@ import { Feature } from '@/store/app-store';
|
|||||||
|
|
||||||
export type ColumnId = Feature['status'];
|
export type ColumnId = Feature['status'];
|
||||||
|
|
||||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
export const COLUMNS: {
|
||||||
{ id: 'backlog', title: 'Backlog', colorClass: 'bg-[var(--status-backlog)]' },
|
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',
|
id: 'in_progress',
|
||||||
title: 'In Progress',
|
title: 'In Progress',
|
||||||
colorClass: 'bg-[var(--status-in-progress)]',
|
colorClass: 'bg-[var(--status-in-progress)]',
|
||||||
|
accent: 'blue',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'waiting_approval',
|
id: 'waiting_approval',
|
||||||
title: 'Waiting Approval',
|
title: 'Waiting Approval',
|
||||||
colorClass: 'bg-[var(--status-waiting)]',
|
colorClass: 'bg-[var(--status-waiting)]',
|
||||||
|
accent: 'orange',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'verified',
|
id: 'verified',
|
||||||
title: 'Verified',
|
title: 'Verified',
|
||||||
colorClass: 'bg-[var(--status-success)]',
|
colorClass: 'bg-[var(--status-success)]',
|
||||||
|
accent: 'green',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export function useBoardColumnFeatures({
|
|||||||
// This ensures features appear in dependency order (dependencies before dependents)
|
// This ensures features appear in dependency order (dependencies before dependents)
|
||||||
// Within the same dependency level, features are sorted by priority
|
// Within the same dependency level, features are sorted by priority
|
||||||
if (map.backlog.length > 0) {
|
if (map.backlog.length > 0) {
|
||||||
const { orderedFeatures } = resolveDependencies(map.backlog);
|
const { orderedFeatures } = resolveDependencies(map.backlog as any) as {
|
||||||
|
orderedFeatures: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
// Get all features to check blocking dependencies against
|
// Get all features to check blocking dependencies against
|
||||||
const allFeatures = features;
|
const allFeatures = features;
|
||||||
@@ -122,7 +124,7 @@ export function useBoardColumnFeatures({
|
|||||||
const blocked: Feature[] = [];
|
const blocked: Feature[] = [];
|
||||||
|
|
||||||
for (const f of orderedFeatures) {
|
for (const f of orderedFeatures) {
|
||||||
if (getBlockingDependencies(f, allFeatures).length > 0) {
|
if (getBlockingDependencies(f as any, allFeatures as any).length > 0) {
|
||||||
blocked.push(f);
|
blocked.push(f);
|
||||||
} else {
|
} else {
|
||||||
unblocked.push(f);
|
unblocked.push(f);
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function KanbanBoard({
|
|||||||
key={column.id}
|
key={column.id}
|
||||||
id={column.id}
|
id={column.id}
|
||||||
title={column.title}
|
title={column.title}
|
||||||
|
accent={column.accent}
|
||||||
colorClass={column.colorClass}
|
colorClass={column.colorClass}
|
||||||
count={columnFeatures.length}
|
count={columnFeatures.length}
|
||||||
width={columnWidth}
|
width={columnWidth}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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,9 +20,11 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WorktreeInfo, DevServerInfo, PRInfo } from '../types';
|
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||||
|
import { TooltipWrapper } from './tooltip-wrapper';
|
||||||
|
|
||||||
interface WorktreeActionsDropdownProps {
|
interface WorktreeActionsDropdownProps {
|
||||||
worktree: WorktreeInfo;
|
worktree: WorktreeInfo;
|
||||||
@@ -35,6 +37,7 @@ interface WorktreeActionsDropdownProps {
|
|||||||
isStartingDevServer: boolean;
|
isStartingDevServer: boolean;
|
||||||
isDevServerRunning: boolean;
|
isDevServerRunning: boolean;
|
||||||
devServerInfo?: DevServerInfo;
|
devServerInfo?: DevServerInfo;
|
||||||
|
gitRepoStatus: GitRepoStatus;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onPull: (worktree: WorktreeInfo) => void;
|
onPull: (worktree: WorktreeInfo) => void;
|
||||||
onPush: (worktree: WorktreeInfo) => void;
|
onPush: (worktree: WorktreeInfo) => void;
|
||||||
@@ -60,6 +63,7 @@ export function WorktreeActionsDropdown({
|
|||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
isDevServerRunning,
|
isDevServerRunning,
|
||||||
devServerInfo,
|
devServerInfo,
|
||||||
|
gitRepoStatus,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onPull,
|
onPull,
|
||||||
onPush,
|
onPush,
|
||||||
@@ -76,6 +80,14 @@ export function WorktreeActionsDropdown({
|
|||||||
// Check if there's a PR associated with this worktree from stored metadata
|
// Check if there's a PR associated with this worktree from stored metadata
|
||||||
const hasPR = !!worktree.pr;
|
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 (
|
return (
|
||||||
<DropdownMenu onOpenChange={onOpenChange}>
|
<DropdownMenu onOpenChange={onOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -92,6 +104,16 @@ export function WorktreeActionsDropdown({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<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 ? (
|
{isDevServerRunning ? (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||||
@@ -124,36 +146,58 @@ export function WorktreeActionsDropdown({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => onPull(worktree)} disabled={isPulling} className="text-xs">
|
<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')} />
|
<Download className={cn('w-3.5 h-3.5 mr-2', isPulling && 'animate-pulse')} />
|
||||||
{isPulling ? 'Pulling...' : 'Pull'}
|
{isPulling ? 'Pulling...' : 'Pull'}
|
||||||
{behindCount > 0 && (
|
{!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">
|
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||||
{behindCount} behind
|
{behindCount} behind
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onPush(worktree)}
|
onClick={() => canPerformGitOps && onPush(worktree)}
|
||||||
disabled={isPushing || aheadCount === 0}
|
disabled={isPushing || aheadCount === 0 || !canPerformGitOps}
|
||||||
className="text-xs"
|
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
|
||||||
>
|
>
|
||||||
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
|
||||||
{isPushing ? 'Pushing...' : 'Push'}
|
{isPushing ? 'Pushing...' : 'Push'}
|
||||||
{aheadCount > 0 && (
|
{!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">
|
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||||
{aheadCount} ahead
|
{aheadCount} ahead
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
{!worktree.isMain && (
|
{!worktree.isMain && (
|
||||||
|
<TooltipWrapper
|
||||||
|
showTooltip={!!gitOpsDisabledReason}
|
||||||
|
tooltipContent={gitOpsDisabledReason}
|
||||||
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onResolveConflicts(worktree)}
|
onClick={() => canPerformGitOps && onResolveConflicts(worktree)}
|
||||||
className="text-xs text-purple-500 focus:text-purple-600"
|
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" />
|
<GitMerge className="w-3.5 h-3.5 mr-2" />
|
||||||
Pull & Resolve Conflicts
|
Pull & Resolve Conflicts
|
||||||
|
{!canPerformGitOps && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
<DropdownMenuItem onClick={() => onOpenInEditor(worktree)} className="text-xs">
|
||||||
@@ -162,17 +206,41 @@ export function WorktreeActionsDropdown({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{worktree.hasChanges && (
|
{worktree.hasChanges && (
|
||||||
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
<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" />
|
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||||
Commit Changes
|
Commit Changes
|
||||||
|
{!gitRepoStatus.isGitRepo && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||||
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
{(!worktree.isMain || worktree.hasChanges) && !hasPR && (
|
||||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
<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" />
|
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||||
Create Pull Request
|
Create Pull Request
|
||||||
|
{!canPerformGitOps && (
|
||||||
|
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</TooltipWrapper>
|
||||||
)}
|
)}
|
||||||
{/* Show PR info and Address Comments button if PR exists */}
|
{/* Show PR info and Address Comments button if PR exists */}
|
||||||
{!worktree.isMain && hasPR && worktree.pr && (
|
{!worktree.isMain && hasPR && worktree.pr && (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo } from '../types';
|
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||||
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
import { BranchSwitchDropdown } from './branch-switch-dropdown';
|
||||||
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ interface WorktreeTabProps {
|
|||||||
isStartingDevServer: boolean;
|
isStartingDevServer: boolean;
|
||||||
aheadCount: number;
|
aheadCount: number;
|
||||||
behindCount: number;
|
behindCount: number;
|
||||||
|
gitRepoStatus: GitRepoStatus;
|
||||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||||
@@ -67,6 +69,7 @@ export function WorktreeTab({
|
|||||||
isStartingDevServer,
|
isStartingDevServer,
|
||||||
aheadCount,
|
aheadCount,
|
||||||
behindCount,
|
behindCount,
|
||||||
|
gitRepoStatus,
|
||||||
onSelectWorktree,
|
onSelectWorktree,
|
||||||
onBranchDropdownOpenChange,
|
onBranchDropdownOpenChange,
|
||||||
onActionsDropdownOpenChange,
|
onActionsDropdownOpenChange,
|
||||||
@@ -85,7 +88,7 @@ export function WorktreeTab({
|
|||||||
onStopDevServer,
|
onStopDevServer,
|
||||||
onOpenDevServerUrl,
|
onOpenDevServerUrl,
|
||||||
}: WorktreeTabProps) {
|
}: WorktreeTabProps) {
|
||||||
let prBadge: JSX.Element | null = null;
|
let prBadge: React.ReactNode | null = null;
|
||||||
if (worktree.pr) {
|
if (worktree.pr) {
|
||||||
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
const prState = worktree.pr.state?.toLowerCase() ?? 'open';
|
||||||
const prStateClasses = (() => {
|
const prStateClasses = (() => {
|
||||||
@@ -320,6 +323,7 @@ export function WorktreeTab({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
isDevServerRunning={isDevServerRunning}
|
isDevServerRunning={isDevServerRunning}
|
||||||
devServerInfo={devServerInfo}
|
devServerInfo={devServerInfo}
|
||||||
|
gitRepoStatus={gitRepoStatus}
|
||||||
onOpenChange={onActionsDropdownOpenChange}
|
onOpenChange={onActionsDropdownOpenChange}
|
||||||
onPull={onPull}
|
onPull={onPull}
|
||||||
onPush={onPush}
|
onPush={onPush}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import type { BranchInfo } from '../types';
|
import type { BranchInfo, GitRepoStatus } from '../types';
|
||||||
|
|
||||||
export function useBranches() {
|
export function useBranches() {
|
||||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||||
@@ -8,8 +8,20 @@ export function useBranches() {
|
|||||||
const [behindCount, setBehindCount] = useState(0);
|
const [behindCount, setBehindCount] = useState(0);
|
||||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||||
const [branchFilter, setBranchFilter] = useState('');
|
const [branchFilter, setBranchFilter] = useState('');
|
||||||
|
const [gitRepoStatus, setGitRepoStatus] = useState<GitRepoStatus>({
|
||||||
|
isGitRepo: true,
|
||||||
|
hasCommits: true,
|
||||||
|
});
|
||||||
|
|
||||||
const fetchBranches = useCallback(async (worktreePath: string) => {
|
/** Helper to reset branch state to initial values */
|
||||||
|
const resetBranchState = useCallback(() => {
|
||||||
|
setBranches([]);
|
||||||
|
setAheadCount(0);
|
||||||
|
setBehindCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBranches = useCallback(
|
||||||
|
async (worktreePath: string) => {
|
||||||
setIsLoadingBranches(true);
|
setIsLoadingBranches(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -22,13 +34,31 @@ export function useBranches() {
|
|||||||
setBranches(result.result.branches);
|
setBranches(result.result.branches);
|
||||||
setAheadCount(result.result.aheadCount || 0);
|
setAheadCount(result.result.aheadCount || 0);
|
||||||
setBehindCount(result.result.behindCount || 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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch branches:', 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 {
|
} finally {
|
||||||
setIsLoadingBranches(false);
|
setIsLoadingBranches(false);
|
||||||
}
|
}
|
||||||
}, []);
|
},
|
||||||
|
[resetBranchState]
|
||||||
|
);
|
||||||
|
|
||||||
const resetBranchFilter = useCallback(() => {
|
const resetBranchFilter = useCallback(() => {
|
||||||
setBranchFilter('');
|
setBranchFilter('');
|
||||||
@@ -48,5 +78,6 @@ export function useBranches() {
|
|||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
resetBranchFilter,
|
resetBranchFilter,
|
||||||
fetchBranches,
|
fetchBranches,
|
||||||
|
gitRepoStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,13 +114,12 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
|||||||
|
|
||||||
const handleOpenDevServerUrl = useCallback(
|
const handleOpenDevServerUrl = useCallback(
|
||||||
(worktree: WorktreeInfo) => {
|
(worktree: WorktreeInfo) => {
|
||||||
const targetPath = worktree.isMain ? projectPath : worktree.path;
|
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
|
||||||
const serverInfo = runningDevServers.get(targetPath);
|
|
||||||
if (serverInfo) {
|
if (serverInfo) {
|
||||||
window.open(serverInfo.url, '_blank');
|
window.open(serverInfo.url, '_blank');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectPath, runningDevServers]
|
[runningDevServers, getWorktreeKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDevServerRunning = useCallback(
|
const isDevServerRunning = useCallback(
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { WorktreeInfo } from '../types';
|
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 {
|
interface UseWorktreeActionsOptions {
|
||||||
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
fetchWorktrees: () => Promise<Array<{ path: string; branch: string }> | undefined>;
|
||||||
fetchBranches: (worktreePath: string) => Promise<void>;
|
fetchBranches: (worktreePath: string) => Promise<void>;
|
||||||
@@ -29,6 +52,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
toast.success(result.result.message);
|
toast.success(result.result.message);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
} else {
|
} else {
|
||||||
|
if (handleGitStatusError(result)) return;
|
||||||
toast.error(result.error || 'Failed to switch branch');
|
toast.error(result.error || 'Failed to switch branch');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -56,6 +80,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
toast.success(result.result.message);
|
toast.success(result.result.message);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
} else {
|
} else {
|
||||||
|
if (handleGitStatusError(result)) return;
|
||||||
toast.error(result.error || 'Failed to pull latest changes');
|
toast.error(result.error || 'Failed to pull latest changes');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -84,6 +109,7 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
|
|||||||
fetchBranches(worktree.path);
|
fetchBranches(worktree.path);
|
||||||
fetchWorktrees();
|
fetchWorktrees();
|
||||||
} else {
|
} else {
|
||||||
|
if (handleGitStatusError(result)) return;
|
||||||
toast.error(result.error || 'Failed to push changes');
|
toast.error(result.error || 'Failed to push changes');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export interface BranchInfo {
|
|||||||
isRemote: boolean;
|
isRemote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitRepoStatus {
|
||||||
|
isGitRepo: boolean;
|
||||||
|
hasCommits: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DevServerInfo {
|
export interface DevServerInfo {
|
||||||
worktreePath: string;
|
worktreePath: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function WorktreePanel({
|
|||||||
setBranchFilter,
|
setBranchFilter,
|
||||||
resetBranchFilter,
|
resetBranchFilter,
|
||||||
fetchBranches,
|
fetchBranches,
|
||||||
|
gitRepoStatus,
|
||||||
} = useBranches();
|
} = useBranches();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -210,6 +211,7 @@ export function WorktreePanel({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
|
gitRepoStatus={gitRepoStatus}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
|
||||||
@@ -264,6 +266,7 @@ export function WorktreePanel({
|
|||||||
isStartingDevServer={isStartingDevServer}
|
isStartingDevServer={isStartingDevServer}
|
||||||
aheadCount={aheadCount}
|
aheadCount={aheadCount}
|
||||||
behindCount={behindCount}
|
behindCount={behindCount}
|
||||||
|
gitRepoStatus={gitRepoStatus}
|
||||||
onSelectWorktree={handleSelectWorktree}
|
onSelectWorktree={handleSelectWorktree}
|
||||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react';
|
||||||
|
import type { EdgeProps } from '@xyflow/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
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']) => {
|
||||||
|
// If source is completed/verified, the dependency is satisfied
|
||||||
|
if (sourceStatus === 'completed' || sourceStatus === 'verified') {
|
||||||
|
return 'var(--status-success)';
|
||||||
|
}
|
||||||
|
// If target is in progress, show active color
|
||||||
|
if (targetStatus === 'in_progress') {
|
||||||
|
return 'var(--status-in-progress)';
|
||||||
|
}
|
||||||
|
// If target is blocked (in backlog with incomplete deps)
|
||||||
|
if (targetStatus === 'backlog') {
|
||||||
|
return 'var(--border)';
|
||||||
|
}
|
||||||
|
// Default
|
||||||
|
return 'var(--border)';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
data,
|
||||||
|
selected,
|
||||||
|
animated,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const edgeData = data as DependencyEdgeData | undefined;
|
||||||
|
|
||||||
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourcePosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetPosition,
|
||||||
|
curvature: 0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHighlighted = edgeData?.isHighlighted ?? false;
|
||||||
|
const isDimmed = edgeData?.isDimmed ?? false;
|
||||||
|
|
||||||
|
const edgeColor = isHighlighted
|
||||||
|
? 'var(--brand-500)'
|
||||||
|
: edgeData
|
||||||
|
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
|
||||||
|
: 'var(--border)';
|
||||||
|
|
||||||
|
const isCompleted =
|
||||||
|
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
|
||||||
|
const isInProgress = edgeData?.targetStatus === 'in_progress';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Background edge for better visibility */}
|
||||||
|
<BaseEdge
|
||||||
|
id={`${id}-bg`}
|
||||||
|
path={edgePath}
|
||||||
|
style={{
|
||||||
|
strokeWidth: isHighlighted ? 6 : 4,
|
||||||
|
stroke: 'var(--background)',
|
||||||
|
opacity: isDimmed ? 0.3 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main edge */}
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
animated && 'animated-edge',
|
||||||
|
isInProgress && 'edge-flowing',
|
||||||
|
isHighlighted && 'graph-edge-highlighted',
|
||||||
|
isDimmed && 'graph-edge-dimmed'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated particles for in-progress edges */}
|
||||||
|
{animated && (
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
className="edge-particle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
isInProgress
|
||||||
|
? 'bg-[var(--status-in-progress)] animate-ping'
|
||||||
|
: 'bg-brand-500 animate-pulse'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useReactFlow, Panel } from '@xyflow/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize2,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
GitBranch,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowDown,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface GraphControlsProps {
|
||||||
|
isLocked: boolean;
|
||||||
|
onToggleLock: () => void;
|
||||||
|
onRunLayout: (direction: 'LR' | 'TB') => void;
|
||||||
|
layoutDirection: 'LR' | 'TB';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphControls({
|
||||||
|
isLocked,
|
||||||
|
onToggleLock,
|
||||||
|
onRunLayout,
|
||||||
|
layoutDirection,
|
||||||
|
}: GraphControlsProps) {
|
||||||
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => zoomIn({ duration: 200 })}
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Zoom In</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => zoomOut({ duration: 200 })}
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Zoom Out</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => fitView({ padding: 0.2, duration: 300 })}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Fit View</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
|
||||||
|
{/* Layout controls */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 p-0',
|
||||||
|
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
|
onClick={() => onRunLayout('LR')}
|
||||||
|
>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Horizontal Layout</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 p-0',
|
||||||
|
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
|
||||||
|
)}
|
||||||
|
onClick={() => onRunLayout('TB')}
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">Vertical Layout</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
|
||||||
|
{/* Lock toggle */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
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" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Panel } from '@xyflow/react';
|
||||||
|
import { Clock, Play, Pause, CheckCircle2, Lock, AlertCircle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const legendItems = [
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
label: 'Backlog',
|
||||||
|
colorClass: 'text-muted-foreground',
|
||||||
|
bgClass: 'bg-muted/50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Play,
|
||||||
|
label: 'In Progress',
|
||||||
|
colorClass: 'text-[var(--status-in-progress)]',
|
||||||
|
bgClass: 'bg-[var(--status-in-progress)]/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Pause,
|
||||||
|
label: 'Waiting',
|
||||||
|
colorClass: 'text-[var(--status-waiting)]',
|
||||||
|
bgClass: 'bg-[var(--status-warning)]/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'Verified',
|
||||||
|
colorClass: 'text-[var(--status-success)]',
|
||||||
|
bgClass: 'bg-[var(--status-success)]/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Lock,
|
||||||
|
label: 'Blocked',
|
||||||
|
colorClass: 'text-orange-500',
|
||||||
|
bgClass: 'bg-orange-500/20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: AlertCircle,
|
||||||
|
label: 'Error',
|
||||||
|
colorClass: 'text-[var(--status-error)]',
|
||||||
|
bgClass: 'bg-[var(--status-error)]/20',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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">
|
||||||
|
{legendItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="flex items-center gap-1.5">
|
||||||
|
<div className={cn('p-1 rounded', item.bgClass)}>
|
||||||
|
<Icon className={cn('w-3 h-3', item.colorClass)} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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';
|
||||||
342
apps/ui/src/components/views/graph-view/components/task-node.tsx
Normal file
342
apps/ui/src/components/views/graph-view/components/task-node.tsx
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Handle, Position } from '@xyflow/react';
|
||||||
|
import type { NodeProps } from '@xyflow/react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Lock,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Eye,
|
||||||
|
MoreVertical,
|
||||||
|
GitBranch,
|
||||||
|
Terminal,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { TaskNodeData } from '../hooks/use-graph-nodes';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
type TaskNodeProps = NodeProps & {
|
||||||
|
data: TaskNodeData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
backlog: {
|
||||||
|
icon: Clock,
|
||||||
|
label: 'Backlog',
|
||||||
|
colorClass: 'text-muted-foreground',
|
||||||
|
borderClass: 'border-border',
|
||||||
|
bgClass: 'bg-card',
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
icon: Play,
|
||||||
|
label: 'In Progress',
|
||||||
|
colorClass: 'text-[var(--status-in-progress)]',
|
||||||
|
borderClass: 'border-[var(--status-in-progress)]',
|
||||||
|
bgClass: 'bg-[var(--status-in-progress-bg)]',
|
||||||
|
},
|
||||||
|
waiting_approval: {
|
||||||
|
icon: Pause,
|
||||||
|
label: 'Waiting Approval',
|
||||||
|
colorClass: 'text-[var(--status-waiting)]',
|
||||||
|
borderClass: 'border-[var(--status-waiting)]',
|
||||||
|
bgClass: 'bg-[var(--status-warning-bg)]',
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'Verified',
|
||||||
|
colorClass: 'text-[var(--status-success)]',
|
||||||
|
borderClass: 'border-[var(--status-success)]',
|
||||||
|
bgClass: 'bg-[var(--status-success-bg)]',
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: 'Completed',
|
||||||
|
colorClass: 'text-[var(--status-success)]',
|
||||||
|
borderClass: 'border-[var(--status-success)]/50',
|
||||||
|
bgClass: 'bg-[var(--status-success-bg)]/50',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityConfig = {
|
||||||
|
1: { label: 'High', colorClass: 'bg-[var(--status-error)] text-white' },
|
||||||
|
2: { label: 'Medium', colorClass: 'bg-[var(--status-warning)] text-black' },
|
||||||
|
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
|
||||||
|
};
|
||||||
|
|
||||||
|
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) */}
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
'hover:!bg-brand-500',
|
||||||
|
isDimmed && 'opacity-30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
|
||||||
|
'transition-all duration-300',
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header with status and actions */}
|
||||||
|
<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>
|
||||||
|
</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
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blocked indicator */}
|
||||||
|
{data.isBlocked && !data.error && data.status === 'backlog' && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="p-1 rounded bg-orange-500/20">
|
||||||
|
<Lock className="w-3 h-3 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs max-w-[200px]">
|
||||||
|
<p>Blocked by {data.blockingDependencies.length} dependencies</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error indicator */}
|
||||||
|
{data.error && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="p-1 rounded bg-[var(--status-error-bg)]">
|
||||||
|
<AlertCircle className="w-3 h-3 text-[var(--status-error)]" />
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs max-w-[250px]">
|
||||||
|
<p>{data.error}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</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()}
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4 text-foreground" />
|
||||||
|
</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?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
{/* Category */}
|
||||||
|
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wide">
|
||||||
|
{data.category}
|
||||||
|
</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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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">
|
||||||
|
<GitBranch className="w-3 h-3" />
|
||||||
|
<span className="truncate">{data.branchName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source handle (right side - provides to dependents) */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
className={cn(
|
||||||
|
'w-3 h-3 !bg-border border-2 border-background',
|
||||||
|
'transition-colors duration-200',
|
||||||
|
'hover:!bg-brand-500',
|
||||||
|
data.status === 'completed' || data.status === 'verified'
|
||||||
|
? '!bg-[var(--status-success)]'
|
||||||
|
: '',
|
||||||
|
isDimmed && 'opacity-30'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
243
apps/ui/src/components/views/graph-view/graph-canvas.tsx
Normal file
243
apps/ui/src/components/views/graph-view/graph-canvas.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
MiniMap,
|
||||||
|
Panel,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
ReactFlowProvider,
|
||||||
|
SelectionMode,
|
||||||
|
ConnectionMode,
|
||||||
|
Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
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 { 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
|
||||||
|
const nodeTypes: any = {
|
||||||
|
task: TaskNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const edgeTypes: any = {
|
||||||
|
dependency: DependencyEdge,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GraphCanvasProps {
|
||||||
|
features: Feature[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (query: string) => void;
|
||||||
|
onNodeDoubleClick?: (featureId: string) => void;
|
||||||
|
nodeActionCallbacks?: NodeActionCallbacks;
|
||||||
|
backgroundStyle?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GraphCanvasInner({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
|
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
|
||||||
|
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
filterResult,
|
||||||
|
actionCallbacks: nodeActionCallbacks,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply layout
|
||||||
|
const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({
|
||||||
|
nodes: initialNodes,
|
||||||
|
edges: initialEdges,
|
||||||
|
});
|
||||||
|
|
||||||
|
// React Flow state
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||||
|
|
||||||
|
// Update nodes/edges when features change
|
||||||
|
useEffect(() => {
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
setEdges(layoutedEdges);
|
||||||
|
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||||
|
|
||||||
|
// Handle layout direction change
|
||||||
|
const handleRunLayout = useCallback(
|
||||||
|
(direction: 'LR' | 'TB') => {
|
||||||
|
setLayoutDirection(direction);
|
||||||
|
runLayout(direction);
|
||||||
|
},
|
||||||
|
[runLayout]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle clear all filters
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
onSearchQueryChange('');
|
||||||
|
setSelectedCategories([]);
|
||||||
|
setSelectedStatuses([]);
|
||||||
|
setIsNegativeFilter(false);
|
||||||
|
}, [onSearchQueryChange]);
|
||||||
|
|
||||||
|
// Handle node double click
|
||||||
|
const handleNodeDoubleClick = useCallback(
|
||||||
|
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
|
||||||
|
onNodeDoubleClick?.(node.id);
|
||||||
|
},
|
||||||
|
[onNodeDoubleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// MiniMap node color based on status
|
||||||
|
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
|
||||||
|
const data = node.data as TaskNodeData | undefined;
|
||||||
|
const status = data?.status;
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
case 'verified':
|
||||||
|
return 'var(--status-success)';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'var(--status-in-progress)';
|
||||||
|
case 'waiting_approval':
|
||||||
|
return 'var(--status-waiting)';
|
||||||
|
default:
|
||||||
|
if (data?.isBlocked) return 'rgb(249, 115, 22)'; // orange-500
|
||||||
|
if (data?.error) return 'var(--status-error)';
|
||||||
|
return 'var(--muted-foreground)';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={isLocked ? undefined : onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
minZoom={0.1}
|
||||||
|
maxZoom={2}
|
||||||
|
selectionMode={SelectionMode.Partial}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
className="graph-canvas"
|
||||||
|
>
|
||||||
|
<Background
|
||||||
|
variant={BackgroundVariant.Dots}
|
||||||
|
gap={20}
|
||||||
|
size={1}
|
||||||
|
color="var(--border)"
|
||||||
|
className="opacity-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={minimapNodeColor}
|
||||||
|
nodeStrokeWidth={3}
|
||||||
|
zoomable
|
||||||
|
pannable
|
||||||
|
className="!bg-popover/90 !border-border rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GraphControls
|
||||||
|
isLocked={isLocked}
|
||||||
|
onToggleLock={() => setIsLocked(!isLocked)}
|
||||||
|
onRunLayout={handleRunLayout}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with provider for hooks to work
|
||||||
|
export function GraphCanvas(props: GraphCanvasProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<GraphCanvasInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
apps/ui/src/components/views/graph-view/graph-view.tsx
Normal file
128
apps/ui/src/components/views/graph-view/graph-view.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
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[];
|
||||||
|
runningAutoTasks: string[];
|
||||||
|
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({
|
||||||
|
features,
|
||||||
|
runningAutoTasks,
|
||||||
|
currentWorktreePath,
|
||||||
|
currentWorktreeBranch,
|
||||||
|
projectPath,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onEditFeature,
|
||||||
|
onViewOutput,
|
||||||
|
onStartTask,
|
||||||
|
onStopTask,
|
||||||
|
onResumeTask,
|
||||||
|
}: GraphViewProps) {
|
||||||
|
const { currentProject } = useAppStore();
|
||||||
|
|
||||||
|
// Use the same background hook as the board view
|
||||||
|
const { backgroundImageStyle } = useBoardBackground({ currentProject });
|
||||||
|
|
||||||
|
// Filter features by current worktree (same logic as board view)
|
||||||
|
const filteredFeatures = useMemo(() => {
|
||||||
|
const effectiveBranch = currentWorktreeBranch;
|
||||||
|
|
||||||
|
return features.filter((f) => {
|
||||||
|
// Skip completed features (they're in archive)
|
||||||
|
if (f.status === 'completed') return false;
|
||||||
|
|
||||||
|
const featureBranch = f.branchName as string | undefined;
|
||||||
|
|
||||||
|
if (!featureBranch) {
|
||||||
|
// No branch assigned - show only on primary worktree
|
||||||
|
return currentWorktreePath === null;
|
||||||
|
} else if (effectiveBranch === null) {
|
||||||
|
// Viewing main but branch not initialized
|
||||||
|
return projectPath
|
||||||
|
? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch)
|
||||||
|
: false;
|
||||||
|
} else {
|
||||||
|
// Match by branch name
|
||||||
|
return featureBranch === effectiveBranch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [features, currentWorktreePath, currentWorktreeBranch, projectPath]);
|
||||||
|
|
||||||
|
// Handle node double click - edit
|
||||||
|
const handleNodeDoubleClick = useCallback(
|
||||||
|
(featureId: string) => {
|
||||||
|
const feature = features.find((f) => f.id === featureId);
|
||||||
|
if (feature) {
|
||||||
|
onEditFeature(feature);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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}
|
||||||
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
|
nodeActionCallbacks={nodeActionCallbacks}
|
||||||
|
backgroundStyle={backgroundImageStyle}
|
||||||
|
className="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
apps/ui/src/components/views/graph-view/hooks/index.ts
Normal file
9
apps/ui/src/components/views/graph-view/hooks/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
useGraphNodes,
|
||||||
|
type TaskNode,
|
||||||
|
type DependencyEdge,
|
||||||
|
type TaskNodeData,
|
||||||
|
type NodeActionCallbacks,
|
||||||
|
} from './use-graph-nodes';
|
||||||
|
export { useGraphLayout } from './use-graph-layout';
|
||||||
|
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import dagre from 'dagre';
|
||||||
|
import { Node, Edge, useReactFlow } from '@xyflow/react';
|
||||||
|
import { TaskNode, DependencyEdge } from './use-graph-nodes';
|
||||||
|
|
||||||
|
const NODE_WIDTH = 280;
|
||||||
|
const NODE_HEIGHT = 120;
|
||||||
|
|
||||||
|
interface UseGraphLayoutProps {
|
||||||
|
nodes: TaskNode[];
|
||||||
|
edges: DependencyEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies dagre layout to position nodes in a hierarchical DAG
|
||||||
|
* Dependencies flow left-to-right
|
||||||
|
*/
|
||||||
|
export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) {
|
||||||
|
const { fitView, setNodes } = useReactFlow();
|
||||||
|
|
||||||
|
const getLayoutedElements = useCallback(
|
||||||
|
(
|
||||||
|
inputNodes: TaskNode[],
|
||||||
|
inputEdges: DependencyEdge[],
|
||||||
|
direction: 'LR' | 'TB' = 'LR'
|
||||||
|
): { nodes: TaskNode[]; edges: DependencyEdge[] } => {
|
||||||
|
const dagreGraph = new dagre.graphlib.Graph();
|
||||||
|
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||||
|
|
||||||
|
const isHorizontal = direction === 'LR';
|
||||||
|
dagreGraph.setGraph({
|
||||||
|
rankdir: direction,
|
||||||
|
nodesep: 50,
|
||||||
|
ranksep: 100,
|
||||||
|
marginx: 50,
|
||||||
|
marginy: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
inputNodes.forEach((node) => {
|
||||||
|
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEdges.forEach((edge) => {
|
||||||
|
dagreGraph.setEdge(edge.source, edge.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
dagre.layout(dagreGraph);
|
||||||
|
|
||||||
|
const layoutedNodes = inputNodes.map((node) => {
|
||||||
|
const nodeWithPosition = dagreGraph.node(node.id);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
||||||
|
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
||||||
|
},
|
||||||
|
targetPosition: isHorizontal ? 'left' : 'top',
|
||||||
|
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||||
|
} as TaskNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges: inputEdges };
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial layout
|
||||||
|
const layoutedElements = useMemo(() => {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
return getLayoutedElements(nodes, edges, 'LR');
|
||||||
|
}, [nodes, edges, getLayoutedElements]);
|
||||||
|
|
||||||
|
// Manual re-layout function
|
||||||
|
const runLayout = useCallback(
|
||||||
|
(direction: 'LR' | 'TB' = 'LR') => {
|
||||||
|
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction);
|
||||||
|
setNodes(layoutedNodes);
|
||||||
|
// Fit view after layout with a small delay to allow DOM updates
|
||||||
|
setTimeout(() => {
|
||||||
|
fitView({ padding: 0.2, duration: 300 });
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
[nodes, edges, getLayoutedElements, setNodes, fitView]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layoutedNodes: layoutedElements.nodes,
|
||||||
|
layoutedEdges: layoutedElements.edges,
|
||||||
|
runLayout,
|
||||||
|
};
|
||||||
|
}
|
||||||
156
apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts
Normal file
156
apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
const nodeList: TaskNode[] = [];
|
||||||
|
const edgeList: DependencyEdge[] = [];
|
||||||
|
const featureMap = new Map<string, Feature>();
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
position: { x: 0, y: 0 }, // Will be set by layout
|
||||||
|
data: {
|
||||||
|
...feature,
|
||||||
|
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) => {
|
||||||
|
// 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,
|
||||||
|
source: depId,
|
||||||
|
target: feature.id,
|
||||||
|
type: 'dependency',
|
||||||
|
animated: isRunning || runningAutoTasks.includes(depId),
|
||||||
|
data: {
|
||||||
|
sourceStatus: sourceFeature.status,
|
||||||
|
targetStatus: feature.status,
|
||||||
|
isHighlighted: edgeIsHighlighted,
|
||||||
|
isDimmed: edgeIsDimmed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
edgeList.push(edge);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { nodes: nodeList, edges: edgeList };
|
||||||
|
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
|
||||||
|
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
9
apps/ui/src/components/views/graph-view/index.ts
Normal file
9
apps/ui/src/components/views/graph-view/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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';
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Bot, Folder, Loader2, RefreshCw, Square, Activity } from 'lucide-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 { getElectronAPI, RunningAgent } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -103,24 +105,37 @@ export function RunningAgentsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex-1 flex flex-col overflow-hidden p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
<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-6 w-6 text-brand-500" />
|
<Activity className="h-5 w-5 text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Running Agents</h1>
|
<h1 className="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||||
<p className="text-sm text-muted-foreground">
|
Running Agents
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
{runningAgents.length === 0
|
{runningAgents.length === 0
|
||||||
? 'No agents currently running'
|
? 'No agents currently running'
|
||||||
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
|
: `${runningAgents.length} agent${runningAgents.length === 1 ? '' : 's'} running across all projects`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
<Button
|
||||||
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
|
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
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,70 +143,73 @@ export function RunningAgentsView() {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
{runningAgents.length === 0 ? (
|
{runningAgents.length === 0 ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
||||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
<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-12 w-12 text-muted-foreground" />
|
<Bot className="h-8 w-8 text-muted-foreground/50" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-medium mb-2">No Running Agents</h2>
|
<h2 className="text-lg font-medium mb-2 text-foreground">No Running Agents</h2>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<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
|
Agents will appear here when they are actively working on features. Start an agent
|
||||||
the Kanban board by dragging a feature to "In Progress".
|
from the Kanban board.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto pr-2">
|
||||||
<div className="space-y-3">
|
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{runningAgents.map((agent) => (
|
{runningAgents.map((agent) => (
|
||||||
<div
|
<div
|
||||||
key={`${agent.projectPath}-${agent.featureId}`}
|
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"
|
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-center gap-4 min-w-0">
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Bot className="h-8 w-8 text-brand-500" />
|
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center">
|
||||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
<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="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 className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent info */}
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className="font-medium truncate">{agent.featureId}</span>
|
<span className="font-semibold text-sm truncate text-foreground">
|
||||||
|
{agent.featureId}
|
||||||
|
</span>
|
||||||
{agent.isAutoMode && (
|
{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">
|
<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
|
AUTO
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
onClick={() => handleNavigateToProject(agent)}
|
<Folder className="h-3 w-3" />
|
||||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
<span className="truncate max-w-[120px]">{agent.projectName}</span>
|
||||||
>
|
</div>
|
||||||
<Folder className="h-3.5 w-3.5" />
|
</div>
|
||||||
<span className="truncate">{agent.projectName}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="mt-auto pt-3 flex items-center gap-2 border-t border-white/5">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleNavigateToProject(agent)}
|
onClick={() => handleNavigateToProject(agent)}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="flex-1 h-8 text-xs hover:bg-white/10"
|
||||||
>
|
>
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleStopAgent(agent.featureId)}
|
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 mr-1.5" />
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
Stop
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,5 +218,8 @@ export function RunningAgentsView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function SettingsView() {
|
|||||||
<AppearanceSection
|
<AppearanceSection
|
||||||
effectiveTheme={effectiveTheme}
|
effectiveTheme={effectiveTheme}
|
||||||
currentProject={settingsProject}
|
currentProject={settingsProject}
|
||||||
onThemeChange={handleSetTheme}
|
onThemeChange={handleSetTheme as (theme: Theme) => void}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import {
|
|||||||
defaultDropAnimationSideEffects,
|
defaultDropAnimationSideEffects,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TopHeader } from '@/components/layout/top-header';
|
||||||
|
import { GlassPanel } from '@/components/ui/glass-panel';
|
||||||
|
|
||||||
interface TerminalStatus {
|
interface TerminalStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -1414,6 +1416,21 @@ export function TerminalView() {
|
|||||||
|
|
||||||
// Terminal view with tabs
|
// Terminal view with tabs
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full overflow-hidden" data-testid="terminal-view">
|
||||||
|
<TopHeader />
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Tabs List */}
|
||||||
|
<div className="flex flex-1 items-center gap-1 overflow-x-auto no-scrollbar mask-gradient-right">
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -1422,244 +1439,187 @@ export function TerminalView() {
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
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) => (
|
{terminalState.tabs.map((tab) => (
|
||||||
<TerminalTabButton
|
<TerminalTabButton
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isActive={tab.id === terminalState.activeTabId}
|
isActive={tab.id === terminalState.activeTabId}
|
||||||
onClick={() => setActiveTerminalTab(tab.id)}
|
onClick={() => setActiveTerminalTab(tab.id)}
|
||||||
onClose={() => killTerminalTab(tab.id)}
|
onClose={() => removeTerminalTab(tab.id)}
|
||||||
onRename={(newName) => renameTerminalTab(tab.id, newName)}
|
onRename={(name) => renameTerminalTab(tab.id, name)}
|
||||||
isDropTarget={activeDragId !== null || activeDragTabId !== null}
|
isDropTarget={activeDragId !== null}
|
||||||
isDraggingTab={activeDragTabId !== null}
|
isDraggingTab={activeDragTabId !== null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
|
{/* Add New Tab Button */}
|
||||||
|
|
||||||
{/* New tab button */}
|
|
||||||
<button
|
<button
|
||||||
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
onClick={() => addTerminalTab()}
|
||||||
onClick={createTerminalInNewTab}
|
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"
|
title="New Tab"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toolbar buttons */}
|
{activeDragId && <NewTabDropZone isDropTarget={true} />}
|
||||||
<div className="flex items-center gap-1 pl-2 border-l border-border">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => createTerminal('horizontal')}
|
|
||||||
title="Split Right"
|
|
||||||
>
|
|
||||||
<SplitSquareHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => createTerminal('vertical')}
|
|
||||||
title="Split Down"
|
|
||||||
>
|
|
||||||
<SplitSquareVertical className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Global Terminal Settings */}
|
<DragOverlay dropAnimation={{ sideEffects: defaultDropAnimationSideEffects({}) }}>
|
||||||
<Popover>
|
{activeDragTabId ? (
|
||||||
<PopoverTrigger asChild>
|
<div className="px-3 py-1.5 text-sm bg-background border-2 border-brand-500 rounded-md shadow-xl opacity-90 cursor-grabbing">
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
variant="ghost"
|
<TerminalIcon className="h-3 w-3" />
|
||||||
size="sm"
|
<span>
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
{terminalState.tabs.find((t) => t.id === activeDragTabId)?.name || 'Tab'}
|
||||||
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Slider
|
|
||||||
value={[terminalState.defaultFontSize]}
|
|
||||||
min={8}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
|
||||||
onValueCommit={() => {
|
|
||||||
toast.info('Font size changed', {
|
|
||||||
description: 'New terminals will use this size',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : activeDragId ? (
|
||||||
{/* Font Family */}
|
<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="space-y-2">
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
<Label className="text-sm">Font Family</Label>
|
<TerminalIcon className="h-8 w-8" />
|
||||||
<select
|
<span>Moving Terminal...</span>
|
||||||
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>
|
</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>
|
|
||||||
|
|
||||||
{/* 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)
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</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 */}
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{terminalState.isUnlocked ? (
|
||||||
|
<Unlock className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{terminalState.isUnlocked ? 'Unlocked' : 'Locked'}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* No Tabs State */
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<p>No tabs open.</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => addTerminalTab()}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -543,6 +543,7 @@ export function TerminalPanel({
|
|||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
screenReaderMode: screenReaderEnabled,
|
screenReaderMode: screenReaderEnabled,
|
||||||
scrollback: terminalScrollback,
|
scrollback: terminalScrollback,
|
||||||
|
allowTransparency: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create fit addon
|
// Create fit addon
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const DEFAULT_TERMINAL_FONT = TERMINAL_FONT_OPTIONS[0].value;
|
|||||||
|
|
||||||
// Dark theme (default)
|
// Dark theme (default)
|
||||||
const darkTheme: TerminalTheme = {
|
const darkTheme: TerminalTheme = {
|
||||||
background: '#0a0a0a',
|
background: 'transparent', // Transparent for glassmorphism
|
||||||
foreground: '#d4d4d4',
|
foreground: '#d4d4d4',
|
||||||
cursor: '#d4d4d4',
|
cursor: '#d4d4d4',
|
||||||
cursorAccent: '#0a0a0a',
|
cursorAccent: '#0a0a0a',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
import { useEffect, useState, useCallback, useDeferredValue } from 'react';
|
||||||
import { Sidebar } from '@/components/layout/sidebar';
|
import { AppLayout } from '@/components/layout/app-layout';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
useFileBrowser,
|
useFileBrowser,
|
||||||
@@ -159,10 +159,9 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
<AppLayout>
|
||||||
<Sidebar />
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
className="h-full flex flex-col overflow-hidden transition-all duration-300"
|
||||||
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -170,12 +169,12 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 z-50 ${
|
||||||
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
</main>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ import type {
|
|||||||
AgentModel,
|
AgentModel,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
|
ThinkingLevel,
|
||||||
|
ModelProvider,
|
||||||
|
FeatureTextFilePath,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
// Re-export ThemeMode for convenience
|
export type {
|
||||||
export type { ThemeMode };
|
AgentModel,
|
||||||
|
ThinkingLevel,
|
||||||
|
ModelProvider,
|
||||||
|
AIProfile,
|
||||||
|
PlanningMode,
|
||||||
|
FeatureTextFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ThemeMode is defined below, no need to re-export here
|
||||||
|
|
||||||
export type ViewMode =
|
export type ViewMode =
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
@@ -47,6 +58,8 @@ export type ThemeMode =
|
|||||||
|
|
||||||
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
|
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
|
||||||
|
|
||||||
|
export type BoardViewMode = 'kanban' | 'graph';
|
||||||
|
|
||||||
export interface ApiKeys {
|
export interface ApiKeys {
|
||||||
anthropic: string;
|
anthropic: string;
|
||||||
google: string;
|
google: string;
|
||||||
@@ -260,13 +273,24 @@ export interface Feature extends Omit<
|
|||||||
titleGenerating?: boolean;
|
titleGenerating?: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
steps: string[]; // Required in UI (not optional)
|
steps?: string[] | undefined; // Optional in UI
|
||||||
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
status: 'backlog' | 'in_progress' | 'waiting_approval' | 'verified' | 'completed';
|
||||||
images?: FeatureImage[]; // UI-specific base64 images
|
images?: FeatureImage[]; // UI-specific base64 images
|
||||||
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
|
||||||
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
|
||||||
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
|
||||||
prUrl?: string; // UI-specific: Pull request URL
|
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)
|
// Parsed task from spec (for spec and full planning modes)
|
||||||
@@ -435,6 +459,7 @@ export interface AppState {
|
|||||||
|
|
||||||
// Kanban Card Display Settings
|
// Kanban Card Display Settings
|
||||||
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
|
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
|
||||||
|
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
|
||||||
|
|
||||||
// Feature Default Settings
|
// Feature Default Settings
|
||||||
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
defaultSkipTests: boolean; // Default value for skip tests when creating new features
|
||||||
@@ -698,6 +723,7 @@ export interface AppActions {
|
|||||||
|
|
||||||
// Kanban Card Settings actions
|
// Kanban Card Settings actions
|
||||||
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
|
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
|
||||||
|
setBoardViewMode: (mode: BoardViewMode) => void;
|
||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip: boolean) => void;
|
setDefaultSkipTests: (skip: boolean) => void;
|
||||||
@@ -877,6 +903,9 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const initialState: AppState = {
|
const initialState: AppState = {
|
||||||
|
claudeRefreshInterval: 60,
|
||||||
|
claudeUsage: null,
|
||||||
|
claudeUsageLastUpdated: null,
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
trashedProjects: [],
|
trashedProjects: [],
|
||||||
@@ -901,6 +930,7 @@ const initialState: AppState = {
|
|||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
maxConcurrency: 3, // Default to 3 concurrent agents
|
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||||
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
kanbanCardDetailLevel: 'standard', // Default to standard detail level
|
||||||
|
boardViewMode: 'kanban', // Default to kanban view
|
||||||
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
||||||
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
||||||
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
useWorktrees: false, // Default to disabled (worktree feature is experimental)
|
||||||
@@ -1451,6 +1481,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
// Kanban Card Settings actions
|
// Kanban Card Settings actions
|
||||||
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
|
setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }),
|
||||||
|
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
|
||||||
|
|
||||||
// Feature Default Settings actions
|
// Feature Default Settings actions
|
||||||
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
||||||
@@ -2658,6 +2689,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
sidebarOpen: state.sidebarOpen,
|
sidebarOpen: state.sidebarOpen,
|
||||||
chatHistoryOpen: state.chatHistoryOpen,
|
chatHistoryOpen: state.chatHistoryOpen,
|
||||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||||
|
boardViewMode: state.boardViewMode,
|
||||||
// Settings
|
// Settings
|
||||||
apiKeys: state.apiKeys,
|
apiKeys: state.apiKeys,
|
||||||
maxConcurrency: state.maxConcurrency,
|
maxConcurrency: state.maxConcurrency,
|
||||||
|
|||||||
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,6 +733,7 @@ export interface WorktreeAPI {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Create a pull request from a worktree
|
// Create a pull request from a worktree
|
||||||
@@ -783,6 +784,7 @@ export interface WorktreeAPI {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Create and checkout a new branch
|
// Create and checkout a new branch
|
||||||
@@ -797,6 +799,7 @@ export interface WorktreeAPI {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// List all local branches
|
// List all local branches
|
||||||
@@ -813,6 +816,7 @@ export interface WorktreeAPI {
|
|||||||
behindCount: number;
|
behindCount: number;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Switch to an existing branch
|
// Switch to an existing branch
|
||||||
@@ -827,6 +831,7 @@ export interface WorktreeAPI {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Open a worktree directory in the editor
|
// Open a worktree directory in the editor
|
||||||
|
|||||||
@@ -89,13 +89,26 @@ await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout
|
|||||||
await page.waitForSelector('[data-testid="welcome-view"]');
|
await page.waitForSelector('[data-testid="welcome-view"]');
|
||||||
```
|
```
|
||||||
|
|
||||||
### Wait for network idle after navigation
|
### Wait for page load after navigation
|
||||||
|
|
||||||
|
**Important:** Use `load` state, NOT `networkidle`. This app has persistent connections (websockets, polling) that prevent the network from ever becoming "idle", causing `networkidle` to timeout.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
|
// Then wait for specific elements to verify the page is ready
|
||||||
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Why not `networkidle`?**
|
||||||
|
|
||||||
|
- `networkidle` requires no network activity for 500ms
|
||||||
|
- Modern SPAs with real-time features (websockets, polling, SSE) never reach this state
|
||||||
|
- Using `networkidle` causes 30+ second timeouts and flaky tests
|
||||||
|
- The `load` state fires when the page finishes loading, which is sufficient
|
||||||
|
- Always follow up with element visibility checks for reliability
|
||||||
|
|
||||||
### Use appropriate timeouts
|
### Use appropriate timeouts
|
||||||
|
|
||||||
- Quick UI updates: 5000ms (default)
|
- Quick UI updates: 5000ms (default)
|
||||||
@@ -267,6 +280,29 @@ npm run test -- project-creation.spec.ts --repeat-each=5
|
|||||||
3. Run with `--headed` to watch the test
|
3. Run with `--headed` to watch the test
|
||||||
4. Add `await page.pause()` to pause execution at a specific point
|
4. Add `await page.pause()` to pause execution at a specific point
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Timeout on `waitForLoadState('networkidle')`
|
||||||
|
|
||||||
|
If tests timeout waiting for network idle, the app likely has persistent connections. Use `load` state instead:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - will timeout with persistent connections
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Good - completes when page loads
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await expect(page.locator('[data-testid="my-element"]')).toBeVisible();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port conflicts
|
||||||
|
|
||||||
|
If you see "Port 3008 is already in use", kill the process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -ti:3008 | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
## Available Test Utilities
|
## Available Test Utilities
|
||||||
|
|
||||||
Import from `./utils`:
|
Import from `./utils`:
|
||||||
@@ -290,8 +326,9 @@ Import from `./utils`:
|
|||||||
|
|
||||||
### Waiting Utilities
|
### Waiting Utilities
|
||||||
|
|
||||||
- `waitForNetworkIdle(page)` - Wait for network to be idle
|
- `waitForNetworkIdle(page)` - Wait for page to load (uses `load` state, not `networkidle`)
|
||||||
- `waitForElement(page, testId)` - Wait for element by test ID
|
- `waitForElement(page, testId)` - Wait for element by test ID
|
||||||
|
- `waitForBoardView(page)` - Navigate to board and wait for it to be visible
|
||||||
|
|
||||||
### Async File Verification
|
### Async File Verification
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test.describe('Project Creation', () => {
|
|||||||
|
|
||||||
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
await setupWelcomeView(page, { workspaceDir: TEST_TEMP_DIR });
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ test.describe('Open Project', () => {
|
|||||||
|
|
||||||
// Navigate to the app
|
// Navigate to the app
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for welcome view to be visible
|
// Wait for welcome view to be visible
|
||||||
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('[data-testid="welcome-view"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Page, Locator } from '@playwright/test';
|
import { Page, Locator } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for the page to reach network idle state
|
* Wait for the page to load
|
||||||
* This is commonly used after navigation or page reload to ensure all network requests have completed
|
* Uses 'load' state instead of 'networkidle' because the app has persistent
|
||||||
|
* connections (websockets/polling) that prevent network from ever being idle.
|
||||||
|
* Tests should wait for specific elements to verify page is ready.
|
||||||
*/
|
*/
|
||||||
export async function waitForNetworkIdle(page: Page): Promise<void> {
|
export async function waitForNetworkIdle(page: Page): Promise<void> {
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ export async function waitForBoardView(page: Page): Promise<void> {
|
|||||||
const currentUrl = page.url();
|
const currentUrl = page.url();
|
||||||
if (!currentUrl.includes('/board')) {
|
if (!currentUrl.includes('/board')) {
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for either board-view (success) or board-view-no-project (store not hydrated yet)
|
// Wait for either board-view (success) or board-view-no-project (store not hydrated yet)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { waitForElement } from '../core/waiting';
|
|||||||
export async function navigateToBoard(page: Page): Promise<void> {
|
export async function navigateToBoard(page: Page): Promise<void> {
|
||||||
// Navigate directly to /board route
|
// Navigate directly to /board route
|
||||||
await page.goto('/board');
|
await page.goto('/board');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for the board view to be visible
|
// Wait for the board view to be visible
|
||||||
await waitForElement(page, 'board-view', { timeout: 10000 });
|
await waitForElement(page, 'board-view', { timeout: 10000 });
|
||||||
@@ -22,7 +22,7 @@ export async function navigateToBoard(page: Page): Promise<void> {
|
|||||||
export async function navigateToContext(page: Page): Promise<void> {
|
export async function navigateToContext(page: Page): Promise<void> {
|
||||||
// Navigate directly to /context route
|
// Navigate directly to /context route
|
||||||
await page.goto('/context');
|
await page.goto('/context');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for loading to complete (if present)
|
// Wait for loading to complete (if present)
|
||||||
const loadingElement = page.locator('[data-testid="context-view-loading"]');
|
const loadingElement = page.locator('[data-testid="context-view-loading"]');
|
||||||
@@ -47,7 +47,7 @@ export async function navigateToContext(page: Page): Promise<void> {
|
|||||||
export async function navigateToSpec(page: Page): Promise<void> {
|
export async function navigateToSpec(page: Page): Promise<void> {
|
||||||
// Navigate directly to /spec route
|
// Navigate directly to /spec route
|
||||||
await page.goto('/spec');
|
await page.goto('/spec');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for loading state to complete first (if present)
|
// Wait for loading state to complete first (if present)
|
||||||
const loadingElement = page.locator('[data-testid="spec-view-loading"]');
|
const loadingElement = page.locator('[data-testid="spec-view-loading"]');
|
||||||
@@ -77,7 +77,7 @@ export async function navigateToSpec(page: Page): Promise<void> {
|
|||||||
export async function navigateToAgent(page: Page): Promise<void> {
|
export async function navigateToAgent(page: Page): Promise<void> {
|
||||||
// Navigate directly to /agent route
|
// Navigate directly to /agent route
|
||||||
await page.goto('/agent');
|
await page.goto('/agent');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for the agent view to be visible
|
// Wait for the agent view to be visible
|
||||||
await waitForElement(page, 'agent-view', { timeout: 10000 });
|
await waitForElement(page, 'agent-view', { timeout: 10000 });
|
||||||
@@ -90,7 +90,7 @@ export async function navigateToAgent(page: Page): Promise<void> {
|
|||||||
export async function navigateToSettings(page: Page): Promise<void> {
|
export async function navigateToSettings(page: Page): Promise<void> {
|
||||||
// Navigate directly to /settings route
|
// Navigate directly to /settings route
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for the settings view to be visible
|
// Wait for the settings view to be visible
|
||||||
await waitForElement(page, 'settings-view', { timeout: 10000 });
|
await waitForElement(page, 'settings-view', { timeout: 10000 });
|
||||||
@@ -105,7 +105,7 @@ export async function navigateToSetup(page: Page): Promise<void> {
|
|||||||
const { setupFirstRun } = await import('../project/setup');
|
const { setupFirstRun } = await import('../project/setup');
|
||||||
await setupFirstRun(page);
|
await setupFirstRun(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
await waitForElement(page, 'setup-view', { timeout: 10000 });
|
await waitForElement(page, 'setup-view', { timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ export async function navigateToSetup(page: Page): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function navigateToWelcome(page: Page): Promise<void> {
|
export async function navigateToWelcome(page: Page): Promise<void> {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('load');
|
||||||
await waitForElement(page, 'welcome-view', { timeout: 10000 });
|
await waitForElement(page, 'welcome-view', { timeout: 10000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1065
index (28).html
Normal file
1065
index (28).html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,24 @@ function isBinaryFile(filePath: string): boolean {
|
|||||||
return BINARY_EXTENSIONS.has(ext);
|
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
|
* Generate a synthetic unified diff for an untracked (new) file
|
||||||
* This is needed because `git diff HEAD` doesn't include untracked files
|
* This is needed because `git diff HEAD` doesn't include untracked files
|
||||||
@@ -44,18 +62,19 @@ Binary file ${relativePath} added
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file stats to check size
|
// Get file stats to check size and type
|
||||||
const stats = await secureFs.stat(fullPath);
|
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) {
|
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
|
||||||
const sizeKB = Math.round(stats.size / 1024);
|
const sizeKB = Math.round(stats.size / 1024);
|
||||||
return `diff --git a/${relativePath} b/${relativePath}
|
return createNewFileDiff(relativePath, '100644', [
|
||||||
new file mode 100644
|
`[File too large to display: ${sizeKB}KB]`,
|
||||||
index 0000000..0000000
|
]);
|
||||||
--- /dev/null
|
|
||||||
+++ b/${relativePath}
|
|
||||||
@@ -0,0 +1 @@
|
|
||||||
+[File too large to display: ${sizeKB}KB]
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read file content
|
// Read file content
|
||||||
@@ -90,14 +109,7 @@ ${addedLines}`;
|
|||||||
// Log the error for debugging
|
// Log the error for debugging
|
||||||
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
|
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
|
||||||
// Return a placeholder diff
|
// Return a placeholder diff
|
||||||
return `diff --git a/${relativePath} b/${relativePath}
|
return createNewFileDiff(relativePath, '100644', ['[Unable to read file content]']);
|
||||||
new file mode 100644
|
|
||||||
index 0000000..0000000
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/${relativePath}
|
|
||||||
@@ -0,0 +1 @@
|
|
||||||
+[Unable to read file content]
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ describe('diff.ts', () => {
|
|||||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||||
expect(diff).toContain('[Unable to read file content]');
|
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', () => {
|
describe('appendUntrackedFileDiffs', () => {
|
||||||
|
|||||||
272
package-lock.json
generated
272
package-lock.json
generated
@@ -100,9 +100,11 @@
|
|||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"geist": "^1.5.1",
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -113,6 +115,7 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"usehooks-ts": "^3.1.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -121,6 +124,7 @@
|
|||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -5740,6 +5744,62 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-drag": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-transition": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/dagre": {
|
||||||
|
"version": "0.7.53",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
|
||||||
|
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -6529,6 +6589,66 @@
|
|||||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
|
||||||
|
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.74",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
|
||||||
|
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/7zip-bin": {
|
"node_modules/7zip-bin": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
|
||||||
@@ -7651,6 +7771,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/clean-stack": {
|
"node_modules/clean-stack": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||||
@@ -8037,6 +8163,121 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dagre": {
|
||||||
|
"version": "0.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||||
|
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphlib": "^2.1.8",
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -9811,6 +10052,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/graphlib": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -11210,7 +11460,12 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"dev": true,
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -15674,6 +15929,21 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user