Merge main into kanban-scaling

Resolves merge conflicts while preserving:
- Kanban scaling improvements (window sizing, bounce prevention, debouncing)
- Main's sidebar refactoring into hooks
- Main's openInEditor functionality for VS Code integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
trueheads
2025-12-22 01:49:45 -06:00
599 changed files with 26666 additions and 24168 deletions

View File

@@ -90,9 +90,9 @@ const {
clearHistory, // Clear conversation
error, // Error state
} = useElectronAgent({
sessionId: "project_xyz",
workingDirectory: "/path/to/project",
onToolUse: (tool) => console.log("Using:", tool),
sessionId: 'project_xyz',
workingDirectory: '/path/to/project',
onToolUse: (tool) => console.log('Using:', tool),
});
```
@@ -160,7 +160,7 @@ Each session file contains:
Session IDs are generated from project paths:
```typescript
const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, "_")}`;
const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}`;
```
This ensures:

View File

@@ -7,24 +7,28 @@ The Automaker Agent Chat now supports multiple concurrent sessions, allowing you
## Features
### ✨ Multiple Sessions
- Create unlimited agent sessions per project
- Each session has its own conversation history
- Switch between sessions instantly
- Sessions persist across app restarts
### 📋 Session Organization
- Custom names for easy identification
- Last message preview
- Message count tracking
- Sort by most recently updated
### 🗄️ Archive & Delete
- Archive old sessions to declutter
- Unarchive when needed
- Permanently delete sessions
- Confirm before destructive actions
### 💾 Automatic Persistence
- All sessions auto-save to disk
- Survive Next.js restarts
- Survive Electron app restarts
@@ -67,6 +71,7 @@ Click the panel icon in the header to show/hide the session manager.
4. The new session is immediately active
**Example session names:**
- "Feature: Dark Mode"
- "Bug: Login redirect"
- "Refactor: API layer"
@@ -93,6 +98,7 @@ Click the **"Clear"** button in the chat header to delete all messages from the
3. Toggle **"Show Archived"** to view archived sessions
**When to archive:**
- Completed features
- Resolved bugs
- Old experiments
@@ -117,16 +123,19 @@ Click the **"Clear"** button in the chat header to delete all messages from the
Sessions are stored in your user data directory:
**macOS:**
```
~/Library/Application Support/automaker/agent-sessions/
```
**Windows:**
```
%APPDATA%/automaker/agent-sessions/
```
**Linux:**
```
~/.config/automaker/agent-sessions/
```
@@ -215,12 +224,14 @@ Use prefixes to organize sessions by type:
### When to Create Multiple Sessions
**Do create separate sessions for:**
- ✅ Different features
- ✅ Unrelated bugs
- ✅ Experimental work
- ✅ Different contexts or approaches
**Don't create separate sessions for:**
- ❌ Same feature, different iterations
- ❌ Related bug fixes
- ❌ Continuation of previous work
@@ -272,7 +283,7 @@ Use prefixes to organize sessions by type:
## Keyboard Shortcuts
*(Coming soon)*
_(Coming soon)_
- `Cmd/Ctrl + K` - Create new session
- `Cmd/Ctrl + [` - Previous session
@@ -284,11 +295,13 @@ Use prefixes to organize sessions by type:
### Session Not Saving
**Check:**
- Electron has write permissions
- Disk space available
- Check Electron console for errors
**Solution:**
```bash
# macOS - Check permissions
ls -la ~/Library/Application\ Support/automaker/
@@ -300,11 +313,13 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
### Can't Switch Sessions
**Check:**
- Session is not archived
- No errors in console
- Agent is not currently processing
**Solution:**
- Wait for current message to complete
- Check for error messages
- Try clearing and reloading
@@ -312,11 +327,13 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
### Session Disappeared
**Check:**
- Not filtered by archive status
- Not accidentally deleted
- Check backup files
**Recovery:**
- Toggle "Show Archived"
- Check filesystem for `.json` files
- Restore from backup if available
@@ -326,15 +343,17 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
For developers integrating session management:
### Create Session
```typescript
const result = await window.electronAPI.sessions.create(
"Session Name",
"/project/path",
"/working/directory"
'Session Name',
'/project/path',
'/working/directory'
);
```
### List Sessions
```typescript
const { sessions } = await window.electronAPI.sessions.list(
false // includeArchived
@@ -342,21 +361,20 @@ const { sessions } = await window.electronAPI.sessions.list(
```
### Update Session
```typescript
await window.electronAPI.sessions.update(
sessionId,
"New Name",
["tag1", "tag2"]
);
await window.electronAPI.sessions.update(sessionId, 'New Name', ['tag1', 'tag2']);
```
### Archive/Unarchive
```typescript
await window.electronAPI.sessions.archive(sessionId);
await window.electronAPI.sessions.unarchive(sessionId);
```
### Delete Session
```typescript
await window.electronAPI.sessions.delete(sessionId);
```

View File

@@ -1,35 +1,111 @@
import { defineConfig, globalIgnores } from "eslint/config";
import js from "@eslint/js";
import ts from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import { defineConfig, globalIgnores } from 'eslint/config';
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
const eslintConfig = defineConfig([
js.configs.recommended,
{
files: ["**/*.ts", "**/*.tsx"],
files: ['**/*.mjs', '**/*.cjs'],
languageOptions: {
globals: {
console: 'readonly',
process: 'readonly',
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
},
},
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
// Browser/DOM APIs
window: 'readonly',
document: 'readonly',
navigator: 'readonly',
Navigator: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
fetch: 'readonly',
WebSocket: 'readonly',
File: 'readonly',
FileList: 'readonly',
FileReader: 'readonly',
Blob: 'readonly',
atob: 'readonly',
crypto: 'readonly',
prompt: 'readonly',
confirm: 'readonly',
getComputedStyle: 'readonly',
requestAnimationFrame: 'readonly',
// DOM Element Types
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLButtonElement: 'readonly',
HTMLSpanElement: 'readonly',
HTMLTextAreaElement: 'readonly',
HTMLHeadingElement: 'readonly',
HTMLParagraphElement: 'readonly',
HTMLImageElement: 'readonly',
Element: 'readonly',
// Event Types
Event: 'readonly',
KeyboardEvent: 'readonly',
DragEvent: 'readonly',
PointerEvent: 'readonly',
CustomEvent: 'readonly',
ClipboardEvent: 'readonly',
WheelEvent: 'readonly',
DataTransfer: 'readonly',
// Web APIs
ResizeObserver: 'readonly',
AbortSignal: 'readonly',
Audio: 'readonly',
ScrollBehavior: 'readonly',
// Timers
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
// Node.js (for scripts and Electron)
process: 'readonly',
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
NodeJS: 'readonly',
// React
React: 'readonly',
JSX: 'readonly',
// Electron
Electron: 'readonly',
// Console
console: 'readonly',
},
},
plugins: {
"@typescript-eslint": ts,
'@typescript-eslint': ts,
},
rules: {
...ts.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
globalIgnores([
"dist/**",
"dist-electron/**",
"node_modules/**",
"server-bundle/**",
"release/**",
"src/routeTree.gen.ts",
'dist/**',
'dist-electron/**',
'node_modules/**',
'server-bundle/**',
'release/**',
'src/routeTree.gen.ts',
]),
]);

View File

@@ -8,7 +8,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script>
// Prevent theme flash - apply stored theme before React hydrates
(function() {
(function () {
try {
const stored = localStorage.getItem('automaker-storage');
if (stored) {
@@ -17,7 +17,10 @@
if (theme && theme !== 'system' && theme !== 'light') {
// Apply the actual theme class (dark, retro, dracula, nord, etc.)
document.documentElement.classList.add(theme);
} else if (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
} else if (
theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
document.documentElement.classList.add('dark');
}
}

View File

@@ -59,6 +59,8 @@
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",

View File

@@ -1,28 +1,27 @@
import { defineConfig, devices } from "@playwright/test";
import { defineConfig, devices } from '@playwright/test';
const port = process.env.TEST_PORT || 3007;
const serverPort = process.env.TEST_SERVER_PORT || 3008;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
const mockAgent =
process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
const mockAgent = process.env.CI === 'true' || process.env.AUTOMAKER_MOCK_AGENT === 'true';
export default defineConfig({
testDir: "./tests",
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: undefined,
reporter: "html",
reporter: 'html',
timeout: 30000,
use: {
baseURL: `http://localhost:${port}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
...(reuseServer
@@ -39,7 +38,7 @@ export default defineConfig({
...process.env,
PORT: String(serverPort),
// Enable mock agent in CI to avoid real API calls
AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false",
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
},
},
@@ -51,10 +50,9 @@ export default defineConfig({
timeout: 120000,
env: {
...process.env,
VITE_SKIP_SETUP: "true",
VITE_SKIP_SETUP: 'true',
// Skip electron plugin in CI - no display available for Electron
VITE_SKIP_ELECTRON:
process.env.CI === "true" ? "true" : undefined,
VITE_SKIP_ELECTRON: process.env.CI === 'true' ? 'true' : undefined,
},
},
],

View File

@@ -27,7 +27,7 @@ const LOCAL_PACKAGES = [
'@automaker/platform',
'@automaker/model-resolver',
'@automaker/dependency-resolver',
'@automaker/git-utils'
'@automaker/git-utils',
];
console.log('🔧 Preparing server for Electron bundling...\n');
@@ -95,13 +95,10 @@ const bundlePkg = {
version: serverPkg.version,
type: 'module',
main: 'dist/index.js',
dependencies
dependencies,
};
writeFileSync(
join(BUNDLE_DIR, 'package.json'),
JSON.stringify(bundlePkg, null, 2)
);
writeFileSync(join(BUNDLE_DIR, 'package.json'), JSON.stringify(bundlePkg, null, 2));
// Step 6: Install production dependencies
console.log('📥 Installing server production dependencies...');
@@ -111,8 +108,8 @@ execSync('npm install --omit=dev', {
env: {
...process.env,
// Prevent npm from using workspace resolution
npm_config_workspace: ''
}
npm_config_workspace: '',
},
});
// Step 7: Rebuild native modules for current architecture
@@ -121,11 +118,13 @@ console.log('🔨 Rebuilding native modules for current architecture...');
try {
execSync('npm rebuild', {
cwd: BUNDLE_DIR,
stdio: 'inherit'
stdio: 'inherit',
});
console.log('✅ Native modules rebuilt successfully');
} catch (error) {
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
console.warn(
'⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.'
);
console.warn(' Error:', error.message);
}

View File

@@ -11,7 +11,7 @@ const path = require('path');
const execAsync = promisify(exec);
exports.default = async function(context) {
exports.default = async function (context) {
const { appOutDir, electronPlatformName, arch, packager } = context;
const electronVersion = packager.config.electronVersion;
@@ -33,19 +33,9 @@ exports.default = async function(context) {
'node_modules'
);
} else if (electronPlatformName === 'win32') {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
serverNodeModulesPath = path.join(appOutDir, 'resources', 'server', 'node_modules');
} else {
serverNodeModulesPath = path.join(
appOutDir,
'resources',
'server',
'node_modules'
);
serverNodeModulesPath = path.join(appOutDir, 'resources', 'server', 'node_modules');
}
try {

View File

@@ -5,17 +5,17 @@
* Creates the necessary test fixture directories and files before running Playwright tests
*/
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve workspace root (apps/ui/scripts -> workspace root)
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
const SPEC_CONTENT = `<app_spec>
<name>Test Project A</name>
@@ -28,7 +28,7 @@ const SPEC_CONTENT = `<app_spec>
`;
function setupFixtures() {
console.log("Setting up E2E test fixtures...");
console.log('Setting up E2E test fixtures...');
console.log(`Workspace root: ${WORKSPACE_ROOT}`);
console.log(`Fixture path: ${FIXTURE_PATH}`);
@@ -43,7 +43,7 @@ function setupFixtures() {
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
console.log("E2E test fixtures setup complete!");
console.log('E2E test fixtures setup complete!');
}
setupFixtures();

View File

@@ -1,15 +1,15 @@
import { useState, useCallback } from "react";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./utils/router";
import { SplashScreen } from "./components/splash-screen";
import { useSettingsMigration } from "./hooks/use-settings-migration";
import "./styles/global.css";
import "./styles/theme-imports";
import { useState, useCallback } from 'react';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { useSettingsMigration } from './hooks/use-settings-migration';
import './styles/global.css';
import './styles/theme-imports';
export default function App() {
const [showSplash, setShowSplash] = useState(() => {
// Only show splash once per session
if (sessionStorage.getItem("automaker-splash-shown")) {
if (sessionStorage.getItem('automaker-splash-shown')) {
return false;
}
return true;
@@ -18,11 +18,11 @@ export default function App() {
// Run settings migration on startup (localStorage -> file storage)
const migrationState = useSettingsMigration();
if (migrationState.migrated) {
console.log("[App] Settings migrated to file storage");
console.log('[App] Settings migrated to file storage');
}
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem("automaker-splash-shown", "true");
sessionStorage.setItem('automaker-splash-shown', 'true');
setShowSplash(false);
}, []);

View File

@@ -1,27 +1,16 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import {
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
API_BRIDGE_UNAVAILABLE: "API_BRIDGE_UNAVAILABLE",
AUTH_ERROR: "AUTH_ERROR",
UNKNOWN: "UNKNOWN",
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
AUTH_ERROR: 'AUTH_ERROR',
UNKNOWN: 'UNKNOWN',
} as const;
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
@@ -116,16 +105,10 @@ export function ClaudeUsagePopover() {
};
// Helper component for the progress bar
const ProgressBar = ({
percentage,
colorClass,
}: {
percentage: number;
colorClass: string;
}) => (
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn("h-full transition-all duration-500", colorClass)}
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
@@ -147,7 +130,8 @@ export function ClaudeUsagePopover() {
stale?: boolean;
}) => {
// Check if percentage is valid (not NaN, not undefined, is a finite number)
const isValidPercentage = typeof percentage === "number" && !isNaN(percentage) && isFinite(percentage);
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
const status = getStatusInfo(safePercentage);
@@ -156,26 +140,24 @@ export function ClaudeUsagePopover() {
return (
<div
className={cn(
"rounded-xl border bg-card/50 p-4 transition-opacity",
isPrimary ? "border-border/60 shadow-sm" : "border-border/40",
(stale || !isValidPercentage) && "opacity-50"
'rounded-xl border bg-card/50 p-4 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className={cn("font-semibold", isPrimary ? "text-sm" : "text-xs")}>
{title}
</h4>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn("w-3.5 h-3.5", status.color)} />
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
"font-mono font-bold",
'font-mono font-bold',
status.color,
isPrimary ? "text-base" : "text-sm"
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
@@ -185,11 +167,14 @@ export function ClaudeUsagePopover() {
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar percentage={safePercentage} colorClass={isValidPercentage ? status.bg : "bg-muted-foreground/30"} />
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-2 flex justify-end">
<p className="text-xs text-muted-foreground flex items-center gap-1">
{title === "Session Usage" && <Clock className="w-3 h-3" />}
{title === 'Session Usage' && <Clock className="w-3 h-3" />}
{resetText}
</p>
</div>
@@ -206,26 +191,21 @@ export function ClaudeUsagePopover() {
const getProgressBarColor = (percentage: number) => {
if (percentage >= 80) return 'bg-red-500';
if (percentage >= 50) return 'bg-yellow-500';
return "bg-green-500";
return 'bg-green-500';
};
const trigger = (
<Button
variant="ghost"
size="sm"
className="h-9 gap-3 bg-secondary border border-border px-3"
>
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
<span className="text-sm font-medium">Usage</span>
{claudeUsage && (
<div className={cn(
"h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity",
isStale && "opacity-60"
)}>
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn(
"h-full transition-all duration-500",
getProgressBarColor(maxPercentage)
)}
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
/>
</div>

View File

@@ -1,41 +1,34 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
import { useState, useRef, useCallback, useEffect } from 'react';
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
import { getHttpApiClient } from "@/lib/http-api-client";
import { useBoardBackgroundSettings } from "@/hooks/use-board-background-settings";
import { toast } from "sonner";
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
import { toast } from 'sonner';
import {
fileToBase64,
validateImageFile,
ACCEPTED_IMAGE_TYPES,
DEFAULT_MAX_FILE_SIZE,
} from '@/lib/image-utils';
interface BoardBackgroundModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function BoardBackgroundModal({
open,
onOpenChange,
}: BoardBackgroundModalProps) {
export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModalProps) {
const { currentProject, boardBackgroundByProject } = useAppStore();
const {
setBoardBackground,
@@ -55,8 +48,7 @@ export function BoardBackgroundModal({
// Get current background settings (live from store)
const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings;
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
const cardOpacity = backgroundSettings.cardOpacity;
const columnOpacity = backgroundSettings.columnOpacity;
@@ -70,12 +62,9 @@ export function BoardBackgroundModal({
// Update preview image when background settings change
useEffect(() => {
if (currentProject && backgroundSettings.imagePath) {
const serverUrl =
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
// Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion
? `&v=${imageVersion}`
: `&v=${Date.now()}`;
const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`;
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
@@ -85,40 +74,17 @@ export function BoardBackgroundModal({
}
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const processFile = useCallback(
async (file: File) => {
if (!currentProject) {
toast.error("No project selected");
toast.error('No project selected');
return;
}
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
toast.error(
"Unsupported file type. Please use JPG, PNG, GIF, or WebP."
);
return;
}
// Validate file size
if (file.size > DEFAULT_MAX_FILE_SIZE) {
const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024);
toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`);
// Validate file
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
toast.error(validation.error);
return;
}
@@ -141,14 +107,14 @@ export function BoardBackgroundModal({
if (result.success && result.path) {
// Update store and persist to server
await setBoardBackground(currentProject.path, result.path);
toast.success("Background image saved");
toast.success('Background image saved');
} else {
toast.error(result.error || "Failed to save background image");
toast.error(result.error || 'Failed to save background image');
setPreviewImage(null);
}
} catch (error) {
console.error("Failed to process image:", error);
toast.error("Failed to process image");
console.error('Failed to process image:', error);
toast.error('Failed to process image');
setPreviewImage(null);
} finally {
setIsProcessing(false);
@@ -191,7 +157,7 @@ export function BoardBackgroundModal({
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = '';
}
},
[processFile]
@@ -209,20 +175,18 @@ export function BoardBackgroundModal({
try {
setIsProcessing(true);
const httpClient = getHttpApiClient();
const result = await httpClient.deleteBoardBackground(
currentProject.path
);
const result = await httpClient.deleteBoardBackground(currentProject.path);
if (result.success) {
await clearBoardBackground(currentProject.path);
setPreviewImage(null);
toast.success("Background image cleared");
toast.success('Background image cleared');
} else {
toast.error(result.error || "Failed to clear background image");
toast.error(result.error || 'Failed to clear background image');
}
} catch (error) {
console.error("Failed to clear background:", error);
toast.error("Failed to clear background");
console.error('Failed to clear background:', error);
toast.error('Failed to clear background');
} finally {
setIsProcessing(false);
}
@@ -298,8 +262,7 @@ export function BoardBackgroundModal({
Board Background Settings
</SheetTitle>
<SheetDescription className="text-muted-foreground">
Set a custom background image for your kanban board and adjust
card/column opacity
Set a custom background image for your kanban board and adjust card/column opacity
</SheetDescription>
</SheetHeader>
@@ -312,7 +275,7 @@ export function BoardBackgroundModal({
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(",")}
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={isProcessing}
@@ -324,14 +287,13 @@ export function BoardBackgroundModal({
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
'relative rounded-lg border-2 border-dashed transition-all duration-200',
{
"border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10":
'border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10':
isDragOver && !isProcessing,
"border-muted-foreground/25": !isDragOver && !isProcessing,
"border-muted-foreground/10 opacity-50 cursor-not-allowed":
isProcessing,
"hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5":
'border-muted-foreground/25': !isDragOver && !isProcessing,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': isProcessing,
'hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5':
!isProcessing && !isDragOver,
}
)}
@@ -379,10 +341,10 @@ export function BoardBackgroundModal({
>
<div
className={cn(
"rounded-full p-3 mb-3",
'rounded-full p-3 mb-3',
isDragOver && !isProcessing
? "bg-brand-500/10 dark:bg-brand-500/20"
: "bg-muted"
? 'bg-brand-500/10 dark:bg-brand-500/20'
: 'bg-muted'
)}
>
{isProcessing ? (
@@ -393,12 +355,12 @@ export function BoardBackgroundModal({
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !isProcessing
? "Drop image here"
: "Click to upload or drag and drop"}
? 'Drop image here'
: 'Click to upload or drag and drop'}
</p>
<p className="text-xs text-muted-foreground mt-1">
JPG, PNG, GIF, or WebP (max{" "}
{Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)
JPG, PNG, GIF, or WebP (max {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}
MB)
</p>
</div>
)}
@@ -410,9 +372,7 @@ export function BoardBackgroundModal({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Opacity</Label>
<span className="text-sm text-muted-foreground">
{cardOpacity}%
</span>
<span className="text-sm text-muted-foreground">{cardOpacity}%</span>
</div>
<Slider
value={[cardOpacity]}
@@ -427,9 +387,7 @@ export function BoardBackgroundModal({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Column Opacity</Label>
<span className="text-sm text-muted-foreground">
{columnOpacity}%
</span>
<span className="text-sm text-muted-foreground">{columnOpacity}%</span>
</div>
<Slider
value={[columnOpacity]}
@@ -460,10 +418,7 @@ export function BoardBackgroundModal({
checked={cardGlassmorphism}
onCheckedChange={handleCardGlassmorphismToggle}
/>
<Label
htmlFor="card-glassmorphism-toggle"
className="cursor-pointer"
>
<Label htmlFor="card-glassmorphism-toggle" className="cursor-pointer">
Card Glassmorphism (blur effect)
</Label>
</div>
@@ -485,9 +440,7 @@ export function BoardBackgroundModal({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Border Opacity</Label>
<span className="text-sm text-muted-foreground">
{cardBorderOpacity}%
</span>
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span>
</div>
<Slider
value={[cardBorderOpacity]}

View File

@@ -1,4 +1,3 @@
import {
Dialog,
DialogContent,
@@ -6,9 +5,9 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
interface DeleteAllArchivedSessionsDialogProps {
open: boolean;
@@ -29,8 +28,7 @@ export function DeleteAllArchivedSessionsDialog({
<DialogHeader>
<DialogTitle>Delete All Archived Sessions</DialogTitle>
<DialogDescription>
Are you sure you want to delete all archived sessions? This action
cannot be undone.
Are you sure you want to delete all archived sessions? This action cannot be undone.
{archivedCount > 0 && (
<span className="block mt-2 text-yellow-500">
{archivedCount} session(s) will be deleted.

View File

@@ -1,6 +1,6 @@
import { MessageSquare } from "lucide-react";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { SessionListItem } from "@/types/electron";
import { MessageSquare } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import type { SessionListItem } from '@/types/electron';
interface DeleteSessionDialogProps {
open: boolean;
@@ -38,12 +38,8 @@ export function DeleteSessionDialog({
<MessageSquare className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{session.name}
</p>
<p className="text-xs text-muted-foreground">
{session.messageCount} messages
</p>
<p className="font-medium text-foreground truncate">{session.name}</p>
<p className="text-xs text-muted-foreground">{session.messageCount} messages</p>
</div>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback } from 'react';
import {
FolderOpen,
Folder,
@@ -9,7 +9,7 @@ import {
CornerDownLeft,
Clock,
X,
} from "lucide-react";
} from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -17,14 +17,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getJSON, setJSON } from "@/lib/storage";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
interface DirectoryEntry {
name: string;
@@ -50,7 +47,7 @@ interface FileBrowserDialogProps {
initialPath?: string;
}
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
const RECENT_FOLDERS_KEY = 'file-browser-recent-folders';
const MAX_RECENT_FOLDERS = 5;
function getRecentFolders(): string[] {
@@ -76,18 +73,18 @@ export function FileBrowserDialog({
open,
onOpenChange,
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder or paste a path directly",
title = 'Select Project Directory',
description = 'Navigate to your project folder or paste a path directly',
initialPath,
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [pathInput, setPathInput] = useState<string>("");
const [currentPath, setCurrentPath] = useState<string>('');
const [pathInput, setPathInput] = useState<string>('');
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const [error, setError] = useState('');
const [warning, setWarning] = useState('');
const [recentFolders, setRecentFolders] = useState<string[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
@@ -98,28 +95,24 @@ export function FileBrowserDialog({
}
}, [open]);
const handleRemoveRecent = useCallback(
(e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
},
[]
);
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
}, []);
const browseDirectory = useCallback(async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
setError('');
setWarning('');
try {
// Get server URL from environment or default
const serverUrl =
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dirPath }),
});
@@ -131,14 +124,12 @@ export function FileBrowserDialog({
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
setWarning(result.warning || "");
setWarning(result.warning || '');
} else {
setError(result.error || "Failed to browse directory");
setError(result.error || 'Failed to browse directory');
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load directories"
);
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setLoading(false);
}
@@ -154,12 +145,12 @@ export function FileBrowserDialog({
// Reset current path when dialog closes
useEffect(() => {
if (!open) {
setCurrentPath("");
setPathInput("");
setCurrentPath('');
setPathInput('');
setParentPath(null);
setDirectories([]);
setError("");
setWarning("");
setError('');
setWarning('');
}
}, [open]);
@@ -189,7 +180,7 @@ export function FileBrowserDialog({
// No default directory, browse home directory
browseDirectory();
}
} catch (err) {
} catch {
// If config fetch fails, try initialPath or fall back to home directory
if (initialPath) {
setPathInput(initialPath);
@@ -230,7 +221,7 @@ export function FileBrowserDialog({
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
handleGoToPath();
}
@@ -252,7 +243,7 @@ export function FileBrowserDialog({
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (currentPath && !loading) {
handleSelect();
@@ -260,8 +251,8 @@ export function FileBrowserDialog({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, currentPath, loading, handleSelect]);
// Helper to get folder name from path
@@ -326,9 +317,7 @@ export function FileBrowserDialog({
title={folder}
>
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
<span className="truncate max-w-[120px]">
{getFolderName(folder)}
</span>
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
<button
onClick={(e) => handleRemoveRecent(e, folder)}
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
@@ -351,15 +340,13 @@ export function FileBrowserDialog({
{drives.map((drive) => (
<Button
key={drive}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
variant={currentPath.startsWith(drive) ? 'default' : 'outline'}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-6 px-2 text-xs"
disabled={loading}
>
{drive.replace("\\", "")}
{drive.replace('\\', '')}
</Button>
))}
</div>
@@ -388,7 +375,7 @@ export function FileBrowserDialog({
</Button>
)}
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
{currentPath || "Loading..."}
{currentPath || 'Loading...'}
</div>
</div>
@@ -396,9 +383,7 @@ export function FileBrowserDialog({
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
{loading && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
Loading directories...
</div>
<div className="text-xs text-muted-foreground">Loading directories...</div>
</div>
)}
@@ -416,9 +401,7 @@ export function FileBrowserDialog({
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
No subdirectories found
</div>
<div className="text-xs text-muted-foreground">No subdirectories found</div>
</div>
)}
@@ -440,8 +423,8 @@ export function FileBrowserDialog({
</div>
<div className="text-[10px] text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path.
Paste a full path above, or click on folders to navigate. Press Enter or click Go to
jump to a path.
</div>
</div>
@@ -458,10 +441,9 @@ export function FileBrowserDialog({
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
Select Current Folder
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
{typeof navigator !== "undefined" &&
navigator.platform?.includes("Mac")
? "⌘"
: "Ctrl"}
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
? '⌘'
: 'Ctrl'}
+
</kbd>
</Button>

View File

@@ -0,0 +1,6 @@
export { BoardBackgroundModal } from './board-background-modal';
export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog';
export { DeleteSessionDialog } from './delete-session-dialog';
export { FileBrowserDialog } from './file-browser-dialog';
export { NewProjectModal } from './new-project-modal';
export { WorkspacePickerModal } from './workspace-picker-modal';

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -6,13 +6,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import {
FolderPlus,
FolderOpen,
@@ -22,15 +22,12 @@ import {
Loader2,
Link,
Folder,
} from "lucide-react";
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import { useFileBrowser } from "@/contexts/file-browser-context";
import {
getDefaultWorkspaceDirectory,
saveLastProjectDirectory,
} from "@/lib/workspace-config";
} from 'lucide-react';
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
import { useFileBrowser } from '@/contexts/file-browser-context';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
interface ValidationErrors {
projectName?: boolean;
@@ -42,20 +39,13 @@ interface ValidationErrors {
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBlankProject: (
projectName: string,
parentDir: string
) => Promise<void>;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
onCreateFromTemplate: (
template: StarterTemplate,
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromCustomUrl: (
repoUrl: string,
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise<void>;
isCreating: boolean;
}
@@ -67,14 +57,13 @@ export function NewProjectModal({
onCreateFromCustomUrl,
isCreating,
}: NewProjectModalProps) {
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank');
const [projectName, setProjectName] = useState('');
const [workspaceDir, setWorkspaceDir] = useState<string>('');
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] =
useState<StarterTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState("");
const [customUrl, setCustomUrl] = useState('');
const [errors, setErrors] = useState<ValidationErrors>({});
const { openFileBrowser } = useFileBrowser();
@@ -89,7 +78,7 @@ export function NewProjectModal({
}
})
.catch((error) => {
console.error("Failed to get default workspace directory:", error);
console.error('Failed to get default workspace directory:', error);
})
.finally(() => {
setIsLoadingWorkspace(false);
@@ -100,11 +89,11 @@ export function NewProjectModal({
// Reset form when modal closes
useEffect(() => {
if (!open) {
setProjectName("");
setProjectName('');
setSelectedTemplate(null);
setUseCustomUrl(false);
setCustomUrl("");
setActiveTab("blank");
setCustomUrl('');
setActiveTab('blank');
setErrors({});
}
}, [open]);
@@ -117,10 +106,7 @@ export function NewProjectModal({
}, [projectName, errors.projectName]);
useEffect(() => {
if (
(selectedTemplate || (useCustomUrl && customUrl)) &&
errors.templateSelection
) {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
setErrors((prev) => ({ ...prev, templateSelection: false }));
}
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
@@ -145,7 +131,7 @@ export function NewProjectModal({
}
// Check template selection (only for template tab)
if (activeTab === "template") {
if (activeTab === 'template') {
if (useCustomUrl) {
if (!customUrl.trim()) {
newErrors.customUrl = true;
@@ -164,7 +150,7 @@ export function NewProjectModal({
// Clear errors and proceed
setErrors({});
if (activeTab === "blank") {
if (activeTab === 'blank') {
await onCreateBlankProject(projectName, workspaceDir);
} else if (useCustomUrl && customUrl) {
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
@@ -181,7 +167,7 @@ export function NewProjectModal({
const handleSelectTemplate = (template: StarterTemplate) => {
setSelectedTemplate(template);
setUseCustomUrl(false);
setCustomUrl("");
setCustomUrl('');
};
const handleToggleCustomUrl = () => {
@@ -193,9 +179,8 @@ export function NewProjectModal({
const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({
title: "Select Base Project Directory",
description:
"Choose the parent directory where your project will be created",
title: 'Select Base Project Directory',
description: 'Choose the parent directory where your project will be created',
initialPath: workspaceDir || undefined,
});
if (selectedPath) {
@@ -211,15 +196,12 @@ export function NewProjectModal({
// Use platform-specific path separator
const pathSep =
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const projectPath =
workspaceDir && projectName
? `${workspaceDir}${pathSep}${projectName}`
: "";
typeof window !== 'undefined' && (window as any).electronAPI
? navigator.platform.indexOf('Win') !== -1
? '\\'
: '/'
: '/';
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -228,9 +210,7 @@ export function NewProjectModal({
data-testid="new-project-modal"
>
<DialogHeader className="pb-2">
<DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template.
</DialogDescription>
@@ -241,13 +221,9 @@ export function NewProjectModal({
<div className="space-y-2">
<Label
htmlFor="project-name"
className={cn(
"text-foreground",
errors.projectName && "text-red-500"
)}
className={cn('text-foreground', errors.projectName && 'text-red-500')}
>
Project Name{" "}
{errors.projectName && <span className="text-red-500">*</span>}
Project Name {errors.projectName && <span className="text-red-500">*</span>}
</Label>
<Input
id="project-name"
@@ -255,33 +231,31 @@ export function NewProjectModal({
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className={cn(
"bg-input text-foreground placeholder:text-muted-foreground",
'bg-input text-foreground placeholder:text-muted-foreground',
errors.projectName
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-border"
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'border-border'
)}
data-testid="project-name-input"
autoFocus
/>
{errors.projectName && (
<p className="text-xs text-red-500">Project name is required</p>
)}
{errors.projectName && <p className="text-xs text-red-500">Project name is required</p>}
</div>
{/* Workspace Directory Display */}
<div
className={cn(
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
'flex items-center gap-2 text-sm',
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
)}
>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
"Loading workspace..."
'Loading workspace...'
) : workspaceDir ? (
<>
Will be created at:{" "}
Will be created at:{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || workspaceDir}
</code>
@@ -305,7 +279,7 @@ export function NewProjectModal({
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
onValueChange={(v) => setActiveTab(v as 'blank' | 'template')}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="w-full justify-start">
@@ -323,9 +297,8 @@ export function NewProjectModal({
<TabsContent value="blank" className="mt-0">
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
Create an empty project with the standard .automaker directory
structure. Perfect for starting from scratch or importing an
existing codebase.
Create an empty project with the standard .automaker directory structure. Perfect
for starting from scratch or importing an existing codebase.
</p>
</div>
</TabsContent>
@@ -342,18 +315,18 @@ export function NewProjectModal({
{/* Preset Templates */}
<div
className={cn(
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
'space-y-3 rounded-lg p-1 -m-1',
errors.templateSelection && 'ring-2 ring-red-500/50'
)}
>
{starterTemplates.map((template) => (
<div
key={template.id}
className={cn(
"p-4 rounded-lg border cursor-pointer transition-all",
'p-4 rounded-lg border cursor-pointer transition-all',
selectedTemplate?.id === template.id && !useCustomUrl
? "border-brand-500 bg-brand-500/10"
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
)}
onClick={() => handleSelectTemplate(template)}
data-testid={`template-${template.id}`}
@@ -361,13 +334,10 @@ export function NewProjectModal({
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-foreground">
{template.name}
</h4>
{selectedTemplate?.id === template.id &&
!useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
<h4 className="font-medium text-foreground">{template.name}</h4>
{selectedTemplate?.id === template.id && !useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
{template.description}
@@ -376,11 +346,7 @@ export function NewProjectModal({
{/* Tech Stack */}
<div className="flex flex-wrap gap-1.5 mb-3">
{template.techStack.slice(0, 6).map((tech) => (
<Badge
key={tech}
variant="secondary"
className="text-xs"
>
<Badge key={tech} variant="secondary" className="text-xs">
{tech}
</Badge>
))}
@@ -394,7 +360,7 @@ export function NewProjectModal({
{/* Key Features */}
<div className="text-xs text-muted-foreground">
<span className="font-medium">Features: </span>
{template.features.slice(0, 3).join(" · ")}
{template.features.slice(0, 3).join(' · ')}
{template.features.length > 3 &&
` · +${template.features.length - 3} more`}
</div>
@@ -419,47 +385,38 @@ export function NewProjectModal({
{/* Custom URL Option */}
<div
className={cn(
"p-4 rounded-lg border cursor-pointer transition-all",
'p-4 rounded-lg border cursor-pointer transition-all',
useCustomUrl
? "border-brand-500 bg-brand-500/10"
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
)}
onClick={handleToggleCustomUrl}
>
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">
Custom GitHub URL
</h4>
{useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
</div>
<p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point.
</p>
{useCustomUrl && (
<div
onClick={(e) => e.stopPropagation()}
className="space-y-1"
>
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
<Input
placeholder="https://github.com/username/repository"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className={cn(
"bg-input text-foreground placeholder:text-muted-foreground",
'bg-input text-foreground placeholder:text-muted-foreground',
errors.customUrl
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-border"
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'border-border'
)}
data-testid="custom-url-input"
/>
{errors.customUrl && (
<p className="text-xs text-red-500">
GitHub URL is required
</p>
<p className="text-xs text-red-500">GitHub URL is required</p>
)}
</div>
)}
@@ -482,14 +439,14 @@ export function NewProjectModal({
onClick={validateAndCreate}
disabled={isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-create-project"
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{activeTab === "template" ? "Cloning..." : "Creating..."}
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
</>
) : (
<>Create Project</>

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -7,10 +6,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Folder, Loader2, FolderOpen, AlertCircle } from "lucide-react";
import { getHttpApiClient } from "@/lib/http-api-client";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
import { getHttpApiClient } from '@/lib/http-api-client';
interface WorkspaceDirectory {
name: string;
@@ -23,11 +22,7 @@ interface WorkspacePickerModalProps {
onSelect: (path: string, name: string) => void;
}
export function WorkspacePickerModal({
open,
onOpenChange,
onSelect,
}: WorkspacePickerModalProps) {
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
const [error, setError] = useState<string | null>(null);
@@ -43,10 +38,10 @@ export function WorkspacePickerModal({
if (result.success && result.directories) {
setDirectories(result.directories);
} else {
setError(result.error || "Failed to load directories");
setError(result.error || 'Failed to load directories');
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load directories");
setError(err instanceof Error ? err.message : 'Failed to load directories');
} finally {
setIsLoading(false);
}
@@ -90,12 +85,7 @@ export function WorkspacePickerModal({
<AlertCircle className="w-6 h-6 text-destructive" />
</div>
<p className="text-sm text-destructive">{error}</p>
<Button
variant="secondary"
size="sm"
onClick={loadDirectories}
className="mt-2"
>
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
Try Again
</Button>
</div>
@@ -128,9 +118,7 @@ export function WorkspacePickerModal({
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
{dir.name}
</p>
<p className="text-xs text-muted-foreground/70 truncate">
{dir.path}
</p>
<p className="text-xs text-muted-foreground/70 truncate">{dir.path}</p>
</div>
</button>
))}

View File

@@ -0,0 +1 @@
export { Sidebar } from './sidebar';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
interface AutomakerLogoProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
}
export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
return (
<div
className={cn(
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
!sidebarOpen && 'flex-col gap-1'
)}
onClick={() => navigate({ to: '/' })}
data-testid="logo-button"
>
{!sidebarOpen ? (
<div className="relative flex items-center justify-center rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-8 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-collapsed"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="iconShadow-collapsed" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-collapsed)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-collapsed)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
</div>
) : (
<div className={cn('flex items-center gap-1', 'hidden lg:flex')}>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="automaker"
className="h-[36.8px] w-[36.8px] group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-expanded"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter id="iconShadow-expanded" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
floodColor="#000000"
floodOpacity="0.25"
/>
</filter>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-expanded)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#iconShadow-expanded)"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="font-bold text-foreground text-[1.7rem] tracking-tight leading-none translate-y-[-2px]">
automaker<span className="text-brand-500">.</span>
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Bug } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
interface BugReportButtonProps {
sidebarExpanded: boolean;
}
export function BugReportButton({ sidebarExpanded }: BugReportButtonProps) {
const handleBugReportClick = useCallback(() => {
const api = getElectronAPI();
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
}, []);
return (
<button
onClick={handleBugReportClick}
className={cn(
'titlebar-no-drag px-3 py-2.5 rounded-xl',
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
'border border-transparent hover:border-border/40',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.97]',
sidebarExpanded && 'absolute right-3'
)}
title="Report Bug / Feature Request"
data-testid={sidebarExpanded ? 'bug-report-link' : 'bug-report-link-collapsed'}
>
<Bug className="w-4 h-4" />
</button>
);
}

View File

@@ -0,0 +1,60 @@
import { PanelLeft, PanelLeftClose } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
interface CollapseToggleButtonProps {
sidebarOpen: boolean;
toggleSidebar: () => void;
shortcut: string;
}
export function CollapseToggleButton({
sidebarOpen,
toggleSidebar,
shortcut,
}: CollapseToggleButtonProps) {
return (
<button
onClick={toggleSidebar}
className={cn(
'hidden lg:flex absolute top-[68px] -right-3 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',
// Premium shadow with glow on hover
'shadow-lg shadow-black/5 hover:shadow-xl hover:shadow-brand-500/10',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'hover:border-brand-500/30',
'transition-all duration-200 ease-out titlebar-no-drag',
'hover:scale-110 active:scale-90'
)}
data-testid="sidebar-collapse-button"
>
{sidebarOpen ? (
<PanelLeftClose className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
) : (
<PanelLeft className="w-3.5 h-3.5 pointer-events-none transition-transform duration-200" />
)}
{/* Tooltip */}
<div
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover/toggle:opacity-100 transition-all duration-200',
'whitespace-nowrap z-50 pointer-events-none',
'translate-x-1 group-hover/toggle:translate-x-0'
)}
data-testid="sidebar-toggle-tooltip"
>
{sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}{' '}
<span
className="ml-1.5 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground"
data-testid="sidebar-toggle-shortcut"
>
{formatShortcut(shortcut, true)}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,10 @@
export { SortableProjectItem } from './sortable-project-item';
export { ThemeMenuItem } from './theme-menu-item';
export { BugReportButton } from './bug-report-button';
export { CollapseToggleButton } from './collapse-toggle-button';
export { AutomakerLogo } from './automaker-logo';
export { SidebarHeader } from './sidebar-header';
export { ProjectActions } from './project-actions';
export { SidebarNavigation } from './sidebar-navigation';
export { ProjectSelectorWithOptions } from './project-selector-with-options';
export { SidebarFooter } from './sidebar-footer';

View File

@@ -0,0 +1,91 @@
import { Plus, FolderOpen, Recycle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { TrashedProject } from '@/lib/electron';
interface ProjectActionsProps {
setShowNewProjectModal: (show: boolean) => void;
handleOpenFolder: () => void;
setShowTrashDialog: (show: boolean) => void;
trashedProjects: TrashedProject[];
shortcuts: {
openProject: string;
};
}
export function ProjectActions({
setShowNewProjectModal,
handleOpenFolder,
setShowTrashDialog,
trashedProjects,
shortcuts,
}: ProjectActionsProps) {
return (
<div className="flex items-center gap-2.5 titlebar-no-drag px-3 mt-5">
<button
onClick={() => setShowNewProjectModal(true)}
className={cn(
'group flex items-center justify-center flex-1 px-3 py-2.5 rounded-xl',
'relative overflow-hidden',
'text-muted-foreground hover:text-foreground',
// Glass background with gradient on hover
'bg-accent/20 hover:bg-gradient-to-br hover:from-brand-500/15 hover:to-brand-600/10',
'border border-border/40 hover:border-brand-500/30',
// Premium shadow
'shadow-sm hover:shadow-md hover:shadow-brand-500/5',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
</button>
<button
onClick={handleOpenFolder}
className={cn(
'group flex items-center justify-center flex-1 px-3 py-2.5 rounded-xl',
'relative overflow-hidden',
'text-muted-foreground hover:text-foreground',
// Glass background
'bg-accent/20 hover:bg-accent/40',
'border border-border/40 hover:border-border/60',
'shadow-sm hover:shadow-md',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={`Open Folder (${shortcuts.openProject})`}
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>
<button
onClick={() => setShowTrashDialog(true)}
className={cn(
'group flex items-center justify-center px-3 h-[42px] rounded-xl',
'relative',
'text-muted-foreground hover:text-destructive',
// Subtle background that turns red on hover
'bg-accent/20 hover:bg-destructive/15',
'border border-border/40 hover:border-destructive/40',
'shadow-sm hover:shadow-md hover:shadow-destructive/10',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title="Recycle Bin"
data-testid="trash-button"
>
<Recycle className="size-4 shrink-0 transition-transform duration-200 group-hover:rotate-12" />
{trashedProjects.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 z-10 flex items-center justify-center min-w-4 h-4 px-1 text-[9px] font-bold rounded-full bg-red-500 text-white shadow-md ring-1 ring-red-600/50">
{trashedProjects.length > 9 ? '9+' : trashedProjects.length}
</span>
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,374 @@
import {
Folder,
ChevronDown,
MoreVertical,
Palette,
Monitor,
Moon,
Sun,
Undo2,
Redo2,
RotateCcw,
Trash2,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { DndContext, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableProjectItem, ThemeMenuItem } from './';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '../constants';
import { useProjectPicker, useDragAndDrop, useProjectTheme } from '../hooks';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
interface ProjectSelectorWithOptionsProps {
sidebarOpen: boolean;
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setShowDeleteProjectDialog: (show: boolean) => void;
}
export function ProjectSelectorWithOptions({
sidebarOpen,
isProjectPickerOpen,
setIsProjectPickerOpen,
setShowDeleteProjectDialog,
}: ProjectSelectorWithOptionsProps) {
// Get data from store
const {
projects,
currentProject,
projectHistory,
setCurrentProject,
reorderProjects,
cyclePrevProject,
cycleNextProject,
clearProjectHistory,
} = useAppStore();
// Get keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
const {
projectSearchQuery,
setProjectSearchQuery,
selectedProjectIndex,
projectSearchInputRef,
filteredProjects,
} = useProjectPicker({
projects,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
});
// Drag-and-drop handlers
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
// Theme management
const {
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
handlePreviewEnter,
handlePreviewLeave,
} = useProjectTheme();
if (!sidebarOpen || projects.length === 0) {
return null;
}
return (
<div className="px-3 mt-3 flex items-center gap-2.5">
<DropdownMenu open={isProjectPickerOpen} onOpenChange={setIsProjectPickerOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex-1 flex items-center justify-between px-3.5 py-3 rounded-xl',
// Premium glass background
'bg-gradient-to-br from-accent/40 to-accent/20',
'hover:from-accent/50 hover:to-accent/30',
'border border-border/50 hover:border-border/70',
// Subtle inner shadow
'shadow-sm shadow-black/5',
'text-foreground titlebar-no-drag min-w-0',
'transition-all duration-200 ease-out',
'hover:scale-[1.01] active:scale-[0.99]',
isProjectPickerOpen &&
'from-brand-500/10 to-brand-600/5 border-brand-500/30 ring-2 ring-brand-500/20 shadow-lg shadow-brand-500/5'
)}
data-testid="project-selector"
>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<Folder className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium truncate">
{currentProject?.name || 'Select Project'}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted text-muted-foreground"
data-testid="project-picker-shortcut"
>
{formatShortcut(shortcuts.projectPicker, true)}
</span>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground shrink-0 transition-transform duration-200',
isProjectPickerOpen && 'rotate-180'
)}
/>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-72 bg-popover/95 backdrop-blur-xl border-border shadow-xl p-1.5"
align="start"
data-testid="project-picker-dropdown"
>
{/* Search input for type-ahead filtering */}
<div className="px-1 pb-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
ref={projectSearchInputRef}
type="text"
placeholder="Search projects..."
value={projectSearchQuery}
onChange={(e) => setProjectSearchQuery(e.target.value)}
className={cn(
'w-full h-9 pl-8 pr-3 text-sm rounded-lg',
'border border-border bg-background/50',
'text-foreground placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/50',
'transition-all duration-200'
)}
data-testid="project-search-input"
/>
</div>
</div>
{filteredProjects.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
No projects found
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5 max-h-64 overflow-y-auto">
{filteredProjects.map((project, index) => (
<SortableProjectItem
key={project.id}
project={project}
currentProjectId={currentProject?.id}
isHighlighted={index === selectedProjectIndex}
onSelect={(p) => {
setCurrentProject(p);
setIsProjectPickerOpen(false);
}}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* Keyboard hint */}
<div className="px-2 pt-2 mt-1.5 border-t border-border/50">
<p className="text-[10px] text-muted-foreground text-center tracking-wide">
<span className="text-foreground/60">arrow</span> navigate{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">enter</span> select{' '}
<span className="mx-1 text-foreground/30">|</span>{' '}
<span className="text-foreground/60">esc</span> close
</p>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Project Options Menu - theme and history */}
{currentProject && (
<DropdownMenu
onOpenChange={(open) => {
// Clear preview theme when the menu closes
if (!open) {
setPreviewTheme(null);
}
}}
>
<DropdownMenuTrigger asChild>
<button
className={cn(
'hidden lg:flex items-center justify-center w-[42px] h-[42px] rounded-lg',
'text-muted-foreground hover:text-foreground',
'bg-transparent hover:bg-accent/60',
'border border-border/50 hover:border-border',
'transition-all duration-200 ease-out titlebar-no-drag',
'hover:scale-[1.02] active:scale-[0.98]'
)}
title="Project options"
data-testid="project-options-menu"
>
<MoreVertical className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-popover/95 backdrop-blur-xl">
{/* Project Theme Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger data-testid="project-theme-trigger">
<Palette className="w-4 h-4 mr-2" />
<span className="flex-1">Project Theme</span>
{currentProject.theme && (
<span className="text-[10px] text-muted-foreground ml-2 capitalize">
{currentProject.theme}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-[420px] bg-popover/95 backdrop-blur-xl"
data-testid="project-theme-menu"
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
}}
>
{/* Use Global Option */}
<DropdownMenuRadioGroup
value={currentProject.theme || ''}
onValueChange={(value) => {
if (currentProject) {
setPreviewTheme(null);
if (value !== '') {
setTheme(value as ThemeMode);
} else {
setTheme(globalTheme);
}
setProjectTheme(
currentProject.id,
value === '' ? null : (value as ThemeMode)
);
}
}}
>
<div
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={() => setPreviewTheme(null)}
>
<DropdownMenuRadioItem
value=""
data-testid="project-theme-global"
className="mx-2"
>
<Monitor className="w-4 h-4 mr-2" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</DropdownMenuRadioItem>
</div>
<DropdownMenuSeparator />
{/* Two Column Layout */}
<div className="flex gap-2 p-2">
{/* Dark Themes Column */}
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Moon className="w-3 h-3" />
Dark
</div>
<div className="space-y-0.5">
{PROJECT_DARK_THEMES.map((option) => (
<ThemeMenuItem
key={option.value}
option={option}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
/>
))}
</div>
</div>
{/* Light Themes Column */}
<div className="flex-1">
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground">
<Sun className="w-3 h-3" />
Light
</div>
<div className="space-y-0.5">
{PROJECT_LIGHT_THEMES.map((option) => (
<ThemeMenuItem
key={option.value}
option={option}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
/>
))}
</div>
</div>
</div>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Project History Section - only show when there's history */}
{projectHistory.length > 1 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Project History
</DropdownMenuLabel>
<DropdownMenuItem onClick={cyclePrevProject} data-testid="cycle-prev-project">
<Undo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Previous</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{formatShortcut(shortcuts.cyclePrevProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleNextProject} data-testid="cycle-next-project">
<Redo2 className="w-4 h-4 mr-2" />
<span className="flex-1">Next</span>
<span className="text-[10px] font-mono text-muted-foreground ml-2">
{formatShortcut(shortcuts.cycleNextProject, true)}
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={clearProjectHistory} data-testid="clear-project-history">
<RotateCcw className="w-4 h-4 mr-2" />
<span>Clear history</span>
</DropdownMenuItem>
</>
)}
{/* Move to Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteProjectDialog(true)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"
data-testid="move-project-to-trash"
>
<Trash2 className="w-4 h-4 mr-2" />
<span>Move to Trash</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}

View File

@@ -0,0 +1,269 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { BookOpen, Activity, Settings } from 'lucide-react';
interface SidebarFooterProps {
sidebarOpen: boolean;
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
hideWiki: boolean;
hideRunningAgents: boolean;
runningAgentsCount: number;
shortcuts: {
settings: string;
};
}
export function SidebarFooter({
sidebarOpen,
isActiveRoute,
navigate,
hideWiki,
hideRunningAgents,
runningAgentsCount,
shortcuts,
}: SidebarFooterProps) {
return (
<div
className={cn(
'shrink-0',
// Top border with gradient fade
'border-t border-border/40',
// Elevated background for visual separation
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
{/* Wiki Link */}
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/wiki' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('wiki')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Wiki' : undefined}
data-testid="wiki-link"
>
<BookOpen
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('wiki')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Wiki
</span>
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Wiki
</span>
)}
</button>
</div>
)}
{/* Running Agents Link */}
{!hideRunningAgents && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/running-agents' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('running-agents')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Running Agents' : undefined}
data-testid="running-agents-link"
>
<div className="relative">
<Activity
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('running-agents')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
{/* Running agents count badge - shown in collapsed state */}
{!sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'absolute -top-1.5 -right-1.5 flex items-center justify-center',
'min-w-4 h-4 px-1 text-[9px] font-bold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200'
)}
data-testid="running-agents-count-collapsed"
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
</div>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Running Agents
</span>
{/* Running agents count badge - shown in expanded state */}
{sidebarOpen && runningAgentsCount > 0 && (
<span
className={cn(
'hidden lg:flex items-center justify-center',
'min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full',
'bg-brand-500 text-white shadow-sm',
'animate-in fade-in zoom-in duration-200',
isActiveRoute('running-agents') && 'bg-brand-600'
)}
data-testid="running-agents-count"
>
{runningAgentsCount > 99 ? '99' : runningAgentsCount}
</span>
)}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Running Agents
{runningAgentsCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 bg-brand-500 text-white rounded-full text-[10px] font-semibold">
{runningAgentsCount}
</span>
)}
</span>
)}
</button>
</div>
)}
{/* Settings Link */}
<div className="p-2">
<button
onClick={() => navigate({ to: '/settings' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('settings')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Settings' : undefined}
data-testid="settings-button"
>
<Settings
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('settings')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:rotate-90 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
Settings
</span>
{sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid="shortcut-settings"
>
{formatShortcut(shortcuts.settings, true)}
</span>
)}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
>
Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>
</span>
)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
interface SidebarHeaderProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
}
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
return (
<>
{/* Logo */}
<div
className={cn(
'h-20 shrink-0 titlebar-drag-region',
// Subtle bottom border with gradient fade
'border-b border-border/40',
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-3 lg:px-5 justify-start' : 'px-3 justify-center'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded />}
</div>
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} />
</div>
)}
</>
);
}

View File

@@ -0,0 +1,138 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
interface SidebarNavigationProps {
currentProject: Project | null;
sidebarOpen: boolean;
navSections: NavSection[];
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
}
export function SidebarNavigation({
currentProject,
sidebarOpen,
navSections,
isActiveRoute,
navigate,
}: SidebarNavigationProps) {
return (
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-5' : 'mt-1')}>
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
<p className="text-muted-foreground text-sm text-center">
<span className="hidden lg:block">Select or create a project above</span>
</p>
</div>
) : currentProject ? (
// Navigation sections when project is selected
navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 && sidebarOpen ? 'mt-6' : ''}>
{/* Section Label */}
{section.label && sidebarOpen && (
<div className="hidden lg:block px-3 mb-2">
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
{section.label}
</span>
</div>
)}
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
{/* Nav Items */}
<div className="space-y-1.5">
{section.items.map((item) => {
const isActive = isActiveRoute(item.id);
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => navigate({ to: `/${item.id}` as const })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActive
? [
// Active: Premium gradient with glow
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
// Inactive: Subtle hover state
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? item.label : undefined}
data-testid={`nav-${item.id}`}
>
<Icon
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'hidden lg:block' : 'hidden'
)}
>
{item.label}
</span>
{item.shortcut && sidebarOpen && (
<span
className={cn(
'hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'
)}
data-testid={`shortcut-${item.id}`}
>
{formatShortcut(item.shortcut, true)}
</span>
)}
{/* Tooltip for collapsed state */}
{!sidebarOpen && (
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0'
)}
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
>
{item.label}
{item.shortcut && (
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(item.shortcut, true)}
</span>
)}
</span>
)}
</button>
);
})}
</div>
</div>
))
) : null}
</nav>
);
}

View File

@@ -0,0 +1,54 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Folder, Check, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SortableProjectItemProps } from '../types';
export function SortableProjectItem({
project,
currentProjectId,
isHighlighted,
onSelect,
}: SortableProjectItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: project.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-center gap-2 px-2.5 py-2 rounded-lg cursor-pointer transition-all duration-200',
'text-muted-foreground hover:text-foreground hover:bg-accent/80',
isDragging && 'bg-accent shadow-lg scale-[1.02]',
isHighlighted && 'bg-brand-500/10 text-foreground ring-1 ring-brand-500/20'
)}
data-testid={`project-option-${project.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-0.5 rounded-md hover:bg-accent/50 cursor-grab active:cursor-grabbing transition-colors"
data-testid={`project-drag-handle-${project.id}`}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
{/* Project content - clickable area */}
<div className="flex items-center gap-2.5 flex-1 min-w-0" onClick={() => onSelect(project)}>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm font-medium">{project.name}</span>
{currentProjectId === project.id && <Check className="h-4 w-4 text-brand-500 shrink-0" />}
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { memo } from 'react';
import { DropdownMenuRadioItem } from '@/components/ui/dropdown-menu';
import type { ThemeMenuItemProps } from '../types';
export const ThemeMenuItem = memo(function ThemeMenuItem({
option,
onPreviewEnter,
onPreviewLeave,
}: ThemeMenuItemProps) {
const Icon = option.icon;
return (
<div
key={option.value}
onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave}
>
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
</div>
);
});

View File

@@ -0,0 +1,24 @@
import { darkThemes, lightThemes } from '@/config/theme-options';
export const PROJECT_DARK_THEMES = darkThemes.map((opt) => ({
value: opt.value,
label: opt.label,
icon: opt.Icon,
color: opt.color,
}));
export const PROJECT_LIGHT_THEMES = lightThemes.map((opt) => ({
value: opt.value,
label: opt.label,
icon: opt.Icon,
color: opt.color,
}));
export const SIDEBAR_FEATURE_FLAGS = {
hideTerminal: import.meta.env.VITE_HIDE_TERMINAL === 'true',
hideWiki: import.meta.env.VITE_HIDE_WIKI === 'true',
hideRunningAgents: import.meta.env.VITE_HIDE_RUNNING_AGENTS === 'true',
hideContext: import.meta.env.VITE_HIDE_CONTEXT === 'true',
hideSpecEditor: import.meta.env.VITE_HIDE_SPEC_EDITOR === 'true',
hideAiProfiles: import.meta.env.VITE_HIDE_AI_PROFILES === 'true',
} as const;

View File

@@ -0,0 +1,2 @@
export { TrashDialog } from './trash-dialog';
export { OnboardingDialog } from './onboarding-dialog';

View File

@@ -0,0 +1,122 @@
import { Rocket, CheckCircle2, Zap, FileText, Sparkles, ArrowRight } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface OnboardingDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
newProjectName: string;
onSkip: () => void;
onGenerateSpec: () => void;
}
export function OnboardingDialog({
open,
onOpenChange,
newProjectName,
onSkip,
onGenerateSpec,
}: OnboardingDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onSkip();
}
onOpenChange(isOpen);
}}
>
<DialogContent className="max-w-2xl bg-popover/95 backdrop-blur-xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-brand-500/10 border border-brand-500/20">
<Rocket className="w-6 h-6 text-brand-500" />
</div>
<div>
<DialogTitle className="text-2xl">Welcome to {newProjectName}!</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1">
Your new project is ready. Let&apos;s get you started.
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-6">
{/* Main explanation */}
<div className="space-y-3">
<p className="text-sm text-foreground leading-relaxed">
Would you like to auto-generate your <strong>app_spec.txt</strong>? This file helps
describe your project and is used to pre-populate your backlog with features to work
on.
</p>
</div>
{/* Benefits list */}
<div className="space-y-3 rounded-xl bg-muted/30 border border-border/50 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">Pre-populate your backlog</p>
<p className="text-xs text-muted-foreground mt-1">
Automatically generate features based on your project specification
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Zap className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">Better AI assistance</p>
<p className="text-xs text-muted-foreground mt-1">
Help AI agents understand your project structure and tech stack
</p>
</div>
</div>
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-brand-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-foreground">Project documentation</p>
<p className="text-xs text-muted-foreground mt-1">
Keep a clear record of your project&apos;s capabilities and features
</p>
</div>
</div>
</div>
{/* Info box */}
<div className="rounded-xl bg-brand-500/5 border border-brand-500/10 p-3">
<p className="text-xs text-muted-foreground leading-relaxed">
<strong className="text-foreground">Tip:</strong> You can always generate or edit your
app_spec.txt later from the Spec Editor in the sidebar.
</p>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground hover:text-foreground"
>
Skip for now
</Button>
<Button
onClick={onGenerateSpec}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
>
<Sparkles className="w-4 h-4 mr-2" />
Generate App Spec
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,116 @@
import { X, Trash2, Undo2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type { TrashedProject } from '@/lib/electron';
interface TrashDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
trashedProjects: TrashedProject[];
activeTrashId: string | null;
handleRestoreProject: (id: string) => void;
handleDeleteProjectFromDisk: (project: TrashedProject) => void;
deleteTrashedProject: (id: string) => void;
handleEmptyTrash: () => void;
isEmptyingTrash: boolean;
}
export function TrashDialog({
open,
onOpenChange,
trashedProjects,
activeTrashId,
handleRestoreProject,
handleDeleteProjectFromDisk,
deleteTrashedProject,
handleEmptyTrash,
isEmptyingTrash,
}: TrashDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover/95 backdrop-blur-xl border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your system Trash.
</DialogDescription>
</DialogHeader>
{trashedProjects.length === 0 ? (
<p className="text-sm text-muted-foreground">Recycle bin is empty.</p>
) : (
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
{trashedProjects.map((project) => (
<div
key={project.id}
className="flex items-start justify-between gap-3 rounded-lg border border-border bg-card/50 p-4"
>
<div className="space-y-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground break-all">{project.path}</p>
<p className="text-[11px] text-muted-foreground/80">
Trashed {new Date(project.trashedAt).toLocaleString()}
</p>
</div>
<div className="flex flex-col gap-2 shrink-0">
<Button
size="sm"
variant="secondary"
onClick={() => handleRestoreProject(project.id)}
data-testid={`restore-project-${project.id}`}
>
<Undo2 className="h-3.5 w-3.5 mr-1.5" />
Restore
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteProjectFromDisk(project)}
disabled={activeTrashId === project.id}
data-testid={`delete-project-disk-${project.id}`}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{activeTrashId === project.id ? 'Deleting...' : 'Delete from disk'}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={() => deleteTrashedProject(project.id)}
data-testid={`remove-project-${project.id}`}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove from list
</Button>
</div>
</div>
))}
</div>
)}
<DialogFooter className="flex justify-between">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{trashedProjects.length > 0 && (
<Button
variant="outline"
onClick={handleEmptyTrash}
disabled={isEmptyingTrash}
data-testid="empty-trash"
>
{isEmptyingTrash ? 'Clearing...' : 'Empty Recycle Bin'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,12 @@
export { useThemePreview } from './use-theme-preview';
export { useSidebarAutoCollapse } from './use-sidebar-auto-collapse';
export { useDragAndDrop } from './use-drag-and-drop';
export { useRunningAgents } from './use-running-agents';
export { useTrashOperations } from './use-trash-operations';
export { useProjectPicker } from './use-project-picker';
export { useSpecRegeneration } from './use-spec-regeneration';
export { useNavigation } from './use-navigation';
export { useProjectCreation } from './use-project-creation';
export { useSetupDialog } from './use-setup-dialog';
export { useTrashDialog } from './use-trash-dialog';
export { useProjectTheme } from './use-project-theme';

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { useSensors, useSensor, PointerSensor, type DragEndEvent } from '@dnd-kit/core';
import type { Project } from '@/lib/electron';
interface UseDragAndDropProps {
projects: Project[];
reorderProjects: (oldIndex: number, newIndex: number) => void;
}
export function useDragAndDrop({ projects, reorderProjects }: UseDragAndDropProps) {
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5, // Small distance to start drag
},
})
);
// Handle drag end for reordering projects
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = projects.findIndex((p) => p.id === active.id);
const newIndex = projects.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderProjects(oldIndex, newIndex);
}
}
},
[projects, reorderProjects]
);
return {
sensors,
handleDragEnd,
};
}

View File

@@ -0,0 +1,211 @@
import { useMemo } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { FileText, LayoutGrid, Bot, BookOpen, UserCircle, Terminal } from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import type { Project } from '@/lib/electron';
interface UseNavigationProps {
shortcuts: {
toggleSidebar: string;
openProject: string;
projectPicker: string;
cyclePrevProject: string;
cycleNextProject: string;
spec: string;
context: string;
profiles: string;
board: string;
agent: string;
terminal: string;
settings: string;
};
hideSpecEditor: boolean;
hideContext: boolean;
hideTerminal: boolean;
hideAiProfiles: boolean;
currentProject: Project | null;
projects: Project[];
projectHistory: string[];
navigate: (opts: NavigateOptions) => void;
toggleSidebar: () => void;
handleOpenFolder: () => void;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void;
cycleNextProject: () => void;
}
export function useNavigation({
shortcuts,
hideSpecEditor,
hideContext,
hideTerminal,
hideAiProfiles,
currentProject,
projects,
projectHistory,
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
}: UseNavigationProps) {
// Build navigation sections
const navSections: NavSection[] = useMemo(() => {
const allToolsItems: NavItem[] = [
{
id: 'spec',
label: 'Spec Editor',
icon: FileText,
shortcut: shortcuts.spec,
},
{
id: 'context',
label: 'Context',
icon: BookOpen,
shortcut: shortcuts.context,
},
{
id: 'profiles',
label: 'AI Profiles',
icon: UserCircle,
shortcut: shortcuts.profiles,
},
];
// Filter out hidden items
const visibleToolsItems = allToolsItems.filter((item) => {
if (item.id === 'spec' && hideSpecEditor) {
return false;
}
if (item.id === 'context' && hideContext) {
return false;
}
if (item.id === 'profiles' && hideAiProfiles) {
return false;
}
return true;
});
// Build project items - Terminal is conditionally included
const projectItems: NavItem[] = [
{
id: 'board',
label: 'Kanban Board',
icon: LayoutGrid,
shortcut: shortcuts.board,
},
{
id: 'agent',
label: 'Agent Runner',
icon: Bot,
shortcut: shortcuts.agent,
},
];
// Add Terminal to Project section if not hidden
if (!hideTerminal) {
projectItems.push({
id: 'terminal',
label: 'Terminal',
icon: Terminal,
shortcut: shortcuts.terminal,
});
}
return [
{
label: 'Project',
items: projectItems,
},
{
label: 'Tools',
items: visibleToolsItems,
},
];
}, [shortcuts, hideSpecEditor, hideContext, hideTerminal, hideAiProfiles]);
// Build keyboard shortcuts for navigation
const navigationShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcutsList: KeyboardShortcut[] = [];
// Sidebar toggle shortcut - always available
shortcutsList.push({
key: shortcuts.toggleSidebar,
action: () => toggleSidebar(),
description: 'Toggle sidebar',
});
// Open project shortcut - opens the folder selection dialog directly
shortcutsList.push({
key: shortcuts.openProject,
action: () => handleOpenFolder(),
description: 'Open folder selection dialog',
});
// Project picker shortcut - only when we have projects
if (projects.length > 0) {
shortcutsList.push({
key: shortcuts.projectPicker,
action: () => setIsProjectPickerOpen((prev) => !prev),
description: 'Toggle project picker',
});
}
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcutsList.push({
key: shortcuts.cyclePrevProject,
action: () => cyclePrevProject(),
description: 'Cycle to previous project (MRU)',
});
shortcutsList.push({
key: shortcuts.cycleNextProject,
action: () => cycleNextProject(),
description: 'Cycle to next project (LRU)',
});
}
// Only enable nav shortcuts if there's a current project
if (currentProject) {
navSections.forEach((section) => {
section.items.forEach((item) => {
if (item.shortcut) {
shortcutsList.push({
key: item.shortcut,
action: () => navigate({ to: `/${item.id}` as const }),
description: `Navigate to ${item.label}`,
});
}
});
});
// Add settings shortcut
shortcutsList.push({
key: shortcuts.settings,
action: () => navigate({ to: '/settings' }),
description: 'Navigate to Settings',
});
}
return shortcutsList;
}, [
shortcuts,
currentProject,
navigate,
toggleSidebar,
projects.length,
handleOpenFolder,
projectHistory.length,
cyclePrevProject,
cycleNextProject,
navSections,
setIsProjectPickerOpen,
]);
return {
navSections,
navigationShortcuts,
};
}

View File

@@ -0,0 +1,279 @@
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
import type { ThemeMode } from '@/store/app-store';
import type { TrashedProject, Project } from '@/lib/electron';
interface UseProjectCreationProps {
trashedProjects: TrashedProject[];
currentProject: Project | null;
globalTheme: ThemeMode;
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
}
export function useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
}: UseProjectCreationProps) {
// Modal state
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
// Onboarding state
const [showOnboardingDialog, setShowOnboardingDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectPath, setNewProjectPath] = useState('');
/**
* Common logic for all project creation flows
*/
const finalizeProjectCreation = useCallback(
async (projectPath: string, projectName: string) => {
try {
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write initial app_spec.txt with proper XML structure
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
const api = getElectronAPI();
await api.fs.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
to understand your project structure and tech stack.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
// Determine theme: try trashed project theme, then current project theme, then global
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
setShowNewProjectModal(false);
// Show onboarding dialog for new project
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success('Project created successfully');
} catch (error) {
console.error('[ProjectCreation] Failed to finalize project:', error);
toast.error('Failed to initialize project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create a blank project with .automaker structure
*/
const handleCreateBlankProject = useCallback(
async (projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.fs.createFolder(projectPath);
// Finalize project setup
await finalizeProjectCreation(projectPath, projectName);
} catch (error) {
console.error('[ProjectCreation] Failed to create blank project:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreatingProject(false);
}
},
[finalizeProjectCreation]
);
/**
* Create project from a starter template
*/
const handleCreateFromTemplate = useCallback(
async (template: StarterTemplate, projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone template repository
await api.git.clone(template.githubUrl, projectPath);
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write app_spec.txt with template-specific info
await api.fs.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was created from the "${template.name}" starter template.
${template.description}
</overview>
<technology_stack>
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
</technology_stack>
<core_capabilities>
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success('Project created from template', {
description: `Created ${projectName} from ${template.name}`,
});
} catch (error) {
console.error('[ProjectCreation] Failed to create from template:', error);
toast.error('Failed to create project from template', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
/**
* Create project from a custom GitHub URL
*/
const handleCreateFromCustomUrl = useCallback(
async (repoUrl: string, projectName: string, parentDir: string) => {
setIsCreatingProject(true);
try {
const api = getElectronAPI();
const projectPath = `${parentDir}/${projectName}`;
// Clone custom repository
await api.git.clone(repoUrl, projectPath);
// Initialize .automaker directory structure
await initializeProject(projectPath);
// Write app_spec.txt with custom URL info
await api.fs.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${projectName}</project_name>
<overview>
This project was cloned from ${repoUrl}.
The AI agent will analyze the project structure.
</overview>
<technology_stack>
<!-- The AI agent will fill this in after analyzing your project -->
</technology_stack>
<core_capabilities>
<!-- List core features and capabilities -->
</core_capabilities>
<implemented_features>
<!-- The AI agent will populate this based on code analysis -->
</implemented_features>
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
setShowOnboardingDialog(true);
toast.success('Project created from repository', {
description: `Created ${projectName} from ${repoUrl}`,
});
} catch (error) {
console.error('[ProjectCreation] Failed to create from custom URL:', error);
toast.error('Failed to create project from URL', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
);
return {
// Modal state
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
// Onboarding state
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
setNewProjectName,
newProjectPath,
setNewProjectPath,
// Handlers
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
};
}

View File

@@ -0,0 +1,105 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { Project } from '@/lib/electron';
interface UseProjectPickerProps {
projects: Project[];
isProjectPickerOpen: boolean;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
setCurrentProject: (project: Project) => void;
}
export function useProjectPicker({
projects,
isProjectPickerOpen,
setIsProjectPickerOpen,
setCurrentProject,
}: UseProjectPickerProps) {
const [projectSearchQuery, setProjectSearchQuery] = useState('');
const [selectedProjectIndex, setSelectedProjectIndex] = useState(0);
const projectSearchInputRef = useRef<HTMLInputElement>(null);
// Filtered projects based on search query
const filteredProjects = useMemo(() => {
if (!projectSearchQuery.trim()) {
return projects;
}
const query = projectSearchQuery.toLowerCase();
return projects.filter((project) => project.name.toLowerCase().includes(query));
}, [projects, projectSearchQuery]);
// Reset selection when filtered results change
useEffect(() => {
setSelectedProjectIndex(0);
}, [filteredProjects.length, projectSearchQuery]);
// Reset search query when dropdown closes
useEffect(() => {
if (!isProjectPickerOpen) {
setProjectSearchQuery('');
setSelectedProjectIndex(0);
}
}, [isProjectPickerOpen]);
// Focus the search input when dropdown opens
useEffect(() => {
if (isProjectPickerOpen) {
// Small delay to ensure the dropdown is rendered
setTimeout(() => {
projectSearchInputRef.current?.focus();
}, 0);
}
}, [isProjectPickerOpen]);
// Handle selecting the currently highlighted project
const selectHighlightedProject = useCallback(() => {
if (filteredProjects.length > 0 && selectedProjectIndex < filteredProjects.length) {
setCurrentProject(filteredProjects[selectedProjectIndex]);
setIsProjectPickerOpen(false);
}
}, [filteredProjects, selectedProjectIndex, setCurrentProject, setIsProjectPickerOpen]);
// Handle keyboard events when project picker is open
useEffect(() => {
if (!isProjectPickerOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsProjectPickerOpen(false);
} else if (event.key === 'Enter') {
event.preventDefault();
selectHighlightedProject();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
setSelectedProjectIndex((prev) => (prev < filteredProjects.length - 1 ? prev + 1 : prev));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setSelectedProjectIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key.toLowerCase() === 'p' && !event.metaKey && !event.ctrlKey) {
// Toggle off when P is pressed (not with modifiers) while dropdown is open
// Only if not typing in the search input
if (document.activeElement !== projectSearchInputRef.current) {
event.preventDefault();
setIsProjectPickerOpen(false);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
isProjectPickerOpen,
selectHighlightedProject,
filteredProjects.length,
setIsProjectPickerOpen,
]);
return {
projectSearchQuery,
setProjectSearchQuery,
selectedProjectIndex,
setSelectedProjectIndex,
projectSearchInputRef,
filteredProjects,
selectHighlightedProject,
};
}

View File

@@ -0,0 +1,25 @@
import { useAppStore } from '@/store/app-store';
import { useThemePreview } from './use-theme-preview';
/**
* Hook that manages project theme state and preview handlers
*/
export function useProjectTheme() {
// Get theme-related values from store
const { theme: globalTheme, setTheme, setProjectTheme, setPreviewTheme } = useAppStore();
// Get debounced preview handlers
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
return {
// Theme state
globalTheme,
setTheme,
setProjectTheme,
setPreviewTheme,
// Preview handlers
handlePreviewEnter,
handlePreviewLeave,
};
}

View File

@@ -0,0 +1,53 @@
import { useState, useEffect, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
export function useRunningAgents() {
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
// Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
setRunningAgentsCount(result.runningAgents.length);
}
}
} catch (error) {
console.error('[Sidebar] Error fetching running agents count:', error);
}
}, []);
// Subscribe to auto-mode events to update running agents count in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) {
// If autoMode is not available, still fetch initial count
fetchRunningAgentsCount();
return;
}
// Initial fetch on mount
fetchRunningAgentsCount();
const unsubscribe = api.autoMode.onEvent((event) => {
// When a feature starts, completes, or errors, refresh the count
if (
event.type === 'auto_mode_feature_complete' ||
event.type === 'auto_mode_error' ||
event.type === 'auto_mode_feature_start'
) {
fetchRunningAgentsCount();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount]);
return {
runningAgentsCount,
};
}

View File

@@ -0,0 +1,147 @@
import { useState, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { FeatureCount } from '@/components/views/spec-view/types';
interface UseSetupDialogProps {
setSpecCreatingForProject: (path: string | null) => void;
newProjectPath: string;
setNewProjectName: (name: string) => void;
setNewProjectPath: (path: string) => void;
setShowOnboardingDialog: (show: boolean) => void;
}
export function useSetupDialog({
setSpecCreatingForProject,
newProjectPath,
setNewProjectName,
setNewProjectPath,
setShowOnboardingDialog,
}: UseSetupDialogProps) {
// Setup dialog state
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState('');
const [projectOverview, setProjectOverview] = useState('');
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
/**
* Handle creating initial spec for new project
*/
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
// Set store state immediately so the loader shows up right away
setSpecCreatingForProject(setupProjectPath);
setShowSetupDialog(false);
try {
const api = getElectronAPI();
if (!api.specRegeneration) {
toast.error('Spec regeneration not available');
setSpecCreatingForProject(null);
return;
}
const result = await api.specRegeneration.create(
setupProjectPath,
projectOverview.trim(),
generateFeatures,
analyzeProject,
generateFeatures ? featureCount : undefined // only pass maxFeatures if generating features
);
if (!result.success) {
console.error('[SetupDialog] Failed to start spec creation:', result.error);
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: result.error,
});
} else {
// Show processing toast to inform user
toast.info('Generating app specification...', {
description: "This may take a minute. You'll be notified when complete.",
});
}
// If successful, we'll wait for the events to update the state
} catch (error) {
console.error('[SetupDialog] Failed to create spec:', error);
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
setSpecCreatingForProject,
]);
/**
* Handle skipping setup
*/
const handleSkipSetup = useCallback(() => {
setShowSetupDialog(false);
setProjectOverview('');
setSetupProjectPath('');
// Clear onboarding state if we came from onboarding
if (newProjectPath) {
setNewProjectName('');
setNewProjectPath('');
}
toast.info('Setup skipped', {
description: 'You can set up your app_spec.txt later from the Spec view.',
});
}, [newProjectPath, setNewProjectName, setNewProjectPath]);
/**
* Handle onboarding dialog - generate spec
*/
const handleOnboardingGenerateSpec = useCallback(() => {
setShowOnboardingDialog(false);
// Navigate to the setup dialog flow
setSetupProjectPath(newProjectPath);
setProjectOverview('');
setShowSetupDialog(true);
}, [newProjectPath, setShowOnboardingDialog]);
/**
* Handle onboarding dialog - skip
*/
const handleOnboardingSkip = useCallback(() => {
setShowOnboardingDialog(false);
setNewProjectName('');
setNewProjectPath('');
toast.info('You can generate your app_spec.txt anytime from the Spec view', {
description: 'Your project is ready to use!',
});
}, [setShowOnboardingDialog, setNewProjectName, setNewProjectPath]);
return {
// State
showSetupDialog,
setShowSetupDialog,
setupProjectPath,
setSetupProjectPath,
projectOverview,
setProjectOverview,
generateFeatures,
setGenerateFeatures,
analyzeProject,
setAnalyzeProject,
featureCount,
setFeatureCount,
// Handlers
handleCreateInitialSpec,
handleSkipSetup,
handleOnboardingGenerateSpec,
handleOnboardingSkip,
};
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
interface UseSidebarAutoCollapseProps {
sidebarOpen: boolean;
toggleSidebar: () => void;
}
export function useSidebarAutoCollapse({
sidebarOpen,
toggleSidebar,
}: UseSidebarAutoCollapseProps) {
const isMountedRef = useRef(false);
// Auto-collapse sidebar on small screens
useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 1024px)'); // lg breakpoint
const handleResize = () => {
if (mediaQuery.matches && sidebarOpen) {
// Auto-collapse on small screens
toggleSidebar();
}
};
// Check on mount only
if (!isMountedRef.current) {
isMountedRef.current = true;
handleResize();
}
// Listen for changes
mediaQuery.addEventListener('change', handleResize);
return () => mediaQuery.removeEventListener('change', handleResize);
}, [sidebarOpen, toggleSidebar]);
// Update Electron window minWidth when sidebar state changes
// This ensures the window can't be resized smaller than what the kanban board needs
useEffect(() => {
const electronAPI = (
window as unknown as {
electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise<void> };
}
).electronAPI;
if (electronAPI?.updateMinWidth) {
electronAPI.updateMinWidth(sidebarOpen);
}
}, [sidebarOpen]);
}

View File

@@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import type { SpecRegenerationEvent } from '@/types/electron';
interface UseSpecRegenerationProps {
creatingSpecProjectPath: string | null;
setupProjectPath: string;
setSpecCreatingForProject: (path: string | null) => void;
setShowSetupDialog: (show: boolean) => void;
setProjectOverview: (overview: string) => void;
setSetupProjectPath: (path: string) => void;
setNewProjectName: (name: string) => void;
setNewProjectPath: (path: string) => void;
}
export function useSpecRegeneration({
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
setShowSetupDialog,
setProjectOverview,
setSetupProjectPath,
setNewProjectName,
setNewProjectPath,
}: UseSpecRegenerationProps) {
// Subscribe to spec regeneration events
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
console.log(
'[Sidebar] Spec regeneration event:',
event.type,
'for project:',
event.projectPath
);
// Only handle events for the project we're currently setting up
if (event.projectPath !== creatingSpecProjectPath && event.projectPath !== setupProjectPath) {
console.log('[Sidebar] Ignoring event - not for project being set up');
return;
}
if (event.type === 'spec_regeneration_complete') {
setSpecCreatingForProject(null);
setShowSetupDialog(false);
setProjectOverview('');
setSetupProjectPath('');
// Clear onboarding state if we came from onboarding
setNewProjectName('');
setNewProjectPath('');
toast.success('App specification created', {
description: 'Your project is now set up and ready to go!',
});
} else if (event.type === 'spec_regeneration_error') {
setSpecCreatingForProject(null);
toast.error('Failed to create specification', {
description: event.error,
});
}
});
return () => {
unsubscribe();
};
}, [
creatingSpecProjectPath,
setupProjectPath,
setSpecCreatingForProject,
setShowSetupDialog,
setProjectOverview,
setSetupProjectPath,
setNewProjectName,
setNewProjectPath,
]);
}

View File

@@ -0,0 +1,53 @@
import { useRef, useCallback, useEffect } from 'react';
import type { ThemeMode } from '@/store/app-store';
interface UseThemePreviewProps {
setPreviewTheme: (theme: ThemeMode | null) => void;
}
export function useThemePreview({ setPreviewTheme }: UseThemePreviewProps) {
// Debounced preview theme handlers to prevent excessive re-renders
const previewTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handlePreviewEnter = useCallback(
(value: string) => {
// Clear any pending timeout
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
// Small delay to debounce rapid hover changes
previewTimeoutRef.current = setTimeout(() => {
setPreviewTheme(value as ThemeMode);
}, 16); // ~1 frame delay
},
[setPreviewTheme]
);
const handlePreviewLeave = useCallback(
(e: React.PointerEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement;
if (!relatedTarget?.closest('[data-testid^="project-theme-"]')) {
// Clear any pending timeout
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
setPreviewTheme(null);
}
},
[setPreviewTheme]
);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (previewTimeoutRef.current) {
clearTimeout(previewTimeoutRef.current);
}
};
}, []);
return {
handlePreviewEnter,
handlePreviewLeave,
};
}

View File

@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTrashOperations } from './use-trash-operations';
import type { TrashedProject } from '@/lib/electron';
interface UseTrashDialogProps {
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
trashedProjects: TrashedProject[];
}
/**
* Hook that combines trash operations with dialog state management
*/
export function useTrashDialog({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
}: UseTrashDialogProps) {
// Dialog state
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Reuse existing trash operations logic
const trashOperations = useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
});
return {
// Dialog state
showTrashDialog,
setShowTrashDialog,
// Trash operations (spread from existing hook)
...trashOperations,
};
}

View File

@@ -0,0 +1,92 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { getElectronAPI, type TrashedProject } from '@/lib/electron';
interface UseTrashOperationsProps {
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
trashedProjects: TrashedProject[];
}
export function useTrashOperations({
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
trashedProjects,
}: UseTrashOperationsProps) {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
const handleRestoreProject = useCallback(
(projectId: string) => {
restoreTrashedProject(projectId);
toast.success('Project restored', {
description: 'Added back to your project list.',
});
},
[restoreTrashedProject]
);
const handleDeleteProjectFromDisk = useCallback(
async (trashedProject: TrashedProject) => {
const confirmed = window.confirm(
`Delete "${trashedProject.name}" from disk?\nThis sends the folder to your system Trash.`
);
if (!confirmed) return;
setActiveTrashId(trashedProject.id);
try {
const api = getElectronAPI();
if (!api.trashItem) {
throw new Error('System Trash is not available in this build.');
}
const result = await api.trashItem(trashedProject.path);
if (!result.success) {
throw new Error(result.error || 'Failed to delete project folder');
}
deleteTrashedProject(trashedProject.id);
toast.success('Project folder sent to system Trash', {
description: trashedProject.path,
});
} catch (error) {
console.error('[Sidebar] Failed to delete project from disk:', error);
toast.error('Failed to delete project folder', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setActiveTrashId(null);
}
},
[deleteTrashedProject]
);
const handleEmptyTrash = useCallback(() => {
if (trashedProjects.length === 0) {
return;
}
const confirmed = window.confirm(
'Clear all projects from recycle bin? This does not delete folders from disk.'
);
if (!confirmed) return;
setIsEmptyingTrash(true);
try {
emptyTrash();
toast.success('Recycle bin cleared');
} finally {
setIsEmptyingTrash(false);
}
}, [emptyTrash, trashedProjects.length]);
return {
activeTrashId,
isEmptyingTrash,
handleRestoreProject,
handleDeleteProjectFromDisk,
handleEmptyTrash,
};
}

View File

@@ -0,0 +1,32 @@
import type { Project } from '@/lib/electron';
import type React from 'react';
export interface NavSection {
label?: string;
items: NavItem[];
}
export interface NavItem {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
shortcut?: string;
}
export interface SortableProjectItemProps {
project: Project;
currentProjectId: string | undefined;
isHighlighted: boolean;
onSelect: (project: Project) => void;
}
export interface ThemeMenuItemProps {
option: {
value: string;
label: string;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
color: string;
};
onPreviewEnter: (value: string) => void;
onPreviewLeave: (e: React.PointerEvent) => void;
}

View File

@@ -1,10 +1,9 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Plus,
MessageSquare,
@@ -15,66 +14,66 @@ import {
X,
ArchiveRestore,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI } from "@/lib/electron";
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
// Random session name generator
const adjectives = [
"Swift",
"Bright",
"Clever",
"Dynamic",
"Eager",
"Focused",
"Gentle",
"Happy",
"Inventive",
"Jolly",
"Keen",
"Lively",
"Mighty",
"Noble",
"Optimal",
"Peaceful",
"Quick",
"Radiant",
"Smart",
"Tranquil",
"Unique",
"Vibrant",
"Wise",
"Zealous",
'Swift',
'Bright',
'Clever',
'Dynamic',
'Eager',
'Focused',
'Gentle',
'Happy',
'Inventive',
'Jolly',
'Keen',
'Lively',
'Mighty',
'Noble',
'Optimal',
'Peaceful',
'Quick',
'Radiant',
'Smart',
'Tranquil',
'Unique',
'Vibrant',
'Wise',
'Zealous',
];
const nouns = [
"Agent",
"Builder",
"Coder",
"Developer",
"Explorer",
"Forge",
"Garden",
"Helper",
"Innovator",
"Journey",
"Kernel",
"Lighthouse",
"Mission",
"Navigator",
"Oracle",
"Project",
"Quest",
"Runner",
"Spark",
"Task",
"Unicorn",
"Voyage",
"Workshop",
'Agent',
'Builder',
'Coder',
'Developer',
'Explorer',
'Forge',
'Garden',
'Helper',
'Innovator',
'Journey',
'Kernel',
'Lighthouse',
'Mission',
'Navigator',
'Oracle',
'Project',
'Quest',
'Runner',
'Spark',
'Task',
'Unicorn',
'Voyage',
'Workshop',
];
function generateRandomSessionName(): string {
@@ -101,19 +100,15 @@ export function SessionManager({
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [editingName, setEditingName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [newSessionName, setNewSessionName] = useState("");
const [runningSessions, setRunningSessions] = useState<Set<string>>(
new Set()
);
const [newSessionName, setNewSessionName] = useState('');
const [runningSessions, setRunningSessions] = useState<Set<string>>(new Set());
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] =
useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
useState(false);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
@@ -131,10 +126,7 @@ export function SessionManager({
}
} catch (err) {
// Ignore errors for individual session checks
console.warn(
`[SessionManager] Failed to check running state for ${session.id}:`,
err
);
console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err);
}
}
@@ -180,14 +172,10 @@ export function SessionManager({
const sessionName = newSessionName.trim() || generateRandomSessionName();
const result = await api.sessions.create(
sessionName,
projectPath,
projectPath
);
const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
setNewSessionName("");
setNewSessionName('');
setIsCreating(false);
await loadSessions();
onSelectSession(result.session.id);
@@ -201,11 +189,7 @@ export function SessionManager({
const sessionName = generateRandomSessionName();
const result = await api.sessions.create(
sessionName,
projectPath,
projectPath
);
const result = await api.sessions.create(sessionName, projectPath, projectPath);
if (result.success && result.session?.id) {
await loadSessions();
@@ -234,7 +218,7 @@ export function SessionManager({
if (result.success) {
setEditingSessionId(null);
setEditingName("");
setEditingName('');
await loadSessions();
}
};
@@ -243,7 +227,7 @@ export function SessionManager({
const handleArchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
console.error("[SessionManager] Sessions API not available");
console.error('[SessionManager] Sessions API not available');
return;
}
@@ -256,10 +240,10 @@ export function SessionManager({
}
await loadSessions();
} else {
console.error("[SessionManager] Archive failed:", result.error);
console.error('[SessionManager] Archive failed:', result.error);
}
} catch (error) {
console.error("[SessionManager] Archive error:", error);
console.error('[SessionManager] Archive error:', error);
}
};
@@ -267,7 +251,7 @@ export function SessionManager({
const handleUnarchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
console.error("[SessionManager] Sessions API not available");
console.error('[SessionManager] Sessions API not available');
return;
}
@@ -276,10 +260,10 @@ export function SessionManager({
if (result.success) {
await loadSessions();
} else {
console.error("[SessionManager] Unarchive failed:", result.error);
console.error('[SessionManager] Unarchive failed:', result.error);
}
} catch (error) {
console.error("[SessionManager] Unarchive error:", error);
console.error('[SessionManager] Unarchive error:', error);
}
};
@@ -324,8 +308,7 @@ export function SessionManager({
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
const displayedSessions =
activeTab === "active" ? activeSessions : archivedSessions;
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
return (
<Card className="h-full flex flex-col rounded-none">
@@ -337,8 +320,8 @@ export function SessionManager({
size="sm"
onClick={() => {
// Switch to active tab if on archived tab
if (activeTab === "archived") {
setActiveTab("active");
if (activeTab === 'archived') {
setActiveTab('active');
}
handleQuickCreateSession();
}}
@@ -354,9 +337,7 @@ export function SessionManager({
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "active" | "archived")
}
onValueChange={(value) => setActiveTab(value as 'active' | 'archived')}
className="w-full"
>
<TabsList className="w-full">
@@ -372,10 +353,7 @@ export function SessionManager({
</Tabs>
</CardHeader>
<CardContent
className="flex-1 overflow-y-auto space-y-2"
data-testid="session-list"
>
<CardContent className="flex-1 overflow-y-auto space-y-2" data-testid="session-list">
{/* Create new session */}
{isCreating && (
<div className="p-3 border rounded-lg bg-muted/50">
@@ -385,10 +363,10 @@ export function SessionManager({
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSession();
if (e.key === "Escape") {
if (e.key === 'Enter') handleCreateSession();
if (e.key === 'Escape') {
setIsCreating(false);
setNewSessionName("");
setNewSessionName('');
}
}}
autoFocus
@@ -401,7 +379,7 @@ export function SessionManager({
variant="ghost"
onClick={() => {
setIsCreating(false);
setNewSessionName("");
setNewSessionName('');
}}
>
<X className="w-4 h-4" />
@@ -411,7 +389,7 @@ export function SessionManager({
)}
{/* Delete All Archived button - shown at the top of archived sessions */}
{activeTab === "archived" && archivedSessions.length > 0 && (
{activeTab === 'archived' && archivedSessions.length > 0 && (
<div className="pb-2 border-b mb-2">
<Button
variant="destructive"
@@ -431,9 +409,9 @@ export function SessionManager({
<div
key={session.id}
className={cn(
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
currentSessionId === session.id && "bg-primary/10 border-primary",
session.isArchived && "opacity-60"
'p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50',
currentSessionId === session.id && 'bg-primary/10 border-primary',
session.isArchived && 'opacity-60'
)}
onClick={() => !session.isArchived && onSelectSession(session.id)}
data-testid={`session-item-${session.id}`}
@@ -446,10 +424,10 @@ export function SessionManager({
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSession(session.id);
if (e.key === "Escape") {
if (e.key === 'Enter') handleRenameSession(session.id);
if (e.key === 'Escape') {
setEditingSessionId(null);
setEditingName("");
setEditingName('');
}
}}
onClick={(e) => e.stopPropagation()}
@@ -472,7 +450,7 @@ export function SessionManager({
onClick={(e) => {
e.stopPropagation();
setEditingSessionId(null);
setEditingName("");
setEditingName('');
}}
className="h-7"
>
@@ -483,16 +461,14 @@ export function SessionManager({
<>
<div className="flex items-center gap-2 mb-1">
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id &&
isCurrentSessionThinking) ||
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<h3 className="font-medium truncate">{session.name}</h3>
{((currentSessionId === session.id &&
isCurrentSessionThinking) ||
{((currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id)) && (
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
thinking...
@@ -500,9 +476,7 @@ export function SessionManager({
)}
</div>
{session.preview && (
<p className="text-xs text-muted-foreground truncate">
{session.preview}
</p>
<p className="text-xs text-muted-foreground truncate">{session.preview}</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
@@ -519,10 +493,7 @@ export function SessionManager({
{/* Actions */}
{!session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
@@ -547,10 +518,7 @@ export function SessionManager({
)}
{session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
@@ -578,14 +546,12 @@ export function SessionManager({
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{activeTab === "active"
? "No active sessions"
: "No archived sessions"}
{activeTab === 'active' ? 'No active sessions' : 'No archived sessions'}
</p>
<p className="text-xs">
{activeTab === "active"
? "Create your first session to get started"
: "Archive sessions to see them here"}
{activeTab === 'active'
? 'Create your first session to get started'
: 'Archive sessions to see them here'}
</p>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo } from 'react';
const TOTAL_DURATION = 2300; // Total animation duration in ms (tightened from 4000)
const LOGO_ENTER_DURATION = 500; // Tightened from 1200
@@ -36,9 +36,7 @@ function generateParticles(count: number): Particle[] {
}
export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const [phase, setPhase] = useState<"enter" | "hold" | "exit" | "done">(
"enter"
);
const [phase, setPhase] = useState<'enter' | 'hold' | 'exit' | 'done'>('enter');
const particles = useMemo(() => generateParticles(50), []);
@@ -46,11 +44,11 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
const timers: NodeJS.Timeout[] = [];
// Phase transitions
timers.push(setTimeout(() => setPhase("hold"), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase("exit"), EXIT_START));
timers.push(setTimeout(() => setPhase('hold'), LOGO_ENTER_DURATION));
timers.push(setTimeout(() => setPhase('exit'), EXIT_START));
timers.push(
setTimeout(() => {
setPhase("done");
setPhase('done');
onComplete();
}, TOTAL_DURATION)
);
@@ -58,7 +56,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
return () => timers.forEach(clearTimeout);
}, [onComplete]);
if (phase === "done") return null;
if (phase === 'done') return null;
return (
<div
@@ -66,10 +64,10 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
fixed inset-0 z-[9999] flex items-center justify-center
bg-background
transition-opacity duration-500 ease-out
${phase === "exit" ? "opacity-0" : "opacity-100"}
${phase === 'exit' ? 'opacity-0' : 'opacity-100'}
`}
style={{
pointerEvents: phase === "exit" ? "none" : "auto",
pointerEvents: phase === 'exit' ? 'none' : 'auto',
}}
>
<style>{`
@@ -91,15 +89,14 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
<div
className="absolute inset-0 opacity-30"
style={{
background:
"radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)",
background: 'radial-gradient(circle at center, var(--brand-500) 0%, transparent 70%)',
}}
/>
{/* Particle container 1 - Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow 60s linear infinite" }}
style={{ animation: 'spin-slow 60s linear infinite' }}
>
{particles.slice(0, 25).map((particle) => (
<div
@@ -107,15 +104,15 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
className="absolute"
style={{
transform:
phase === "exit"
phase === 'exit'
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
phase === 'enter'
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
: phase === 'exit'
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
: 'all 300ms ease-out',
}}
>
<div
@@ -125,15 +122,10 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
opacity: phase === 'enter' ? 0 : phase === 'hold' ? particle.opacity : 0,
transform: phase === 'exit' ? 'scale(0)' : 'scale(1)',
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
transition: 'opacity 300ms ease-out, transform 300ms ease-out',
}}
/>
</div>
@@ -143,7 +135,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
{/* Particle container 2 - Counter-Clockwise */}
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ animation: "spin-slow-reverse 75s linear infinite" }}
style={{ animation: 'spin-slow-reverse 75s linear infinite' }}
>
{particles.slice(25).map((particle) => (
<div
@@ -151,15 +143,15 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
className="absolute"
style={{
transform:
phase === "exit"
phase === 'exit'
? `translate(${Math.cos((particle.angle * Math.PI) / 180) * particle.distance}px, ${Math.sin((particle.angle * Math.PI) / 180) * particle.distance}px)`
: `translate(${particle.x}px, ${particle.y}px)`,
transition:
phase === "enter"
phase === 'enter'
? `all 600ms ease-out ${PARTICLES_ENTER_DELAY + particle.delay}ms`
: phase === "exit"
: phase === 'exit'
? `all 800ms cubic-bezier(0.4, 0, 1, 1) ${particle.delay * 0.3}ms`
: "all 300ms ease-out",
: 'all 300ms ease-out',
}}
>
<div
@@ -169,16 +161,11 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
height: particle.size,
background: `linear-gradient(135deg, var(--brand-400), var(--brand-600))`,
boxShadow: `0 0 ${particle.size * 2}px var(--brand-500)`,
opacity:
phase === "enter"
? 0
: phase === "hold"
? particle.opacity
: 0,
transform: phase === "exit" ? "scale(0)" : "scale(1)",
opacity: phase === 'enter' ? 0 : phase === 'hold' ? particle.opacity : 0,
transform: phase === 'exit' ? 'scale(0)' : 'scale(1)',
animation: `float ${particle.floatDuration}ms ease-in-out infinite`,
animationDelay: `${particle.delay}ms`,
transition: "opacity 300ms ease-out, transform 300ms ease-out",
transition: 'opacity 300ms ease-out, transform 300ms ease-out',
}}
/>
</div>
@@ -189,30 +176,29 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
<div
className="relative z-10"
style={{
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
opacity: phase === 'enter' ? 0 : phase === 'exit' ? 0 : 1,
transform:
phase === "enter"
? "scale(0.3) rotate(-20deg)"
: phase === "exit"
? "scale(2.5) translateY(-100px)"
: "scale(1) rotate(0deg)",
phase === 'enter'
? 'scale(0.3) rotate(-20deg)'
: phase === 'exit'
? 'scale(2.5) translateY(-100px)'
: 'scale(1) rotate(0deg)',
transition:
phase === "enter"
phase === 'enter'
? `all ${LOGO_ENTER_DURATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`
: phase === "exit"
? "all 600ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
: phase === 'exit'
? 'all 600ms cubic-bezier(0.4, 0, 1, 1)'
: 'all 300ms ease-out',
}}
>
{/* Glow effect behind logo */}
<div
className="absolute inset-0 blur-3xl"
style={{
background:
"radial-gradient(circle, var(--brand-500) 0%, transparent 70%)",
transform: "scale(2.5)",
opacity: phase === "hold" ? 0.6 : 0,
transition: "opacity 500ms ease-out",
background: 'radial-gradient(circle, var(--brand-500) 0%, transparent 70%)',
transform: 'scale(2.5)',
opacity: phase === 'hold' ? 0.6 : 0,
transition: 'opacity 500ms ease-out',
}}
/>
@@ -226,7 +212,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
style={{
width: 120,
height: 120,
filter: "drop-shadow(0 0 30px var(--brand-500))",
filter: 'drop-shadow(0 0 30px var(--brand-500))',
}}
>
<defs>
@@ -238,16 +224,10 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: "var(--brand-400)" }} />
<stop offset="100%" style={{ stopColor: "var(--brand-600)" }} />
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
<filter
id="splash-shadow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<filter id="splash-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow
dx="0"
dy="4"
@@ -257,14 +237,7 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
/>
</filter>
</defs>
<rect
x="16"
y="16"
width="224"
height="224"
rx="56"
fill="url(#splash-bg)"
/>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#splash-bg)" />
<g
fill="none"
stroke="#FFFFFF"
@@ -284,20 +257,20 @@ export function SplashScreen({ onComplete }: { onComplete: () => void }) {
<div
className="absolute flex items-center gap-1"
style={{
top: "calc(50% + 80px)",
opacity: phase === "enter" ? 0 : phase === "exit" ? 0 : 1,
top: 'calc(50% + 80px)',
opacity: phase === 'enter' ? 0 : phase === 'exit' ? 0 : 1,
transform:
phase === "enter"
? "translateY(20px)"
: phase === "exit"
? "translateY(-30px) scale(1.2)"
: "translateY(0)",
phase === 'enter'
? 'translateY(20px)'
: phase === 'exit'
? 'translateY(-30px) scale(1.2)'
: 'translateY(0)',
transition:
phase === "enter"
phase === 'enter'
? `all 600ms ease-out ${LOGO_ENTER_DURATION - 200}ms`
: phase === "exit"
? "all 500ms cubic-bezier(0.4, 0, 1, 1)"
: "all 300ms ease-out",
: phase === 'exit'
? 'all 500ms cubic-bezier(0.4, 0, 1, 1)'
: 'all 300ms ease-out',
}}
>
<span className="font-bold text-foreground text-4xl tracking-tight leading-none">

View File

@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import * as React from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
type AccordionType = "single" | "multiple";
type AccordionType = 'single' | 'multiple';
interface AccordionContextValue {
type: AccordionType;
@@ -12,12 +13,10 @@ interface AccordionContextValue {
collapsible?: boolean;
}
const AccordionContext = React.createContext<AccordionContextValue | null>(
null
);
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
type?: "single" | "multiple";
type?: 'single' | 'multiple';
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
@@ -27,7 +26,7 @@ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(
{
type = "single",
type = 'single',
value,
defaultValue,
onValueChange,
@@ -38,13 +37,11 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
},
ref
) => {
const [internalValue, setInternalValue] = React.useState<string | string[]>(
() => {
if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue;
return type === "single" ? "" : [];
}
);
const [internalValue, setInternalValue] = React.useState<string | string[]>(() => {
if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue;
return type === 'single' ? '' : [];
});
const currentValue = value !== undefined ? value : internalValue;
@@ -52,9 +49,9 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(itemValue: string) => {
let newValue: string | string[];
if (type === "single") {
if (type === 'single') {
if (currentValue === itemValue && collapsible) {
newValue = "";
newValue = '';
} else if (currentValue === itemValue && !collapsible) {
return;
} else {
@@ -91,27 +88,21 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
return (
<AccordionContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion"
className={cn("w-full", className)}
{...props}
>
<div ref={ref} data-slot="accordion" className={cn('w-full', className)} {...props}>
{children}
</div>
</AccordionContext.Provider>
);
}
);
Accordion.displayName = "Accordion";
Accordion.displayName = 'Accordion';
interface AccordionItemContextValue {
value: string;
isOpen: boolean;
}
const AccordionItemContext =
React.createContext<AccordionItemContextValue | null>(null);
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
@@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
const accordionContext = React.useContext(AccordionContext);
if (!accordionContext) {
throw new Error("AccordionItem must be used within an Accordion");
throw new Error('AccordionItem must be used within an Accordion');
}
const isOpen = Array.isArray(accordionContext.value)
? accordionContext.value.includes(value)
: accordionContext.value === value;
const contextValue = React.useMemo(
() => ({ value, isOpen }),
[value, isOpen]
);
const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]);
return (
<AccordionItemContext.Provider value={contextValue}>
<div
ref={ref}
data-slot="accordion-item"
data-state={isOpen ? "open" : "closed"}
className={cn("border-b border-border", className)}
data-state={isOpen ? 'open' : 'closed'}
className={cn('border-b border-border', className)}
{...props}
>
{children}
@@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
);
}
);
AccordionItem.displayName = "AccordionItem";
AccordionItem.displayName = 'AccordionItem';
interface AccordionTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
const AccordionTrigger = React.forwardRef<
HTMLButtonElement,
AccordionTriggerProps
>(({ className, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext);
const itemContext = React.useContext(AccordionItemContext);
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
({ className, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext);
const itemContext = React.useContext(AccordionItemContext);
if (!accordionContext || !itemContext) {
throw new Error("AccordionTrigger must be used within an AccordionItem");
if (!accordionContext || !itemContext) {
throw new Error('AccordionTrigger must be used within an AccordionItem');
}
const { onValueChange } = accordionContext;
const { value, isOpen } = itemContext;
return (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? 'open' : 'closed'}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
}
const { onValueChange } = accordionContext;
const { value, isOpen } = itemContext;
return (
<div data-slot="accordion-header" className="flex">
<button
ref={ref}
type="button"
data-slot="accordion-trigger"
data-state={isOpen ? "open" : "closed"}
aria-expanded={isOpen}
onClick={() => onValueChange(value)}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</button>
</div>
);
});
AccordionTrigger.displayName = "AccordionTrigger";
);
AccordionTrigger.displayName = 'AccordionTrigger';
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
@@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
const [height, setHeight] = React.useState<number | undefined>(undefined);
if (!itemContext) {
throw new Error("AccordionContent must be used within an AccordionItem");
throw new Error('AccordionContent must be used within an AccordionItem');
}
const { isOpen } = itemContext;
@@ -220,16 +206,16 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
return (
<div
data-slot="accordion-content"
data-state={isOpen ? "open" : "closed"}
data-state={isOpen ? 'open' : 'closed'}
className="overflow-hidden text-sm transition-all duration-200 ease-out"
style={{
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
height: isOpen ? (height !== undefined ? `${height}px` : 'auto') : 0,
opacity: isOpen ? 1 : 0,
}}
{...props}
>
<div ref={contentRef}>
<div ref={ref} className={cn("pb-4 pt-0", className)}>
<div ref={ref} className={cn('pb-4 pt-0', className)}>
{children}
</div>
</div>
@@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
);
}
);
AccordionContent.displayName = "AccordionContent";
AccordionContent.displayName = 'AccordionContent';
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,9 +1,8 @@
import * as React from 'react';
import { Check, ChevronsUpDown, LucideIcon } from 'lucide-react';
import * as React from "react";
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
@@ -11,12 +10,8 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export interface AutocompleteOption {
value: string;
@@ -38,12 +33,12 @@ interface AutocompleteProps {
icon?: LucideIcon;
allowCreate?: boolean;
createLabel?: (value: string) => string;
"data-testid"?: string;
'data-testid'?: string;
itemTestIdPrefix?: string;
}
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
if (typeof opt === "string") {
if (typeof opt === 'string') {
return { value: opt, label: opt };
}
return { ...opt, label: opt.label ?? opt.value };
@@ -53,27 +48,24 @@ export function Autocomplete({
value,
onChange,
options,
placeholder = "Select an option...",
searchPlaceholder = "Search...",
emptyMessage = "No results found.",
placeholder = 'Select an option...',
searchPlaceholder = 'Search...',
emptyMessage = 'No results found.',
className,
disabled = false,
error = false,
icon: Icon,
allowCreate = false,
createLabel = (v) => `Create "${v}"`,
"data-testid": testId,
itemTestIdPrefix = "option",
'data-testid': testId,
itemTestIdPrefix = 'option',
}: AutocompleteProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const [inputValue, setInputValue] = React.useState('');
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const normalizedOptions = React.useMemo(
() => options.map(normalizeOption),
[options]
);
const normalizedOptions = React.useMemo(() => options.map(normalizeOption), [options]);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
@@ -98,9 +90,7 @@ export function Autocomplete({
if (!inputValue) return normalizedOptions;
const lower = inputValue.toLowerCase();
return normalizedOptions.filter(
(opt) =>
opt.value.toLowerCase().includes(lower) ||
opt.label?.toLowerCase().includes(lower)
(opt) => opt.value.toLowerCase().includes(lower) || opt.label?.toLowerCase().includes(lower)
);
}, [normalizedOptions, inputValue]);
@@ -108,9 +98,7 @@ export function Autocomplete({
const isNewValue =
allowCreate &&
inputValue.trim() &&
!normalizedOptions.some(
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
);
!normalizedOptions.some((opt) => opt.value.toLowerCase() === inputValue.toLowerCase());
// Get display value
const displayValue = React.useMemo(() => {
@@ -129,17 +117,15 @@ export function Autocomplete({
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between",
Icon && "font-mono text-sm",
error && "border-destructive focus-visible:ring-destructive",
'w-full justify-between',
Icon && 'font-mono text-sm',
error && 'border-destructive focus-visible:ring-destructive',
className
)}
data-testid={testId}
>
<span className="flex items-center gap-2 truncate">
{Icon && (
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{Icon && <Icon className="w-4 h-4 shrink-0 text-muted-foreground" />}
{displayValue || placeholder}
</span>
<ChevronsUpDown className="opacity-50 shrink-0" />
@@ -163,8 +149,7 @@ export function Autocomplete({
<CommandEmpty>
{isNewValue ? (
<div className="py-2 px-3 text-sm">
Press enter to create{" "}
<code className="bg-muted px-1 rounded">{inputValue}</code>
Press enter to create <code className="bg-muted px-1 rounded">{inputValue}</code>
</div>
) : (
emptyMessage
@@ -177,7 +162,7 @@ export function Autocomplete({
value={inputValue}
onSelect={() => {
onChange(inputValue);
setInputValue("");
setInputValue('');
setOpen(false);
}}
className="text-[var(--status-success)]"
@@ -185,9 +170,7 @@ export function Autocomplete({
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{createLabel(inputValue)}
<span className="ml-auto text-xs text-muted-foreground">
(new)
</span>
<span className="ml-auto text-xs text-muted-foreground">(new)</span>
</CommandItem>
)}
{filteredOptions.map((option) => (
@@ -195,24 +178,19 @@ export function Autocomplete({
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setInputValue("");
onChange(currentValue === value ? '' : currentValue);
setInputValue('');
setOpen(false);
}}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, '-')}`}
>
{Icon && <Icon className="w-4 h-4 mr-2" />}
{option.label}
<Check
className={cn(
"ml-auto",
value === option.value ? "opacity-100" : "opacity-0"
)}
className={cn('ml-auto', value === option.value ? 'opacity-100' : 'opacity-0')}
/>
{option.badge && (
<span className="ml-2 text-xs text-muted-foreground">
({option.badge})
</span>
<span className="ml-2 text-xs text-muted-foreground">({option.badge})</span>
)}
</CommandItem>
))}

View File

@@ -1,57 +1,48 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm",
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-white hover:bg-destructive/90",
outline:
"text-foreground border-border bg-background/50 backdrop-blur-sm",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-white hover:bg-destructive/90',
outline: 'text-foreground border-border bg-background/50 backdrop-blur-sm',
// Semantic status variants using CSS variables
success:
"border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30",
'border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30',
warning:
"border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30",
'border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30',
error:
"border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30",
info:
"border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30",
'border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30',
info: 'border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30',
// Muted variants for subtle indication
muted:
"border-border/50 bg-muted/50 text-muted-foreground",
brand:
"border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30",
muted: 'border-border/50 bg-muted/50 text-muted-foreground',
brand: 'border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30',
},
size: {
default: "px-2.5 py-0.5 text-xs",
sm: "px-2 py-0.5 text-[10px]",
lg: "px-3 py-1 text-sm",
default: 'px-2.5 py-0.5 text-xs',
sm: 'px-2 py-0.5 text-[10px]',
lg: 'px-3 py-1 text-sm',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, size, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
);
return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import { GitBranch } from "lucide-react";
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
import * as React from 'react';
import { GitBranch } from 'lucide-react';
import { Autocomplete, AutocompleteOption } from '@/components/ui/autocomplete';
interface BranchAutocompleteProps {
value: string;
@@ -12,7 +11,7 @@ interface BranchAutocompleteProps {
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
'data-testid'?: string;
}
export function BranchAutocomplete({
@@ -20,24 +19,25 @@ export function BranchAutocomplete({
onChange,
branches,
branchCardCounts,
placeholder = "Select a branch...",
placeholder = 'Select a branch...',
className,
disabled = false,
error = false,
"data-testid": testId,
'data-testid': testId,
}: BranchAutocompleteProps) {
// Always include "main" at the top of suggestions
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
const branchSet = new Set(["main", ...branches]);
const branchSet = new Set(['main', ...branches]);
return Array.from(branchSet).map((branch) => {
const cardCount = branchCardCounts?.[branch];
// Show card count if available, otherwise show "default" for main branch only
const badge = branchCardCounts !== undefined
? String(cardCount ?? 0)
: branch === "main"
? "default"
: undefined;
const badge =
branchCardCounts !== undefined
? String(cardCount ?? 0)
: branch === 'main'
? 'default'
: undefined;
return {
value: branch,
label: branch,

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -11,43 +11,35 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25',
destructive:
"bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
'bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline active:scale-100",
"animated-outline":
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline active:scale-100',
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none',
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
);
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return (
<Loader2
className={cn("size-4 animate-spin", className)}
aria-hidden="true"
/>
);
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
}
function Button({
@@ -59,7 +51,7 @@ function Button({
disabled,
children,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
loading?: boolean;
@@ -67,28 +59,28 @@ function Button({
const isDisabled = disabled || loading;
// Special handling for animated-outline variant
if (variant === "animated-outline" && !asChild) {
if (variant === 'animated-outline' && !asChild) {
return (
<button
className={cn(
buttonVariants({ variant, size }),
"p-[1px]", // Force 1px padding for the gradient border
'group p-[1px]', // Force 1px padding for the gradient border, group for hover animation
className
)}
data-slot="button"
disabled={isDisabled}
{...props}
>
{/* Animated rotating gradient border - smoother animation */}
<span className="absolute inset-[-1000%] animate-[spin_3s_linear_infinite] animated-outline-gradient opacity-75 transition-opacity duration-300 group-hover:opacity-100" />
{/* Animated rotating gradient border - only animates on hover for GPU efficiency */}
<span className="absolute inset-[-1000%] animated-outline-gradient opacity-75 transition-opacity duration-300 group-hover:animate-[spin_3s_linear_infinite] group-hover:opacity-100" />
{/* Inner content container */}
<span
className={cn(
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200",
size === "sm" && "px-3 text-xs gap-1.5",
size === "lg" && "px-8",
size === "icon" && "p-0 gap-0"
'animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200',
size === 'sm' && 'px-3 text-xs gap-1.5',
size === 'lg' && 'px-8',
size === 'icon' && 'p-0 gap-0'
)}
>
{loading && <ButtonSpinner />}
@@ -98,7 +90,7 @@ function Button({
);
}
const Comp = asChild ? Slot : "button";
const Comp = asChild ? Slot : 'button';
return (
<Comp

View File

@@ -1,8 +1,8 @@
import * as React from "react";
import * as React from 'react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
interface CardProps extends React.ComponentProps<"div"> {
interface CardProps extends React.ComponentProps<'div'> {
gradient?: boolean;
}
@@ -11,12 +11,12 @@ function Card({ className, gradient = false, ...props }: CardProps) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6",
'bg-card text-card-foreground flex flex-col gap-1 rounded-xl border border-white/10 backdrop-blur-md py-6',
// Premium layered shadow
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
'shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]',
// Gradient border option
gradient &&
"relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
'relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10',
className
)}
{...props}
@@ -24,12 +24,12 @@ function Card({ className, gradient = false, ...props }: CardProps) {
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
@@ -37,65 +37,48 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold tracking-tight", className)}
className={cn('leading-none font-semibold tracking-tight', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
className={cn('text-muted-foreground text-sm leading-relaxed', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center gap-3 px-6 [.border-t]:pt-6", className)}
className={cn('flex items-center gap-3 px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@@ -1,6 +1,5 @@
import { Tag } from "lucide-react";
import { Autocomplete } from "@/components/ui/autocomplete";
import { Tag } from 'lucide-react';
import { Autocomplete } from '@/components/ui/autocomplete';
interface CategoryAutocompleteProps {
value: string;
@@ -10,18 +9,18 @@ interface CategoryAutocompleteProps {
className?: string;
disabled?: boolean;
error?: boolean;
"data-testid"?: string;
'data-testid'?: string;
}
export function CategoryAutocomplete({
value,
onChange,
suggestions,
placeholder = "Select or type a category...",
placeholder = 'Select or type a category...',
className,
disabled = false,
error = false,
"data-testid": testId,
'data-testid': testId,
}: CategoryAutocompleteProps) {
return (
<Autocomplete

View File

@@ -1,13 +1,15 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils";
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
checked?: boolean | "indeterminate";
defaultChecked?: boolean | "indeterminate";
interface CheckboxProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
'checked' | 'defaultChecked'
> {
checked?: boolean | 'indeterminate';
defaultChecked?: boolean | 'indeterminate';
onCheckedChange?: (checked: boolean) => void;
required?: boolean;
}
@@ -31,7 +33,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
<CheckboxRoot
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80',
className
)}
onCheckedChange={(checked) => {
@@ -42,9 +44,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
}}
{...props}
>
<CheckboxIndicator
className={cn("flex items-center justify-center text-current")}
>
<CheckboxIndicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxIndicator>
</CheckboxRoot>

View File

@@ -1,45 +1,41 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
)
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
@@ -48,7 +44,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
@@ -56,7 +52,7 @@ function CommandDialog({
</Command>
</DialogContent>
</Dialog>
)
);
}
function CommandInput({
@@ -64,49 +60,38 @@ function CommandInput({
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
)
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
{...props}
/>
)
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
);
}
function CommandGroup({
@@ -117,12 +102,12 @@ function CommandGroup({
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
)
);
}
function CommandSeparator({
@@ -132,16 +117,13 @@ function CommandSeparator({
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
@@ -151,23 +133,17 @@ function CommandItem({
)}
{...props}
/>
)
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
);
}
export {
@@ -180,4 +156,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";
import { useState, useEffect } from 'react';
import { Clock } from 'lucide-react';
interface CountUpTimerProps {
startedAt: string; // ISO timestamp string
@@ -16,8 +15,8 @@ function formatElapsedTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const paddedMinutes = minutes.toString().padStart(2, "0");
const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
const paddedMinutes = minutes.toString().padStart(2, '0');
const paddedSeconds = remainingSeconds.toString().padStart(2, '0');
return `${paddedMinutes}:${paddedSeconds}`;
}
@@ -26,7 +25,7 @@ function formatElapsedTime(seconds: number): string {
* CountUpTimer component that displays elapsed time since a given start time
* Updates every second to show the current elapsed time in MM:SS format
*/
export function CountUpTimer({ startedAt, className = "" }: CountUpTimerProps) {
export function CountUpTimer({ startedAt, className = '' }: CountUpTimerProps) {
const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { Trash2 } from "lucide-react";
import { Trash2 } from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -6,10 +6,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import type { ReactNode } from "react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import type { ReactNode } from 'react';
interface DeleteConfirmDialogProps {
open: boolean;
@@ -34,9 +34,9 @@ export function DeleteConfirmDialog({
title,
description,
children,
confirmText = "Delete",
testId = "delete-confirm-dialog",
confirmTestId = "confirm-delete-button",
confirmText = 'Delete',
testId = 'delete-confirm-dialog',
confirmTestId = 'confirm-delete-button',
}: DeleteConfirmDialogProps) {
const handleConfirm = () => {
onConfirm();
@@ -45,18 +45,13 @@ export function DeleteConfirmDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="bg-popover border-border max-w-md"
data-testid={testId}
>
<DialogContent className="bg-popover border-border max-w-md" data-testid={testId}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{description}
</DialogDescription>
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
</DialogHeader>
{children}
@@ -74,7 +69,7 @@ export function DeleteConfirmDialog({
variant="destructive"
onClick={handleConfirm}
data-testid={confirmTestId}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
className="px-4"
>

View File

@@ -1,19 +1,38 @@
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore, type FeatureImagePath } from "@/store/app-store";
import React, { useState, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
import {
sanitizeFilename,
fileToBase64,
fileToText,
isTextFile,
isImageFile,
validateTextFile,
getTextFileMimeType,
generateFileId,
ACCEPTED_IMAGE_TYPES,
ACCEPTED_TEXT_EXTENSIONS,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_TEXT_FILE_SIZE,
formatFileSize,
} from '@/lib/image-utils';
// Map to store preview data by image ID (persisted across component re-mounts)
export type ImagePreviewMap = Map<string, string>;
// Re-export for convenience
export type { FeatureImagePath, FeatureTextFilePath };
interface DescriptionImageDropZoneProps {
value: string;
onChange: (value: string) => void;
images: FeatureImagePath[];
onImagesChange: (images: FeatureImagePath[]) => void;
textFiles?: FeatureTextFilePath[];
onTextFilesChange?: (textFiles: FeatureTextFilePath[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
@@ -26,21 +45,14 @@ interface DescriptionImageDropZoneProps {
error?: boolean; // Show error state with red border
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function DescriptionImageDropZone({
value,
onChange,
images,
onImagesChange,
placeholder = "Describe the feature...",
textFiles = [],
onTextFilesChange,
placeholder = 'Describe the feature...',
className,
disabled = false,
maxFiles = 5,
@@ -59,71 +71,61 @@ export function DescriptionImageDropZone({
// Determine which preview map to use - prefer parent-controlled state
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
if (onPreviewMapChange) {
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
onPreviewMapChange(newMap);
} else {
setLocalPreviewImages((prev) => {
const newMap = typeof updater === 'function' ? updater(prev) : updater;
return newMap;
});
}
}, [onPreviewMapChange, previewMap, localPreviewImages]);
const setPreviewImages = useCallback(
(updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
if (onPreviewMapChange) {
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
onPreviewMapChange(newMap);
} else {
setLocalPreviewImages((prev) => {
const newMap = typeof updater === 'function' ? updater(prev) : updater;
return newMap;
});
}
},
[onPreviewMapChange, previewMap, localPreviewImages]
);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentProject = useAppStore((state) => state.currentProject);
// Construct server URL for loading saved images
const getImageServerUrl = useCallback((imagePath: string): string => {
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const projectPath = currentProject?.path || "";
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
}, [currentProject?.path]);
const getImageServerUrl = useCallback(
(imagePath: string): string => {
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const projectPath = currentProject?.path || '';
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
},
[currentProject?.path]
);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
const saveImageToTemp = useCallback(
async (base64Data: string, filename: string, mimeType: string): Promise<string | null> => {
try {
const api = getElectronAPI();
// Check if saveImageToTemp method exists
if (!api.saveImageToTemp) {
// Fallback path when saveImageToTemp is not available
console.log('[DescriptionImageDropZone] Using fallback path for image');
return `.automaker/images/${Date.now()}_${filename}`;
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const saveImageToTemp = useCallback(async (
base64Data: string,
filename: string,
mimeType: string
): Promise<string | null> => {
try {
const api = getElectronAPI();
// Check if saveImageToTemp method exists
if (!api.saveImageToTemp) {
// Fallback path when saveImageToTemp is not available
console.log("[DescriptionImageDropZone] Using fallback path for image");
return `.automaker/images/${Date.now()}_${filename}`;
// Get projectPath from the store if available
const projectPath = currentProject?.path;
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
if (result.success && result.path) {
return result.path;
}
console.error('[DescriptionImageDropZone] Failed to save image:', result.error);
return null;
} catch (error) {
console.error('[DescriptionImageDropZone] Error saving image:', error);
return null;
}
// Get projectPath from the store if available
const projectPath = currentProject?.path;
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
if (result.success && result.path) {
return result.path;
}
console.error("[DescriptionImageDropZone] Failed to save image:", result.error);
return null;
} catch (error) {
console.error("[DescriptionImageDropZone] Error saving image:", error);
return null;
}
}, [currentProject?.path]);
},
[currentProject?.path]
);
const processFiles = useCallback(
async (files: FileList) => {
@@ -131,58 +133,89 @@ export function DescriptionImageDropZone({
setIsProcessing(true);
const newImages: FeatureImagePath[] = [];
const newTextFiles: FeatureTextFilePath[] = [];
const newPreviews = new Map(previewImages);
const errors: string[] = [];
// Calculate total current files
const currentTotalFiles = images.length + textFiles.length;
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + images.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const tempPath = await saveImageToTemp(base64, file.name, file.type);
if (tempPath) {
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const imagePathRef: FeatureImagePath = {
id: imageId,
path: tempPath,
filename: file.name,
mimeType: file.type,
};
newImages.push(imagePathRef);
// Store preview for display
newPreviews.set(imageId, base64);
} else {
errors.push(`${file.name}: Failed to save image.`);
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file, DEFAULT_MAX_TEXT_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
// Check if we've reached max files
const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles;
if (totalFiles >= maxFiles) {
errors.push(`Maximum ${maxFiles} files allowed.`);
break;
}
try {
const content = await fileToText(file);
const sanitizedName = sanitizeFilename(file.name);
const textFilePath: FeatureTextFilePath = {
id: generateFileId(),
path: '', // Text files don't need to be saved to disk
filename: sanitizedName,
mimeType: getTextFileMimeType(file.name),
content,
};
newTextFiles.push(textFilePath);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
}
// Check if it's an image file
else if (isImageFile(file)) {
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
continue;
}
// Check if we've reached max files
const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles;
if (totalFiles >= maxFiles) {
errors.push(`Maximum ${maxFiles} files allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const sanitizedName = sanitizeFilename(file.name);
const tempPath = await saveImageToTemp(base64, sanitizedName, file.type);
if (tempPath) {
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const imagePathRef: FeatureImagePath = {
id: imageId,
path: tempPath,
filename: sanitizedName,
mimeType: file.type,
};
newImages.push(imagePathRef);
// Store preview for display
newPreviews.set(imageId, base64);
} else {
errors.push(`${file.name}: Failed to save image.`);
}
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
console.warn('File upload errors:', errors);
}
if (newImages.length > 0) {
@@ -190,9 +223,24 @@ export function DescriptionImageDropZone({
setPreviewImages(newPreviews);
}
if (newTextFiles.length > 0 && onTextFilesChange) {
onTextFilesChange([...textFiles, ...newTextFiles]);
}
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
[
disabled,
isProcessing,
images,
textFiles,
maxFiles,
maxFileSize,
onImagesChange,
onTextFilesChange,
previewImages,
saveImageToTemp,
]
);
const handleDrop = useCallback(
@@ -236,7 +284,7 @@ export function DescriptionImageDropZone({
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = '';
}
},
[processFiles]
@@ -260,6 +308,15 @@ export function DescriptionImageDropZone({
[images, onImagesChange]
);
const removeTextFile = useCallback(
(fileId: string) => {
if (onTextFilesChange) {
onTextFilesChange(textFiles.filter((file) => file.id !== fileId));
}
},
[textFiles, onTextFilesChange]
);
// Handle paste events to detect and process images from clipboard
// Works across all OS (Windows, Linux, macOS)
const handlePaste = useCallback(
@@ -276,17 +333,15 @@ export function DescriptionImageDropZone({
const item = clipboardItems[i];
// Check if the item is an image
if (item.type.startsWith("image/")) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
// Generate a filename for pasted images since they don't have one
const extension = item.type.split("/")[1] || "png";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const renamedFile = new File(
[file],
`pasted-image-${timestamp}.${extension}`,
{ type: file.type }
);
const extension = item.type.split('/')[1] || 'png';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, {
type: file.type,
});
imageFiles.push(renamedFile);
}
}
@@ -307,17 +362,17 @@ export function DescriptionImageDropZone({
);
return (
<div className={cn("relative", className)}>
<div className={cn('relative', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
accept={[...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_TEXT_EXTENSIONS].join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
data-testid="description-image-input"
data-testid="description-file-input"
/>
{/* Drop zone wrapper */}
@@ -325,13 +380,9 @@ export function DescriptionImageDropZone({
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-md transition-all duration-200",
{
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
isDragOver && !disabled,
}
)}
className={cn('relative rounded-md transition-all duration-200', {
'ring-2 ring-blue-400 ring-offset-2 ring-offset-background': isDragOver && !disabled,
})}
>
{/* Drag overlay */}
{isDragOver && !disabled && (
@@ -341,7 +392,7 @@ export function DescriptionImageDropZone({
>
<div className="flex flex-col items-center gap-2 text-blue-400">
<ImageIcon className="w-8 h-8" />
<span className="text-sm font-medium">Drop images here</span>
<span className="text-sm font-medium">Drop files here</span>
</div>
</div>
)}
@@ -355,17 +406,14 @@ export function DescriptionImageDropZone({
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={error}
className={cn(
"min-h-[120px]",
isProcessing && "opacity-50 pointer-events-none"
)}
className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')}
data-testid="feature-description-input"
/>
</div>
{/* Hint text */}
<p className="text-xs text-muted-foreground mt-1">
Paste, drag and drop images, or{" "}
Paste, drag and drop files, or{' '}
<button
type="button"
onClick={handleBrowseClick}
@@ -373,30 +421,34 @@ export function DescriptionImageDropZone({
disabled={disabled || isProcessing}
>
browse
</button>{" "}
to attach context images
</button>{' '}
to attach context (images, .txt, .md)
</p>
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving images...</span>
<span>Processing files...</span>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="mt-3 space-y-2" data-testid="description-image-previews">
{/* File previews (images and text files) */}
{(images.length > 0 || textFiles.length > 0) && (
<div className="mt-3 space-y-2" data-testid="description-file-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} attached
{images.length + textFiles.length} file
{images.length + textFiles.length > 1 ? 's' : ''} attached
</p>
<button
type="button"
onClick={() => {
onImagesChange([]);
setPreviewImages(new Map());
if (onTextFilesChange) {
onTextFilesChange([]);
}
}}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
@@ -405,6 +457,7 @@ export function DescriptionImageDropZone({
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image previews */}
{images.map((image) => (
<div
key={image.id}
@@ -447,9 +500,39 @@ export function DescriptionImageDropZone({
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
<p className="text-[10px] text-white truncate">{image.filename}</p>
</div>
</div>
))}
{/* Text file previews */}
{textFiles.map((file) => (
<div
key={file.id}
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
data-testid={`description-text-file-preview-${file.id}`}
>
{/* Text file icon */}
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
<FileText className="w-6 h-6 text-muted-foreground" />
</div>
{/* Remove button */}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTextFile(file.id);
}}
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`remove-description-text-file-${file.id}`}
>
<X className="h-3 w-3" />
</button>
)}
{/* Filename and size tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">{file.filename}</p>
<p className="text-[9px] text-white/70">{formatFileSize(file.content.length)}</p>
</div>
</div>
))}

View File

@@ -1,9 +1,8 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent<
@@ -35,27 +34,19 @@ const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardR
} & React.RefAttributes<HTMLParagraphElement>
>;
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
@@ -75,10 +66,10 @@ function DialogOverlay({
<DialogOverlayPrimitive
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"duration-200",
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'duration-200',
className
)}
{...props}
@@ -88,90 +79,81 @@ function DialogOverlay({
export type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"ref"
'ref'
> & {
showCloseButton?: boolean;
compact?: boolean;
};
const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
// Check if className contains a custom max-width
const hasCustomMaxWidth = typeof className === 'string' && className.includes('max-w-');
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogContentPrimitive
ref={ref}
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
"flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]",
"bg-card border border-border rounded-xl shadow-2xl",
// Premium shadow
"shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]",
// Animations - smoother with scale
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
"duration-200",
compact
? "max-w-4xl p-4"
: !hasCustomMaxWidth
? "sm:max-w-2xl p-6"
: "p-6",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogClosePrimitive
data-slot="dialog-close"
className={cn(
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
"hover:opacity-100 hover:bg-muted",
"focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none",
"disabled:pointer-events-none disabled:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
"p-1.5",
compact ? "top-2 right-3" : "top-4 right-4"
)}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogClosePrimitive>
)}
</DialogContentPrimitive>
</DialogPortal>
);
});
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogContentPrimitive
ref={ref}
data-slot="dialog-content"
className={cn(
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
'bg-card border border-border rounded-xl shadow-2xl',
// Premium shadow
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
// Animations - smoother with scale
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]',
'duration-200',
compact ? 'max-w-4xl p-4' : !hasCustomMaxWidth ? 'sm:max-w-2xl p-6' : 'p-6',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogClosePrimitive
data-slot="dialog-close"
className={cn(
'absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer',
'hover:opacity-100 hover:bg-muted',
'focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none',
'disabled:pointer-events-none disabled:cursor-not-allowed',
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4',
'p-1.5',
compact ? 'top-2 right-3' : 'top-4 right-4'
)}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogClosePrimitive>
)}
</DialogContentPrimitive>
</DialogPortal>
);
}
);
DialogContent.displayName = "DialogContent";
DialogContent.displayName = 'DialogContent';
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6",
className
)}
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6', className)}
{...props}
/>
);
@@ -188,7 +170,7 @@ function DialogTitle({
return (
<DialogTitlePrimitive
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
{...props}
>
{children}
@@ -209,7 +191,7 @@ function DialogDescription({
return (
<DialogDescriptionPrimitive
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
className={cn('text-muted-foreground text-sm leading-relaxed', className)}
title={title}
{...props}
>

View File

@@ -1,44 +1,49 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const DropdownMenuTriggerPrimitive =
DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
children?: React.ReactNode;
asChild?: boolean;
} & React.RefAttributes<HTMLButtonElement>
>;
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuSubTriggerPrimitive =
DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioGroupPrimitive =
DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
} & React.HTMLAttributes<HTMLDivElement> &
React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuRadioItemPrimitive =
DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode;
className?: string;
} & React.HTMLAttributes<HTMLDivElement> &
React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
@@ -47,26 +52,29 @@ const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardR
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuCheckboxItemPrimitive =
DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLSpanElement>
>;
const DropdownMenuItemIndicatorPrimitive =
DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
children?: React.ReactNode;
} & React.RefAttributes<HTMLSpanElement>
>;
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenuSeparatorPrimitive =
DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
function DropdownMenuTrigger({
children,
@@ -80,39 +88,35 @@ function DropdownMenuTrigger({
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
{children}
</DropdownMenuTriggerPrimitive>
)
);
}
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
function DropdownMenuRadioGroup({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
return (
<DropdownMenuRadioGroupPrimitive {...props}>
{children}
</DropdownMenuRadioGroupPrimitive>
)
return <DropdownMenuRadioGroupPrimitive {...props}>{children}</DropdownMenuRadioGroupPrimitive>;
}
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
children?: React.ReactNode
className?: string
inset?: boolean;
children?: React.ReactNode;
className?: string;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuSubTriggerPrimitive
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
inset && "pl-8",
'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent',
inset && 'pl-8',
className
)}
{...props}
@@ -120,8 +124,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuSubTriggerPrimitive>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -133,14 +137,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -153,35 +157,35 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
children?: React.ReactNode
inset?: boolean;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
inset && "pl-8",
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
</DropdownMenuItemPrimitive>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -193,7 +197,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuCheckboxItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
className
)}
checked={checked}
@@ -206,20 +210,19 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuCheckboxItemPrimitive>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
children?: React.ReactNode
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<DropdownMenuRadioItemPrimitive
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
className
)}
{...props}
@@ -231,30 +234,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuRadioItemPrimitive>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
children?: React.ReactNode
className?: string
inset?: boolean;
children?: React.ReactNode;
className?: string;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuLabelPrimitive
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
>
{children}
</DropdownMenuLabelPrimitive>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -264,24 +263,21 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuSeparatorPrimitive
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
className={cn('ml-auto text-xs tracking-widest text-brand-400/70', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@@ -299,4 +295,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View File

@@ -1,7 +1,14 @@
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
import React, { useState, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { ImageIcon, X, Upload } from 'lucide-react';
import {
fileToBase64,
generateImageId,
ACCEPTED_IMAGE_TYPES,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
validateImageFile,
} from '@/lib/image-utils';
export interface FeatureImage {
id: string;
@@ -20,19 +27,10 @@ interface FeatureImageUploadProps {
disabled?: boolean;
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function FeatureImageUpload({
images,
onImagesChange,
maxFiles = 5,
maxFiles = DEFAULT_MAX_FILES,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
disabled = false,
@@ -41,21 +39,6 @@ export function FeatureImageUpload({
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const processFiles = useCallback(
async (files: FileList) => {
if (disabled || isProcessing) return;
@@ -65,20 +48,10 @@ export function FeatureImageUpload({
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
// Validate file
const validation = validateImageFile(file, maxFileSize);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
@@ -91,20 +64,20 @@ export function FeatureImageUpload({
try {
const base64 = await fileToBase64(file);
const imageAttachment: FeatureImage = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
console.warn('Image upload errors:', errors);
}
if (newImages.length > 0) {
@@ -157,7 +130,7 @@ export function FeatureImageUpload({
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
fileInputRef.current.value = '';
}
},
[processFiles]
@@ -180,22 +153,14 @@ export function FeatureImageUpload({
onImagesChange([]);
}, [onImagesChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
return (
<div className={cn("relative", className)}>
<div className={cn('relative', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
@@ -209,13 +174,12 @@ export function FeatureImageUpload({
onDragLeave={handleDragLeave}
onClick={handleBrowseClick}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
'relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer',
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20":
isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
'border-muted-foreground/25': !isDragOver && !disabled,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled,
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10':
!disabled && !isDragOver,
}
)}
@@ -224,10 +188,8 @@ export function FeatureImageUpload({
<div className="flex flex-col items-center justify-center p-4 text-center">
<div
className={cn(
"rounded-full p-2 mb-2",
isDragOver && !disabled
? "bg-blue-100 dark:bg-blue-900/30"
: "bg-muted"
'rounded-full p-2 mb-2',
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
)}
>
{isProcessing ? (
@@ -237,13 +199,10 @@ export function FeatureImageUpload({
)}
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !disabled
? "Drop images here"
: "Click or drag images here"}
{isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Up to {maxFiles} images, max{" "}
{Math.round(maxFileSize / (1024 * 1024))}MB each
Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
</div>
</div>
@@ -253,7 +212,7 @@ export function FeatureImageUpload({
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} selected
{images.length} image{images.length > 1 ? 's' : ''} selected
</p>
<button
type="button"
@@ -295,9 +254,7 @@ export function FeatureImageUpload({
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
<p className="text-[10px] text-white truncate">{image.filename}</p>
</div>
</div>
))}

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
import {
File,
FileText,
@@ -14,9 +13,9 @@ import {
RefreshCw,
GitBranch,
AlertCircle,
} from "lucide-react";
import { Button } from "./button";
import type { FileStatus } from "@/types/electron";
} from 'lucide-react';
import { Button } from './button';
import type { FileStatus } from '@/types/electron';
interface GitDiffPanelProps {
projectPath: string;
@@ -31,7 +30,7 @@ interface GitDiffPanelProps {
interface ParsedDiffHunk {
header: string;
lines: {
type: "context" | "addition" | "deletion" | "header";
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
@@ -47,16 +46,16 @@ interface ParsedFileDiff {
const getFileIcon = (status: string) => {
switch (status) {
case "A":
case "?":
case 'A':
case '?':
return <FilePlus className="w-4 h-4 text-green-500" />;
case "D":
case 'D':
return <FileX className="w-4 h-4 text-red-500" />;
case "M":
case "U":
case 'M':
case 'U':
return <FilePen className="w-4 h-4 text-amber-500" />;
case "R":
case "C":
case 'R':
case 'C':
return <File className="w-4 h-4 text-blue-500" />;
default:
return <FileText className="w-4 h-4 text-muted-foreground" />;
@@ -65,40 +64,40 @@ const getFileIcon = (status: string) => {
const getStatusBadgeColor = (status: string) => {
switch (status) {
case "A":
case "?":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "D":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "M":
case "U":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "R":
case "C":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return "bg-muted text-muted-foreground border-border";
return 'bg-muted text-muted-foreground border-border';
}
};
const getStatusDisplayName = (status: string) => {
switch (status) {
case "A":
return "Added";
case "?":
return "Untracked";
case "D":
return "Deleted";
case "M":
return "Modified";
case "U":
return "Updated";
case "R":
return "Renamed";
case "C":
return "Copied";
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return "Changed";
return 'Changed';
}
};
@@ -109,7 +108,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split("\n");
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
@@ -119,7 +118,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
const line = lines[i];
// New file diff
if (line.startsWith("diff --git")) {
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
@@ -129,7 +128,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : "unknown",
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
@@ -137,34 +136,30 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
}
// New file indicator
if (line.startsWith("new file mode")) {
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith("deleted file mode")) {
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith("rename from") || line.startsWith("rename to")) {
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ")
) {
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
// Hunk header
if (line.startsWith("@@")) {
if (line.startsWith('@@')) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
@@ -174,31 +169,31 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
lines: [{ type: 'header', content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith("+")) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: "addition",
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith("-")) {
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: "deletion",
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(" ") || line === "") {
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: "context",
content: line.substring(1) || "",
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
@@ -223,52 +218,52 @@ function DiffLine({
content,
lineNumber,
}: {
type: "context" | "addition" | "deletion" | "header";
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: "bg-transparent",
addition: "bg-green-500/10",
deletion: "bg-red-500/10",
header: "bg-blue-500/10",
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: "text-foreground-secondary",
addition: "text-green-400",
deletion: "text-red-400",
header: "text-blue-400",
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: " ",
addition: "+",
deletion: "-",
header: "",
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === "header") {
if (type === 'header') {
return (
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn("flex font-mono text-xs", bgClass[type])}>
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ""}
{lineNumber?.old ?? ''}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ""}
{lineNumber?.new ?? ''}
</span>
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
{content || "\u00A0"}
<span className={cn('flex-1 px-2 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
@@ -284,11 +279,11 @@ function FileDiffSection({
onToggle: () => void;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
);
@@ -323,12 +318,8 @@ function FileDiffSection({
renamed
</span>
)}
{additions > 0 && (
<span className="text-xs text-green-400">+{additions}</span>
)}
{deletions > 0 && (
<span className="text-xs text-red-400">-{deletions}</span>
)}
{additions > 0 && <span className="text-xs text-green-400">+{additions}</span>}
{deletions > 0 && <span className="text-xs text-red-400">-{deletions}</span>}
</div>
</button>
{isExpanded && (
@@ -362,7 +353,7 @@ export function GitDiffPanel({
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>("");
const [diffContent, setDiffContent] = useState<string>('');
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
@@ -374,30 +365,30 @@ export function GitDiffPanel({
// Use worktree API if worktrees are enabled, otherwise use git API for main project
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error("Worktree API not available");
throw new Error('Worktree API not available');
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
setDiffContent(result.diff || '');
} else {
setError(result.error || "Failed to load diffs");
setError(result.error || 'Failed to load diffs');
}
} else {
// Use git API for main project diffs
if (!api?.git?.getDiffs) {
throw new Error("Git API not available");
throw new Error('Git API not available');
}
const result = await api.git.getDiffs(projectPath);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
setDiffContent(result.diff || '');
} else {
setError(result.error || "Failed to load diffs");
setError(result.error || 'Failed to load diffs');
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load diffs");
setError(err instanceof Error ? err.message : 'Failed to load diffs');
} finally {
setIsLoading(false);
}
@@ -437,8 +428,7 @@ export function GitDiffPanel({
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'addition').length,
0
),
0
@@ -447,8 +437,7 @@ export function GitDiffPanel({
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
),
0
@@ -457,7 +446,7 @@ export function GitDiffPanel({
return (
<div
className={cn(
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
'rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden',
className
)}
data-testid="git-diff-panel"
@@ -481,14 +470,10 @@ export function GitDiffPanel({
{!isExpanded && files.length > 0 && (
<>
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
{files.length} {files.length === 1 ? 'file' : 'files'}
</span>
{totalAdditions > 0 && (
<span className="text-green-400">+{totalAdditions}</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions}</span>
)}
{totalAdditions > 0 && <span className="text-green-400">+{totalAdditions}</span>}
{totalDeletions > 0 && <span className="text-red-400">-{totalDeletions}</span>}
</>
)}
</div>
@@ -506,12 +491,7 @@ export function GitDiffPanel({
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="mt-2"
>
<Button variant="ghost" size="sm" onClick={loadDiffs} className="mt-2">
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
@@ -528,19 +508,22 @@ export function GitDiffPanel({
<div className="flex items-center gap-4 flex-wrap">
{(() => {
// Group files by status
const statusGroups = files.reduce((acc, file) => {
const status = file.status;
if (!acc[status]) {
acc[status] = {
count: 0,
statusText: getStatusDisplayName(status),
files: []
};
}
acc[status].count += 1;
acc[status].files.push(file.path);
return acc;
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
const statusGroups = files.reduce(
(acc, file) => {
const status = file.status;
if (!acc[status]) {
acc[status] = {
count: 0,
statusText: getStatusDisplayName(status),
files: [],
};
}
acc[status].count += 1;
acc[status].files.push(file.path);
return acc;
},
{} as Record<string, { count: number; statusText: string; files: string[] }>
);
return Object.entries(statusGroups).map(([status, group]) => (
<div
@@ -552,7 +535,7 @@ export function GitDiffPanel({
{getFileIcon(status)}
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(status)
)}
>
@@ -579,12 +562,7 @@ export function GitDiffPanel({
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="text-xs h-7"
>
<Button variant="ghost" size="sm" onClick={loadDiffs} className="text-xs h-7">
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
@@ -594,17 +572,13 @@ export function GitDiffPanel({
{/* Stats */}
<div className="flex items-center gap-4 text-sm mt-2">
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"} changed
{files.length} {files.length === 1 ? 'file' : 'files'} changed
</span>
{totalAdditions > 0 && (
<span className="text-green-400">
+{totalAdditions} additions
</span>
<span className="text-green-400">+{totalAdditions} additions</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">
-{totalDeletions} deletions
</span>
<span className="text-red-400">-{totalDeletions} deletions</span>
)}
</div>
</div>
@@ -634,7 +608,7 @@ export function GitDiffPanel({
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
'text-xs px-1.5 py-0.5 rounded border font-medium',
getStatusBadgeColor(file.status)
)}
>
@@ -642,9 +616,9 @@ export function GitDiffPanel({
</span>
</div>
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
{file.status === "?" ? (
{file.status === '?' ? (
<span>New file - content preview not available</span>
) : file.status === "D" ? (
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useCallback, useRef } from "react";
import { Button, buttonVariants } from "./button";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
import React, { useEffect, useCallback, useRef } from 'react';
import { Button, buttonVariants } from './button';
import { cn } from '@/lib/utils';
import type { VariantProps } from 'class-variance-authority';
export interface HotkeyConfig {
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
@@ -18,8 +17,7 @@ export interface HotkeyConfig {
}
export interface HotkeyButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
extends React.ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
/** Hotkey configuration - can be a simple key string or a full config object */
hotkey?: string | HotkeyConfig;
/** Whether to show the hotkey indicator badge */
@@ -38,14 +36,14 @@ export interface HotkeyButtonProps
* Get the modifier key symbol based on platform
*/
function getModifierSymbol(isMac: boolean): string {
return isMac ? "⌘" : "Ctrl";
return isMac ? '⌘' : 'Ctrl';
}
/**
* Parse hotkey config into a normalized format
*/
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
if (typeof hotkey === "string") {
if (typeof hotkey === 'string') {
return { key: hotkey };
}
return hotkey;
@@ -54,10 +52,7 @@ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
/**
* Generate the display label for the hotkey
*/
function getHotkeyDisplayLabel(
config: HotkeyConfig,
isMac: boolean
): React.ReactNode {
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
if (config.label) {
return config.label;
}
@@ -74,10 +69,7 @@ function getHotkeyDisplayLabel(
if (config.shift) {
parts.push(
<span
key="shift"
className="leading-none flex items-center justify-center"
>
<span key="shift" className="leading-none flex items-center justify-center">
</span>
);
@@ -86,7 +78,7 @@ function getHotkeyDisplayLabel(
if (config.alt) {
parts.push(
<span key="alt" className="leading-none flex items-center justify-center">
{isMac ? "⌥" : "Alt"}
{isMac ? '⌥' : 'Alt'}
</span>
);
}
@@ -94,36 +86,36 @@ function getHotkeyDisplayLabel(
// Convert key to display format
let keyDisplay = config.key;
switch (config.key.toLowerCase()) {
case "enter":
keyDisplay = "↵";
case 'enter':
keyDisplay = '↵';
break;
case "escape":
case "esc":
keyDisplay = "Esc";
case 'escape':
case 'esc':
keyDisplay = 'Esc';
break;
case "arrowup":
keyDisplay = "↑";
case 'arrowup':
keyDisplay = '↑';
break;
case "arrowdown":
keyDisplay = "↓";
case 'arrowdown':
keyDisplay = '↓';
break;
case "arrowleft":
keyDisplay = "←";
case 'arrowleft':
keyDisplay = '←';
break;
case "arrowright":
keyDisplay = "→";
case 'arrowright':
keyDisplay = '→';
break;
case "backspace":
keyDisplay = "⌫";
case 'backspace':
keyDisplay = '⌫';
break;
case "delete":
keyDisplay = "⌦";
case 'delete':
keyDisplay = '⌦';
break;
case "tab":
keyDisplay = "⇥";
case 'tab':
keyDisplay = '⇥';
break;
case " ":
keyDisplay = "Space";
case ' ':
keyDisplay = 'Space';
break;
default:
// Capitalize single letters
@@ -148,16 +140,16 @@ function isInputElement(element: Element | null): boolean {
if (!element) return false;
const tagName = element.tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true;
}
if (element.getAttribute("contenteditable") === "true") {
if (element.getAttribute('contenteditable') === 'true') {
return true;
}
const role = element.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
const role = element.getAttribute('role');
if (role === 'textbox' || role === 'searchbox' || role === 'combobox') {
return true;
}
@@ -194,7 +186,7 @@ export function HotkeyButton({
// Detect platform on mount
useEffect(() => {
setIsMac(navigator.platform.toLowerCase().includes("mac"));
setIsMac(navigator.platform.toLowerCase().includes('mac'));
}, []);
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
@@ -205,11 +197,7 @@ export function HotkeyButton({
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
if (
!scopeRef &&
!config.cmdCtrl &&
isInputElement(document.activeElement)
) {
if (!scopeRef && !config.cmdCtrl && isInputElement(document.activeElement)) {
return;
}
@@ -233,8 +221,7 @@ export function HotkeyButton({
if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current;
const isVisible =
scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
scopeEl.offsetParent !== null || getComputedStyle(scopeEl).display !== 'none';
if (!isVisible) return;
}
@@ -257,9 +244,9 @@ export function HotkeyButton({
useEffect(() => {
if (!config || !hotkeyActive) return;
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [config, hotkeyActive, handleKeyDown]);
@@ -285,7 +272,7 @@ export function HotkeyButton({
asChild={asChild}
{...props}
>
{typeof children === "string" ? (
{typeof children === 'string' ? (
<>
{children}
{hotkeyIndicator}

View File

@@ -1,8 +1,16 @@
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
import type { ImageAttachment } from "@/store/app-store";
import React, { useState, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { ImageIcon, X, Upload } from 'lucide-react';
import type { ImageAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
formatFileSize,
validateImageFile,
ACCEPTED_IMAGE_TYPES,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
interface ImageDropZoneProps {
onImagesSelected: (images: ImageAttachment[]) => void;
@@ -14,12 +22,9 @@ interface ImageDropZoneProps {
images?: ImageAttachment[]; // Optional controlled images prop
}
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function ImageDropZone({
onImagesSelected,
maxFiles = 5,
maxFiles = DEFAULT_MAX_FILES,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
children,
@@ -35,88 +40,93 @@ export function ImageDropZone({
const selectedImages = images ?? internalImages;
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
const updateImages = useCallback((newImages: ImageAttachment[]) => {
if (images === undefined) {
setInternalImages(newImages);
}
onImagesSelected(newImages);
}, [images, onImagesSelected]);
const updateImages = useCallback(
(newImages: ImageAttachment[]) => {
if (images === undefined) {
setInternalImages(newImages);
}
onImagesSelected(newImages);
},
[images, onImagesSelected]
);
const processFiles = useCallback(async (files: FileList) => {
if (disabled || isProcessing) return;
const processFiles = useCallback(
async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: ImageAttachment[] = [];
const errors: string[] = [];
setIsProcessing(true);
const newImages: ImageAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
continue;
for (const file of Array.from(files)) {
// Validate file
const validation = validateImageFile(file, maxFileSize);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
continue;
if (errors.length > 0) {
console.warn('Image upload errors:', errors);
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
if (newImages.length > 0) {
const allImages = [...selectedImages, ...newImages];
updateImages(allImages);
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
setIsProcessing(false);
},
[disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
}
},
[disabled, processFiles]
);
if (errors.length > 0) {
console.warn('Image upload errors:', errors);
// You could show these errors to the user via a toast or notification
}
if (newImages.length > 0) {
const allImages = [...selectedImages, ...newImages];
updateImages(allImages);
}
setIsProcessing(false);
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
}, [disabled, processFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
},
[disabled]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -124,16 +134,19 @@ export function ImageDropZone({
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [processFiles]);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
},
[processFiles]
);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
@@ -141,17 +154,20 @@ export function ImageDropZone({
}
}, [disabled]);
const removeImage = useCallback((imageId: string) => {
const updated = selectedImages.filter(img => img.id !== imageId);
updateImages(updated);
}, [selectedImages, updateImages]);
const removeImage = useCallback(
(imageId: string) => {
const updated = selectedImages.filter((img) => img.id !== imageId);
updateImages(updated);
},
[selectedImages, updateImages]
);
const clearAllImages = useCallback(() => {
updateImages([]);
}, [updateImages]);
return (
<div className={cn("relative", className)}>
<div className={cn('relative', className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
@@ -168,22 +184,22 @@ export function ImageDropZone({
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
}
)}
className={cn('relative rounded-lg border-2 border-dashed transition-all duration-200', {
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
'border-muted-foreground/25': !isDragOver && !disabled,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled,
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10':
!disabled && !isDragOver,
})}
>
{children || (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className={cn(
"rounded-full p-3 mb-4",
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
)}>
<div
className={cn(
'rounded-full p-3 mb-4',
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
@@ -191,10 +207,13 @@ export function ImageDropZone({
)}
</div>
<p className="text-sm font-medium text-foreground mb-1">
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
{isDragOver && !disabled
? 'Drop your images here'
: 'Drag images here or click to browse'}
</p>
<p className="text-xs text-muted-foreground">
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
{maxFiles > 1 ? `Up to ${maxFiles} images` : '1 image'}, max{' '}
{Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
{!disabled && (
<button
@@ -231,7 +250,7 @@ export function ImageDropZone({
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
<div className="w-8 h-8 rounded overflow-hidden bg-muted shrink-0">
<img
src={image.data}
alt={image.filename}
@@ -240,13 +259,9 @@ export function ImageDropZone({
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{image.filename}
</p>
<p className="text-xs font-medium text-foreground truncate">{image.filename}</p>
{image.size !== undefined && (
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size)}
</p>
<p className="text-xs text-muted-foreground">{formatFileSize(image.size)}</p>
)}
</div>
{/* Remove button */}
@@ -266,26 +281,3 @@ export function ImageDropZone({
</div>
);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
interface InputProps extends React.ComponentProps<"input"> {
interface InputProps extends React.ComponentProps<'input'> {
startAddon?: React.ReactNode;
endAddon?: React.ReactNode;
}
@@ -15,17 +15,17 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
// Inner shadow for depth
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
// Animated focus ring
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
'transition-[color,box-shadow,border-color] duration-200 ease-out',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
// Adjust padding for addons
startAddon && "pl-0",
endAddon && "pr-0",
hasAddons && "border-0 shadow-none focus-visible:ring-0",
startAddon && 'pl-0',
endAddon && 'pr-0',
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
className
)}
{...props}
@@ -39,12 +39,12 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
return (
<div
className={cn(
"flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs",
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
"transition-[box-shadow,border-color] duration-200 ease-out",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
"has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
"has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive"
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
'transition-[box-shadow,border-color] duration-200 ease-out',
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
)}
>
{startAddon && (
@@ -62,4 +62,4 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
);
}
export { Input }
export { Input };

View File

@@ -1,153 +1,155 @@
import * as React from "react";
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
import type { KeyboardShortcuts } from "@/store/app-store";
import { cn } from "@/lib/utils";
import * as React from 'react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
useAppStore,
DEFAULT_KEYBOARD_SHORTCUTS,
parseShortcut,
formatShortcut,
} from '@/store/app-store';
import type { KeyboardShortcuts } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
// Detect if running on Mac
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const isMac =
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
// Keyboard layout - US QWERTY
const KEYBOARD_ROWS = [
// Number row
[
{ key: "`", label: "`", width: 1 },
{ key: "1", label: "1", width: 1 },
{ key: "2", label: "2", width: 1 },
{ key: "3", label: "3", width: 1 },
{ key: "4", label: "4", width: 1 },
{ key: "5", label: "5", width: 1 },
{ key: "6", label: "6", width: 1 },
{ key: "7", label: "7", width: 1 },
{ key: "8", label: "8", width: 1 },
{ key: "9", label: "9", width: 1 },
{ key: "0", label: "0", width: 1 },
{ key: "-", label: "-", width: 1 },
{ key: "=", label: "=", width: 1 },
{ key: '`', label: '`', width: 1 },
{ key: '1', label: '1', width: 1 },
{ key: '2', label: '2', width: 1 },
{ key: '3', label: '3', width: 1 },
{ key: '4', label: '4', width: 1 },
{ key: '5', label: '5', width: 1 },
{ key: '6', label: '6', width: 1 },
{ key: '7', label: '7', width: 1 },
{ key: '8', label: '8', width: 1 },
{ key: '9', label: '9', width: 1 },
{ key: '0', label: '0', width: 1 },
{ key: '-', label: '-', width: 1 },
{ key: '=', label: '=', width: 1 },
],
// Top letter row
[
{ key: "Q", label: "Q", width: 1 },
{ key: "W", label: "W", width: 1 },
{ key: "E", label: "E", width: 1 },
{ key: "R", label: "R", width: 1 },
{ key: "T", label: "T", width: 1 },
{ key: "Y", label: "Y", width: 1 },
{ key: "U", label: "U", width: 1 },
{ key: "I", label: "I", width: 1 },
{ key: "O", label: "O", width: 1 },
{ key: "P", label: "P", width: 1 },
{ key: "[", label: "[", width: 1 },
{ key: "]", label: "]", width: 1 },
{ key: "\\", label: "\\", width: 1 },
{ key: 'Q', label: 'Q', width: 1 },
{ key: 'W', label: 'W', width: 1 },
{ key: 'E', label: 'E', width: 1 },
{ key: 'R', label: 'R', width: 1 },
{ key: 'T', label: 'T', width: 1 },
{ key: 'Y', label: 'Y', width: 1 },
{ key: 'U', label: 'U', width: 1 },
{ key: 'I', label: 'I', width: 1 },
{ key: 'O', label: 'O', width: 1 },
{ key: 'P', label: 'P', width: 1 },
{ key: '[', label: '[', width: 1 },
{ key: ']', label: ']', width: 1 },
{ key: '\\', label: '\\', width: 1 },
],
// Home row
[
{ key: "A", label: "A", width: 1 },
{ key: "S", label: "S", width: 1 },
{ key: "D", label: "D", width: 1 },
{ key: "F", label: "F", width: 1 },
{ key: "G", label: "G", width: 1 },
{ key: "H", label: "H", width: 1 },
{ key: "J", label: "J", width: 1 },
{ key: "K", label: "K", width: 1 },
{ key: "L", label: "L", width: 1 },
{ key: ";", label: ";", width: 1 },
{ key: 'A', label: 'A', width: 1 },
{ key: 'S', label: 'S', width: 1 },
{ key: 'D', label: 'D', width: 1 },
{ key: 'F', label: 'F', width: 1 },
{ key: 'G', label: 'G', width: 1 },
{ key: 'H', label: 'H', width: 1 },
{ key: 'J', label: 'J', width: 1 },
{ key: 'K', label: 'K', width: 1 },
{ key: 'L', label: 'L', width: 1 },
{ key: ';', label: ';', width: 1 },
{ key: "'", label: "'", width: 1 },
],
// Bottom letter row
[
{ key: "Z", label: "Z", width: 1 },
{ key: "X", label: "X", width: 1 },
{ key: "C", label: "C", width: 1 },
{ key: "V", label: "V", width: 1 },
{ key: "B", label: "B", width: 1 },
{ key: "N", label: "N", width: 1 },
{ key: "M", label: "M", width: 1 },
{ key: ",", label: ",", width: 1 },
{ key: ".", label: ".", width: 1 },
{ key: "/", label: "/", width: 1 },
{ key: 'Z', label: 'Z', width: 1 },
{ key: 'X', label: 'X', width: 1 },
{ key: 'C', label: 'C', width: 1 },
{ key: 'V', label: 'V', width: 1 },
{ key: 'B', label: 'B', width: 1 },
{ key: 'N', label: 'N', width: 1 },
{ key: 'M', label: 'M', width: 1 },
{ key: ',', label: ',', width: 1 },
{ key: '.', label: '.', width: 1 },
{ key: '/', label: '/', width: 1 },
],
];
// Map shortcut names to human-readable labels
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
board: "Kanban Board",
agent: "Agent Runner",
spec: "Spec Editor",
context: "Context",
settings: "Settings",
profiles: "AI Profiles",
terminal: "Terminal",
toggleSidebar: "Toggle Sidebar",
addFeature: "Add Feature",
addContextFile: "Add Context File",
startNext: "Start Next",
newSession: "New Session",
openProject: "Open Project",
projectPicker: "Project Picker",
cyclePrevProject: "Prev Project",
cycleNextProject: "Next Project",
addProfile: "Add Profile",
splitTerminalRight: "Split Right",
splitTerminalDown: "Split Down",
closeTerminal: "Close Terminal",
board: 'Kanban Board',
agent: 'Agent Runner',
spec: 'Spec Editor',
context: 'Context',
settings: 'Settings',
profiles: 'AI Profiles',
terminal: 'Terminal',
toggleSidebar: 'Toggle Sidebar',
addFeature: 'Add Feature',
addContextFile: 'Add Context File',
startNext: 'Start Next',
newSession: 'New Session',
openProject: 'Open Project',
projectPicker: 'Project Picker',
cyclePrevProject: 'Prev Project',
cycleNextProject: 'Next Project',
addProfile: 'Add Profile',
splitTerminalRight: 'Split Right',
splitTerminalDown: 'Split Down',
closeTerminal: 'Close Terminal',
newTerminalTab: 'New Tab',
};
// Categorize shortcuts for color coding
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
board: "navigation",
agent: "navigation",
spec: "navigation",
context: "navigation",
settings: "navigation",
profiles: "navigation",
terminal: "navigation",
toggleSidebar: "ui",
addFeature: "action",
addContextFile: "action",
startNext: "action",
newSession: "action",
openProject: "action",
projectPicker: "action",
cyclePrevProject: "action",
cycleNextProject: "action",
addProfile: "action",
splitTerminalRight: "action",
splitTerminalDown: "action",
closeTerminal: "action",
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
board: 'navigation',
agent: 'navigation',
spec: 'navigation',
context: 'navigation',
settings: 'navigation',
profiles: 'navigation',
terminal: 'navigation',
toggleSidebar: 'ui',
addFeature: 'action',
addContextFile: 'action',
startNext: 'action',
newSession: 'action',
openProject: 'action',
projectPicker: 'action',
cyclePrevProject: 'action',
cycleNextProject: 'action',
addProfile: 'action',
splitTerminalRight: 'action',
splitTerminalDown: 'action',
closeTerminal: 'action',
newTerminalTab: 'action',
};
// Category colors
const CATEGORY_COLORS = {
navigation: {
bg: "bg-blue-500/20",
border: "border-blue-500/50",
text: "text-blue-400",
label: "Navigation",
bg: 'bg-blue-500/20',
border: 'border-blue-500/50',
text: 'text-blue-400',
label: 'Navigation',
},
ui: {
bg: "bg-purple-500/20",
border: "border-purple-500/50",
text: "text-purple-400",
label: "UI Controls",
bg: 'bg-purple-500/20',
border: 'border-purple-500/50',
text: 'text-purple-400',
label: 'UI Controls',
},
action: {
bg: "bg-green-500/20",
border: "border-green-500/50",
text: "text-green-400",
label: "Actions",
bg: 'bg-green-500/20',
border: 'border-green-500/50',
text: 'text-green-400',
label: 'Actions',
},
};
@@ -161,10 +163,13 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
const { keyboardShortcuts } = useAppStore();
// Merge with defaults to ensure new shortcuts are always shown
const mergedShortcuts = React.useMemo(() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}), [keyboardShortcuts]);
const mergedShortcuts = React.useMemo(
() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}),
[keyboardShortcuts]
);
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
const keyToShortcuts = React.useMemo(() => {
@@ -187,12 +192,10 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
const normalizedKey = keyDef.key.toUpperCase();
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
const shortcuts = shortcutInfos.map(s => s.name);
const shortcuts = shortcutInfos.map((s) => s.name);
const isBound = shortcuts.length > 0;
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
const isModified = shortcuts.some(
(s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
);
const isModified = shortcuts.some((s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]);
// Get category for coloring (use first shortcut's category if multiple)
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
@@ -203,25 +206,25 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
key={keyDef.key}
onClick={() => onKeySelect?.(keyDef.key)}
className={cn(
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
"h-12 min-w-11 py-1",
'relative flex flex-col items-center justify-center rounded-lg border transition-all',
'h-12 min-w-11 py-1',
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
// Base styles
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
!isBound && 'bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20',
// Bound key styles
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
// Selected state
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
isSelected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
// Modified indicator
isModified && "ring-1 ring-yellow-500/50"
isModified && 'ring-1 ring-yellow-500/50'
)}
data-testid={`keyboard-key-${keyDef.key}`}
>
{/* Key label - always at top */}
<span
className={cn(
"text-sm font-mono font-bold leading-none",
isBound && colors ? colors.text : "text-muted-foreground"
'text-sm font-mono font-bold leading-none',
isBound && colors ? colors.text : 'text-muted-foreground'
)}
>
{keyDef.label}
@@ -229,17 +232,20 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
{/* Shortcut label - always takes up space to maintain consistent height */}
<span
className={cn(
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
'text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5',
isBound && shortcuts.length > 0
? (colors ? colors.text : "text-muted-foreground")
: "opacity-0"
? colors
? colors.text
: 'text-muted-foreground'
: 'opacity-0'
)}
>
{isBound && shortcuts.length > 0
? (shortcuts.length === 1
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
: `${shortcuts.length}x`)
: "\u00A0" // Non-breaking space to maintain height
{
isBound && shortcuts.length > 0
? shortcuts.length === 1
? (SHORTCUT_LABELS[shortcuts[0]]?.split(' ')[0] ?? shortcuts[0])
: `${shortcuts.length}x`
: '\u00A0' // Non-breaking space to maintain height
}
</span>
{isModified && (
@@ -262,10 +268,11 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
<div key={shortcut} className="flex items-center gap-2">
<span
className={cn(
"w-2 h-2 rounded-full",
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
: "bg-muted-foreground"
'w-2 h-2 rounded-full',
SHORTCUT_CATEGORIES[shortcut] &&
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace('/20', '')
: 'bg-muted-foreground'
)}
/>
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
@@ -289,18 +296,12 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
return (
<TooltipProvider>
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center text-xs">
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
<div key={key} className="flex items-center gap-2">
<div
className={cn(
"w-4 h-4 rounded border",
colors.bg,
colors.border
)}
/>
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
<span className={colors.text}>{colors.label}</span>
</div>
))}
@@ -326,19 +327,17 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
{/* Stats */}
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
configured
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
shortcuts configured
</span>
<span>
<strong className="text-foreground">
{Object.keys(keyToShortcuts).length}
</strong>{" "}
keys in use
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys
in use
</span>
<span>
<strong className="text-foreground">
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
</strong>{" "}
</strong>{' '}
keys available
</span>
</div>
@@ -354,19 +353,27 @@ interface ShortcutReferencePanelProps {
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
const [keyValue, setKeyValue] = React.useState("");
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(
null
);
const [keyValue, setKeyValue] = React.useState('');
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
// Merge with defaults to ensure new shortcuts are always shown
const mergedShortcuts = React.useMemo(() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}), [keyboardShortcuts]);
const mergedShortcuts = React.useMemo(
() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}),
[keyboardShortcuts]
);
const groupedShortcuts = React.useMemo(() => {
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
const groups: Record<
string,
Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>
> = {
navigation: [],
ui: [],
action: [],
@@ -388,20 +395,25 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
// Build the full shortcut string from key + modifiers
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
const parts: string[] = [];
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
if (mods.shift) parts.push("Shift");
if (mods.cmdCtrl) parts.push(isMac ? 'Cmd' : 'Ctrl');
if (mods.alt) parts.push(isMac ? 'Opt' : 'Alt');
if (mods.shift) parts.push('Shift');
parts.push(key.toUpperCase());
return parts.join("+");
return parts.join('+');
}, []);
// Check for conflicts with other shortcuts
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
const conflict = Object.entries(mergedShortcuts).find(
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
);
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
}, [mergedShortcuts]);
const checkConflict = React.useCallback(
(shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
const conflict = Object.entries(mergedShortcuts).find(
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
);
return conflict
? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0])
: null;
},
[mergedShortcuts]
);
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
const currentValue = mergedShortcuts[key];
@@ -421,14 +433,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
const shortcutStr = buildShortcutString(keyValue, modifiers);
setKeyboardShortcut(editingShortcut, shortcutStr);
setEditingShortcut(null);
setKeyValue("");
setKeyValue('');
setModifiers({ shift: false, cmdCtrl: false, alt: false });
setShortcutError(null);
};
const handleCancelEdit = () => {
setEditingShortcut(null);
setKeyValue("");
setKeyValue('');
setModifiers({ shift: false, cmdCtrl: false, alt: false });
setShortcutError(null);
};
@@ -437,7 +449,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
setKeyValue(value);
// Check for conflicts with full shortcut string
if (!value) {
setShortcutError("Key cannot be empty");
setShortcutError('Key cannot be empty');
} else {
const shortcutStr = buildShortcutString(value, modifiers);
const conflictLabel = checkConflict(shortcutStr, currentKey);
@@ -449,7 +461,11 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
}
};
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
const handleModifierChange = (
modifier: keyof typeof modifiers,
checked: boolean,
currentKey: keyof KeyboardShortcuts
) => {
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
const newModifiers = checked
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
@@ -470,9 +486,9 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !shortcutError && keyValue) {
if (e.key === 'Enter' && !shortcutError && keyValue) {
handleSaveShortcut();
} else if (e.key === "Escape") {
} else if (e.key === 'Escape') {
handleCancelEdit();
}
};
@@ -484,176 +500,194 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
return (
<TooltipProvider>
<div className="space-y-4" data-testid="shortcut-reference-panel">
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn("text-sm font-semibold", colors.text)}>
{colors.label}
</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
isEditing ? "border-brand-500" : "border-sidebar-border",
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌘" : "Ctrl"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
{isMac ? "⌥" : "Alt"}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
"w-12 h-7 text-center font-mono text-xs uppercase",
shortcutError && "border-red-500 focus-visible:ring-red-500"
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
"px-2 py-1 text-xs font-mono rounded border",
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
{editable && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => resetKeyboardShortcuts()}
className="gap-2 text-xs"
data-testid="reset-all-shortcuts-button"
>
<RotateCcw className="w-3 h-3" />
Reset All to Defaults
</Button>
</div>
);
})}
</div>
)}
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
return (
<div key={category} className="space-y-2">
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (
<div
key={key}
className={cn(
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
isEditing ? 'border-brand-500' : 'border-sidebar-border',
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
)}
onClick={() => editable && !isEditing && handleStartEdit(key)}
data-testid={`shortcut-row-${key}`}
>
<span className="text-sm text-foreground">{label}</span>
<div className="flex items-center gap-2">
{isEditing ? (
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{/* Modifier checkboxes */}
<div className="flex items-center gap-1.5 text-xs">
<div className="flex items-center gap-1">
<Checkbox
id={`mod-cmd-${key}`}
checked={modifiers.cmdCtrl}
onCheckedChange={(checked) =>
handleModifierChange('cmdCtrl', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-cmd-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌘' : 'Ctrl'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-alt-${key}`}
checked={modifiers.alt}
onCheckedChange={(checked) =>
handleModifierChange('alt', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-alt-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
{isMac ? '⌥' : 'Alt'}
</Label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id={`mod-shift-${key}`}
checked={modifiers.shift}
onCheckedChange={(checked) =>
handleModifierChange('shift', !!checked, key)
}
className="h-3.5 w-3.5"
/>
<Label
htmlFor={`mod-shift-${key}`}
className="text-xs text-muted-foreground cursor-pointer"
>
</Label>
</div>
</div>
<span className="text-muted-foreground">+</span>
<Input
value={keyValue}
onChange={(e) => handleKeyChange(e.target.value, key)}
onKeyDown={handleKeyDown}
className={cn(
'w-12 h-7 text-center font-mono text-xs uppercase',
shortcutError && 'border-red-500 focus-visible:ring-red-500'
)}
placeholder="Key"
maxLength={1}
autoFocus
data-testid={`edit-shortcut-input-${key}`}
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
onClick={(e) => {
e.stopPropagation();
handleSaveShortcut();
}}
disabled={!!shortcutError || !keyValue}
data-testid={`save-shortcut-${key}`}
>
<CheckCircle2 className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCancelEdit();
}}
data-testid={`cancel-shortcut-${key}`}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<>
<kbd
className={cn(
'px-2 py-1 text-xs font-mono rounded border',
colors.bg,
colors.border,
colors.text
)}
>
{formatShortcut(value, true)}
</kbd>
{isModified && editable && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
onClick={(e) => {
e.stopPropagation();
handleResetShortcut(key);
}}
data-testid={`reset-shortcut-${key}`}
>
<RotateCcw className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
</TooltipContent>
</Tooltip>
)}
{isModified && !editable && (
<span className="w-2 h-2 rounded-full bg-yellow-500" />
)}
{editable && !isModified && (
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
</>
)}
</div>
</div>
);
})}
</div>
{editingShortcut &&
shortcutError &&
SHORTCUT_CATEGORIES[editingShortcut] === category && (
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
)}
</div>
);
})}
</div>
</TooltipProvider>
);
}

View File

@@ -1,23 +1,19 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from '@/lib/utils';
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
)
);
}
export { Label }
export { Label };

View File

@@ -1,5 +1,4 @@
import { useState, useMemo, useEffect, useRef } from "react";
import { useState, useMemo, useEffect, useRef } from 'react';
import {
ChevronDown,
ChevronRight,
@@ -24,8 +23,8 @@ import {
Circle,
Play,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
getLogTypeColors,
@@ -33,7 +32,7 @@ import {
type LogEntry,
type LogEntryType,
type ToolCategory,
} from "@/lib/log-parser";
} from '@/lib/log-parser';
interface LogViewerProps {
output: string;
@@ -42,23 +41,23 @@ interface LogViewerProps {
const getLogIcon = (type: LogEntryType) => {
switch (type) {
case "prompt":
case 'prompt':
return <MessageSquare className="w-4 h-4" />;
case "tool_call":
case 'tool_call':
return <Wrench className="w-4 h-4" />;
case "tool_result":
case 'tool_result':
return <FileOutput className="w-4 h-4" />;
case "phase":
case 'phase':
return <Zap className="w-4 h-4" />;
case "error":
case 'error':
return <AlertCircle className="w-4 h-4" />;
case "success":
case 'success':
return <CheckCircle2 className="w-4 h-4" />;
case "warning":
case 'warning':
return <AlertTriangle className="w-4 h-4" />;
case "thinking":
case 'thinking':
return <Brain className="w-4 h-4" />;
case "debug":
case 'debug':
return <Bug className="w-4 h-4" />;
default:
return <Info className="w-4 h-4" />;
@@ -70,19 +69,19 @@ const getLogIcon = (type: LogEntryType) => {
*/
const getToolCategoryIcon = (category: ToolCategory | undefined) => {
switch (category) {
case "read":
case 'read':
return <Eye className="w-4 h-4" />;
case "edit":
case 'edit':
return <Pencil className="w-4 h-4" />;
case "write":
case 'write':
return <FileOutput className="w-4 h-4" />;
case "bash":
case 'bash':
return <Terminal className="w-4 h-4" />;
case "search":
case 'search':
return <Search className="w-4 h-4" />;
case "todo":
case 'todo':
return <ListTodo className="w-4 h-4" />;
case "task":
case 'task':
return <Layers className="w-4 h-4" />;
default:
return <Wrench className="w-4 h-4" />;
@@ -94,22 +93,22 @@ const getToolCategoryIcon = (category: ToolCategory | undefined) => {
*/
const getToolCategoryColor = (category: ToolCategory | undefined): string => {
switch (category) {
case "read":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
case "edit":
return "text-amber-400 bg-amber-500/10 border-amber-500/30";
case "write":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/30";
case "bash":
return "text-purple-400 bg-purple-500/10 border-purple-500/30";
case "search":
return "text-cyan-400 bg-cyan-500/10 border-cyan-500/30";
case "todo":
return "text-green-400 bg-green-500/10 border-green-500/30";
case "task":
return "text-indigo-400 bg-indigo-500/10 border-indigo-500/30";
case 'read':
return 'text-blue-400 bg-blue-500/10 border-blue-500/30';
case 'edit':
return 'text-amber-400 bg-amber-500/10 border-amber-500/30';
case 'write':
return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30';
case 'bash':
return 'text-purple-400 bg-purple-500/10 border-purple-500/30';
case 'search':
return 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30';
case 'todo':
return 'text-green-400 bg-green-500/10 border-green-500/30';
case 'task':
return 'text-indigo-400 bg-indigo-500/10 border-indigo-500/30';
default:
return "text-zinc-400 bg-zinc-500/10 border-zinc-500/30";
return 'text-zinc-400 bg-zinc-500/10 border-zinc-500/30';
}
};
@@ -118,7 +117,7 @@ const getToolCategoryColor = (category: ToolCategory | undefined): string => {
*/
interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed";
status: 'pending' | 'in_progress' | 'completed';
activeForm?: string;
}
@@ -144,41 +143,41 @@ function parseTodoContent(content: string): TodoItem[] | null {
* Renders a list of todo items with status icons and colors
*/
function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
const getStatusIcon = (status: TodoItem["status"]) => {
const getStatusIcon = (status: TodoItem['status']) => {
switch (status) {
case "completed":
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case "in_progress":
case 'in_progress':
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
case "pending":
case 'pending':
return <Circle className="w-4 h-4 text-zinc-500" />;
default:
return <Circle className="w-4 h-4 text-zinc-500" />;
}
};
const getStatusColor = (status: TodoItem["status"]) => {
const getStatusColor = (status: TodoItem['status']) => {
switch (status) {
case "completed":
return "text-emerald-300 line-through opacity-70";
case "in_progress":
return "text-amber-300";
case "pending":
return "text-zinc-400";
case 'completed':
return 'text-emerald-300 line-through opacity-70';
case 'in_progress':
return 'text-amber-300';
case 'pending':
return 'text-zinc-400';
default:
return "text-zinc-400";
return 'text-zinc-400';
}
};
const getStatusBadge = (status: TodoItem["status"]) => {
const getStatusBadge = (status: TodoItem['status']) => {
switch (status) {
case "completed":
case 'completed':
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400 ml-auto">
Done
</span>
);
case "in_progress":
case 'in_progress':
return (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400 ml-auto">
In Progress
@@ -195,21 +194,17 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
<div
key={index}
className={cn(
"flex items-start gap-2 p-2 rounded-md transition-colors",
todo.status === "in_progress" && "bg-amber-500/5 border border-amber-500/20",
todo.status === "completed" && "bg-emerald-500/5",
todo.status === "pending" && "bg-zinc-800/30"
'flex items-start gap-2 p-2 rounded-md transition-colors',
todo.status === 'in_progress' && 'bg-amber-500/5 border border-amber-500/20',
todo.status === 'completed' && 'bg-emerald-500/5',
todo.status === 'pending' && 'bg-zinc-800/30'
)}
>
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
<div className="flex-1 min-w-0">
<p className={cn("text-sm", getStatusColor(todo.status))}>
{todo.content}
</p>
{todo.status === "in_progress" && todo.activeForm && (
<p className="text-xs text-amber-400/70 mt-0.5 italic">
{todo.activeForm}
</p>
<p className={cn('text-sm', getStatusColor(todo.status))}>{todo.content}</p>
{todo.status === 'in_progress' && todo.activeForm && (
<p className="text-xs text-amber-400/70 mt-0.5 italic">{todo.activeForm}</p>
)}
</div>
{getStatusBadge(todo.status)}
@@ -230,12 +225,12 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const hasContent = entry.content.length > 100;
// For tool_call entries, use tool-specific styling
const isToolCall = entry.type === "tool_call";
const isToolCall = entry.type === 'tool_call';
const toolCategory = entry.metadata?.toolCategory;
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : "";
const toolCategoryColors = isToolCall ? getToolCategoryColor(toolCategory) : '';
// Check if this is a TodoWrite entry and parse the todos
const isTodoWrite = entry.metadata?.toolName === "TodoWrite";
const isTodoWrite = entry.metadata?.toolName === 'TodoWrite';
const parsedTodos = useMemo(() => {
if (!isTodoWrite) return null;
return parseTodoContent(entry.content);
@@ -246,7 +241,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// Get collapsed preview text - prefer smart summary for tool calls
const collapsedPreview = useMemo(() => {
if (isExpanded) return "";
if (isExpanded) return '';
// Use smart summary if available
if (entry.metadata?.summary) {
@@ -254,7 +249,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
}
// Fallback to truncated content
return entry.content.slice(0, 80) + (entry.content.length > 80 ? "..." : "");
return entry.content.slice(0, 80) + (entry.content.length > 80 ? '...' : '');
}, [isExpanded, entry.metadata?.summary, entry.content]);
// Format content - detect and highlight JSON
@@ -265,30 +260,30 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// since we already show the tool name in the header badge
if (isToolCall) {
// Remove "🔧 Tool: ToolName\n" or "Tool: ToolName\n" prefix
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, "");
content = content.replace(/^(?:🔧\s*)?Tool:\s*\w+\s*\n?/i, '');
// Remove standalone "Input:" label (keep the JSON that follows)
content = content.replace(/^Input:\s*\n?/i, "");
content = content.replace(/^Input:\s*\n?/i, '');
content = content.trim();
}
// For summary entries, remove the <summary> and </summary> tags
if (entry.title === "Summary") {
content = content.replace(/^<summary>\s*/i, "");
content = content.replace(/\s*<\/summary>\s*$/i, "");
if (entry.title === 'Summary') {
content = content.replace(/^<summary>\s*/i, '');
content = content.replace(/\s*<\/summary>\s*$/i, '');
content = content.trim();
}
// Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
let lastIndex = 0;
const parts: { type: "text" | "json"; content: string }[] = [];
const parts: { type: 'text' | 'json'; content: string }[] = [];
let match;
while ((match = jsonRegex.exec(content)) !== null) {
// Add text before JSON
if (match.index > lastIndex) {
parts.push({
type: "text",
type: 'text',
content: content.slice(lastIndex, match.index),
});
}
@@ -297,12 +292,12 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
try {
const parsed = JSON.parse(match[1]);
parts.push({
type: "json",
type: 'json',
content: JSON.stringify(parsed, null, 2),
});
} catch {
// Not valid JSON, treat as text
parts.push({ type: "text", content: match[1] });
parts.push({ type: 'text', content: match[1] });
}
lastIndex = match.index + match[1].length;
@@ -310,25 +305,25 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// Add remaining text
if (lastIndex < content.length) {
parts.push({ type: "text", content: content.slice(lastIndex) });
parts.push({ type: 'text', content: content.slice(lastIndex) });
}
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
return parts.length > 0 ? parts : [{ type: 'text' as const, content }];
}, [entry.content, entry.title, isToolCall]);
// Get colors - use tool category colors for tool_call entries
const colorParts = toolCategoryColors.split(" ");
const textColor = isToolCall ? (colorParts[0] || "text-zinc-400") : colors.text;
const bgColor = isToolCall ? (colorParts[1] || "bg-zinc-500/10") : colors.bg;
const borderColor = isToolCall ? (colorParts[2] || "border-zinc-500/30") : colors.border;
const colorParts = toolCategoryColors.split(' ');
const textColor = isToolCall ? colorParts[0] || 'text-zinc-400' : colors.text;
const bgColor = isToolCall ? colorParts[1] || 'bg-zinc-500/10' : colors.bg;
const borderColor = isToolCall ? colorParts[2] || 'border-zinc-500/30' : colors.border;
return (
<div
className={cn(
"rounded-lg border transition-all duration-200",
'rounded-lg border transition-all duration-200',
bgColor,
borderColor,
"hover:brightness-110"
'hover:brightness-110'
)}
data-testid={`log-entry-${entry.type}`}
>
@@ -347,13 +342,18 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<span className="w-4 flex-shrink-0" />
)}
<span className={cn("flex-shrink-0", isToolCall ? toolCategoryColors.split(" ")[0] : colors.icon)}>
<span
className={cn(
'flex-shrink-0',
isToolCall ? toolCategoryColors.split(' ')[0] : colors.icon
)}
>
{icon}
</span>
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
'text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0',
isToolCall ? toolCategoryColors : colors.badge
)}
data-testid="log-entry-badge"
@@ -361,16 +361,11 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
{entry.title}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
{collapsedPreview}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">{collapsedPreview}</span>
</button>
{(isExpanded || !hasContent) && (
<div
className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`}
>
<div className="px-4 pb-3 pt-1" data-testid={`log-entry-content-${entry.id}`}>
{/* Render TodoWrite entries with special formatting */}
{parsedTodos ? (
<TodoListRenderer todos={parsedTodos} />
@@ -378,17 +373,12 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
<div className="font-mono text-xs space-y-1">
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === "json" ? (
{part.type === 'json' ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto scrollbar-styled text-xs text-primary">
{part.content}
</pre>
) : (
<pre
className={cn(
"whitespace-pre-wrap break-words",
textColor
)}
>
<pre className={cn('whitespace-pre-wrap break-words', textColor)}>
{part.content}
</pre>
)}
@@ -415,7 +405,7 @@ interface ToolCategoryStats {
export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
const [hiddenTypes, setHiddenTypes] = useState<Set<LogEntryType>>(new Set());
const [hiddenCategories, setHiddenCategories] = useState<Set<ToolCategory>>(new Set());
// Track if user has "Expand All" mode active - new entries will auto-expand when this is true
@@ -468,7 +458,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
// Calculate stats for tool categories
const stats = useMemo(() => {
const toolCalls = entries.filter((e) => e.type === "tool_call");
const toolCalls = entries.filter((e) => e.type === 'tool_call');
const byCategory: ToolCategoryStats = {
read: 0,
edit: 0,
@@ -481,14 +471,14 @@ export function LogViewer({ output, className }: LogViewerProps) {
};
toolCalls.forEach((tc) => {
const cat = tc.metadata?.toolCategory || "other";
const cat = tc.metadata?.toolCategory || 'other';
byCategory[cat]++;
});
return {
total: toolCalls.length,
byCategory,
errors: entries.filter((e) => e.type === "error").length,
errors: entries.filter((e) => e.type === 'error').length,
};
}, [entries]);
@@ -499,7 +489,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
if (hiddenTypes.has(entry.type)) return false;
// Filter by hidden tool categories (for tool_call entries)
if (entry.type === "tool_call" && entry.metadata?.toolCategory) {
if (entry.type === 'tool_call' && entry.metadata?.toolCategory) {
if (hiddenCategories.has(entry.metadata.toolCategory)) return false;
}
@@ -572,7 +562,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
};
const clearFilters = () => {
setSearchQuery("");
setSearchQuery('');
setHiddenTypes(new Set());
setHiddenCategories(new Set());
};
@@ -596,151 +586,156 @@ export function LogViewer({ output, className }: LogViewerProps) {
}
// Count entries by type
const typeCounts = entries.reduce((acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const typeCounts = entries.reduce(
(acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
// Tool categories to display in stats bar
const toolCategoryLabels: { key: ToolCategory; label: string }[] = [
{ key: "read", label: "Read" },
{ key: "edit", label: "Edit" },
{ key: "write", label: "Write" },
{ key: "bash", label: "Bash" },
{ key: "search", label: "Search" },
{ key: "todo", label: "Todo" },
{ key: "task", label: "Task" },
{ key: "other", label: "Other" },
{ key: 'read', label: 'Read' },
{ key: 'edit', label: 'Edit' },
{ key: 'write', label: 'Write' },
{ key: 'bash', label: 'Bash' },
{ key: 'search', label: 'Search' },
{ key: 'todo', label: 'Todo' },
{ key: 'task', label: 'Task' },
{ key: 'other', label: 'Other' },
];
return (
<div className={cn("flex flex-col", className)}>
<div className={cn('flex flex-col', className)}>
{/* Sticky header with search, stats, and filters */}
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
{/* Search bar */}
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
data-testid="log-search-input"
/>
{searchQuery && (
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
data-testid="log-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
data-testid="log-search-clear"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{hasActiveFilters && (
<button
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
data-testid="log-search-clear"
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
Clear Filters
</button>
)}
</div>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
Clear Filters
</button>
)}
</div>
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
{toolCategoryLabels.map(({ key, label }) => {
const count = stats.byCategory[key];
if (count === 0) return null;
const isHidden = hiddenCategories.has(key);
const colorClasses = getToolCategoryColor(key);
return (
<button
key={key}
onClick={() => toggleCategoryFilter(key)}
className={cn(
"text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1",
colorClasses,
isHidden && "opacity-40 line-through"
)}
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
data-testid={`log-category-filter-${key}`}
>
{getToolCategoryIcon(key)}
<span>{count}</span>
</button>
);
})}
{stats.errors > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{stats.errors}
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
)}
</div>
)}
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
return (
<button
key={type}
onClick={() => toggleTypeFilter(type as LogEntryType)}
className={cn(
"text-xs px-2 py-0.5 rounded-full transition-all",
colors.badge,
isHidden && "opacity-40 line-through"
)}
title={isHidden ? `Show ${type}` : `Hide ${type}`}
data-testid={`log-type-filter-${type}`}
>
{type}: {count}
</button>
);
})}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
{filteredEntries.length}/{entries.length}
</span>
<button
onClick={expandAll}
className={cn(
"text-xs px-2 py-1 rounded transition-colors",
expandAllMode
? "text-primary bg-primary/20 hover:bg-primary/30"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
{toolCategoryLabels.map(({ key, label }) => {
const count = stats.byCategory[key];
if (count === 0) return null;
const isHidden = hiddenCategories.has(key);
const colorClasses = getToolCategoryColor(key);
return (
<button
key={key}
onClick={() => toggleCategoryFilter(key)}
className={cn(
'text-xs px-2 py-0.5 rounded-full border transition-all flex items-center gap-1',
colorClasses,
isHidden && 'opacity-40 line-through'
)}
title={isHidden ? `Show ${label} tools` : `Hide ${label} tools`}
data-testid={`log-category-filter-${key}`}
>
{getToolCategoryIcon(key)}
<span>{count}</span>
</button>
);
})}
{stats.errors > 0 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/30 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{stats.errors}
</span>
)}
data-testid="log-expand-all"
title={expandAllMode ? "Expand All (Active - new items will auto-expand)" : "Expand All"}
>
Expand All{expandAllMode ? " (On)" : ""}
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-collapse-all"
>
Collapse All
</button>
</div>
)}
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
return (
<button
key={type}
onClick={() => toggleTypeFilter(type as LogEntryType)}
className={cn(
'text-xs px-2 py-0.5 rounded-full transition-all',
colors.badge,
isHidden && 'opacity-40 line-through'
)}
title={isHidden ? `Show ${type}` : `Hide ${type}`}
data-testid={`log-type-filter-${type}`}
>
{type}: {count}
</button>
);
})}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
{filteredEntries.length}/{entries.length}
</span>
<button
onClick={expandAll}
className={cn(
'text-xs px-2 py-1 rounded transition-colors',
expandAllMode
? 'text-primary bg-primary/20 hover:bg-primary/30'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
)}
data-testid="log-expand-all"
title={
expandAllMode ? 'Expand All (Active - new items will auto-expand)' : 'Expand All'
}
>
Expand All{expandAllMode ? ' (On)' : ''}
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-collapse-all"
>
Collapse All
</button>
</div>
</div>
</div>
</div>
{/* Log entries */}
<div className="space-y-2 mt-2" data-testid="log-entries-container">
@@ -748,10 +743,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
<div className="text-center py-4 text-zinc-500 text-sm">
No entries match your filters.
{hasActiveFilters && (
<button
onClick={clearFilters}
className="ml-2 text-primary hover:underline"
>
<button onClick={clearFilters} className="ml-2 text-primary hover:underline">
Clear filters
</button>
)}

View File

@@ -1,6 +1,5 @@
import ReactMarkdown from "react-markdown";
import { cn } from "@/lib/utils";
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/utils';
interface MarkdownProps {
children: string;
@@ -15,29 +14,29 @@ export function Markdown({ children, className }: MarkdownProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none",
'prose prose-sm prose-invert max-w-none',
// Headings
"[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
"[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2",
"[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
'[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
'[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2',
'[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2',
'[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1',
// Paragraphs
"[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2",
'[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2',
// Lists
"[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4",
"[&_li]:text-foreground-secondary [&_li]:my-0.5",
'[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4',
'[&_li]:text-foreground-secondary [&_li]:my-0.5',
// Code
"[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
"[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto",
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
'[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm',
'[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0',
// Strong/Bold
"[&_strong]:text-foreground [&_strong]:font-semibold",
'[&_strong]:text-foreground [&_strong]:font-semibold',
// Links
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
'[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline',
// Blockquotes
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2",
'[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2',
// Horizontal rules
"[&_hr]:border-border [&_hr]:my-4",
'[&_hr]:border-border [&_hr]:my-4',
className
)}
>

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const PopoverTriggerPrimitive = PopoverPrimitive.Trigger as React.ForwardRefExoticComponent<
@@ -18,10 +17,8 @@ const PopoverContentPrimitive = PopoverPrimitive.Content as React.ForwardRefExot
} & React.RefAttributes<HTMLDivElement>
>;
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
@@ -36,12 +33,12 @@ function PopoverTrigger({
<PopoverTriggerPrimitive data-slot="popover-trigger" asChild={asChild} {...props}>
{children}
</PopoverTriggerPrimitive>
)
);
}
function PopoverContent({
className,
align = "center",
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
@@ -54,19 +51,17 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,22 +1,16 @@
"use client";
'use client';
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
);
return <RadioGroupPrimitive.Root className={cn('grid gap-2', className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
@@ -28,7 +22,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
@@ -42,5 +36,3 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
@@ -38,10 +38,7 @@ const SelectScrollUpButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
@@ -55,29 +52,25 @@ const SelectScrollDownButton = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
@@ -86,9 +79,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
@@ -105,7 +98,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
@@ -118,7 +111,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
@@ -140,7 +133,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));

View File

@@ -1,29 +1,24 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
@@ -33,13 +28,13 @@ interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
SheetOverlayProps & { "data-slot": string }
SheetOverlayProps & { 'data-slot': string }
>;
return (
<Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
@@ -48,21 +43,16 @@ const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
};
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: "top" | "right" | "bottom" | "left";
side?: 'top' | 'right' | 'bottom' | 'left';
forceMount?: true;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: PointerEvent) => void;
onInteractOutside?: (event: Event) => void;
}
const SheetContent = ({
className,
children,
side = "right",
...props
}: SheetContentProps) => {
const SheetContent = ({ className, children, side = 'right', ...props }: SheetContentProps) => {
const Content = SheetPrimitive.Content as React.ComponentType<
SheetContentProps & { "data-slot": string }
SheetContentProps & { 'data-slot': string }
>;
const Close = SheetPrimitive.Close as React.ComponentType<{
className: string;
@@ -75,15 +65,15 @@ const SheetContent = ({
<Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className
)}
{...props}
@@ -98,21 +88,21 @@ const SheetContent = ({
);
};
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
@@ -122,28 +112,27 @@ interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
const Title = SheetPrimitive.Title as React.ComponentType<
SheetTitleProps & { "data-slot": string }
SheetTitleProps & { 'data-slot': string }
>;
return (
<Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
};
interface SheetDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
interface SheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
const Description = SheetPrimitive.Description as React.ComponentType<
SheetDescriptionProps & { "data-slot": string }
SheetDescriptionProps & { 'data-slot': string }
>;
return (
<Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);

View File

@@ -1,7 +1,6 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const SliderRootPrimitive = SliderPrimitive.Root as React.ForwardRefExoticComponent<
@@ -30,7 +29,7 @@ const SliderThumbPrimitive = SliderPrimitive.Thumb as React.ForwardRefExoticComp
} & React.RefAttributes<HTMLSpanElement>
>;
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'defaultValue' | 'dir'> {
value?: number[];
defaultValue?: number[];
onValueChange?: (value: number[]) => void;
@@ -39,29 +38,24 @@ interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defau
max?: number;
step?: number;
disabled?: boolean;
orientation?: "horizontal" | "vertical";
dir?: "ltr" | "rtl";
orientation?: 'horizontal' | 'vertical';
dir?: 'ltr' | 'rtl';
inverted?: boolean;
minStepsBetweenThumbs?: number;
}
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
({ className, ...props }, ref) => (
<SliderRootPrimitive
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
</SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderRootPrimitive>
)
);
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(({ className, ...props }, ref) => (
<SliderRootPrimitive
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderTrackPrimitive className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderRangePrimitive className="slider-range absolute h-full bg-primary" />
</SliderTrackPrimitive>
<SliderThumbPrimitive className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderRootPrimitive>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -1,9 +1,9 @@
"use client";
'use client';
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
'pointer-events-none block h-5 w-5 rounded-full bg-foreground shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
@@ -27,5 +27,3 @@ const Switch = React.forwardRef<
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const TabsRootPrimitive = TabsPrimitive.Root as React.ForwardRefExoticComponent<
@@ -42,14 +41,10 @@ function Tabs({
className?: string;
}) {
return (
<TabsRootPrimitive
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
>
<TabsRootPrimitive data-slot="tabs" className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</TabsRootPrimitive>
)
);
}
function TabsList({
@@ -64,14 +59,14 @@ function TabsList({
<TabsListPrimitive
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border',
className
)}
{...props}
>
{children}
</TabsListPrimitive>
)
);
}
function TabsTrigger({
@@ -86,11 +81,11 @@ function TabsTrigger({
<TabsTriggerPrimitive
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
"text-foreground/70 hover:text-foreground hover:bg-accent",
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
'inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer',
'text-foreground/70 hover:text-foreground hover:bg-accent',
'data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1',
'disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
@@ -98,7 +93,7 @@ function TabsTrigger({
>
{children}
</TabsTriggerPrimitive>
)
);
}
function TabsContent({
@@ -112,12 +107,12 @@ function TabsContent({
return (
<TabsContentPrimitive
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
className={cn('flex-1 outline-none', className)}
{...props}
>
{children}
</TabsContentPrimitive>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { Badge } from "@/components/ui/badge";
import { useState, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
interface TaskInfo {
id: string;
description: string;
status: "pending" | "in_progress" | "completed";
status: 'pending' | 'in_progress' | 'completed';
filePath?: string;
phase?: string;
}
@@ -53,18 +53,19 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
description: t.description,
filePath: t.filePath,
phase: t.phase,
status: index < completedCount
? "completed" as const
: t.id === currentId
? "in_progress" as const
: "pending" as const,
status:
index < completedCount
? ('completed' as const)
: t.id === currentId
? ('in_progress' as const)
: ('pending' as const),
}));
setTasks(initialTasks);
setCurrentTaskId(currentId || null);
}
} catch (error) {
console.error("Failed to load initial tasks:", error);
console.error('Failed to load initial tasks:', error);
} finally {
setIsLoading(false);
}
@@ -82,52 +83,52 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
// Only handle events for this feature
if (!("featureId" in event) || event.featureId !== featureId) return;
if (!('featureId' in event) || event.featureId !== featureId) return;
switch (event.type) {
case "auto_mode_task_started":
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
case 'auto_mode_task_started':
if ('taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
setCurrentTaskId(taskEvent.taskId);
setTasks((prev) => {
// Check if task already exists
const existingIndex = prev.findIndex((t) => t.id === taskEvent.taskId);
if (existingIndex !== -1) {
// Update status to in_progress and mark previous as completed
return prev.map((t, idx) => {
if (t.id === taskEvent.taskId) {
return { ...t, status: "in_progress" as const };
}
// If we are moving to a task that is further down the list, assume previous ones are completed
// This is a heuristic, but usually correct for sequential execution
if (idx < existingIndex && t.status !== "completed") {
return { ...t, status: "completed" as const };
}
return t;
if (t.id === taskEvent.taskId) {
return { ...t, status: 'in_progress' as const };
}
// If we are moving to a task that is further down the list, assume previous ones are completed
// This is a heuristic, but usually correct for sequential execution
if (idx < existingIndex && t.status !== 'completed') {
return { ...t, status: 'completed' as const };
}
return t;
});
}
// Add new task if it doesn't exist (fallback)
return [
...prev,
{
id: taskEvent.taskId,
description: taskEvent.taskDescription,
status: "in_progress" as const,
status: 'in_progress' as const,
},
];
});
}
break;
case "auto_mode_task_complete":
if ("taskId" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
case 'auto_mode_task_complete':
if ('taskId' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
setTasks((prev) =>
prev.map((t) =>
t.id === taskEvent.taskId ? { ...t, status: "completed" as const } : t
t.id === taskEvent.taskId ? { ...t, status: 'completed' as const } : t
)
);
setCurrentTaskId(null);
@@ -139,7 +140,7 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
return unsubscribe;
}, [featureId]);
const completedCount = tasks.filter((t) => t.status === "completed").length;
const completedCount = tasks.filter((t) => t.status === 'completed').length;
const totalCount = tasks.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
@@ -148,20 +149,27 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
}
return (
<div className={cn("group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200", className)}>
<div
className={cn(
'group rounded-xl border bg-card/50 shadow-sm overflow-hidden transition-all duration-200',
className
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 bg-muted/10 hover:bg-muted/20 transition-colors"
>
<div className="flex items-center gap-3">
<div className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors",
isExpanded ? "bg-background border-border" : "bg-muted border-transparent"
)}>
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm transition-colors',
isExpanded ? 'bg-background border-border' : 'bg-muted border-transparent'
)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-foreground/70" />
<ChevronDown className="h-4 w-4 text-foreground/70" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-col items-start gap-0.5">
@@ -175,77 +183,104 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
<div className="flex items-center gap-3">
{/* Circular Progress (Mini) */}
<div className="relative h-8 w-8 flex items-center justify-center">
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
<circle className="text-muted/20" cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor" />
<circle
className="text-primary transition-all duration-500 ease-in-out"
cx="12" cy="12" r="10" strokeWidth="3" fill="none" stroke="currentColor"
strokeDasharray={63}
strokeDashoffset={63 - (63 * progressPercent) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
<svg className="h-full w-full -rotate-90 text-muted/20" viewBox="0 0 24 24">
<circle
className="text-muted/20"
cx="12"
cy="12"
r="10"
strokeWidth="3"
fill="none"
stroke="currentColor"
/>
<circle
className="text-primary transition-all duration-500 ease-in-out"
cx="12"
cy="12"
r="10"
strokeWidth="3"
fill="none"
stroke="currentColor"
strokeDasharray={63}
strokeDashoffset={63 - (63 * progressPercent) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-bold">{progressPercent}%</span>
</div>
</div>
</button>
<div className={cn(
"grid transition-all duration-300 ease-in-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}>
<div
className={cn(
'grid transition-all duration-300 ease-in-out',
isExpanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
>
<div className="overflow-hidden">
<div className="p-5 pt-2 relative max-h-[300px] overflow-y-auto scrollbar-visible">
{/* Vertical Connector Line */}
<div className="absolute left-[2.35rem] top-4 bottom-8 w-px bg-gradient-to-b from-border/80 via-border/40 to-transparent" />
<div className="space-y-5">
{tasks.map((task, index) => {
const isActive = task.status === "in_progress";
const isCompleted = task.status === "completed";
const isPending = task.status === "pending";
const isActive = task.status === 'in_progress';
const isCompleted = task.status === 'completed';
const isPending = task.status === 'pending';
return (
<div
key={task.id}
<div
key={task.id}
className={cn(
"relative flex gap-4 group/item transition-all duration-300",
isPending && "opacity-60 hover:opacity-100"
'relative flex gap-4 group/item transition-all duration-300',
isPending && 'opacity-60 hover:opacity-100'
)}
>
{/* Icon Status */}
<div className={cn(
"relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300",
isCompleted && "bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400",
isActive && "bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110",
isPending && "bg-muted border-border text-muted-foreground"
)}>
<div
className={cn(
'relative z-10 flex h-7 w-7 items-center justify-center rounded-full border shadow-sm transition-all duration-300',
isCompleted &&
'bg-green-500/10 border-green-500/20 text-green-600 dark:text-green-400',
isActive &&
'bg-primary border-primary text-primary-foreground ring-4 ring-primary/10 scale-110',
isPending && 'bg-muted border-border text-muted-foreground'
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>
{/* Task Content */}
<div className={cn(
"flex-1 pt-1 min-w-0 transition-all",
isActive && "translate-x-1"
)}>
<div
className={cn(
'flex-1 pt-1 min-w-0 transition-all',
isActive && 'translate-x-1'
)}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-4">
<p className={cn(
"text-sm font-medium leading-none truncate pr-4",
isCompleted && "text-muted-foreground line-through decoration-border/60",
isActive && "text-primary font-semibold"
)}>
{task.description}
</p>
{isActive && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse">
Active
</Badge>
)}
<p
className={cn(
'text-sm font-medium leading-none truncate pr-4',
isCompleted &&
'text-muted-foreground line-through decoration-border/60',
isActive && 'text-primary font-semibold'
)}
>
{task.description}
</p>
{isActive && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] bg-primary/5 text-primary border-primary/20 animate-pulse"
>
Active
</Badge>
)}
</div>
{(task.filePath || isActive) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
{task.filePath ? (
@@ -256,7 +291,7 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
</span>
</>
) : (
<span className="h-3 block" /> /* Spacer */
<span className="h-3 block" /> /* Spacer */
)}
</div>
)}
@@ -271,4 +306,4 @@ export function TaskProgressPanel({ featureId, projectPath, className }: TaskPro
</div>
</div>
);
}
}

View File

@@ -1,24 +1,24 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
"placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
'placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none',
// Inner shadow for depth
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
// Animated focus ring
"transition-[color,box-shadow,border-color] duration-200 ease-out",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'transition-[color,box-shadow,border-color] duration-200 ease-out',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
)
);
}
export { Textarea }
export { Textarea };

View File

@@ -1,8 +1,7 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const TooltipTriggerPrimitive = TooltipPrimitive.Trigger as React.ForwardRefExoticComponent<
@@ -18,9 +17,9 @@ const TooltipContentPrimitive = TooltipPrimitive.Content as React.ForwardRefExot
} & React.RefAttributes<HTMLDivElement>
>;
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
function TooltipTrigger({
children,
@@ -34,7 +33,7 @@ function TooltipTrigger({
<TooltipTriggerPrimitive asChild={asChild} {...props}>
{children}
</TooltipTriggerPrimitive>
)
);
}
const TooltipContent = React.forwardRef<
@@ -48,23 +47,23 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground",
'z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground',
// Premium shadow
"shadow-lg shadow-black/10",
'shadow-lg shadow-black/10',
// Faster, snappier animations
"animate-in fade-in-0 zoom-in-95 duration-150",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100",
'animate-in fade-in-0 zoom-in-95 duration-150',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100',
// Slide from edge
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=left]:slide-in-from-right-1",
"data-[side=right]:slide-in-from-left-1",
"data-[side=top]:slide-in-from-bottom-1",
'data-[side=bottom]:slide-in-from-top-1',
'data-[side=left]:slide-in-from-right-1',
'data-[side=right]:slide-in-from-left-1',
'data-[side=top]:slide-in-from-bottom-1',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,102 +1,96 @@
import CodeMirror from "@uiw/react-codemirror";
import { xml } from "@codemirror/lang-xml";
import { EditorView } from "@codemirror/view";
import { Extension } from "@codemirror/state";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
import { cn } from "@/lib/utils";
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
"data-testid"?: string;
'data-testid'?: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: "var(--primary)" },
{ tag: t.angleBracket, color: "var(--muted-foreground)" },
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
// Attributes
{ tag: t.attributeName, color: "var(--chart-2, oklch(0.6 0.118 184.704))" },
{ tag: t.attributeValue, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Strings and content
{ tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
{ tag: t.content, color: "var(--foreground)" },
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },
// Comments
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Special
{ tag: t.processingInstruction, color: "var(--muted-foreground)" },
{ tag: t.documentMeta, color: "var(--muted-foreground)" },
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "0.875rem",
fontFamily: "ui-monospace, monospace",
backgroundColor: "transparent",
color: "var(--foreground)",
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
".cm-scroller": {
overflow: "auto",
fontFamily: "ui-monospace, monospace",
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
".cm-content": {
padding: "1rem",
minHeight: "100%",
caretColor: "var(--primary)",
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--primary)",
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: "oklch(0.55 0.25 265 / 0.3)",
},
".cm-activeLine": {
backgroundColor: "transparent",
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
".cm-line": {
padding: "0",
'.cm-activeLine': {
backgroundColor: 'transparent',
},
"&.cm-focused": {
outline: "none",
'.cm-line': {
padding: '0',
},
".cm-gutters": {
display: "none",
'&.cm-focused': {
outline: 'none',
},
".cm-placeholder": {
color: "var(--muted-foreground)",
fontStyle: "italic",
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [
xml(),
syntaxHighlighting(syntaxColors),
editorTheme,
];
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
export function XmlSyntaxEditor({
value,
onChange,
placeholder,
className,
"data-testid": testId,
'data-testid': testId,
}: XmlSyntaxEditorProps) {
return (
<div className={cn("w-full h-full", className)} data-testid={testId}>
<div className={cn('w-full h-full', className)} data-testid={testId}>
<CodeMirror
value={value}
onChange={onChange}

View File

@@ -1,16 +1,9 @@
import { useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
FileText,
FolderOpen,
@@ -22,9 +15,9 @@ import {
File,
Pencil,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
interface ToolResult {
success: boolean;
@@ -45,20 +38,18 @@ export function AgentToolsView() {
const api = getElectronAPI();
// Read File Tool State
const [readFilePath, setReadFilePath] = useState("");
const [readFilePath, setReadFilePath] = useState('');
const [readFileResult, setReadFileResult] = useState<ToolResult | null>(null);
const [isReadingFile, setIsReadingFile] = useState(false);
// Write File Tool State
const [writeFilePath, setWriteFilePath] = useState("");
const [writeFileContent, setWriteFileContent] = useState("");
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(
null
);
const [writeFilePath, setWriteFilePath] = useState('');
const [writeFileContent, setWriteFileContent] = useState('');
const [writeFileResult, setWriteFileResult] = useState<ToolResult | null>(null);
const [isWritingFile, setIsWritingFile] = useState(false);
// Terminal Tool State
const [terminalCommand, setTerminalCommand] = useState("ls");
const [terminalCommand, setTerminalCommand] = useState('ls');
const [terminalResult, setTerminalResult] = useState<ToolResult | null>(null);
const [isRunningCommand, setIsRunningCommand] = useState(false);
@@ -85,7 +76,7 @@ export function AgentToolsView() {
} else {
setReadFileResult({
success: false,
error: result.error || "Failed to read file",
error: result.error || 'Failed to read file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File read failed: ${result.error}`);
@@ -93,7 +84,7 @@ export function AgentToolsView() {
} catch (error) {
setReadFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -124,7 +115,7 @@ export function AgentToolsView() {
} else {
setWriteFileResult({
success: false,
error: result.error || "Failed to write file",
error: result.error || 'Failed to write file',
timestamp: new Date(),
});
console.log(`[Agent Tool] File write failed: ${result.error}`);
@@ -132,7 +123,7 @@ export function AgentToolsView() {
} catch (error) {
setWriteFileResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -154,13 +145,12 @@ export function AgentToolsView() {
// Simulated outputs for common commands (preview mode)
// In production, the agent executes commands via Claude SDK
const simulatedOutputs: Record<string, string> = {
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
pwd: currentProject?.path || "/Users/demo/project",
"echo hello": "hello",
whoami: "automaker-agent",
ls: 'app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json',
pwd: currentProject?.path || '/Users/demo/project',
'echo hello': 'hello',
whoami: 'automaker-agent',
date: new Date().toString(),
"cat package.json":
'{\n "name": "demo-project",\n "version": "1.0.0"\n}',
'cat package.json': '{\n "name": "demo-project",\n "version": "1.0.0"\n}',
};
// Simulate command execution delay
@@ -175,13 +165,11 @@ export function AgentToolsView() {
output: output,
timestamp: new Date(),
});
console.log(
`[Agent Tool] Command executed successfully: ${terminalCommand}`
);
console.log(`[Agent Tool] Command executed successfully: ${terminalCommand}`);
} catch (error) {
setTerminalResult({
success: false,
error: error instanceof Error ? error.message : "Unknown error",
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date(),
});
} finally {
@@ -191,26 +179,18 @@ export function AgentToolsView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="agent-tools-no-project"
>
<div className="flex-1 flex items-center justify-center" data-testid="agent-tools-no-project">
<div className="text-center">
<Wrench className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
<p className="text-muted-foreground">
Open or create a project to test agent tools.
</p>
<p className="text-muted-foreground">Open or create a project to test agent tools.</p>
</div>
</div>
);
}
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 content-bg" data-testid="agent-tools-view">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-border bg-glass backdrop-blur-md">
<Wrench className="w-5 h-5 text-primary" />
@@ -232,9 +212,7 @@ export function AgentToolsView() {
<File className="w-5 h-5 text-blue-500" />
<CardTitle className="text-lg">Read File</CardTitle>
</div>
<CardDescription>
Agent requests to read a file from the filesystem
</CardDescription>
<CardDescription>Agent requests to read a file from the filesystem</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -270,10 +248,10 @@ export function AgentToolsView() {
{readFileResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
readFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="read-file-result"
>
@@ -284,13 +262,11 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{readFileResult.success ? "Success" : "Failed"}
{readFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{readFileResult.success
? readFileResult.output
: readFileResult.error}
{readFileResult.success ? readFileResult.output : readFileResult.error}
</pre>
</div>
)}
@@ -304,9 +280,7 @@ export function AgentToolsView() {
<Pencil className="w-5 h-5 text-green-500" />
<CardTitle className="text-lg">Write File</CardTitle>
</div>
<CardDescription>
Agent requests to write content to a file
</CardDescription>
<CardDescription>Agent requests to write content to a file</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -332,11 +306,7 @@ export function AgentToolsView() {
</div>
<Button
onClick={handleWriteFile}
disabled={
isWritingFile ||
!writeFilePath.trim() ||
!writeFileContent.trim()
}
disabled={isWritingFile || !writeFilePath.trim() || !writeFileContent.trim()}
className="w-full"
data-testid="write-file-button"
>
@@ -357,10 +327,10 @@ export function AgentToolsView() {
{writeFileResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
writeFileResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="write-file-result"
>
@@ -371,13 +341,11 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{writeFileResult.success ? "Success" : "Failed"}
{writeFileResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">
{writeFileResult.success
? writeFileResult.output
: writeFileResult.error}
{writeFileResult.success ? writeFileResult.output : writeFileResult.error}
</pre>
</div>
)}
@@ -391,9 +359,7 @@ export function AgentToolsView() {
<Terminal className="w-5 h-5 text-purple-500" />
<CardTitle className="text-lg">Run Terminal</CardTitle>
</div>
<CardDescription>
Agent requests to execute a terminal command
</CardDescription>
<CardDescription>Agent requests to execute a terminal command</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -429,10 +395,10 @@ export function AgentToolsView() {
{terminalResult && (
<div
className={cn(
"p-3 rounded-md border",
'p-3 rounded-md border',
terminalResult.success
? "bg-green-500/10 border-green-500/20"
: "bg-red-500/10 border-red-500/20"
? 'bg-green-500/10 border-green-500/20'
: 'bg-red-500/10 border-red-500/20'
)}
data-testid="terminal-result"
>
@@ -443,15 +409,13 @@ export function AgentToolsView() {
<XCircle className="w-4 h-4 text-red-500" />
)}
<span className="text-sm font-medium">
{terminalResult.success ? "Success" : "Failed"}
{terminalResult.success ? 'Success' : 'Failed'}
</span>
</div>
<pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap font-mono bg-black/50 text-green-400 p-2 rounded">
$ {terminalCommand}
{"\n"}
{terminalResult.success
? terminalResult.output
: terminalResult.error}
{'\n'}
{terminalResult.success ? terminalResult.output : terminalResult.error}
</pre>
</div>
)}
@@ -463,15 +427,12 @@ export function AgentToolsView() {
<Card className="mt-6" data-testid="tool-log">
<CardHeader>
<CardTitle className="text-lg">Tool Execution Log</CardTitle>
<CardDescription>
View agent tool requests and responses
</CardDescription>
<CardDescription>View agent tool requests and responses</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">
Open your browser&apos;s developer console to see detailed agent
tool logs.
Open your browser&apos;s developer console to see detailed agent tool logs.
</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Read File - Agent requests file content from filesystem</li>

View File

@@ -1,14 +1,12 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore, type AgentModel } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useAppStore, type AgentModel } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ImageDropZone } from '@/components/ui/image-drop-zone';
import {
Bot,
Send,
User,
Loader2,
Sparkles,
Wrench,
Trash2,
@@ -18,37 +16,52 @@ import {
X,
ImageIcon,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
import { SessionManager } from "@/components/session-manager";
import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useElectronAgent } from '@/hooks/use-electron-agent';
import { SessionManager } from '@/components/session-manager';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
formatFileSize,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} from '@/hooks/use-keyboard-shortcuts';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
} from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
useAppStore();
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState("");
const [input, setInput] = useState('');
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
@@ -72,7 +85,7 @@ export function AgentView() {
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || "",
sessionId: currentSessionId || '',
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
@@ -108,10 +121,7 @@ export function AgentView() {
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log(
"[AgentView] Restoring last selected session:",
lastSessionId
);
console.log('[AgentView] Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
@@ -122,17 +132,23 @@ export function AgentView() {
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
if (
(!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) ||
isProcessing
)
return;
const messageContent = input;
const messageImages = selectedImages;
const messageTextFiles = selectedTextFiles;
setInput("");
setInput('');
setSelectedImages([]);
setSelectedTextFiles([]);
setShowImageDropZone(false);
await sendMessage(messageContent, messageImages);
}, [input, selectedImages, isProcessing, sendMessage]);
await sendMessage(messageContent, messageImages, messageTextFiles);
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
@@ -142,88 +158,99 @@ export function AgentView() {
setShowImageDropZone(!showImageDropZone);
}, [showImageDropZone]);
// Helper function to convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}, []);
// Process dropped files
// Process dropped files (images and text files)
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const newImages: ImageAttachment[] = [];
const newTextFiles: TextFileAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
// Check if we've reached max files
if (newImages.length + selectedImages.length >= MAX_FILES) {
errors.push(`Maximum ${MAX_FILES} images allowed.`);
break;
try {
const content = await fileToText(file);
const textFileAttachment: TextFileAttachment = {
id: generateFileId(),
content,
mimeType: getTextFileMimeType(file.name),
filename: file.name,
size: file.size,
};
newTextFiles.push(textFileAttachment);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
}
// Check if it's an image file
else if (isImageFile(file)) {
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
console.warn('File upload errors:', errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
},
[isProcessing, selectedImages, fileToBase64]
[isProcessing, selectedImages, selectedTextFiles]
);
// Remove individual image
@@ -231,6 +258,11 @@ export function AgentView() {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Remove individual text file
const removeTextFile = useCallback((fileId: string) => {
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
}, []);
// Drag and drop handlers for the input area
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
@@ -239,7 +271,7 @@ export function AgentView() {
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes("Files")) {
if (e.dataTransfer.types.includes('Files')) {
setIsDragOver(true);
}
},
@@ -285,7 +317,7 @@ export function AgentView() {
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
@@ -309,9 +341,9 @@ export function AgentView() {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && file.type.startsWith("image/")) {
if (file && file.type.startsWith('image/')) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
@@ -329,14 +361,14 @@ export function AgentView() {
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClearChat = async () => {
if (!confirm("Are you sure you want to clear this conversation?")) return;
if (!confirm('Are you sure you want to clear this conversation?')) return;
await clearHistory();
};
@@ -347,14 +379,13 @@ export function AgentView() {
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const container = messagesContainerRef.current;
if (!container) return;
@@ -375,7 +406,7 @@ export function AgentView() {
if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom("smooth");
scrollToBottom('smooth');
}, 100);
}
}, [messages, isUserAtBottom, scrollToBottom]);
@@ -385,7 +416,7 @@ export function AgentView() {
if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom("auto");
scrollToBottom('auto');
setIsUserAtBottom(true);
}, 100);
}
@@ -414,7 +445,7 @@ export function AgentView() {
quickCreateSessionRef.current();
}
},
description: "Create new session",
description: 'Create new session',
});
}
@@ -434,9 +465,7 @@ export function AgentView() {
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<h2 className="text-xl font-semibold mb-3 text-foreground">
No Project Selected
</h2>
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
<p className="text-muted-foreground leading-relaxed">
Open or create a project to start working with the AI agent.
</p>
@@ -450,8 +479,8 @@ export function AgentView() {
messages.length === 0
? [
{
id: "welcome",
role: "assistant" as const,
id: 'welcome',
role: 'assistant' as const,
content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
timestamp: new Date().toISOString(),
@@ -460,10 +489,7 @@ export function AgentView() {
: messages;
return (
<div
className="flex-1 flex overflow-hidden bg-background"
data-testid="agent-view"
>
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
@@ -498,12 +524,10 @@ export function AgentView() {
<Bot className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold text-foreground">
AI Agent
</h1>
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && " - Connecting..."}
{currentSessionId && !isConnected && ' - Connecting...'}
</p>
</div>
</div>
@@ -521,7 +545,10 @@ export function AgentView() {
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace("Claude ", "") || "Sonnet"}
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
@@ -530,17 +557,12 @@ export function AgentView() {
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn(
"cursor-pointer",
selectedModel === model.id && "bg-accent"
)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">
{model.description}
</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
@@ -554,9 +576,7 @@ export function AgentView() {
</div>
)}
{agentError && (
<span className="text-xs text-destructive font-medium">
{agentError}
</span>
<span className="text-xs text-destructive font-medium">{agentError}</span>
)}
{currentSessionId && messages.length > 0 && (
<Button
@@ -583,9 +603,7 @@ export function AgentView() {
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">
No Session Selected
</h2>
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
@@ -595,7 +613,7 @@ export function AgentView() {
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? "View" : "Show"} Sessions
{showSessionManager ? 'View' : 'Show'} Sessions
</Button>
</div>
</div>
@@ -610,20 +628,20 @@ export function AgentView() {
<div
key={message.id}
className={cn(
"flex gap-4 max-w-4xl",
message.role === "user" ? "flex-row-reverse ml-auto" : ""
'flex gap-4 max-w-4xl',
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
)}
>
{/* Avatar */}
<div
className={cn(
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
message.role === "assistant"
? "bg-primary/10 ring-1 ring-primary/20"
: "bg-muted ring-1 ring-border"
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
)}
>
{message.role === "assistant" ? (
{message.role === 'assistant' ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
@@ -633,76 +651,67 @@ export function AgentView() {
{/* Message Bubble */}
<div
className={cn(
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-card border border-border"
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
)}
>
{message.role === "assistant" ? (
{message.role === 'assistant' ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{/* Display attached images for user messages */}
{message.role === "user" &&
message.images &&
message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? "s" : ""} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith("data:")
? image.data
: `data:${image.mimeType || "image/png"};base64,${
image.data
}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={
image.filename ||
`Attached image ${index + 1}`
}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
{message.role === 'user' && message.images && message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? 's' : ''} attached
</span>
</div>
)}
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith('data:')
? image.data
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={image.filename || `Attached image ${index + 1}`}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
<p
className={cn(
"text-[11px] mt-2 font-medium",
message.role === "user"
? "text-primary-foreground/70"
: "text-muted-foreground"
'text-[11px] mt-2 font-medium',
message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
@@ -720,20 +729,18 @@ export function AgentView() {
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "0ms" }}
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "150ms" }}
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: "300ms" }}
style={{ animationDelay: '300ms' }}
/>
</div>
<span className="text-sm text-muted-foreground">
Thinking...
</span>
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>
</div>
@@ -755,16 +762,19 @@ export function AgentView() {
/>
)}
{/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{selectedImages.length > 0 && !showImageDropZone && (
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
</p>
<button
onClick={() => setSelectedImages([])}
onClick={() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
@@ -772,6 +782,7 @@ export function AgentView() {
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
@@ -808,6 +819,35 @@ export function AgentView() {
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeTextFile(file.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</div>
)}
@@ -815,8 +855,8 @@ export function AgentView() {
{/* Text Input and Controls */}
<div
className={cn(
"flex gap-2 transition-all duration-200 rounded-xl p-1",
isDragOver && "bg-primary/5 ring-2 ring-primary/30"
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -827,9 +867,7 @@ export function AgentView() {
<Input
ref={inputRef}
placeholder={
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -838,16 +876,17 @@ export function AgentView() {
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
selectedImages.length > 0 && "border-primary/30",
isDragOver && "border-primary bg-primary/5"
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{selectedImages.length > 0 && !isDragOver && (
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""}
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
</div>
)}
{isDragOver && (
@@ -858,19 +897,19 @@ export function AgentView() {
)}
</div>
{/* Image Attachment Button */}
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
"h-11 w-11 rounded-xl border-border",
showImageDropZone &&
"bg-primary/10 text-primary border-primary/30",
selectedImages.length > 0 && "border-primary/30 text-primary"
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30 text-primary'
)}
title="Attach images"
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
@@ -879,7 +918,9 @@ export function AgentView() {
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) ||
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
isProcessing ||
!isConnected
}
@@ -892,11 +933,9 @@ export function AgentView() {
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{" "}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
Enter
</kbd>{" "}
to send
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send
</p>
</div>
)}
@@ -904,12 +943,3 @@ export function AgentView() {
</div>
);
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}

View File

@@ -1,20 +1,8 @@
import { useCallback, useState } from "react";
import {
useAppStore,
FileTreeNode,
ProjectAnalysis,
Feature,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useCallback, useState } from 'react';
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Folder,
FolderOpen,
@@ -30,29 +18,29 @@ import {
CheckCircle,
AlertCircle,
ListChecks,
} from "lucide-react";
import { cn } from "@/lib/utils";
} from 'lucide-react';
import { cn } from '@/lib/utils';
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
".cache",
"coverage",
"__pycache__",
".pytest_cache",
".venv",
"venv",
".env",
'node_modules',
'.git',
'.next',
'dist',
'build',
'.DS_Store',
'*.log',
'.cache',
'coverage',
'__pycache__',
'.pytest_cache',
'.venv',
'venv',
'.env',
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith("*")) {
if (pattern.startsWith('*')) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
@@ -60,8 +48,8 @@ const shouldIgnore = (name: string) => {
};
const getExtension = (filename: string): string => {
const parts = filename.split(".");
return parts.length > 1 ? parts.pop() || "" : "";
const parts = filename.split('.');
return parts.length > 1 ? parts.pop() || '' : '';
};
export function AnalysisView() {
@@ -74,9 +62,7 @@ export function AnalysisView() {
clearAnalysis,
} = useAppStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
const [specGenerated, setSpecGenerated] = useState(false);
const [specError, setSpecError] = useState<string | null>(null);
@@ -123,7 +109,7 @@ export function AnalysisView() {
return nodes;
} catch (error) {
console.error("Failed to scan directory:", path, error);
console.error('Failed to scan directory:', path, error);
return [];
}
},
@@ -148,7 +134,7 @@ export function AnalysisView() {
if (item.extension) {
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
} else {
byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
byExt['(no extension)'] = (byExt['(no extension)'] || 0) + 1;
}
}
}
@@ -179,17 +165,11 @@ export function AnalysisView() {
setProjectAnalysis(analysis);
} catch (error) {
console.error("Analysis failed:", error);
console.error('Analysis failed:', error);
} finally {
setIsAnalyzing(false);
}
}, [
currentProject,
setIsAnalyzing,
clearAnalysis,
scanDirectory,
setProjectAnalysis,
]);
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
// Generate app_spec.txt from analysis
const generateSpec = useCallback(async () => {
@@ -204,7 +184,7 @@ export function AnalysisView() {
// Read key files to understand the project better
const fileContents: Record<string, string> = {};
const keyFiles = ["package.json", "README.md", "tsconfig.json"];
const keyFiles = ['package.json', 'README.md', 'tsconfig.json'];
// Collect file paths from analysis
const collectFilePaths = (
@@ -217,15 +197,13 @@ export function AnalysisView() {
if (!node.isDirectory) {
paths.push(node.path);
} else if (node.children && currentDepth < maxDepth) {
paths.push(
...collectFilePaths(node.children, maxDepth, currentDepth + 1)
);
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
}
}
return paths;
};
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
collectFilePaths(projectAnalysis.fileTree);
// Try to read key configuration files
for (const keyFile of keyFiles) {
@@ -245,40 +223,34 @@ export function AnalysisView() {
const extensions = projectAnalysis.filesByExtension;
// Check package.json for dependencies
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"])
stack.push("React");
if (pkg.dependencies?.next) stack.push("Next.js");
if (pkg.dependencies?.vue) stack.push("Vue");
if (pkg.dependencies?.angular) stack.push("Angular");
if (pkg.dependencies?.express) stack.push("Express");
if (pkg.dependencies?.electron) stack.push("Electron");
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React');
if (pkg.dependencies?.next) stack.push('Next.js');
if (pkg.dependencies?.vue) stack.push('Vue');
if (pkg.dependencies?.angular) stack.push('Angular');
if (pkg.dependencies?.express) stack.push('Express');
if (pkg.dependencies?.electron) stack.push('Electron');
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
stack.push("TypeScript");
if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
)
stack.push("Tailwind CSS");
stack.push('TypeScript');
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss)
stack.push('Tailwind CSS');
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
stack.push("Playwright");
if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
stack.push("Jest");
stack.push('Playwright');
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest');
} catch {
// Ignore JSON parse errors
}
}
// Detect by file extensions
if (extensions["ts"] || extensions["tsx"]) stack.push("TypeScript");
if (extensions["py"]) stack.push("Python");
if (extensions["go"]) stack.push("Go");
if (extensions["rs"]) stack.push("Rust");
if (extensions["java"]) stack.push("Java");
if (extensions["css"] || extensions["scss"] || extensions["sass"])
stack.push("CSS/SCSS");
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript');
if (extensions['py']) stack.push('Python');
if (extensions['go']) stack.push('Go');
if (extensions['rs']) stack.push('Rust');
if (extensions['java']) stack.push('Java');
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS');
// Remove duplicates
return [...new Set(stack)];
@@ -286,9 +258,9 @@ export function AnalysisView() {
// Get project name from package.json or folder name
const getProjectName = () => {
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.name) return pkg.name;
} catch {
// Ignore JSON parse errors
@@ -300,30 +272,30 @@ export function AnalysisView() {
// Get project description from package.json or README
const getProjectDescription = () => {
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
if (pkg.description) return pkg.description;
} catch {
// Ignore JSON parse errors
}
}
if (fileContents["README.md"]) {
if (fileContents['README.md']) {
// Extract first paragraph from README
const lines = fileContents["README.md"].split("\n");
const lines = fileContents['README.md'].split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (
trimmed &&
!trimmed.startsWith("#") &&
!trimmed.startsWith("!") &&
!trimmed.startsWith('#') &&
!trimmed.startsWith('!') &&
trimmed.length > 20
) {
return trimmed.substring(0, 200);
}
}
}
return "A software project";
return 'A software project';
};
// Group files by directory for structure analysis
@@ -336,7 +308,7 @@ export function AnalysisView() {
for (const dir of topLevelDirs) {
structure.push(` <directory name="${dir}" />`);
}
return structure.join("\n");
return structure.join('\n');
};
const projectName = getProjectName();
@@ -356,20 +328,15 @@ export function AnalysisView() {
<languages>
${Object.entries(projectAnalysis.filesByExtension)
.filter(([ext]: [string, number]) =>
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
ext
)
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext)
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5)
.map(
([ext, count]: [string, number]) =>
` <language ext=".${ext}" count="${count}" />`
)
.join("\n")}
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
.join('\n')}
</languages>
<frameworks>
${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')}
</frameworks>
</technology_stack>
@@ -387,11 +354,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
.slice(0, 10)
.map(
([ext, count]: [string, number]) =>
` <extension type="${
ext.startsWith("(") ? ext : "." + ext
}" count="${count}" />`
` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`
)
.join("\n")}
.join('\n')}
</file_breakdown>
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
@@ -405,13 +370,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (writeResult.success) {
setSpecGenerated(true);
} else {
setSpecError(writeResult.error || "Failed to write spec file");
setSpecError(writeResult.error || 'Failed to write spec file');
}
} catch (error) {
console.error("Failed to generate spec:", error);
setSpecError(
error instanceof Error ? error.message : "Failed to generate spec"
);
console.error('Failed to generate spec:', error);
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
} finally {
setIsGeneratingSpec(false);
}
@@ -430,7 +393,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Read key files to understand the project
const fileContents: Record<string, string> = {};
const keyFiles = ["package.json", "README.md"];
const keyFiles = ['package.json', 'README.md'];
// Try to read key configuration files
for (const keyFile of keyFiles) {
@@ -481,21 +444,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for test directories and files
const hasTests =
topLevelDirs.includes("tests") ||
topLevelDirs.includes("test") ||
topLevelDirs.includes("__tests__") ||
allFilePaths.some(
(p) => p.includes(".spec.") || p.includes(".test.")
);
topLevelDirs.includes('tests') ||
topLevelDirs.includes('test') ||
topLevelDirs.includes('__tests__') ||
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.'));
if (hasTests) {
detectedFeatures.push({
category: "Testing",
description: "Automated test suite",
category: 'Testing',
description: 'Automated test suite',
steps: [
"Step 1: Tests directory exists",
"Step 2: Test files are present",
"Step 3: Run test suite",
'Step 1: Tests directory exists',
'Step 2: Test files are present',
'Step 3: Run test suite',
],
passes: true,
});
@@ -503,50 +464,50 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for components directory (UI components)
const hasComponents =
topLevelDirs.includes("components") ||
allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
topLevelDirs.includes('components') ||
allFilePaths.some((p) => p.toLowerCase().includes('/components/'));
if (hasComponents) {
detectedFeatures.push({
category: "UI/Design",
description: "Component-based UI architecture",
category: 'UI/Design',
description: 'Component-based UI architecture',
steps: [
"Step 1: Components directory exists",
"Step 2: UI components are defined",
"Step 3: Components are reusable",
'Step 1: Components directory exists',
'Step 2: UI components are defined',
'Step 3: Components are reusable',
],
passes: true,
});
}
// Check for src directory (organized source code)
if (topLevelDirs.includes("src")) {
if (topLevelDirs.includes('src')) {
detectedFeatures.push({
category: "Project Structure",
description: "Organized source code structure",
category: 'Project Structure',
description: 'Organized source code structure',
steps: [
"Step 1: Source directory exists",
"Step 2: Code is properly organized",
"Step 3: Follows best practices",
'Step 1: Source directory exists',
'Step 2: Code is properly organized',
'Step 3: Follows best practices',
],
passes: true,
});
}
// Check package.json for dependencies and detect features
if (fileContents["package.json"]) {
if (fileContents['package.json']) {
try {
const pkg = JSON.parse(fileContents["package.json"]);
const pkg = JSON.parse(fileContents['package.json']);
// React/Next.js app detection
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"]) {
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
detectedFeatures.push({
category: "Frontend",
description: "React-based user interface",
category: 'Frontend',
description: 'React-based user interface',
steps: [
"Step 1: React is installed",
"Step 2: Components render correctly",
"Step 3: State management works",
'Step 1: React is installed',
'Step 2: Components render correctly',
'Step 3: State management works',
],
passes: true,
});
@@ -554,12 +515,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (pkg.dependencies?.next) {
detectedFeatures.push({
category: "Framework",
description: "Next.js framework integration",
category: 'Framework',
description: 'Next.js framework integration',
steps: [
"Step 1: Next.js is configured",
"Step 2: Pages/routes are defined",
"Step 3: Server-side rendering works",
'Step 1: Next.js is configured',
'Step 2: Pages/routes are defined',
'Step 3: Server-side rendering works',
],
passes: true,
});
@@ -569,33 +530,30 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (
pkg.devDependencies?.typescript ||
pkg.dependencies?.typescript ||
extensions["ts"] ||
extensions["tsx"]
extensions['ts'] ||
extensions['tsx']
) {
detectedFeatures.push({
category: "Developer Experience",
description: "TypeScript type safety",
category: 'Developer Experience',
description: 'TypeScript type safety',
steps: [
"Step 1: TypeScript is configured",
"Step 2: Type definitions exist",
"Step 3: Code compiles without errors",
'Step 1: TypeScript is configured',
'Step 2: Type definitions exist',
'Step 3: Code compiles without errors',
],
passes: true,
});
}
// Tailwind CSS
if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
) {
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) {
detectedFeatures.push({
category: "UI/Design",
description: "Tailwind CSS styling",
category: 'UI/Design',
description: 'Tailwind CSS styling',
steps: [
"Step 1: Tailwind is configured",
"Step 2: Styles are applied",
"Step 3: Responsive design works",
'Step 1: Tailwind is configured',
'Step 2: Styles are applied',
'Step 3: Responsive design works',
],
passes: true,
});
@@ -604,12 +562,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
// ESLint/Prettier (code quality)
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
detectedFeatures.push({
category: "Developer Experience",
description: "Code quality tools",
category: 'Developer Experience',
description: 'Code quality tools',
steps: [
"Step 1: Linter is configured",
"Step 2: Code passes lint checks",
"Step 3: Formatting is consistent",
'Step 1: Linter is configured',
'Step 2: Code passes lint checks',
'Step 3: Formatting is consistent',
],
passes: true,
});
@@ -618,29 +576,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Electron (desktop app)
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
detectedFeatures.push({
category: "Platform",
description: "Electron desktop application",
category: 'Platform',
description: 'Electron desktop application',
steps: [
"Step 1: Electron is configured",
"Step 2: Main process runs",
"Step 3: Renderer process loads",
'Step 1: Electron is configured',
'Step 2: Main process runs',
'Step 3: Renderer process loads',
],
passes: true,
});
}
// Playwright testing
if (
pkg.devDependencies?.playwright ||
pkg.devDependencies?.["@playwright/test"]
) {
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) {
detectedFeatures.push({
category: "Testing",
description: "Playwright end-to-end testing",
category: 'Testing',
description: 'Playwright end-to-end testing',
steps: [
"Step 1: Playwright is configured",
"Step 2: E2E tests are defined",
"Step 3: Tests pass successfully",
'Step 1: Playwright is configured',
'Step 2: E2E tests are defined',
'Step 3: Tests pass successfully',
],
passes: true,
});
@@ -651,17 +606,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
// Check for documentation
if (
topLevelFiles.includes("readme.md") ||
topLevelDirs.includes("docs")
) {
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
detectedFeatures.push({
category: "Documentation",
description: "Project documentation",
category: 'Documentation',
description: 'Project documentation',
steps: [
"Step 1: README exists",
"Step 2: Documentation is comprehensive",
"Step 3: Setup instructions are clear",
'Step 1: README exists',
'Step 2: Documentation is comprehensive',
'Step 3: Setup instructions are clear',
],
passes: true,
});
@@ -669,18 +621,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for CI/CD configuration
const hasCICD =
topLevelDirs.includes(".github") ||
topLevelFiles.includes(".gitlab-ci.yml") ||
topLevelFiles.includes(".travis.yml");
topLevelDirs.includes('.github') ||
topLevelFiles.includes('.gitlab-ci.yml') ||
topLevelFiles.includes('.travis.yml');
if (hasCICD) {
detectedFeatures.push({
category: "DevOps",
description: "CI/CD pipeline configuration",
category: 'DevOps',
description: 'CI/CD pipeline configuration',
steps: [
"Step 1: CI config exists",
"Step 2: Pipeline runs on push",
"Step 3: Automated checks pass",
'Step 1: CI config exists',
'Step 2: Pipeline runs on push',
'Step 3: Automated checks pass',
],
passes: true,
});
@@ -688,20 +640,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for API routes (Next.js API or Express)
const hasAPIRoutes = allFilePaths.some(
(p) =>
p.includes("/api/") ||
p.includes("/routes/") ||
p.includes("/endpoints/")
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/')
);
if (hasAPIRoutes) {
detectedFeatures.push({
category: "Backend",
description: "API endpoints",
category: 'Backend',
description: 'API endpoints',
steps: [
"Step 1: API routes are defined",
"Step 2: Endpoints respond correctly",
"Step 3: Error handling is implemented",
'Step 1: API routes are defined',
'Step 2: Endpoints respond correctly',
'Step 3: Error handling is implemented',
],
passes: true,
});
@@ -710,37 +659,34 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for state management
const hasStateManagement = allFilePaths.some(
(p) =>
p.includes("/store/") ||
p.includes("/stores/") ||
p.includes("/redux/") ||
p.includes("/context/")
p.includes('/store/') ||
p.includes('/stores/') ||
p.includes('/redux/') ||
p.includes('/context/')
);
if (hasStateManagement) {
detectedFeatures.push({
category: "Architecture",
description: "State management system",
category: 'Architecture',
description: 'State management system',
steps: [
"Step 1: Store is configured",
"Step 2: State updates correctly",
"Step 3: Components access state",
'Step 1: Store is configured',
'Step 2: State updates correctly',
'Step 3: Components access state',
],
passes: true,
});
}
// Check for configuration files
if (
topLevelFiles.includes("tsconfig.json") ||
topLevelFiles.includes("package.json")
) {
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) {
detectedFeatures.push({
category: "Configuration",
description: "Project configuration files",
category: 'Configuration',
description: 'Project configuration files',
steps: [
"Step 1: Config files exist",
"Step 2: Configuration is valid",
"Step 3: Build process works",
'Step 1: Config files exist',
'Step 2: Configuration is valid',
'Step 3: Build process works',
],
passes: true,
});
@@ -752,12 +698,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
// If no features were detected, add a default feature
if (detectedFeatures.length === 0) {
detectedFeatures.push({
category: "Core",
description: "Basic project structure",
category: 'Core',
description: 'Basic project structure',
steps: [
"Step 1: Project directory exists",
"Step 2: Files are present",
"Step 3: Project can be loaded",
'Step 1: Project directory exists',
'Step 2: Files are present',
'Step 3: Project can be loaded',
],
passes: true,
});
@@ -765,7 +711,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Create each feature using the features API
if (!api.features) {
throw new Error("Features API not available");
throw new Error('Features API not available');
}
for (const detectedFeature of detectedFeatures) {
@@ -774,17 +720,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
category: detectedFeature.category,
description: detectedFeature.description,
steps: detectedFeature.steps,
status: "backlog",
status: 'backlog',
});
}
setFeatureListGenerated(true);
} catch (error) {
console.error("Failed to generate feature list:", error);
console.error('Failed to generate feature list:', error);
setFeatureListError(
error instanceof Error
? error.message
: "Failed to generate feature list"
error instanceof Error ? error.message : 'Failed to generate feature list'
);
} finally {
setIsGeneratingFeatureList(false);
@@ -810,7 +754,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
<div
className={cn(
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm"
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm'
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
@@ -840,17 +784,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
)}
<span className="truncate">{node.name}</span>
{node.extension && (
<span className="text-xs text-muted-foreground ml-auto">
.{node.extension}
</span>
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
)}
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child: FileTreeNode) =>
renderNode(child, depth + 1)
)}
</div>
<div>{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}</div>
)}
</div>
);
@@ -868,26 +806,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="analysis-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="analysis-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3">
<Search className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Project Analysis</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
</div>
</div>
<Button
onClick={runAnalysis}
disabled={isAnalyzing}
data-testid="analyze-project-button"
>
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -909,13 +838,10 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Click &quot;Analyze Project&quot; to scan your codebase and get
insights about its structure.
Click &quot;Analyze Project&quot; to scan your codebase and get insights about its
structure.
</p>
<Button
onClick={runAnalysis}
data-testid="analyze-project-button-empty"
>
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
<Search className="w-4 h-4 mr-2" />
Start Analysis
</Button>
@@ -936,27 +862,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
Statistics
</CardTitle>
<CardDescription>
Analyzed{" "}
{new Date(projectAnalysis.analyzedAt).toLocaleString()}
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Total Files
</span>
<span className="text-sm text-muted-foreground">Total Files</span>
<span className="font-medium" data-testid="total-files">
{projectAnalysis.totalFiles}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Total Directories
</span>
<span
className="font-medium"
data-testid="total-directories"
>
<span className="text-sm text-muted-foreground">Total Directories</span>
<span className="font-medium" data-testid="total-directories">
{projectAnalysis.totalDirectories}
</span>
</div>
@@ -973,15 +891,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort(
(a: [string, number], b: [string, number]) =>
b[1] - a[1]
)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 15)
.map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm">
<span className="text-muted-foreground font-mono">
{ext.startsWith("(") ? ext : `.${ext}`}
{ext.startsWith('(') ? ext : `.${ext}`}
</span>
<span>{count}</span>
</div>
@@ -997,14 +912,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<FileText className="w-4 h-4" />
Generate Specification
</CardTitle>
<CardDescription>
Create app_spec.txt from analysis
</CardDescription>
<CardDescription>Create app_spec.txt from analysis</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Generate a project specification file based on the analyzed
codebase structure and detected technologies.
Generate a project specification file based on the analyzed codebase structure
and detected technologies.
</p>
<Button
onClick={generateSpec}
@@ -1052,15 +965,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
<ListChecks className="w-4 h-4" />
Generate Feature List
</CardTitle>
<CardDescription>
Create features from analysis
</CardDescription>
<CardDescription>Create features from analysis</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Automatically detect and generate a feature list based on
the analyzed codebase structure, dependencies, and project
configuration.
Automatically detect and generate a feature list based on the analyzed codebase
structure, dependencies, and project configuration.
</p>
<Button
onClick={generateFeatureList}
@@ -1110,18 +1020,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
File Tree
</CardTitle>
<CardDescription>
{projectAnalysis.totalFiles} files in{" "}
{projectAnalysis.totalDirectories} directories
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '}
directories
</CardDescription>
</CardHeader>
<CardContent
className="p-0 overflow-y-auto h-full"
data-testid="analysis-file-tree"
>
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
<div className="p-2">
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
renderNode(node)
)}
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
</div>
</CardContent>
</Card>

View File

@@ -1,26 +1,26 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import {
PointerSensor,
useSensor,
useSensors,
rectIntersection,
pointerWithin,
} from "@dnd-kit/core";
import { useAppStore, Feature } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import type { AutoModeEvent } from "@/types/electron";
import { pathsEqual } from "@/lib/utils";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import { RefreshCw } from "lucide-react";
import { useAutoMode } from "@/hooks/use-auto-mode";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useWindowState } from "@/hooks/use-window-state";
} from '@dnd-kit/core';
import { useAppStore, Feature } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { pathsEqual } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
// Board-view specific imports
import { BoardHeader } from "./board-view/board-header";
import { BoardSearchBar } from "./board-view/board-search-bar";
import { BoardControls } from "./board-view/board-controls";
import { KanbanBoard } from "./board-view/kanban-board";
import { BoardHeader } from './board-view/board-header';
import { BoardSearchBar } from './board-view/board-search-bar';
import { BoardControls } from './board-view/board-controls';
import { KanbanBoard } from './board-view/kanban-board';
import {
AddFeatureDialog,
AgentOutputModal,
@@ -31,15 +31,15 @@ import {
FeatureSuggestionsDialog,
FollowUpDialog,
PlanApprovalDialog,
} from "./board-view/dialogs";
import { CreateWorktreeDialog } from "./board-view/dialogs/create-worktree-dialog";
import { DeleteWorktreeDialog } from "./board-view/dialogs/delete-worktree-dialog";
import { CommitWorktreeDialog } from "./board-view/dialogs/commit-worktree-dialog";
import { CreatePRDialog } from "./board-view/dialogs/create-pr-dialog";
import { CreateBranchDialog } from "./board-view/dialogs/create-branch-dialog";
import { WorktreePanel } from "./board-view/worktree-panel";
import type { PRInfo, WorktreeInfo } from "./board-view/worktree-panel/types";
import { COLUMNS } from "./board-view/constants";
} from './board-view/dialogs';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS } from './board-view/constants';
import {
useBoardFeatures,
useBoardDragDrop,
@@ -51,12 +51,10 @@ import {
useBoardPersistence,
useFollowUpState,
useSuggestionsState,
} from "./board-view/hooks";
} from './board-view/hooks';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType<
ReturnType<typeof useAppStore.getState>["getWorktrees"]
> = [];
const EMPTY_WORKTREES: ReturnType<ReturnType<typeof useAppStore.getState>['getWorktrees']> = [];
/** Delay before starting a newly created feature to allow state to settle */
const FEATURE_CREATION_SETTLE_DELAY_MS = 500;
@@ -98,26 +96,18 @@ export function BoardView() {
const [isMounted, setIsMounted] = useState(false);
const [showOutputModal, setShowOutputModal] = useState(false);
const [outputFeature, setOutputFeature] = useState<Feature | null>(null);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(
new Set()
);
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [featuresWithContext, setFeaturesWithContext] = useState<Set<string>>(new Set());
const [showArchiveAllVerifiedDialog, setShowArchiveAllVerifiedDialog] = useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] = useState(false);
const [showCompletedModal, setShowCompletedModal] = useState(false);
const [deleteCompletedFeature, setDeleteCompletedFeature] =
useState<Feature | null>(null);
const [deleteCompletedFeature, setDeleteCompletedFeature] = useState<Feature | null>(null);
// State for viewing plan in read-only mode
const [viewPlanFeature, setViewPlanFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] =
useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] =
useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] =
useState(false);
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
@@ -158,7 +148,7 @@ export function BoardView() {
closeSuggestionsDialog,
} = useSuggestionsState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
const [isPlanApprovalLoading, setIsPlanApprovalLoading] = useState(false);
// Derive spec creation state from store - check if current project is the one being created
@@ -175,14 +165,11 @@ export function BoardView() {
return false;
}
const result = await api.autoMode.contextExists(
currentProject.path,
featureId
);
const result = await api.autoMode.contextExists(currentProject.path, featureId);
return result.success && result.exists === true;
} catch (error) {
console.error("[Board] Error checking context:", error);
console.error('[Board] Error checking context:', error);
return false;
}
},
@@ -228,9 +215,7 @@ export function BoardView() {
// Get unique categories from existing features AND persisted categories for autocomplete suggestions
const categorySuggestions = useMemo(() => {
const featureCategories = hookFeatures
.map((f) => f.category)
.filter(Boolean);
const featureCategories = hookFeatures.map((f) => f.category).filter(Boolean);
// Merge feature categories with persisted categories
const allCategories = [...featureCategories, ...persistedCategories];
return [...new Set(allCategories)].sort();
@@ -264,7 +249,7 @@ export function BoardView() {
setBranchSuggestions(localBranches);
}
} catch (error) {
console.error("[BoardView] Error fetching branches:", error);
console.error('[BoardView] Error fetching branches:', error);
setBranchSuggestions([]);
}
};
@@ -276,8 +261,8 @@ export function BoardView() {
const branchCardCounts = useMemo(() => {
return hookFeatures.reduce(
(counts, feature) => {
if (feature.status !== "completed") {
const branch = feature.branchName ?? "main";
if (feature.status !== 'completed') {
const branch = feature.branchName ?? 'main';
counts[branch] = (counts[branch] || 0) + 1;
}
return counts;
@@ -304,8 +289,9 @@ export function BoardView() {
}, []);
// Use persistence hook
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } =
useBoardPersistence({ currentProject });
const { persistFeatureCreate, persistFeatureUpdate, persistFeatureDelete } = useBoardPersistence({
currentProject,
});
// Memoize the removed worktrees handler to prevent infinite loops
const handleRemovedWorktrees = useCallback(
@@ -332,15 +318,13 @@ export function BoardView() {
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === "in_progress";
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject
? getCurrentWorktree(currentProject.path)
: null;
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const worktreesByProject = useAppStore((s) => s.worktreesByProject);
const worktrees = useMemo(
@@ -359,9 +343,7 @@ export function BoardView() {
return worktrees.find((w) => w.isMain);
} else {
// Specific worktree selected - find it by path
return worktrees.find(
(w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)
);
return worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
}
}, [worktrees, currentWorktreePath]);
@@ -371,7 +353,7 @@ export function BoardView() {
// Get the branch for the currently selected worktree (for defaulting new features)
// Use the branch from selectedWorktree, or fall back to main worktree's branch
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || "main";
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Extract all action handlers into a hook
const {
@@ -422,9 +404,7 @@ export function BoardView() {
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find(
(w) => w.branch === newWorktree.branch
);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
@@ -435,17 +415,10 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch,
});
@@ -460,17 +433,17 @@ export function BoardView() {
// Create the feature
const featureData = {
category: "PR Review",
category: 'PR Review',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
priority: 1, // High priority for PR feedback
planningMode: "skip" as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -483,7 +456,7 @@ export function BoardView() {
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.status === 'backlog' &&
f.description.includes(`PR #${prNumber}`)
);
@@ -502,17 +475,17 @@ export function BoardView() {
// Create the feature
const featureData = {
category: "Maintenance",
category: 'Maintenance',
description,
steps: [],
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: "opus" as const,
thinkingLevel: "none" as const,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: worktree.branch,
priority: 1, // High priority for conflict resolution
planningMode: "skip" as const,
planningMode: 'skip' as const,
requirePlanApproval: false,
};
@@ -524,8 +497,8 @@ export function BoardView() {
const newFeature = latestFeatures.find(
(f) =>
f.branchName === worktree.branch &&
f.status === "backlog" &&
f.description.includes("Pull latest from origin/main")
f.status === 'backlog' &&
f.description.includes('Pull latest from origin/main')
);
if (newFeature) {
@@ -573,22 +546,21 @@ export function BoardView() {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath =
"projectPath" in event ? event.projectPath : undefined;
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case "auto_mode_feature_start":
case 'auto_mode_feature_start':
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case "auto_mode_feature_complete":
case "auto_mode_error":
case 'auto_mode_feature_complete':
case 'auto_mode_error':
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
@@ -629,8 +601,7 @@ export function BoardView() {
// Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning =
runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
// No available slots, skip check
@@ -643,7 +614,7 @@ export function BoardView() {
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
if (f.status !== "backlog") return false;
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
@@ -700,9 +671,8 @@ export function BoardView() {
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
const primaryBranch =
(currentProject.path
? getPrimaryWorktreeBranch(currentProject.path)
: null) || "main";
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
@@ -728,7 +698,7 @@ export function BoardView() {
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 1000);
const interval = setInterval(checkAndStartFeatures, 3000);
return () => {
// Mark as inactive to prevent any pending async operations from continuing
@@ -788,9 +758,7 @@ export function BoardView() {
// Find feature for pending plan approval
const pendingApprovalFeature = useMemo(() => {
if (!pendingPlanApproval) return null;
return (
hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null
);
return hookFeatures.find((f) => f.id === pendingPlanApproval.featureId) || null;
}, [pendingPlanApproval, hookFeatures]);
// Handle plan approval
@@ -803,7 +771,7 @@ export function BoardView() {
try {
const api = getElectronAPI();
if (!api?.autoMode?.approvePlan) {
throw new Error("Plan approval API not available");
throw new Error('Plan approval API not available');
}
const result = await api.autoMode.approvePlan(
@@ -819,7 +787,7 @@ export function BoardView() {
const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, {
planSpec: {
status: "approved",
status: 'approved',
content: editedPlan || pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
approvedAt: new Date().toISOString(),
@@ -829,10 +797,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error("[Board] Failed to approve plan:", result.error);
console.error('[Board] Failed to approve plan:', result.error);
}
} catch (error) {
console.error("[Board] Error approving plan:", error);
console.error('[Board] Error approving plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -858,7 +826,7 @@ export function BoardView() {
try {
const api = getElectronAPI();
if (!api?.autoMode?.approvePlan) {
throw new Error("Plan approval API not available");
throw new Error('Plan approval API not available');
}
const result = await api.autoMode.approvePlan(
@@ -874,9 +842,9 @@ export function BoardView() {
// Get current feature to preserve version
const currentFeature = hookFeatures.find((f) => f.id === featureId);
updateFeature(featureId, {
status: "backlog",
status: 'backlog',
planSpec: {
status: "rejected",
status: 'rejected',
content: pendingPlanApproval.planContent,
version: currentFeature?.planSpec?.version || 1,
reviewedByUser: true,
@@ -885,10 +853,10 @@ export function BoardView() {
// Reload features from server to ensure sync
loadFeatures();
} else {
console.error("[Board] Failed to reject plan:", result.error);
console.error('[Board] Failed to reject plan:', result.error);
}
} catch (error) {
console.error("[Board] Error rejecting plan:", error);
console.error('[Board] Error rejecting plan:', error);
} finally {
setIsPlanApprovalLoading(false);
setPendingPlanApproval(null);
@@ -911,8 +879,8 @@ export function BoardView() {
// Determine the planning mode for approval (skip should never have a plan requiring approval)
const mode = feature.planningMode;
const approvalMode: "lite" | "spec" | "full" =
mode === "lite" || mode === "spec" || mode === "full" ? mode : "spec";
const approvalMode: 'lite' | 'spec' | 'full' =
mode === 'lite' || mode === 'spec' || mode === 'full' ? mode : 'spec';
// Re-open the approval dialog with the feature's plan data
setPendingPlanApproval({
@@ -927,10 +895,7 @@ export function BoardView() {
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="board-view-no-project"
>
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
<p className="text-muted-foreground">No project selected</p>
</div>
);
@@ -938,10 +903,7 @@ export function BoardView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="board-view-loading"
>
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
@@ -970,7 +932,7 @@ export function BoardView() {
addFeatureShortcut={{
key: shortcuts.addFeature,
action: () => setShowAddDialog(true),
description: "Add new feature",
description: 'Add new feature',
}}
isMounted={isMounted}
/>
@@ -1125,8 +1087,8 @@ export function BoardView() {
<AgentOutputModal
open={showOutputModal}
onClose={() => setShowOutputModal(false)}
featureDescription={outputFeature?.description || ""}
featureId={outputFeature?.id || ""}
featureDescription={outputFeature?.description || ''}
featureId={outputFeature?.id || ''}
featureStatus={outputFeature?.status}
onNumberKeyPress={handleOutputModalNumberKeyPress}
/>
@@ -1135,7 +1097,7 @@ export function BoardView() {
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
onOpenChange={setShowArchiveAllVerifiedDialog}
verifiedCount={getColumnFeatures("verified").length}
verifiedCount={getColumnFeatures('verified').length}
onConfirm={async () => {
await handleArchiveAllVerified();
setShowArchiveAllVerifiedDialog(false);
@@ -1177,7 +1139,7 @@ export function BoardView() {
}
}}
feature={pendingApprovalFeature}
planContent={pendingPlanApproval?.planContent || ""}
planContent={pendingPlanApproval?.planContent || ''}
onApprove={handlePlanApprove}
onReject={handlePlanReject}
isLoading={isPlanApprovalLoading}
@@ -1212,17 +1174,10 @@ export function BoardView() {
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [
...currentWorktrees,
newWorktreeInfo,
]);
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
// Now set the current worktree with both path and branch
setCurrentWorktree(
currentProject.path,
newWorktree.path,
newWorktree.branch
);
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
// Trigger refresh to get full worktree details (hasChanges, etc.)
setWorktreeRefreshKey((k) => k + 1);
@@ -1237,9 +1192,7 @@ export function BoardView() {
worktree={selectedWorktreeForAction}
affectedFeatureCount={
selectedWorktreeForAction
? hookFeatures.filter(
(f) => f.branchName === selectedWorktreeForAction.branch
).length
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
onDeleted={(deletedWorktree, _deletedBranch) => {
@@ -1291,9 +1244,7 @@ export function BoardView() {
// Persist changes asynchronously and in parallel
Promise.all(
featuresToUpdate.map((feature) =>
persistFeatureUpdate(feature.id, { prUrl })
)
featuresToUpdate.map((feature) => persistFeatureUpdate(feature.id, { prUrl }))
).catch(console.error);
}
setWorktreeRefreshKey((k) => k + 1);

View File

@@ -1,16 +1,15 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BoardControlsProps {
isMounted: boolean;
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
kanbanCardDetailLevel: "minimal" | "standard" | "detailed";
onDetailLevelChange: (level: "minimal" | "standard" | "detailed") => void;
kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed';
onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void;
}
export function BoardControls({
@@ -57,7 +56,7 @@ export function BoardControls({
<Archive className="w-4 h-4" />
{completedCount > 0 && (
<span className="absolute -top-1 -right-1 bg-brand-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{completedCount > 99 ? "99+" : completedCount}
{completedCount > 99 ? '99+' : completedCount}
</span>
)}
</Button>
@@ -75,12 +74,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("minimal")}
onClick={() => onDetailLevelChange('minimal')}
className={cn(
"p-2 rounded-l-lg transition-colors",
kanbanCardDetailLevel === "minimal"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
'p-2 rounded-l-lg transition-colors',
kanbanCardDetailLevel === 'minimal'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-minimal"
>
@@ -94,12 +93,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("standard")}
onClick={() => onDetailLevelChange('standard')}
className={cn(
"p-2 transition-colors",
kanbanCardDetailLevel === "standard"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
'p-2 transition-colors',
kanbanCardDetailLevel === 'standard'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-standard"
>
@@ -113,12 +112,12 @@ export function BoardControls({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onDetailLevelChange("detailed")}
onClick={() => onDetailLevelChange('detailed')}
className={cn(
"p-2 rounded-r-lg transition-colors",
kanbanCardDetailLevel === "detailed"
? "bg-brand-500/20 text-brand-500"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
'p-2 rounded-r-lg transition-colors',
kanbanCardDetailLevel === 'detailed'
? 'bg-brand-500/20 text-brand-500'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="kanban-toggle-detailed"
>

View File

@@ -1,13 +1,11 @@
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, Bot } from "lucide-react";
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { ClaudeUsagePopover } from "@/components/claude-usage-popover";
import { useAppStore } from "@/store/app-store";
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
interface BoardHeaderProps {
projectName: string;
@@ -36,7 +34,8 @@ export function BoardHeader({
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
return (
@@ -78,10 +77,7 @@ export function BoardHeader({
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
<Label
htmlFor="auto-mode-toggle"
className="text-sm font-medium cursor-pointer"
>
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
</Label>
<Switch

View File

@@ -1,7 +1,6 @@
import { useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Search, X, Loader2 } from "lucide-react";
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
interface BoardSearchBarProps {
searchQuery: string;
@@ -25,7 +24,7 @@ export function BoardSearchBar({
const handleKeyDown = (e: KeyboardEvent) => {
// Only focus if not typing in an input/textarea
if (
e.key === "/" &&
e.key === '/' &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLTextAreaElement)
) {
@@ -34,8 +33,8 @@ export function BoardSearchBar({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
@@ -53,7 +52,7 @@ export function BoardSearchBar({
/>
{searchQuery ? (
<button
onClick={() => onSearchChange("")}
onClick={() => onSearchChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-sm hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
data-testid="kanban-search-clear"
aria-label="Clear search"
@@ -70,19 +69,18 @@ export function BoardSearchBar({
)}
</div>
{/* Spec Creation Loading Badge */}
{isCreatingSpec &&
currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
{isCreatingSpec && currentProjectPath === creatingSpecProjectPath && (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-brand-500/10 border border-brand-500/20 shrink-0"
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>
</div>
)}
</div>
);
}

View File

@@ -1,2 +1,2 @@
export { KanbanCard } from "./kanban-card/kanban-card";
export { KanbanColumn } from "./kanban-column";
export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';

View File

@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
import { useEffect, useState } from 'react';
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
import {
AgentTaskInfo,
parseAgentContext,
formatModelName,
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { cn } from "@/lib/utils";
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import {
Cpu,
Brain,
@@ -17,21 +17,21 @@ import {
Circle,
Loader2,
Wrench,
} from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { SummaryDialog } from "./summary-dialog";
} from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
/**
* Formats thinking level for compact display
*/
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
if (!level || level === "none") return "";
if (!level || level === 'none') return '';
const labels: Record<ThinkingLevel, string> = {
none: "",
low: "Low",
medium: "Med",
high: "High",
ultrathink: "Ultra",
none: '',
low: 'Low',
medium: 'Med',
high: 'High',
ultrathink: 'Ultra',
};
return labels[level];
}
@@ -53,7 +53,7 @@ export function AgentInfoPanel({
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const showAgentInfo = kanbanCardDetailLevel === "detailed";
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
useEffect(() => {
const loadContext = async () => {
@@ -63,22 +63,18 @@ export function AgentInfoPanel({
return;
}
if (feature.status === "backlog") {
if (feature.status === 'backlog') {
setAgentInfo(null);
return;
}
try {
const api = getElectronAPI();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) return;
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
feature.id
);
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
@@ -94,68 +90,61 @@ export function AgentInfoPanel({
}
}
} catch {
// eslint-disable-next-line no-undef
console.debug("[KanbanCard] No context file for feature:", feature.id);
console.debug('[KanbanCard] No context file for feature:', feature.id);
}
};
loadContext();
if (isCurrentAutoTask) {
// eslint-disable-next-line no-undef
const interval = setInterval(loadContext, 3000);
return () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
};
}
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
// Model/Preset Info for Backlog Cards
if (showAgentInfo && feature.status === "backlog") {
if (showAgentInfo && feature.status === 'backlog') {
return (
<div className="mb-3 space-y-2 overflow-hidden">
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
<div className="flex items-center gap-1 text-purple-400">
<Brain className="w-3 h-3" />
<span className="font-medium">
{formatThinkingLevel(feature.thinkingLevel)}
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
</span>
</div>
)}
) : null}
</div>
</div>
);
}
// Agent Info Panel for non-backlog cards
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
return (
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-[11px] flex-wrap">
<div className="flex items-center gap-1 text-[var(--status-info)]">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
{agentInfo.currentPhase && (
<div
className={cn(
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
agentInfo.currentPhase === "planning" &&
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
agentInfo.currentPhase === "action" &&
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
agentInfo.currentPhase === "verification" &&
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
agentInfo.currentPhase === 'planning' &&
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
agentInfo.currentPhase === 'action' &&
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
agentInfo.currentPhase === 'verification' &&
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
)}
>
{agentInfo.currentPhase}
@@ -169,31 +158,26 @@ export function AgentInfoPanel({
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
<ListTodo className="w-3 h-3" />
<span>
{agentInfo.todos.filter((t) => t.status === "completed").length}
/{agentInfo.todos.length} tasks
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
{agentInfo.todos.length} tasks
</span>
</div>
<div className="space-y-0.5 max-h-16 overflow-y-auto">
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-[10px]"
>
{todo.status === "completed" ? (
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === "in_progress" ? (
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}
<span
className={cn(
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-muted-foreground/60 line-through",
todo.status === "in_progress" &&
"text-[var(--status-warning)]",
todo.status === "pending" && "text-muted-foreground/80"
'break-words hyphens-auto line-clamp-2 leading-relaxed',
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
todo.status === 'pending' && 'text-muted-foreground/80'
)}
>
{todo.content}
@@ -210,8 +194,7 @@ export function AgentInfoPanel({
)}
{/* Summary for waiting_approval and verified */}
{(feature.status === "waiting_approval" ||
feature.status === "verified") && (
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
@@ -238,27 +221,20 @@ export function AgentInfoPanel({
</p>
</div>
)}
{!feature.summary &&
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
</span>
{agentInfo.todos.length > 0 && (
<span className="flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
{
agentInfo.todos.filter((t) => t.status === "completed")
.length
}{" "}
tasks done
</span>
)}
</div>
)}
)}
</div>
)}
</>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
PlayCircle,
@@ -10,7 +10,7 @@ import {
Eye,
Wand2,
Archive,
} from "lucide-react";
} from 'lucide-react';
interface CardActionsProps {
feature: Feature;
@@ -52,7 +52,7 @@ export function CardActions({
{isCurrentAutoTask && (
<>
{/* Approve Plan button - PRIORITY: shows even when agent is "running" (paused for approval) */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -109,10 +109,10 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{/* Approve Plan button - shows when plan is generated and waiting for approval */}
{feature.planSpec?.status === "generated" && onApprovePlan && (
{feature.planSpec?.status === 'generated' && onApprovePlan && (
<Button
variant="default"
size="sm"
@@ -191,7 +191,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "verified" && (
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{/* Logs button */}
{onViewOutput && (
@@ -229,7 +229,7 @@ export function CardActions({
)}
</>
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{/* Refine prompt button */}
{onFollowUp && (
@@ -282,7 +282,7 @@ export function CardActions({
) : null}
</>
)}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<Button
variant="secondary"

View File

@@ -1,19 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Feature, useAppStore } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { AlertCircle, Lock, Hand, Sparkles } from "lucide-react";
import { getBlockingDependencies } from "@automaker/dependency-resolver";
import { useEffect, useMemo, useState } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
"data-testid"?: string;
'data-testid'?: string;
title?: string;
}
@@ -21,16 +16,11 @@ interface CardBadgeProps {
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({
children,
className,
"data-testid": dataTestId,
title,
}: CardBadgeProps) {
function CardBadge({ children, className, 'data-testid': dataTestId, title }: CardBadgeProps) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
@@ -50,7 +40,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== "backlog") {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
@@ -62,7 +52,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog");
feature.status === 'backlog');
if (!showStatusBadges) {
return null;
@@ -77,8 +67,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium",
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]"
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
@@ -96,14 +86,14 @@ export function CardBadges({ feature }: CardBadgesProps) {
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === "backlog" && (
feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold",
"bg-orange-500/20 border-orange-500/50 text-orange-500"
'inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold',
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
@@ -112,10 +102,8 @@ export function CardBadges({ feature }: CardBadgesProps) {
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{" "}
{blockingDependencies.length === 1
? "dependency"
: "dependencies"}
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
@@ -123,7 +111,7 @@ export function CardBadges({ feature }: CardBadgesProps) {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(", ")}
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
@@ -141,11 +129,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
const isJustFinished = useMemo(() => {
if (
!feature.justFinishedAt ||
feature.status !== "waiting_approval" ||
feature.error
) {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
@@ -154,7 +138,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval') {
return;
}
@@ -179,7 +163,7 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === "backlog") ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
if (!showPriorityBadges) {
@@ -195,45 +179,39 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
<TooltipTrigger asChild>
<CardBadge
className={cn(
"bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5", // badge style from example
'bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5', // badge style from example
feature.priority === 1 &&
"bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]",
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
"bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]",
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
"bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]"
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
H
</span>
<span className="font-bold text-xs flex items-center gap-0.5">H</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">
M
</span>
<span className="font-bold text-xs flex items-center gap-0.5">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">
L
</span>
<span className="font-bold text-xs flex items-center gap-0.5">L</span>
)}
</CardBadge>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
{feature.priority === 1
? "High Priority"
? 'High Priority'
: feature.priority === 2
? "Medium Priority"
: "Low Priority"}
? 'Medium Priority'
: 'Low Priority'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === "backlog" && (
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -1,5 +1,5 @@
import { Feature } from "@/store/app-store";
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from "lucide-react";
import { Feature } from '@/store/app-store';
import { GitBranch, GitPullRequest, ExternalLink, CheckCircle2, Circle } from 'lucide-react';
interface CardContentSectionsProps {
feature: Feature;
@@ -25,10 +25,10 @@ export function CardContentSections({
)}
{/* PR URL Display */}
{typeof feature.prUrl === "string" &&
{typeof feature.prUrl === 'string' &&
/^https?:\/\//i.test(feature.prUrl) &&
(() => {
const prNumber = feature.prUrl.split("/").pop();
const prNumber = feature.prUrl.split('/').pop();
return (
<div className="mb-2">
<a
@@ -43,7 +43,7 @@ export function CardContentSections({
>
<GitPullRequest className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[150px]">
{prNumber ? `Pull Request #${prNumber}` : "Pull Request"}
{prNumber ? `Pull Request #${prNumber}` : 'Pull Request'}
</span>
<ExternalLink className="w-2.5 h-2.5 shrink-0" />
</a>
@@ -59,14 +59,12 @@ export function CardContentSections({
key={index}
className="flex items-start gap-2 text-[11px] text-muted-foreground/80"
>
{feature.status === "verified" ? (
{feature.status === 'verified' ? (
<CheckCircle2 className="w-3 h-3 mt-0.5 text-[var(--status-success)] shrink-0" />
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0 text-muted-foreground/50" />
)}
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
{step}
</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -79,4 +77,3 @@ export function CardContentSections({
</>
);
}

View File

@@ -1,18 +1,14 @@
import { useState } from "react";
import { Feature } from "@/store/app-store";
import { cn } from "@/lib/utils";
import {
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useState } from 'react';
import { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import {
GripVertical,
Edit,
@@ -23,10 +19,10 @@ import {
ChevronDown,
ChevronUp,
Cpu,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { formatModelName, DEFAULT_MODEL } from "@/lib/agent-context-parser";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
} from 'lucide-react';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
interface CardHeaderProps {
feature: Feature;
@@ -100,9 +96,7 @@ export function CardHeaderSection({
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -111,7 +105,7 @@ export function CardHeaderSection({
)}
{/* Backlog header */}
{!isCurrentAutoTask && feature.status === "backlog" && (
{!isCurrentAutoTask && feature.status === 'backlog' && (
<div className="absolute top-2 right-2">
<Button
variant="ghost"
@@ -128,8 +122,7 @@ export function CardHeaderSection({
{/* Waiting approval / Verified header */}
{!isCurrentAutoTask &&
(feature.status === "waiting_approval" ||
feature.status === "verified") && (
(feature.status === 'waiting_approval' || feature.status === 'verified') && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -142,9 +135,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`edit-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Edit"
>
@@ -161,9 +152,7 @@ export function CardHeaderSection({
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`logs-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Logs"
>
@@ -177,9 +166,7 @@ export function CardHeaderSection({
onClick={handleDeleteClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`delete-${
feature.status === "waiting_approval"
? "waiting"
: "verified"
feature.status === 'waiting_approval' ? 'waiting' : 'verified'
}-${feature.id}`}
title="Delete"
>
@@ -190,7 +177,7 @@ export function CardHeaderSection({
)}
{/* In progress header */}
{!isCurrentAutoTask && feature.status === "in_progress" && (
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
<div className="absolute top-2 right-2 flex items-center gap-1">
<Button
@@ -246,9 +233,7 @@ export function CardHeaderSection({
<div className="px-2 py-1.5 text-[10px] text-muted-foreground border-t mt-1 pt-1.5">
<div className="flex items-center gap-1">
<Cpu className="w-3 h-3" />
<span>
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
<span>{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
</div>
</div>
</DropdownMenuContent>
@@ -271,9 +256,7 @@ export function CardHeaderSection({
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground italic">
Generating title...
</span>
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (
<CardTitle className="text-sm font-semibold text-foreground mb-1 line-clamp-2">
@@ -282,13 +265,13 @@ export function CardHeaderSection({
) : null}
<CardDescription
className={cn(
"text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground",
!isDescriptionExpanded && "line-clamp-3"
'text-xs leading-snug break-words hyphens-auto overflow-hidden text-muted-foreground',
!isDescriptionExpanded && 'line-clamp-3'
)}
>
{feature.description || feature.summary || feature.id}
</CardDescription>
{(feature.description || feature.summary || "").length > 100 && (
{(feature.description || feature.summary || '').length > 100 && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -327,4 +310,3 @@ export function CardHeaderSection({
</CardHeader>
);
}

View File

@@ -0,0 +1,7 @@
export { AgentInfoPanel } from './agent-info-panel';
export { CardActions } from './card-actions';
export { CardBadges, PriorityBadges } from './card-badges';
export { CardContentSections } from './card-content-sections';
export { CardHeaderSection } from './card-header';
export { KanbanCard } from './kanban-card';
export { SummaryDialog } from './summary-dialog';

View File

@@ -1,14 +1,14 @@
import React, { memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Feature, useAppStore } from "@/store/app-store";
import { CardBadges, PriorityBadges } from "./card-badges";
import { CardHeaderSection } from "./card-header";
import { CardContentSections } from "./card-content-sections";
import { AgentInfoPanel } from "./agent-info-panel";
import { CardActions } from "./card-actions";
import React, { memo } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import { Feature, useAppStore } from '@/store/app-store';
import { CardBadges, PriorityBadges } from './card-badges';
import { CardHeaderSection } from './card-header';
import { CardContentSections } from './card-content-sections';
import { AgentInfoPanel } from './agent-info-panel';
import { CardActions } from './card-actions';
interface KanbanCardProps {
feature: Feature;
@@ -63,23 +63,14 @@ export const KanbanCard = memo(function KanbanCard({
}: KanbanCardProps) {
const { kanbanCardDetailLevel, useWorktrees } = useAppStore();
const showSteps =
kanbanCardDetailLevel === "standard" ||
kanbanCardDetailLevel === "detailed";
const showSteps = kanbanCardDetailLevel === 'standard' || kanbanCardDetailLevel === 'detailed';
const isDraggable =
feature.status === "backlog" ||
feature.status === "waiting_approval" ||
feature.status === "verified" ||
(feature.status === "in_progress" && !isCurrentAutoTask);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
feature.status === 'backlog' ||
feature.status === 'waiting_approval' ||
feature.status === 'verified' ||
(feature.status === 'in_progress' && !isCurrentAutoTask);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature.id,
disabled: !isDraggable,
});
@@ -92,10 +83,10 @@ export const KanbanCard = memo(function KanbanCard({
const borderStyle: React.CSSProperties = { ...style };
if (!cardBorderEnabled) {
(borderStyle as Record<string, string>).borderWidth = "0px";
(borderStyle as Record<string, string>).borderColor = "transparent";
(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>).borderWidth = '1px';
(borderStyle as Record<string, string>).borderColor =
`color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
}
@@ -105,28 +96,22 @@ export const KanbanCard = memo(function KanbanCard({
ref={setNodeRef}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing relative kanban-card-content select-none",
"transition-all duration-200 ease-out",
'cursor-grab active:cursor-grabbing relative kanban-card-content select-none',
'transition-all duration-200 ease-out',
// Premium shadow system
"shadow-sm hover:shadow-md hover:shadow-black/10",
'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",
'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',
// Error state - using CSS variable
feature.error &&
!isCurrentAutoTask &&
"border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg",
!isDraggable && "cursor-default"
'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
!isDraggable && 'cursor-default'
)}
data-testid={`kanban-card-${feature.id}`}
onDoubleClick={onEdit}
@@ -137,8 +122,8 @@ export const KanbanCard = memo(function KanbanCard({
{!isDragging && (
<div
className={cn(
"absolute inset-0 rounded-xl bg-card -z-10",
glassmorphism && "backdrop-blur-sm"
'absolute inset-0 rounded-xl bg-card -z-10',
glassmorphism && 'backdrop-blur-sm'
)}
style={{ opacity: opacity / 100 }}
/>
@@ -149,9 +134,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Category row */}
<div className="px-3 pt-4">
<span className="text-[11px] text-muted-foreground/70 font-medium">
{feature.category}
</span>
<span className="text-[11px] text-muted-foreground/70 font-medium">{feature.category}</span>
</div>
{/* Priority and Manual Verification badges */}
@@ -169,11 +152,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardContent className="px-3 pt-0 pb-0">
{/* Content Sections */}
<CardContentSections
feature={feature}
useWorktrees={useWorktrees}
showSteps={showSteps}
/>
<CardContentSections feature={feature} useWorktrees={useWorktrees} showSteps={showSteps} />
{/* Agent Info Panel */}
<AgentInfoPanel

Some files were not shown because too many files have changed in this diff Show More