Merge pull request #210 from AutoMaker-Org/refactor/folder-pattern-compliance

refactor: sidebar
This commit is contained in:
Web Dev Cody
2025-12-21 19:47:56 -05:00
committed by GitHub
90 changed files with 5972 additions and 5339 deletions

View File

@@ -0,0 +1,644 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ClaudeUsageService } from '@/services/claude-usage-service.js';
import { spawn } from 'child_process';
import * as pty from 'node-pty';
import * as os from 'os';
vi.mock('child_process');
vi.mock('node-pty');
vi.mock('os');
describe('claude-usage-service.ts', () => {
let service: ClaudeUsageService;
let mockSpawnProcess: any;
let mockPtyProcess: any;
beforeEach(() => {
vi.clearAllMocks();
service = new ClaudeUsageService();
// Mock spawn process for isAvailable and Mac commands
mockSpawnProcess = {
on: vi.fn(),
kill: vi.fn(),
stdout: {
on: vi.fn(),
},
stderr: {
on: vi.fn(),
},
};
// Mock PTY process for Windows
mockPtyProcess = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(spawn).mockReturnValue(mockSpawnProcess as any);
vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess);
});
describe('isAvailable', () => {
it('should return true when Claude CLI is available', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
// Simulate successful which/where command
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(0); // Exit code 0 = found
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(true);
expect(spawn).toHaveBeenCalledWith('which', ['claude']);
});
it('should return false when Claude CLI is not available', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(1); // Exit code 1 = not found
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(false);
});
it('should return false on error', async () => {
vi.mocked(os.platform).mockReturnValue('darwin');
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'error') {
callback(new Error('Command failed'));
}
return mockSpawnProcess;
});
const result = await service.isAvailable();
expect(result).toBe(false);
});
it("should use 'where' command on Windows", async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const windowsService = new ClaudeUsageService(); // Create new service after platform mock
mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => {
if (event === 'close') {
callback(0);
}
return mockSpawnProcess;
});
await windowsService.isAvailable();
expect(spawn).toHaveBeenCalledWith('where', ['claude']);
});
});
describe('stripAnsiCodes', () => {
it('should strip ANSI color codes from text', () => {
const service = new ClaudeUsageService();
const input = '\x1B[31mRed text\x1B[0m Normal text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Red text Normal text');
});
it('should handle text without ANSI codes', () => {
const service = new ClaudeUsageService();
const input = 'Plain text';
// @ts-expect-error - accessing private method for testing
const result = service.stripAnsiCodes(input);
expect(result).toBe('Plain text');
});
});
describe('parseResetTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should parse duration format with hours and minutes', () => {
const service = new ClaudeUsageService();
const text = 'Resets in 2h 15m';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const expected = new Date('2025-01-15T12:15:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should parse duration format with only minutes', () => {
const service = new ClaudeUsageService();
const text = 'Resets in 30m';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const expected = new Date('2025-01-15T10:30:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should parse simple time format (AM)', () => {
const service = new ClaudeUsageService();
const text = 'Resets 11am';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
// Should be today at 11am, or tomorrow if already passed
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(11);
expect(resultDate.getMinutes()).toBe(0);
});
it('should parse simple time format (PM)', () => {
const service = new ClaudeUsageService();
const text = 'Resets 3pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(15);
expect(resultDate.getMinutes()).toBe(0);
});
it('should parse date format with month, day, and time', () => {
const service = new ClaudeUsageService();
const text = 'Resets Dec 22 at 8pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'weekly');
const resultDate = new Date(result);
expect(resultDate.getMonth()).toBe(11); // December = 11
expect(resultDate.getDate()).toBe(22);
expect(resultDate.getHours()).toBe(20);
});
it('should parse date format with comma separator', () => {
const service = new ClaudeUsageService();
const text = 'Resets Jan 15, 3:30pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'weekly');
const resultDate = new Date(result);
expect(resultDate.getMonth()).toBe(0); // January = 0
expect(resultDate.getDate()).toBe(15);
expect(resultDate.getHours()).toBe(15);
expect(resultDate.getMinutes()).toBe(30);
});
it('should handle 12am correctly', () => {
const service = new ClaudeUsageService();
const text = 'Resets 12am';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(0);
});
it('should handle 12pm correctly', () => {
const service = new ClaudeUsageService();
const text = 'Resets 12pm';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
const resultDate = new Date(result);
expect(resultDate.getHours()).toBe(12);
});
it('should return default reset time for unparseable text', () => {
const service = new ClaudeUsageService();
const text = 'Invalid reset text';
// @ts-expect-error - accessing private method for testing
const result = service.parseResetTime(text, 'session');
// @ts-expect-error - accessing private method for testing
const defaultResult = service.getDefaultResetTime('session');
expect(result).toBe(defaultResult);
});
});
describe('getDefaultResetTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); // Wednesday
});
afterEach(() => {
vi.useRealTimers();
});
it('should return session default (5 hours from now)', () => {
const service = new ClaudeUsageService();
// @ts-expect-error - accessing private method for testing
const result = service.getDefaultResetTime('session');
const expected = new Date('2025-01-15T15:00:00Z');
expect(new Date(result)).toEqual(expected);
});
it('should return weekly default (next Monday at noon)', () => {
const service = new ClaudeUsageService();
// @ts-expect-error - accessing private method for testing
const result = service.getDefaultResetTime('weekly');
const resultDate = new Date(result);
// Next Monday from Wednesday should be 5 days away
expect(resultDate.getDay()).toBe(1); // Monday
expect(resultDate.getHours()).toBe(12);
expect(resultDate.getMinutes()).toBe(59);
});
});
describe('parseSection', () => {
it('should parse section with percentage left', () => {
const service = new ClaudeUsageService();
const lines = ['Current session', '████████████████░░░░ 65% left', 'Resets in 2h 15m'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.percentage).toBe(35); // 100 - 65 = 35% used
expect(result.resetText).toBe('Resets in 2h 15m');
});
it('should parse section with percentage used', () => {
const service = new ClaudeUsageService();
const lines = [
'Current week (all models)',
'██████████░░░░░░░░░░ 40% used',
'Resets Jan 15, 3:30pm',
];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current week (all models)', 'weekly');
expect(result.percentage).toBe(40); // Already in % used
});
it('should return zero percentage when section not found', () => {
const service = new ClaudeUsageService();
const lines = ['Some other text', 'No matching section'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.percentage).toBe(0);
});
it('should strip timezone from reset text', () => {
const service = new ClaudeUsageService();
const lines = ['Current session', '65% left', 'Resets 3pm (America/Los_Angeles)'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'Current session', 'session');
expect(result.resetText).toBe('Resets 3pm');
expect(result.resetText).not.toContain('America/Los_Angeles');
});
it('should handle case-insensitive section matching', () => {
const service = new ClaudeUsageService();
const lines = ['CURRENT SESSION', '65% left', 'Resets in 2h'];
// @ts-expect-error - accessing private method for testing
const result = service.parseSection(lines, 'current session', 'session');
expect(result.percentage).toBe(35);
});
});
describe('parseUsageOutput', () => {
it('should parse complete usage output', () => {
const service = new ClaudeUsageService();
const output = `
Claude Code v1.0.27
Current session
████████████████░░░░ 65% left
Resets in 2h 15m
Current week (all models)
██████████░░░░░░░░░░ 35% left
Resets Jan 15, 3:30pm (America/Los_Angeles)
Current week (Sonnet only)
████████████████████ 80% left
Resets Jan 15, 3:30pm (America/Los_Angeles)
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(result.weeklyPercentage).toBe(65); // 100 - 35
expect(result.sonnetWeeklyPercentage).toBe(20); // 100 - 80
expect(result.sessionResetText).toContain('Resets in 2h 15m');
expect(result.weeklyResetText).toContain('Resets Jan 15, 3:30pm');
expect(result.userTimezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
});
it('should handle output with ANSI codes', () => {
const service = new ClaudeUsageService();
const output = `
\x1B[1mClaude Code v1.0.27\x1B[0m
\x1B[1mCurrent session\x1B[0m
\x1B[32m████████████████░░░░\x1B[0m 65% left
Resets in 2h 15m
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(35);
});
it('should handle Opus section name', () => {
const service = new ClaudeUsageService();
const output = `
Current session
65% left
Resets in 2h
Current week (all models)
35% left
Resets Jan 15, 3pm
Current week (Opus)
90% left
Resets Jan 15, 3pm
`;
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sonnetWeeklyPercentage).toBe(10); // 100 - 90
});
it('should set default values for missing sections', () => {
const service = new ClaudeUsageService();
const output = 'Claude Code v1.0.27';
// @ts-expect-error - accessing private method for testing
const result = service.parseUsageOutput(output);
expect(result.sessionPercentage).toBe(0);
expect(result.weeklyPercentage).toBe(0);
expect(result.sonnetWeeklyPercentage).toBe(0);
expect(result.sessionTokensUsed).toBe(0);
expect(result.sessionLimit).toBe(0);
expect(result.costUsed).toBeNull();
expect(result.costLimit).toBeNull();
expect(result.costCurrency).toBeNull();
});
});
describe('executeClaudeUsageCommandMac', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.spyOn(process, 'env', 'get').mockReturnValue({ HOME: '/Users/testuser' });
});
it('should execute expect script and return output', async () => {
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
// Simulate stdout data
stdoutCallback!(Buffer.from(mockOutput));
// Simulate successful close
closeCallback!(0);
const result = await promise;
expect(result.sessionPercentage).toBe(35); // 100 - 65
expect(spawn).toHaveBeenCalledWith(
'expect',
expect.arrayContaining(['-c']),
expect.any(Object)
);
});
it('should handle authentication errors', async () => {
const mockOutput = 'token_expired';
let stdoutCallback: Function;
let closeCallback: Function;
mockSpawnProcess.stdout = {
on: vi.fn((event: string, callback: Function) => {
if (event === 'data') {
stdoutCallback = callback;
}
}),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn((event: string, callback: Function) => {
if (event === 'close') {
closeCallback = callback;
}
return mockSpawnProcess;
});
const promise = service.fetchUsageData();
stdoutCallback!(Buffer.from(mockOutput));
closeCallback!(1);
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout', async () => {
vi.useFakeTimers();
mockSpawnProcess.stdout = {
on: vi.fn(),
};
mockSpawnProcess.stderr = {
on: vi.fn(),
};
mockSpawnProcess.on = vi.fn(() => mockSpawnProcess);
mockSpawnProcess.kill = vi.fn();
const promise = service.fetchUsageData();
// Advance time past timeout (30 seconds)
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
vi.useRealTimers();
});
});
describe('executeClaudeUsageCommandWindows', () => {
beforeEach(() => {
vi.mocked(os.platform).mockReturnValue('win32');
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\testuser');
vi.spyOn(process, 'env', 'get').mockReturnValue({ USERPROFILE: 'C:\\Users\\testuser' });
});
it('should use node-pty on Windows and return output', async () => {
const windowsService = new ClaudeUsageService(); // Create new service for Windows platform
const mockOutput = `
Current session
65% left
Resets in 2h
`;
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
// Simulate data
dataCallback!(mockOutput);
// Simulate successful exit
exitCallback!({ exitCode: 0 });
const result = await promise;
expect(result.sessionPercentage).toBe(35);
expect(pty.spawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'claude', '/usage'],
expect.any(Object)
);
});
it('should send escape key after seeing usage data', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const mockOutput = 'Current session\n65% left';
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
// Simulate seeing usage data
dataCallback!(mockOutput);
// Advance time to trigger escape key sending
vi.advanceTimersByTime(2100);
expect(mockPty.write).toHaveBeenCalledWith('\x1b');
// Complete the promise to avoid unhandled rejection
exitCallback!({ exitCode: 0 });
await promise;
vi.useRealTimers();
});
it('should handle authentication errors on Windows', async () => {
const windowsService = new ClaudeUsageService();
let dataCallback: Function | undefined;
let exitCallback: Function | undefined;
const mockPty = {
onData: vi.fn((callback: Function) => {
dataCallback = callback;
}),
onExit: vi.fn((callback: Function) => {
exitCallback = callback;
}),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
dataCallback!('authentication_error');
exitCallback!({ exitCode: 1 });
await expect(promise).rejects.toThrow('Authentication required');
});
it('should handle timeout on Windows', async () => {
vi.useFakeTimers();
const windowsService = new ClaudeUsageService();
const mockPty = {
onData: vi.fn(),
onExit: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
};
vi.mocked(pty.spawn).mockReturnValue(mockPty as any);
const promise = windowsService.fetchUsageData();
vi.advanceTimersByTime(31000);
await expect(promise).rejects.toThrow('Command timed out');
expect(mockPty.kill).toHaveBeenCalled();
vi.useRealTimers();
});
});
});

View File

@@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser";
const eslintConfig = defineConfig([
js.configs.recommended,
{
files: ["**/*.mjs", "**/*.cjs"],
languageOptions: {
globals: {
console: "readonly",
process: "readonly",
require: "readonly",
__dirname: "readonly",
__filename: "readonly",
},
},
},
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
@@ -13,6 +25,70 @@ const eslintConfig = defineConfig([
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,

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,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,140 @@
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,35 @@
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]);
}

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,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,10 +1,9 @@
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 } from 'lucide-react';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore, type FeatureImagePath } from '@/store/app-store';
// Map to store preview data by image ID (persisted across component re-mounts)
export type ImagePreviewMap = Map<string, string>;
@@ -26,13 +25,7 @@ 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 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({
@@ -40,7 +33,7 @@ export function DescriptionImageDropZone({
onChange,
images,
onImagesChange,
placeholder = "Describe the feature...",
placeholder = 'Describe the feature...',
className,
disabled = false,
maxFiles = 5,
@@ -59,71 +52,76 @@ 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") {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
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}`;
}
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;
// 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;
}
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) => {
@@ -137,18 +135,14 @@ export function DescriptionImageDropZone({
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.`
);
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.`
);
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
continue;
}
@@ -176,13 +170,13 @@ export function DescriptionImageDropZone({
} else {
errors.push(`${file.name}: Failed to save image.`);
}
} 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) {
@@ -192,7 +186,16 @@ export function DescriptionImageDropZone({
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
[
disabled,
isProcessing,
images,
maxFiles,
maxFileSize,
onImagesChange,
previewImages,
saveImageToTemp,
]
);
const handleDrop = useCallback(
@@ -236,7 +239,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]
@@ -276,17 +279,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,13 +308,13 @@ 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.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
@@ -325,13 +326,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 && (
@@ -355,17 +352,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 images, or{' '}
<button
type="button"
onClick={handleBrowseClick}
@@ -373,7 +367,7 @@ export function DescriptionImageDropZone({
disabled={disabled || isProcessing}
>
browse
</button>{" "}
</button>{' '}
to attach context images
</p>
@@ -390,7 +384,7 @@ export function DescriptionImageDropZone({
<div className="mt-3 space-y-2" data-testid="description-image-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} image{images.length > 1 ? 's' : ''} attached
</p>
<button
type="button"
@@ -447,9 +441,7 @@ 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>
))}

View File

@@ -1,7 +1,6 @@
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';
export interface FeatureImage {
id: string;
@@ -20,13 +19,7 @@ interface FeatureImageUploadProps {
disabled?: boolean;
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
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({
@@ -45,13 +38,13 @@ export function FeatureImageUpload({
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
};
@@ -67,18 +60,14 @@ export function FeatureImageUpload({
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.`
);
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.`
);
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
continue;
}
@@ -98,13 +87,13 @@ export function FeatureImageUpload({
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 +146,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 +169,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 +190,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 +204,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 +215,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 +228,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 +270,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,8 +1,7 @@
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';
interface ImageDropZoneProps {
onImagesSelected: (images: ImageAttachment[]) => void;
@@ -35,88 +34,100 @@ 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 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 + selectedImages.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
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 {
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);
// You could show these errors to the user via a toast or notification
}
// 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 +135,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 +155,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 +185,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 +208,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 +251,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 +260,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 */}
@@ -288,4 +304,4 @@ function formatFileSize(bytes: number): string {
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,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,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,36 @@ 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";
} 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';
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 [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 +69,7 @@ export function AgentView() {
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || "",
sessionId: currentSessionId || '',
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
@@ -108,10 +105,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]);
@@ -127,7 +121,7 @@ export function AgentView() {
const messageContent = input;
const messageImages = selectedImages;
setInput("");
setInput('');
setSelectedImages([]);
setShowImageDropZone(false);
@@ -147,13 +141,13 @@ export function AgentView() {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}, []);
@@ -164,11 +158,11 @@ export function AgentView() {
if (isProcessing) return;
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
@@ -179,18 +173,14 @@ export function AgentView() {
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.`
);
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
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.`
);
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
continue;
}
@@ -216,7 +206,7 @@ export function AgentView() {
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
console.warn('Image upload errors:', errors);
}
if (newImages.length > 0) {
@@ -239,7 +229,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 +275,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 +299,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 +319,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 +337,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 +364,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 +374,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 +403,7 @@ export function AgentView() {
quickCreateSessionRef.current();
}
},
description: "Create new session",
description: 'Create new session',
});
}
@@ -434,9 +423,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 +437,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 +447,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 +482,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 +503,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 +515,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 +534,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 +561,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 +571,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 +586,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 +609,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 +687,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>
@@ -761,7 +726,7 @@ export function AgentView() {
<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 > 1 ? 's' : ''} attached
</p>
<button
onClick={() => setSelectedImages([])}
@@ -815,8 +780,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 +792,7 @@ export function AgentView() {
<Input
ref={inputRef}
placeholder={
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
isDragOver ? 'Drop your images here...' : 'Describe what you want to build...'
}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -838,16 +801,16 @@ 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 && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{selectedImages.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 > 1 ? 's' : ''}
</div>
)}
{isDragOver && (
@@ -865,10 +828,9 @@ export function AgentView() {
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 && 'border-primary/30 text-primary'
)}
title="Attach images"
>
@@ -879,9 +841,7 @@ export function AgentView() {
<Button
onClick={handleSend}
disabled={
(!input.trim() && selectedImages.length === 0) ||
isProcessing ||
!isConnected
(!input.trim() && selectedImages.length === 0) || isProcessing || !isConnected
}
className="h-11 px-4 rounded-xl"
data-testid="send-message"
@@ -892,11 +852,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>
)}
@@ -907,9 +865,9 @@ export function AgentView() {
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
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 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,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,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

@@ -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,19 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { useAppStore } from '@/store/app-store';
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps {
open: boolean;
@@ -26,7 +25,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw" | "changes";
type ViewMode = 'parsed' | 'raw' | 'changes';
export function AgentOutputModal({
open,
@@ -36,13 +35,13 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>("");
const [output, setOutput] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [projectPath, setProjectPath] = useState<string>('');
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const projectPathRef = useRef<string>('');
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -75,22 +74,19 @@ export function AgentOutputModal({
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
const result = await api.features.getAgentOutput(currentProject.path, featureId);
if (result.success) {
setOutput(result.content || "");
setOutput(result.content || '');
} else {
setOutput("");
setOutput('');
}
} else {
setOutput("");
setOutput('');
}
} catch (error) {
console.error("Failed to load output:", error);
setOutput("");
console.error('Failed to load output:', error);
setOutput('');
} finally {
setIsLoading(false);
}
@@ -108,38 +104,32 @@ export function AgentOutputModal({
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
if ('featureId' in event && event.featureId !== featureId) {
return;
}
let newContent = "";
let newContent = '';
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
case 'auto_mode_progress':
newContent = event.content || '';
break;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}\n` : ""
}`;
case 'auto_mode_tool': {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
break;
case "auto_mode_phase":
}
case 'auto_mode_phase': {
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
}
case 'auto_mode_error':
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
case 'auto_mode_ultrathink_preparation': {
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
@@ -169,66 +159,74 @@ export function AgentOutputModal({
newContent = prepContent;
break;
case "planning_started":
}
case 'planning_started': {
// Show when planning mode begins
if ("mode" in event && "message" in event) {
if ('mode' in event && 'message' in event) {
const modeLabel =
event.mode === "lite"
? "Lite"
: event.mode === "spec"
? "Spec"
: "Full";
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
case "plan_approval_required":
}
case 'plan_approval_required':
// Show when plan requires approval
if ("planningMode" in event) {
if ('planningMode' in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case "plan_approved":
case 'plan_approved':
// Show when plan is manually approved
if ("hasEdits" in event) {
if ('hasEdits' in event) {
newContent = event.hasEdits
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
: `\n✅ Plan approved - continuing to implementation...\n`;
}
break;
case "plan_auto_approved":
case 'plan_auto_approved':
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case "plan_revision_requested":
case 'plan_revision_requested': {
// Show when user requests plan revision
if ("planVersion" in event) {
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
if ('planVersion' in event) {
const revisionEvent = event as Extract<
AutoModeEvent,
{ type: 'plan_revision_requested' }
>;
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
}
break;
case "auto_mode_task_started":
}
case 'auto_mode_task_started': {
// Show when a task starts
if ("taskId" in event && "taskDescription" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
if ('taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
}
break;
case "auto_mode_task_complete":
}
case 'auto_mode_task_complete': {
// Show task completion progress
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
if ('taskId' in event && 'tasksCompleted' in event && 'tasksTotal' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
}
break;
case "auto_mode_phase_complete":
}
case 'auto_mode_phase_complete': {
// Show phase completion for full mode
if ("phaseNumber" in event) {
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
if ('phaseNumber' in event) {
const phaseEvent = event as Extract<
AutoModeEvent,
{ type: 'auto_mode_phase_complete' }
>;
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
}
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
}
case 'auto_mode_feature_complete': {
const emoji = event.passes ? '✅' : '⚠️';
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
@@ -239,6 +237,7 @@ export function AgentOutputModal({
}, 1500);
}
break;
}
}
if (newContent) {
@@ -267,20 +266,15 @@ export function AgentOutputModal({
const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers
if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
/^[0-9]$/.test(event.key)
) {
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener('keydown', handleKeyDown);
};
}, [open, onNumberKeyPress]);
@@ -293,19 +287,18 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "parsed"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-parsed"
>
@@ -313,11 +306,11 @@ export function AgentOutputModal({
Logs
</button>
<button
onClick={() => setViewMode("changes")}
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-changes"
>
@@ -325,11 +318,11 @@ export function AgentOutputModal({
Changes
</button>
<button
onClick={() => setViewMode("raw")}
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "raw"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
data-testid="view-mode-raw"
>
@@ -353,7 +346,7 @@ export function AgentOutputModal({
className="flex-shrink-0 mx-1"
/>
{viewMode === "changes" ? (
{viewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -386,19 +379,17 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
) : viewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}
</div>
</>
)}

View File

@@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -7,30 +6,29 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
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 { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
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 { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
ImagePreviewMap,
} from "@/components/ui/description-image-dropzone";
} from '@/components/ui/description-image-dropzone';
import {
MessageSquare,
Settings2,
SlidersHorizontal,
FlaskConical,
Sparkles,
ChevronDown,
GitBranch,
} from "lucide-react";
import { toast } from "sonner";
import { getElectronAPI } from "@/lib/electron";
import { modelSupportsThinking } from "@/lib/utils";
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { modelSupportsThinking } from '@/lib/utils';
import {
Feature,
AgentModel,
@@ -38,7 +36,7 @@ import {
AIProfile,
useAppStore,
PlanningMode,
} from "@/store/app-store";
} from '@/store/app-store';
import {
ModelSelector,
ThinkingLevelSelector,
@@ -47,14 +45,14 @@ import {
PrioritySelector,
BranchSelector,
PlanningModeSelector,
} from "../shared";
} from '../shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DependencyTreeDialog } from "./dependency-tree-dialog";
} from '@/components/ui/dropdown-menu';
import { DependencyTreeDialog } from './dependency-tree-dialog';
interface EditFeatureDialogProps {
feature: Feature | null;
@@ -104,16 +102,19 @@ export function EditFeatureDialog({
// If feature has no branchName, default to using current branch
return !feature?.branchName;
});
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
useState<ImagePreviewMap>(() => new Map());
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
() => new Map()
);
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [isEnhancing, setIsEnhancing] = useState(false);
const [enhancementMode, setEnhancementMode] = useState<
"improve" | "technical" | "simplify" | "acceptance"
>("improve");
'improve' | 'technical' | 'simplify' | 'acceptance'
>('improve');
const [showDependencyTree, setShowDependencyTree] = useState(false);
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(feature?.requirePlanApproval ?? false);
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Get enhancement model and worktrees setting from store
const { enhancementModel, useWorktrees } = useAppStore();
@@ -135,33 +136,31 @@ export function EditFeatureDialog({
if (!editingFeature) return;
// Validate branch selection when "other branch" is selected and branch selector is enabled
const isBranchSelectorEnabled = editingFeature.status === "backlog";
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
if (
useWorktrees &&
isBranchSelectorEnabled &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
) {
toast.error("Please select a branch name");
toast.error('Please select a branch name');
return;
}
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
selectedModel
)
? editingFeature.thinkingLevel ?? "none"
: "none";
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
? (editingFeature.thinkingLevel ?? 'none')
: 'none';
// Use current branch if toggle is on
// If currentBranch is provided (non-primary worktree), use it
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
const finalBranchName = useCurrentBranch
? (currentBranch || "")
: editingFeature.branchName || "";
? currentBranch || ''
: editingFeature.branchName || '';
const updates = {
title: editingFeature.title ?? "",
title: editingFeature.title ?? '',
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
@@ -192,16 +191,11 @@ export function EditFeatureDialog({
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
});
};
const handleProfileSelect = (
model: AgentModel,
thinkingLevel: ThinkingLevel
) => {
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
if (!editingFeature) return;
setEditingFeature({
...editingFeature,
@@ -224,16 +218,14 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) =>
prev ? { ...prev, description: enhancedText } : prev
);
toast.success("Description enhanced!");
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
toast.success('Description enhanced!');
} else {
toast.error(result?.error || "Failed to enhance description");
toast.error(result?.error || 'Failed to enhance description');
}
} catch (error) {
console.error("Enhancement failed:", error);
toast.error("Failed to enhance description");
console.error('Enhancement failed:', error);
toast.error('Failed to enhance description');
} finally {
setIsEnhancing(false);
}
@@ -267,10 +259,7 @@ export function EditFeatureDialog({
<DialogTitle>Edit Feature</DialogTitle>
<DialogDescription>Modify the feature details.</DialogDescription>
</DialogHeader>
<Tabs
defaultValue="prompt"
className="py-4 flex-1 min-h-0 flex flex-col"
>
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
<MessageSquare className="w-4 h-4 mr-2" />
@@ -287,10 +276,7 @@ export function EditFeatureDialog({
</TabsList>
{/* Prompt Tab */}
<TabsContent
value="prompt"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
@@ -318,7 +304,7 @@ export function EditFeatureDialog({
<Label htmlFor="edit-title">Title (optional)</Label>
<Input
id="edit-title"
value={editingFeature.title ?? ""}
value={editingFeature.title ?? ''}
onChange={(e) =>
setEditingFeature({
...editingFeature,
@@ -332,38 +318,25 @@ export function EditFeatureDialog({
<div className="flex w-fit items-center gap-3 select-none cursor-default">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] justify-between"
>
{enhancementMode === "improve" && "Improve Clarity"}
{enhancementMode === "technical" && "Add Technical Details"}
{enhancementMode === "simplify" && "Simplify"}
{enhancementMode === "acceptance" &&
"Add Acceptance Criteria"}
<Button variant="outline" size="sm" className="w-[180px] justify-between">
{enhancementMode === 'improve' && 'Improve Clarity'}
{enhancementMode === 'technical' && 'Add Technical Details'}
{enhancementMode === 'simplify' && 'Simplify'}
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => setEnhancementMode("improve")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
Improve Clarity
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("technical")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
Add Technical Details
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("simplify")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
Simplify
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEnhancementMode("acceptance")}
>
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
Add Acceptance Criteria
</DropdownMenuItem>
</DropdownMenuContent>
@@ -400,7 +373,7 @@ export function EditFeatureDialog({
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ""}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
@@ -410,7 +383,7 @@ export function EditFeatureDialog({
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== "backlog"}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
)}
@@ -429,17 +402,12 @@ export function EditFeatureDialog({
</TabsContent>
{/* Model Tab */}
<TabsContent
value="model"
className="space-y-4 overflow-y-auto cursor-default"
>
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
{/* Show Advanced Options Toggle */}
{showProfilesOnly && (
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
Simple Mode Active
</p>
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
<p className="text-xs text-muted-foreground">
Only showing AI profiles. Advanced model tweaking is hidden.
</p>
@@ -447,13 +415,11 @@ export function EditFeatureDialog({
<Button
variant="outline"
size="sm"
onClick={() =>
setShowEditAdvancedOptions(!showEditAdvancedOptions)
}
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
data-testid="edit-show-advanced-options-toggle"
>
<Settings2 className="w-4 h-4 mr-2" />
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
</Button>
</div>
)}
@@ -461,29 +427,28 @@ export function EditFeatureDialog({
{/* Quick Select Profile Section */}
<ProfileQuickSelect
profiles={aiProfiles}
selectedModel={editingFeature.model ?? "opus"}
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
selectedModel={editingFeature.model ?? 'opus'}
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
onSelect={handleProfileSelect}
testIdPrefix="edit-profile-quick-select"
/>
{/* Separator */}
{aiProfiles.length > 0 &&
(!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
<div className="border-t border-border" />
)}
{/* Claude Models Section */}
{(!showProfilesOnly || showEditAdvancedOptions) && (
<>
<ModelSelector
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
onModelSelect={handleModelSelect}
testIdPrefix="edit-model-select"
/>
{editModelAllowsThinking && (
<ThinkingLevelSelector
selectedLevel={editingFeature.thinkingLevel ?? "none"}
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
onLevelSelect={(level) =>
setEditingFeature({
...editingFeature,
@@ -515,13 +480,9 @@ export function EditFeatureDialog({
{/* Testing Section */}
<TestingTabContent
skipTests={editingFeature.skipTests ?? false}
onSkipTestsChange={(skipTests) =>
setEditingFeature({ ...editingFeature, skipTests })
}
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
steps={editingFeature.steps}
onStepsChange={(steps) =>
setEditingFeature({ ...editingFeature, steps })
}
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
testIdPrefix="edit"
/>
</TabsContent>
@@ -541,12 +502,12 @@ export function EditFeatureDialog({
</Button>
<HotkeyButton
onClick={handleUpdate}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={!!editingFeature}
data-testid="confirm-edit-feature"
disabled={
useWorktrees &&
editingFeature.status === "backlog" &&
editingFeature.status === 'backlog' &&
!useCurrentBranch &&
!editingFeature.branchName?.trim()
}

View File

@@ -1,22 +1,14 @@
import {
DndContext,
DragOverlay,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { KanbanColumn, KanbanCard } from "./components";
import { Feature } from "@/store/app-store";
import { FastForward, Lightbulb, Archive } from "lucide-react";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { useResponsiveKanban } from "@/hooks/use-responsive-kanban";
import { COLUMNS, ColumnId } from "./constants";
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { KanbanColumn, KanbanCard } from './components';
import { Feature } from '@/store/app-store';
import { FastForward, Lightbulb, Archive } from 'lucide-react';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { COLUMNS, ColumnId } from './constants';
interface KanbanBoardProps {
sensors: any;
@@ -93,10 +85,7 @@ export function KanbanBoard({
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
@@ -118,8 +107,7 @@ export function KanbanBoard({
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
column.id === 'verified' && columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
@@ -130,7 +118,7 @@ export function KanbanBoard({
<Archive className="w-3 h-3 mr-1" />
Archive All
</Button>
) : column.id === "backlog" ? (
) : column.id === 'backlog' ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
@@ -175,9 +163,8 @@ export function KanbanBoard({
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
if (column.id === 'in_progress' && index < 10) {
shortcutKey = index === 9 ? '0' : String(index + 1);
}
return (
<KanbanCard
@@ -190,29 +177,19 @@ export function KanbanBoard({
onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() =>
onMoveBackToInProgress(feature)
}
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)}
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
opacity={backgroundSettings.cardOpacity}
glassmorphism={
backgroundSettings.cardGlassmorphism
}
cardBorderEnabled={
backgroundSettings.cardBorderEnabled
}
cardBorderOpacity={
backgroundSettings.cardBorderOpacity
}
glassmorphism={backgroundSettings.cardGlassmorphism}
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
/>
);
})}
@@ -225,7 +202,7 @@ export function KanbanBoard({
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.18, 0.67, 0.6, 1.22)",
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{activeFeature && (

View File

@@ -1,10 +1,9 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Card } from '@/components/ui/card';
import {
Plus,
RefreshCw,
@@ -14,17 +13,16 @@ import {
Save,
Upload,
File,
X,
BookOpen,
EditIcon,
Eye,
Pencil,
} from "lucide-react";
} from 'lucide-react';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
} from '@/hooks/use-keyboard-shortcuts';
import {
Dialog,
DialogContent,
@@ -32,15 +30,15 @@ import {
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Markdown } from "../ui/markdown";
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { Markdown } from '../ui/markdown';
interface ContextFile {
name: string;
type: "text" | "image";
type: 'text' | 'image';
content?: string;
path: string;
}
@@ -53,17 +51,15 @@ export function ContextView() {
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [editedContent, setEditedContent] = useState('');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameFileName, setRenameFileName] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
null
);
const [newFileContent, setNewFileContent] = useState("");
const [renameFileName, setRenameFileName] = useState('');
const [newFileName, setNewFileName] = useState('');
const [newFileType, setNewFileType] = useState<'text' | 'image'>('text');
const [uploadedImageData, setUploadedImageData] = useState<string | null>(null);
const [newFileContent, setNewFileContent] = useState('');
const [isDropHovering, setIsDropHovering] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
@@ -73,7 +69,7 @@ export function ContextView() {
{
key: shortcuts.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
description: 'Add new context file',
},
],
[shortcuts]
@@ -87,22 +83,14 @@ export function ContextView() {
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return ext === ".md" || ext === ".markdown";
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ext === '.md' || ext === '.markdown';
};
// Determine if a file is an image based on extension
const isImageFile = (filename: string): boolean => {
const imageExtensions = [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
];
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return imageExtensions.includes(ext);
};
@@ -125,13 +113,13 @@ export function ContextView() {
.filter((entry) => entry.isFile)
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? "image" : "text",
type: isImageFile(entry.name) ? 'image' : 'text',
path: `${contextPath}/${entry.name}`,
}));
setContextFiles(files);
}
} catch (error) {
console.error("Failed to load context files:", error);
console.error('Failed to load context files:', error);
} finally {
setIsLoading(false);
}
@@ -152,7 +140,7 @@ export function ContextView() {
setHasChanges(false);
}
} catch (error) {
console.error("Failed to load file content:", error);
console.error('Failed to load file content:', error);
}
}, []);
@@ -176,7 +164,7 @@ export function ContextView() {
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
console.error("Failed to save file:", error);
console.error('Failed to save file:', error);
} finally {
setIsSaving(false);
}
@@ -198,32 +186,32 @@ export function ContextView() {
let filename = newFileName.trim();
// Add default extension if not provided
if (newFileType === "text" && !filename.includes(".")) {
filename += ".md";
if (newFileType === 'text' && !filename.includes('.')) {
filename += '.md';
}
const filePath = `${contextPath}/${filename}`;
if (newFileType === "image" && uploadedImageData) {
if (newFileType === 'image' && uploadedImageData) {
// Write image data
await api.writeFile(filePath, uploadedImageData);
} else {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
// Only reload files on success
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
console.error('Failed to add file:', error);
// Optionally show error toast to user here
} finally {
// Close dialog and reset state
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setNewFileName('');
setNewFileType('text');
setUploadedImageData(null);
setNewFileContent("");
setNewFileContent('');
setIsDropHovering(false);
}
};
@@ -238,11 +226,11 @@ export function ContextView() {
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent("");
setEditedContent('');
setHasChanges(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to delete file:", error);
console.error('Failed to delete file:', error);
}
};
@@ -264,14 +252,14 @@ export function ContextView() {
// Check if file with new name already exists
const exists = await api.exists(newPath);
if (exists) {
console.error("A file with this name already exists");
console.error('A file with this name already exists');
return;
}
// Read current file content
const result = await api.readFile(selectedFile.path);
if (!result.success || result.content === undefined) {
console.error("Failed to read file for rename");
console.error('Failed to read file for rename');
return;
}
@@ -282,7 +270,7 @@ export function ContextView() {
await api.deleteFile(selectedFile.path);
setIsRenameDialogOpen(false);
setRenameFileName("");
setRenameFileName('');
// Reload files and select the renamed file
await loadContextFiles();
@@ -290,13 +278,13 @@ export function ContextView() {
// Update selected file with new name and path
const renamedFile: ContextFile = {
name: newName,
type: isImageFile(newName) ? "image" : "text",
type: isImageFile(newName) ? 'image' : 'text',
path: newPath,
content: result.content,
};
setSelectedFile(renamedFile);
} catch (error) {
console.error("Failed to rename file:", error);
console.error('Failed to rename file:', error);
}
};
@@ -352,9 +340,7 @@ export function ContextView() {
};
// Handle drag and drop for .txt and .md files in the add context dialog textarea
const handleTextAreaDrop = async (
e: React.DragEvent<HTMLTextAreaElement>
) => {
const handleTextAreaDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
@@ -366,8 +352,8 @@ export function ContextView() {
const fileName = file.name.toLowerCase();
// Only accept .txt and .md files
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
console.warn("Only .txt and .md files are supported for drag and drop");
if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) {
console.warn('Only .txt and .md files are supported for drag and drop');
return;
}
@@ -409,20 +395,14 @@ export function ContextView() {
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-loading"
>
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="context-view"
>
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="context-view">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
@@ -462,10 +442,7 @@ export function ContextView() {
Context Files ({contextFiles.length})
</h2>
</div>
<div
className="flex-1 overflow-y-auto p-2"
data-testid="context-file-list"
>
<div className="flex-1 overflow-y-auto p-2" data-testid="context-file-list">
{contextFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
@@ -481,10 +458,10 @@ export function ContextView() {
<div
key={file.path}
className={cn(
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors',
selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
? 'bg-primary/20 text-foreground border border-primary/30'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
<button
@@ -492,7 +469,7 @@ export function ContextView() {
className="flex-1 flex items-center gap-2 text-left min-w-0"
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
{file.type === 'image' ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
@@ -525,38 +502,35 @@ export function ContextView() {
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
{selectedFile.type === "image" ? (
{selectedFile.type === 'image' ? (
<ImageIcon className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{selectedFile.name}
</span>
<span className="text-sm font-medium">{selectedFile.name}</span>
</div>
<div className="flex gap-2">
{selectedFile.type === "text" &&
isMarkdownFile(selectedFile.name) && (
<Button
variant={"outline"}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<EditIcon className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
)}
{selectedFile.type === "text" && (
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
<Button
variant={'outline'}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<EditIcon className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
)}
{selectedFile.type === 'text' && (
<Button
size="sm"
onClick={saveFile}
@@ -564,7 +538,7 @@ export function ContextView() {
data-testid="save-context-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
</Button>
)}
<Button
@@ -581,7 +555,7 @@ export function ContextView() {
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{selectedFile.type === "image" ? (
{selectedFile.type === 'image' ? (
<div
className="h-full flex items-center justify-center bg-card rounded-lg"
data-testid="image-preview"
@@ -614,12 +588,8 @@ export function ContextView() {
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">
Select a file to view or edit
</p>
<p className="text-muted-foreground text-sm mt-1">
Or drop files here to add them
</p>
<p className="text-foreground-secondary">Select a file to view or edit</p>
<p className="text-muted-foreground text-sm mt-1">Or drop files here to add them</p>
</div>
</div>
)}
@@ -634,25 +604,23 @@ export function ContextView() {
>
<DialogHeader>
<DialogTitle>Add Context File</DialogTitle>
<DialogDescription>
Add a new text or image file to the context.
</DialogDescription>
<DialogDescription>Add a new text or image file to the context.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={newFileType === "text" ? "default" : "outline"}
variant={newFileType === 'text' ? 'default' : 'outline'}
size="sm"
onClick={() => setNewFileType("text")}
onClick={() => setNewFileType('text')}
data-testid="add-text-type"
>
<FileText className="w-4 h-4 mr-2" />
Text
</Button>
<Button
variant={newFileType === "image" ? "default" : "outline"}
variant={newFileType === 'image' ? 'default' : 'outline'}
size="sm"
onClick={() => setNewFileType("image")}
onClick={() => setNewFileType('image')}
data-testid="add-image-type"
>
<ImageIcon className="w-4 h-4 mr-2" />
@@ -666,20 +634,18 @@ export function ContextView() {
id="filename"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={
newFileType === "text" ? "context.md" : "image.png"
}
placeholder={newFileType === 'text' ? 'context.md' : 'image.png'}
data-testid="new-file-name"
/>
</div>
{newFileType === "text" && (
{newFileType === 'text' && (
<div className="space-y-2">
<Label htmlFor="context-content">Context Content</Label>
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-primary"
'relative rounded-lg transition-colors',
isDropHovering && 'ring-2 ring-primary'
)}
>
<textarea
@@ -691,8 +657,8 @@ export function ContextView() {
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
isDropHovering && "border-primary bg-primary/10"
'w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent',
isDropHovering && 'border-primary bg-primary/10'
)}
spellCheck={false}
data-testid="new-file-content"
@@ -701,9 +667,7 @@ export function ContextView() {
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">
Drop .txt or .md file here
</span>
<span className="text-sm font-medium">Drop .txt or .md file here</span>
</div>
</div>
)}
@@ -714,7 +678,7 @@ export function ContextView() {
</div>
)}
{newFileType === "image" && (
{newFileType === 'image' && (
<div className="space-y-2">
<Label>Upload Image</Label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
@@ -740,9 +704,7 @@ export function ContextView() {
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
)}
<span className="text-sm text-muted-foreground">
{uploadedImageData
? "Click to change"
: "Click to upload"}
{uploadedImageData ? 'Click to change' : 'Click to upload'}
</span>
</label>
</div>
@@ -754,9 +716,9 @@ export function ContextView() {
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileName('');
setUploadedImageData(null);
setNewFileContent("");
setNewFileContent('');
setIsDropHovering(false);
}}
>
@@ -764,11 +726,8 @@ export function ContextView() {
</Button>
<HotkeyButton
onClick={handleAddFile}
disabled={
!newFileName.trim() ||
(newFileType === "image" && !uploadedImageData)
}
hotkey={{ key: "Enter", cmdCtrl: true }}
disabled={!newFileName.trim() || (newFileType === 'image' && !uploadedImageData)}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={isAddDialogOpen}
data-testid="confirm-add-file"
>
@@ -784,15 +743,11 @@ export function ContextView() {
<DialogHeader>
<DialogTitle>Delete Context File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This
action cannot be undone.
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
@@ -812,9 +767,7 @@ export function ContextView() {
<DialogContent data-testid="rename-context-dialog">
<DialogHeader>
<DialogTitle>Rename Context File</DialogTitle>
<DialogDescription>
Enter a new name for "{selectedFile?.name}".
</DialogDescription>
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="space-y-2">
@@ -826,7 +779,7 @@ export function ContextView() {
placeholder="Enter new filename"
data-testid="rename-file-input"
onKeyDown={(e) => {
if (e.key === "Enter" && renameFileName.trim()) {
if (e.key === 'Enter' && renameFileName.trim()) {
handleRenameFile();
}
}}
@@ -838,7 +791,7 @@ export function ContextView() {
variant="outline"
onClick={() => {
setIsRenameDialogOpen(false);
setRenameFileName("");
setRenameFileName('');
}}
>
Cancel

View File

@@ -1,12 +1,6 @@
import { Label } from "@/components/ui/label";
import {
CheckCircle2,
AlertCircle,
Info,
Terminal,
Sparkles,
} from "lucide-react";
import type { ClaudeAuthStatus } from "@/store/setup-store";
import { Label } from '@/components/ui/label';
import { CheckCircle2, AlertCircle, Info, Terminal } from 'lucide-react';
import type { ClaudeAuthStatus } from '@/store/setup-store';
interface AuthenticationStatusDisplayProps {
claudeAuthStatus: ClaudeAuthStatus | null;
@@ -39,35 +33,29 @@ export function AuthenticationStatusDisplay({
<div className="p-3 rounded-lg bg-card border border-border">
<div className="flex items-center gap-2 mb-1.5">
<Terminal className="w-4 h-4 text-brand-500" />
<span className="text-sm font-medium text-foreground">
Claude (Anthropic)
</span>
<span className="text-sm font-medium text-foreground">Claude (Anthropic)</span>
</div>
<div className="space-y-1.5 text-xs min-h-12">
{claudeAuthStatus?.authenticated ? (
<>
<div className="flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
<span className="text-green-400 font-medium">
Authenticated
</span>
<span className="text-green-400 font-medium">Authenticated</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Info className="w-3 h-3 shrink-0" />
<span>
{claudeAuthStatus.method === "oauth_token"
? "Using stored OAuth token (subscription)"
: claudeAuthStatus.method === "api_key_env"
? "Using ANTHROPIC_API_KEY"
: claudeAuthStatus.method === "api_key"
? "Using stored API key"
: claudeAuthStatus.method === "credentials_file"
? "Using credentials file"
: claudeAuthStatus.method === "cli_authenticated"
? "Using Claude CLI authentication"
: `Using ${
claudeAuthStatus.method || "detected"
} authentication`}
{claudeAuthStatus.method === 'oauth_token'
? 'Using stored OAuth token (subscription)'
: claudeAuthStatus.method === 'api_key_env'
? 'Using ANTHROPIC_API_KEY'
: claudeAuthStatus.method === 'api_key'
? 'Using stored API key'
: claudeAuthStatus.method === 'credentials_file'
? 'Using credentials file'
: claudeAuthStatus.method === 'cli_authenticated'
? 'Using Claude CLI authentication'
: `Using ${claudeAuthStatus.method || 'detected'} authentication`}
</span>
</div>
</>

View File

@@ -0,0 +1 @@
export { useApiKeyManagement } from './use-api-key-management';

View File

@@ -0,0 +1,4 @@
export { ApiKeyField } from './api-key-field';
export { ApiKeysSection } from './api-keys-section';
export { AuthenticationStatusDisplay } from './authentication-status-display';
export { SecurityNotice } from './security-notice';

View File

@@ -0,0 +1 @@
export { AppearanceSection } from './appearance-section';

View File

@@ -0,0 +1 @@
export { AudioSection } from './audio-section';

View File

@@ -0,0 +1 @@
export { ClaudeCliStatus } from './claude-cli-status';

View File

@@ -1,6 +1,6 @@
import { Folder, Trash2 } from "lucide-react";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import type { Project } from "@/lib/electron";
import { Folder } from 'lucide-react';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import type { Project } from '@/lib/electron';
interface DeleteProjectDialogProps {
open: boolean;
@@ -39,18 +39,13 @@ export function DeleteProjectDialog({
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{project.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{project.path}
</p>
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
The folder will remain on disk until you permanently delete it from
Trash.
The folder will remain on disk until you permanently delete it from Trash.
</p>
</>
)}

View File

@@ -0,0 +1,4 @@
export { DeleteProjectDialog } from './delete-project-dialog';
export { KeyboardMapDialog } from './keyboard-map-dialog';
export { SettingsHeader } from './settings-header';
export { SettingsNavigation } from './settings-navigation';

View File

@@ -0,0 +1,2 @@
export { NAV_ITEMS } from './navigation';
export type { NavigationItem } from './navigation';

View File

@@ -0,0 +1 @@
export { DangerZoneSection } from './danger-zone-section';

View File

@@ -0,0 +1 @@
export { FeatureDefaultsSection } from './feature-defaults-section';

View File

@@ -0,0 +1 @@
export { KeyboardShortcutsSection } from './keyboard-shortcuts-section';

View File

@@ -0,0 +1,2 @@
export type { Theme } from '@/config/theme-options';
export type { CliStatus, KanbanDetailLevel, Project, ApiKeys } from './types';

View File

@@ -1,24 +1,17 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
} from '@/components/ui/accordion';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import {
CheckCircle2,
Loader2,
@@ -31,14 +24,13 @@ import {
RefreshCw,
Download,
Info,
AlertTriangle,
ShieldCheck,
XCircle,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { StatusBadge, TerminalOutput } from "../components";
import { useCliStatus, useCliInstallation, useTokenSave } from "../hooks";
} from 'lucide-react';
import { toast } from 'sonner';
import { StatusBadge, TerminalOutput } from '../components';
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
interface ClaudeSetupStepProps {
onNext: () => void;
@@ -46,17 +38,13 @@ interface ClaudeSetupStepProps {
onSkip: () => void;
}
type VerificationStatus = "idle" | "verifying" | "verified" | "error";
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
// Claude Setup Step
// Users can either:
// 1. Have Claude CLI installed and authenticated (verified by running a test query)
// 2. Provide an Anthropic API key manually
export function ClaudeSetupStep({
onNext,
onBack,
onSkip,
}: ClaudeSetupStepProps) {
export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps) {
const {
claudeCliStatus,
claudeAuthStatus,
@@ -66,21 +54,16 @@ export function ClaudeSetupStep({
} = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [apiKey, setApiKey] = useState("");
const [apiKey, setApiKey] = useState('');
// CLI Verification state
const [cliVerificationStatus, setCliVerificationStatus] =
useState<VerificationStatus>("idle");
const [cliVerificationError, setCliVerificationError] = useState<
string | null
>(null);
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
// API Key Verification state
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
useState<VerificationStatus>("idle");
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<
string | null
>(null);
useState<VerificationStatus>('idle');
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(null);
// Delete API Key state
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
@@ -96,14 +79,11 @@ export function ClaudeSetupStep({
[]
);
const getStoreState = useCallback(
() => useSetupStore.getState().claudeCliStatus,
[]
);
const getStoreState = useCallback(() => useSetupStore.getState().claudeCliStatus, []);
// Use custom hooks
const { isChecking, checkStatus } = useCliStatus({
cliType: "claude",
cliType: 'claude',
statusApi,
setCliStatus: setClaudeCliStatus,
setAuthStatus: setClaudeAuthStatus,
@@ -114,120 +94,114 @@ export function ClaudeSetupStep({
}, [checkStatus]);
const { isInstalling, installProgress, install } = useCliInstallation({
cliType: "claude",
cliType: 'claude',
installApi,
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
onSuccess: onInstallSuccess,
getStoreState,
});
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave(
{
provider: "anthropic",
onSuccess: () => {
setClaudeAuthStatus({
authenticated: true,
method: "api_key",
hasCredentialsFile: false,
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, anthropic: apiKey });
toast.success("API key saved successfully!");
},
}
);
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
provider: 'anthropic',
onSuccess: () => {
setClaudeAuthStatus({
authenticated: true,
method: 'api_key',
hasCredentialsFile: false,
apiKeyValid: true,
});
setApiKeys({ ...apiKeys, anthropic: apiKey });
toast.success('API key saved successfully!');
},
});
// Verify CLI authentication by running a test query (uses CLI credentials only, not API key)
const verifyCliAuth = useCallback(async () => {
setCliVerificationStatus("verifying");
setCliVerificationStatus('verifying');
setCliVerificationError(null);
try {
const api = getElectronAPI();
if (!api.setup?.verifyClaudeAuth) {
setCliVerificationStatus("error");
setCliVerificationError("Verification API not available");
setCliVerificationStatus('error');
setCliVerificationError('Verification API not available');
return;
}
// Pass "cli" to verify CLI authentication only (ignores any API key)
const result = await api.setup.verifyClaudeAuth("cli");
const result = await api.setup.verifyClaudeAuth('cli');
// Check for "Limit reached" error - treat as unverified
const hasLimitReachedError =
result.error?.toLowerCase().includes("limit reached") ||
result.error?.toLowerCase().includes("rate limit");
result.error?.toLowerCase().includes('limit reached') ||
result.error?.toLowerCase().includes('rate limit');
if (result.authenticated && !hasLimitReachedError) {
setCliVerificationStatus("verified");
setCliVerificationStatus('verified');
setClaudeAuthStatus({
authenticated: true,
method: "cli_authenticated",
method: 'cli_authenticated',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("Claude CLI authentication verified!");
toast.success('Claude CLI authentication verified!');
} else {
setCliVerificationStatus("error");
setCliVerificationStatus('error');
setCliVerificationError(
hasLimitReachedError
? "Rate limit reached. Please try again later."
: result.error || "Authentication failed"
? 'Rate limit reached. Please try again later.'
: result.error || 'Authentication failed'
);
setClaudeAuthStatus({
authenticated: false,
method: "none",
method: 'none',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Verification failed";
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
// Also check for limit reached in caught errors
const isLimitError =
errorMessage.toLowerCase().includes("limit reached") ||
errorMessage.toLowerCase().includes("rate limit");
setCliVerificationStatus("error");
errorMessage.toLowerCase().includes('limit reached') ||
errorMessage.toLowerCase().includes('rate limit');
setCliVerificationStatus('error');
setCliVerificationError(
isLimitError
? "Rate limit reached. Please try again later."
: errorMessage
isLimitError ? 'Rate limit reached. Please try again later.' : errorMessage
);
}
}, [claudeAuthStatus, setClaudeAuthStatus]);
// Verify API Key authentication (uses API key only)
const verifyApiKeyAuth = useCallback(async () => {
setApiKeyVerificationStatus("verifying");
setApiKeyVerificationStatus('verifying');
setApiKeyVerificationError(null);
try {
const api = getElectronAPI();
if (!api.setup?.verifyClaudeAuth) {
setApiKeyVerificationStatus("error");
setApiKeyVerificationError("Verification API not available");
setApiKeyVerificationStatus('error');
setApiKeyVerificationError('Verification API not available');
return;
}
// Pass "api_key" to verify API key authentication only
const result = await api.setup.verifyClaudeAuth("api_key");
const result = await api.setup.verifyClaudeAuth('api_key');
if (result.authenticated) {
setApiKeyVerificationStatus("verified");
setApiKeyVerificationStatus('verified');
setClaudeAuthStatus({
authenticated: true,
method: "api_key",
method: 'api_key',
hasCredentialsFile: false,
apiKeyValid: true,
});
toast.success("API key authentication verified!");
toast.success('API key authentication verified!');
} else {
setApiKeyVerificationStatus("error");
setApiKeyVerificationError(result.error || "Authentication failed");
setApiKeyVerificationStatus('error');
setApiKeyVerificationError(result.error || 'Authentication failed');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Verification failed";
setApiKeyVerificationStatus("error");
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
setApiKeyVerificationStatus('error');
setApiKeyVerificationError(errorMessage);
}
}, [setClaudeAuthStatus]);
@@ -238,29 +212,28 @@ export function ClaudeSetupStep({
try {
const api = getElectronAPI();
if (!api.setup?.deleteApiKey) {
toast.error("Delete API not available");
toast.error('Delete API not available');
return;
}
const result = await api.setup.deleteApiKey("anthropic");
const result = await api.setup.deleteApiKey('anthropic');
if (result.success) {
// Clear local state
setApiKey("");
setApiKeys({ ...apiKeys, anthropic: "" });
setApiKeyVerificationStatus("idle");
setApiKey('');
setApiKeys({ ...apiKeys, anthropic: '' });
setApiKeyVerificationStatus('idle');
setApiKeyVerificationError(null);
setClaudeAuthStatus({
authenticated: false,
method: "none",
method: 'none',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
});
toast.success("API key deleted successfully");
toast.success('API key deleted successfully');
} else {
toast.error(result.error || "Failed to delete API key");
toast.error(result.error || 'Failed to delete API key');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to delete API key";
const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key';
toast.error(errorMessage);
} finally {
setIsDeletingApiKey(false);
@@ -282,30 +255,30 @@ export function ClaudeSetupStep({
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success("Command copied to clipboard");
toast.success('Command copied to clipboard');
};
// User is ready if either method is verified
const hasApiKey =
!!apiKeys.anthropic ||
claudeAuthStatus?.method === "api_key" ||
claudeAuthStatus?.method === "api_key_env";
const isCliVerified = cliVerificationStatus === "verified";
const isApiKeyVerified = apiKeyVerificationStatus === "verified";
claudeAuthStatus?.method === 'api_key' ||
claudeAuthStatus?.method === 'api_key_env';
const isCliVerified = cliVerificationStatus === 'verified';
const isApiKeyVerified = apiKeyVerificationStatus === 'verified';
const isReady = isCliVerified || isApiKeyVerified;
const getAuthMethodLabel = () => {
if (isApiKeyVerified) return "API Key";
if (isCliVerified) return "Claude CLI";
if (isApiKeyVerified) return 'API Key';
if (isCliVerified) return 'Claude CLI';
return null;
};
// Helper to get status badge for CLI
const getCliStatusBadge = () => {
if (cliVerificationStatus === "verified") {
if (cliVerificationStatus === 'verified') {
return <StatusBadge status="authenticated" label="Verified" />;
}
if (cliVerificationStatus === "error") {
if (cliVerificationStatus === 'error') {
return <StatusBadge status="error" label="Error" />;
}
if (isChecking) {
@@ -320,10 +293,10 @@ export function ClaudeSetupStep({
// Helper to get status badge for API Key
const getApiKeyStatusBadge = () => {
if (apiKeyVerificationStatus === "verified") {
if (apiKeyVerificationStatus === 'verified') {
return <StatusBadge status="authenticated" label="Verified" />;
}
if (apiKeyVerificationStatus === "error") {
if (apiKeyVerificationStatus === 'error') {
return <StatusBadge status="error" label="Error" />;
}
if (hasApiKey) {
@@ -339,9 +312,7 @@ export function ClaudeSetupStep({
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
<Terminal className="w-8 h-8 text-brand-500" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">
API Key Setup
</h2>
<h2 className="text-2xl font-bold text-foreground mb-2">API Key Setup</h2>
<p className="text-muted-foreground">Configure for code generation</p>
</div>
@@ -353,15 +324,8 @@ export function ClaudeSetupStep({
<Info className="w-5 h-5" />
Authentication Methods
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={checkStatus}
disabled={isChecking}
>
<RefreshCw
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
/>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
</Button>
</div>
<CardDescription>
@@ -377,16 +341,14 @@ export function ClaudeSetupStep({
<div className="flex items-center gap-3">
<Terminal
className={`w-5 h-5 ${
cliVerificationStatus === "verified"
? "text-green-500"
: "text-muted-foreground"
cliVerificationStatus === 'verified'
? 'text-green-500'
: 'text-muted-foreground'
}`}
/>
<div className="text-left">
<p className="font-medium text-foreground">Claude CLI</p>
<p className="text-sm text-muted-foreground">
Use Claude Code subscription
</p>
<p className="text-sm text-muted-foreground">Use Claude Code subscription</p>
</div>
</div>
{getCliStatusBadge()}
@@ -398,15 +360,11 @@ export function ClaudeSetupStep({
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-muted-foreground" />
<p className="font-medium text-foreground">
Install Claude CLI
</p>
<p className="font-medium text-foreground">Install Claude CLI</p>
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
macOS / Linux
</Label>
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
curl -fsSL https://claude.ai/install.sh | bash
@@ -415,9 +373,7 @@ export function ClaudeSetupStep({
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
"curl -fsSL https://claude.ai/install.sh | bash"
)
copyCommand('curl -fsSL https://claude.ai/install.sh | bash')
}
>
<Copy className="w-4 h-4" />
@@ -426,9 +382,7 @@ export function ClaudeSetupStep({
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
Windows
</Label>
<Label className="text-sm text-muted-foreground">Windows</Label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
irm https://claude.ai/install.ps1 | iex
@@ -436,20 +390,14 @@ export function ClaudeSetupStep({
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
"irm https://claude.ai/install.ps1 | iex"
)
}
onClick={() => copyCommand('irm https://claude.ai/install.ps1 | iex')}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{isInstalling && (
<TerminalOutput lines={installProgress.output} />
)}
{isInstalling && <TerminalOutput lines={installProgress.output} />}
<Button
onClick={install}
@@ -480,27 +428,21 @@ export function ClaudeSetupStep({
)}
{/* CLI Verification Status */}
{cliVerificationStatus === "verifying" && (
{cliVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Verifying CLI authentication...
</p>
<p className="text-sm text-muted-foreground">
Running a test query
</p>
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
</div>
</div>
)}
{cliVerificationStatus === "verified" && (
{cliVerificationStatus === 'verified' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
CLI Authentication verified!
</p>
<p className="font-medium text-foreground">CLI Authentication verified!</p>
<p className="text-sm text-muted-foreground">
Your Claude CLI is working correctly.
</p>
@@ -508,17 +450,13 @@ export function ClaudeSetupStep({
</div>
)}
{cliVerificationStatus === "error" && cliVerificationError && (
{cliVerificationStatus === 'error' && cliVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1">
<p className="font-medium text-foreground">
Verification failed
</p>
<p className="text-sm text-red-400 mt-1">
{cliVerificationError}
</p>
{cliVerificationError.includes("login") && (
<p className="font-medium text-foreground">Verification failed</p>
<p className="text-sm text-red-400 mt-1">{cliVerificationError}</p>
{cliVerificationError.includes('login') && (
<div className="mt-3 p-3 rounded bg-muted/50">
<p className="text-sm text-muted-foreground mb-2">
Run this command in your terminal:
@@ -530,7 +468,7 @@ export function ClaudeSetupStep({
<Button
variant="ghost"
size="icon"
onClick={() => copyCommand("claude login")}
onClick={() => copyCommand('claude login')}
>
<Copy className="w-4 h-4" />
</Button>
@@ -542,22 +480,19 @@ export function ClaudeSetupStep({
)}
{/* CLI Verify Button - Hide if CLI is verified */}
{cliVerificationStatus !== "verified" && (
{cliVerificationStatus !== 'verified' && (
<Button
onClick={verifyCliAuth}
disabled={
cliVerificationStatus === "verifying" ||
!claudeCliStatus?.installed
}
disabled={cliVerificationStatus === 'verifying' || !claudeCliStatus?.installed}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid="verify-cli-button"
>
{cliVerificationStatus === "verifying" ? (
{cliVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : cliVerificationStatus === "error" ? (
) : cliVerificationStatus === 'error' ? (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Verification
@@ -580,15 +515,13 @@ export function ClaudeSetupStep({
<div className="flex items-center gap-3">
<Key
className={`w-5 h-5 ${
apiKeyVerificationStatus === "verified"
? "text-green-500"
: "text-muted-foreground"
apiKeyVerificationStatus === 'verified'
? 'text-green-500'
: 'text-muted-foreground'
}`}
/>
<div className="text-left">
<p className="font-medium text-foreground">
Anthropic API Key
</p>
<p className="font-medium text-foreground">Anthropic API Key</p>
<p className="text-sm text-muted-foreground">
Pay-per-use with your own API key
</p>
@@ -614,7 +547,7 @@ export function ClaudeSetupStep({
data-testid="anthropic-api-key-input"
/>
<p className="text-xs text-muted-foreground">
Don&apos;t have an API key?{" "}
Don&apos;t have an API key?{' '}
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
@@ -640,7 +573,7 @@ export function ClaudeSetupStep({
Saving...
</>
) : (
"Save API Key"
'Save API Key'
)}
</Button>
{hasApiKey && (
@@ -662,27 +595,21 @@ export function ClaudeSetupStep({
</div>
{/* API Key Verification Status */}
{apiKeyVerificationStatus === "verifying" && (
{apiKeyVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<div>
<p className="font-medium text-foreground">
Verifying API key...
</p>
<p className="text-sm text-muted-foreground">
Running a test query
</p>
<p className="font-medium text-foreground">Verifying API key...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
</div>
</div>
)}
{apiKeyVerificationStatus === "verified" && (
{apiKeyVerificationStatus === 'verified' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">
API Key verified!
</p>
<p className="font-medium text-foreground">API Key verified!</p>
<p className="text-sm text-muted-foreground">
Your API key is working correctly.
</p>
@@ -690,37 +617,30 @@ export function ClaudeSetupStep({
</div>
)}
{apiKeyVerificationStatus === "error" &&
apiKeyVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1">
<p className="font-medium text-foreground">
Verification failed
</p>
<p className="text-sm text-red-400 mt-1">
{apiKeyVerificationError}
</p>
</div>
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
<div className="flex-1">
<p className="font-medium text-foreground">Verification failed</p>
<p className="text-sm text-red-400 mt-1">{apiKeyVerificationError}</p>
</div>
)}
</div>
)}
{/* API Key Verify Button - Hide if API key is verified */}
{apiKeyVerificationStatus !== "verified" && (
{apiKeyVerificationStatus !== 'verified' && (
<Button
onClick={verifyApiKeyAuth}
disabled={
apiKeyVerificationStatus === "verifying" || !hasApiKey
}
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
data-testid="verify-api-key-button"
>
{apiKeyVerificationStatus === "verifying" ? (
{apiKeyVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : apiKeyVerificationStatus === "error" ? (
) : apiKeyVerificationStatus === 'error' ? (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Retry Verification
@@ -741,20 +661,12 @@ export function ClaudeSetupStep({
{/* Navigation */}
<div className="flex justify-between pt-4">
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground"
>
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onSkip}
className="text-muted-foreground"
>
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
Skip for now
</Button>
<Button

View File

@@ -1,40 +1,28 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
import { useSetupStore } from "@/store/setup-store";
import { useAppStore } from "@/store/app-store";
import { Button } from '@/components/ui/button';
import { CheckCircle2, Sparkles } from 'lucide-react';
interface CompleteStepProps {
onFinish: () => void;
}
export function CompleteStep({ onFinish }: CompleteStepProps) {
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
const { apiKeys } = useAppStore();
const claudeReady =
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
apiKeys.anthropic;
return (
<div className="text-center space-y-6">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
<div className="w-20 h-20 rounded-full bg-linear-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
<CheckCircle2 className="w-10 h-10 text-white" />
</div>
<div>
<h2 className="text-3xl font-bold text-foreground mb-3">
Setup Complete!
</h2>
<h2 className="text-3xl font-bold text-foreground mb-3">Setup Complete!</h2>
<p className="text-muted-foreground max-w-md mx-auto">
Your development environment is configured. You&apos;re ready to start
building with AI-powered assistance.
Your development environment is configured. You&apos;re ready to start building with
AI-powered assistance.
</p>
</div>
<Button
size="lg"
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
onClick={onFinish}
data-testid="setup-finish-button"
>

View File

@@ -1,6 +1,5 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Terminal, ArrowRight } from "lucide-react";
import { Button } from '@/components/ui/button';
import { ArrowRight } from 'lucide-react';
interface WelcomeStepProps {
onNext: () => void;
@@ -10,17 +9,14 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
return (
<div className="text-center space-y-6">
<div className="flex items-center justify-center mx-auto">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
</div>
<div>
<h2 className="text-3xl font-bold text-foreground mb-3">
Welcome to Automaker
</h2>
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
<p className="text-muted-foreground max-w-md mx-auto">
To get started, we&apos;ll need to verify either claude code cli is
installed or you have Anthropic api keys
To get started, we&apos;ll need to verify either claude code cli is installed or you have
Anthropic api keys
</p>
</div>

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Terminal as TerminalIcon,
Plus,
@@ -12,17 +11,13 @@ import {
RefreshCw,
X,
SquarePlus,
} from "lucide-react";
import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store";
import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Panel,
PanelGroup,
PanelResizeHandle,
} from "react-resizable-panels";
import { TerminalPanel } from "./terminal-view/terminal-panel";
} from 'lucide-react';
import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { TerminalPanel } from './terminal-view/terminal-panel';
import {
DndContext,
DragEndEvent,
@@ -34,8 +29,8 @@ import {
closestCenter,
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { cn } from "@/lib/utils";
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
interface TerminalStatus {
enabled: boolean;
@@ -64,18 +59,18 @@ function TerminalTabButton({
}) {
const { setNodeRef, isOver } = useDroppable({
id: `tab-${tab.id}`,
data: { type: "tab", tabId: tab.id },
data: { type: 'tab', tabId: tab.id },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors",
'flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors',
isActive
? "bg-background border-brand-500 text-foreground"
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
isOver && isDropTarget && "ring-2 ring-green-500"
? 'bg-background border-brand-500 text-foreground'
: 'bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent',
isOver && isDropTarget && 'ring-2 ring-green-500'
)}
onClick={onClick}
>
@@ -97,18 +92,18 @@ function TerminalTabButton({
// New tab drop zone
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: "new-tab-zone",
data: { type: "new-tab" },
id: 'new-tab-zone',
data: { type: 'new-tab' },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all",
'flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all',
isOver && isDropTarget
? "border-green-500 bg-green-500/10 text-green-500"
: "border-transparent text-muted-foreground hover:border-border"
? 'border-green-500 bg-green-500/10 text-green-500'
: 'border-transparent text-muted-foreground hover:border-border'
)}
>
<SquarePlus className="h-4 w-4" />
@@ -135,7 +130,7 @@ export function TerminalView() {
const [status, setStatus] = useState<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [password, setPassword] = useState('');
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
@@ -143,7 +138,7 @@ export function TerminalView() {
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(false);
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
@@ -159,7 +154,7 @@ export function TerminalView() {
};
// Get active tab
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
const activeTab = terminalState.tabs.find((t) => t.id === terminalState.activeTabId);
// DnD sensors with activation constraint to avoid accidental drags
const sensors = useSensors(
@@ -178,43 +173,46 @@ export function TerminalView() {
// Handle drag over - track which tab we're hovering
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
if (over?.data?.current?.type === "tab") {
if (over?.data?.current?.type === 'tab') {
setDragOverTabId(over.data.current.tabId);
} else if (over?.data?.current?.type === "new-tab") {
setDragOverTabId("new");
} else if (over?.data?.current?.type === 'new-tab') {
setDragOverTabId('new');
} else {
setDragOverTabId(null);
}
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
setDragOverTabId(null);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
setDragOverTabId(null);
if (!over) return;
if (!over) return;
const activeId = active.id as string;
const overData = over.data?.current;
const activeId = active.id as string;
const overData = over.data?.current;
// If dropped on a tab, move terminal to that tab
if (overData?.type === "tab") {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on a tab, move terminal to that tab
if (overData?.type === 'tab') {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === "new-tab") {
moveTerminalToTab(activeId, "new");
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === 'new-tab') {
moveTerminalToTab(activeId, 'new');
return;
}
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
}, [swapTerminals, moveTerminalToTab]);
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
},
[swapTerminals, moveTerminalToTab]
);
// Fetch terminal status
const fetchStatus = useCallback(async () => {
@@ -229,11 +227,11 @@ export function TerminalView() {
setTerminalUnlocked(true);
}
} else {
setError(data.error || "Failed to get terminal status");
setError(data.error || 'Failed to get terminal status');
}
} catch (err) {
setError("Failed to connect to server");
console.error("[Terminal] Status fetch error:", err);
setError('Failed to connect to server');
console.error('[Terminal] Status fetch error:', err);
} finally {
setLoading(false);
}
@@ -251,21 +249,21 @@ export function TerminalView() {
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await response.json();
if (data.success) {
setTerminalUnlocked(true, data.data.token);
setPassword("");
setPassword('');
} else {
setAuthError(data.error || "Authentication failed");
setAuthError(data.error || 'Authentication failed');
}
} catch (err) {
setAuthError("Failed to authenticate");
console.error("[Terminal] Auth error:", err);
setAuthError('Failed to authenticate');
console.error('[Terminal] Auth error:', err);
} finally {
setAuthLoading(false);
}
@@ -273,21 +271,24 @@ export function TerminalView() {
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
if (!canCreateTerminal("[Terminal] Debounced terminal creation")) {
const createTerminal = async (
direction?: 'horizontal' | 'vertical',
targetSessionId?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
return;
}
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
@@ -300,10 +301,10 @@ export function TerminalView() {
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
} else {
console.error("[Terminal] Failed to create session:", data.error);
console.error('[Terminal] Failed to create session:', data.error);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
console.error('[Terminal] Create session error:', err);
} finally {
isCreatingRef.current = false;
}
@@ -311,21 +312,21 @@ export function TerminalView() {
// Create terminal in new tab
const createTerminalInNewTab = async () => {
if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return;
}
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
@@ -341,7 +342,7 @@ export function TerminalView() {
addTerminalToTab(data.data.id, tabId);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
console.error('[Terminal] Create session error:', err);
} finally {
isCreatingRef.current = false;
}
@@ -352,16 +353,16 @@ export function TerminalView() {
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
headers['X-Terminal-Token'] = terminalState.authToken;
}
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: "DELETE",
method: 'DELETE',
headers,
});
removeTerminalFromLayout(sessionId);
} catch (err) {
console.error("[Terminal] Kill session error:", err);
console.error('[Terminal] Kill session error:', err);
}
};
@@ -391,25 +392,20 @@ export function TerminalView() {
const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey;
const altMatches = needsAlt ? e.altKey : !e.altKey;
return (
e.key.toLowerCase() === key &&
cmdMatches &&
shiftMatches &&
altMatches
);
return e.key.toLowerCase() === key && cmdMatches && shiftMatches && altMatches;
};
// Split terminal right (Cmd+D / Ctrl+D)
if (matchesShortcut(shortcuts.splitTerminalRight)) {
e.preventDefault();
createTerminal("horizontal", terminalState.activeSessionId);
createTerminal('horizontal', terminalState.activeSessionId);
return;
}
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
if (matchesShortcut(shortcuts.splitTerminalDown)) {
e.preventDefault();
createTerminal("vertical", terminalState.activeSessionId);
createTerminal('vertical', terminalState.activeSessionId);
return;
}
@@ -427,7 +423,7 @@ export function TerminalView() {
// Collect all terminal IDs from a panel tree in order
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
if (panel.type === "terminal") {
if (panel.type === 'terminal') {
return [panel.sessionId];
}
return panel.panels.flatMap(getTerminalIds);
@@ -436,16 +432,16 @@ export function TerminalView() {
// Get a STABLE key for a panel - based only on terminal IDs, not tree structure
// This prevents unnecessary remounts when layout structure changes
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") {
if (panel.type === 'terminal') {
return panel.sessionId;
}
// Use joined terminal IDs - stable regardless of nesting depth
return `group-${getTerminalIds(panel).join("-")}`;
return `group-${getTerminalIds(panel).join('-')}`;
};
// Render panel content recursively
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
if (content.type === "terminal") {
if (content.type === 'terminal') {
// Use per-terminal fontSize or fall back to default
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
return (
@@ -456,8 +452,8 @@ export function TerminalView() {
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)}
onSplitVertical={() => createTerminal('vertical', content.sessionId)}
isDragging={activeDragId === content.sessionId}
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
@@ -466,15 +462,14 @@ export function TerminalView() {
);
}
const isHorizontal = content.direction === "horizontal";
const isHorizontal = content.direction === 'horizontal';
const defaultSizePerPanel = 100 / content.panels.length;
return (
<PanelGroup direction={content.direction}>
{content.panels.map((panel, index) => {
const panelSize = panel.type === "terminal" && panel.size
? panel.size
: defaultSizePerPanel;
const panelSize =
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
const panelKey = getPanelKey(panel);
return (
@@ -484,8 +479,8 @@ export function TerminalView() {
key={`handle-${panelKey}`}
className={
isHorizontal
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
: "h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
? 'w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
: 'h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500'
}
/>
)}
@@ -534,7 +529,9 @@ export function TerminalView() {
</div>
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
<p className="text-muted-foreground max-w-md">
Terminal access has been disabled. Set <code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your server .env file to enable it.
Terminal access has been disabled. Set{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your
server .env file to enable it.
</p>
</div>
);
@@ -561,9 +558,7 @@ export function TerminalView() {
disabled={authLoading}
autoFocus
/>
{authError && (
<p className="text-sm text-destructive">{authError}</p>
)}
{authError && <p className="text-sm text-destructive">{authError}</p>}
<Button type="submit" className="w-full" disabled={authLoading || !password}>
{authLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
@@ -577,8 +572,8 @@ export function TerminalView() {
{status.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
@@ -597,7 +592,8 @@ export function TerminalView() {
Create a new terminal session to start executing commands.
{currentProject && (
<span className="block mt-2 text-sm">
Working directory: <code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
Working directory:{' '}
<code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
</span>
)}
</p>
@@ -610,8 +606,8 @@ export function TerminalView() {
{status?.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
{status.platform.isWSL && ' (WSL)'}
{' | '}Shell: {status.platform.defaultShell}
</p>
)}
</div>
@@ -644,9 +640,7 @@ export function TerminalView() {
))}
{/* New tab drop zone (visible when dragging) */}
{activeDragId && (
<NewTabDropZone isDropTarget={true} />
)}
{activeDragId && <NewTabDropZone isDropTarget={true} />}
{/* New tab button */}
<button
@@ -664,7 +658,7 @@ export function TerminalView() {
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("horizontal")}
onClick={() => createTerminal('horizontal')}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
@@ -673,7 +667,7 @@ export function TerminalView() {
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("vertical")}
onClick={() => createTerminal('vertical')}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
@@ -688,11 +682,7 @@ export function TerminalView() {
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button
variant="outline"
size="sm"
onClick={() => createTerminal()}
>
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
@@ -707,11 +697,7 @@ export function TerminalView() {
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === "new"
? "New tab"
: dragOverTabId
? "Move to tab"
: "Terminal"}
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
</span>
</div>
) : null}

View File

@@ -1,6 +1,5 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -8,10 +7,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAppStore, type ThemeMode } from "@/store/app-store";
import { getElectronAPI, type Project } from "@/lib/electron";
import { initializeProject } from "@/lib/project-init";
} from '@/components/ui/dialog';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
import {
FolderOpen,
Plus,
@@ -21,19 +20,19 @@ import {
MessageSquare,
ChevronDown,
Loader2,
} from "lucide-react";
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
import { NewProjectModal } from "@/components/new-project-modal";
import { getHttpApiClient } from "@/lib/http-api-client";
import type { StarterTemplate } from "@/lib/templates";
import { useNavigate } from "@tanstack/react-router";
} from '@/components/ui/dropdown-menu';
import { toast } from 'sonner';
import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { StarterTemplate } from '@/lib/templates';
import { useNavigate } from '@tanstack/react-router';
export function WelcomeView() {
const {
@@ -66,24 +65,24 @@ export function WelcomeView() {
const api = getElectronAPI();
if (!api.autoMode?.analyzeProject) {
console.log("[Welcome] Auto mode API not available, skipping analysis");
console.log('[Welcome] Auto mode API not available, skipping analysis');
return;
}
setIsAnalyzing(true);
try {
console.log("[Welcome] Starting project analysis for:", projectPath);
console.log('[Welcome] Starting project analysis for:', projectPath);
const result = await api.autoMode.analyzeProject(projectPath);
if (result.success) {
toast.success("Project analyzed", {
description: "AI agent has analyzed your project structure",
toast.success('Project analyzed', {
description: 'AI agent has analyzed your project structure',
});
} else {
console.error("[Welcome] Project analysis failed:", result.error);
console.error('[Welcome] Project analysis failed:', result.error);
}
} catch (error) {
console.error("[Welcome] Failed to analyze project:", error);
console.error('[Welcome] Failed to analyze project:', error);
} finally {
setIsAnalyzing(false);
}
@@ -100,8 +99,8 @@ export function WelcomeView() {
const initResult = await initializeProject(path);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
@@ -113,7 +112,7 @@ export function WelcomeView() {
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Show initialization dialog if files were created
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
@@ -126,26 +125,23 @@ export function WelcomeView() {
setShowInitDialog(true);
// Kick off agent to analyze the project and update app_spec.txt
console.log(
"[Welcome] Project initialized, created files:",
initResult.createdFiles
);
console.log("[Welcome] Kicking off project analysis agent...");
console.log('[Welcome] Project initialized, created files:', initResult.createdFiles);
console.log('[Welcome] Kicking off project analysis agent...');
// Start analysis in background (don't await, let it run async)
analyzeProject(path);
} else {
toast.success("Project opened", {
toast.success('Project opened', {
description: `Opened ${name}`,
});
}
// Navigate to the board view
navigate({ to: "/board" });
navigate({ to: '/board' });
} catch (error) {
console.error("[Welcome] Failed to open project:", error);
toast.error("Failed to open project", {
description: error instanceof Error ? error.message : "Unknown error",
console.error('[Welcome] Failed to open project:', error);
toast.error('Failed to open project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsOpening(false);
@@ -178,21 +174,19 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// Extract folder name from path (works on both Windows and Mac/Linux)
const name =
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
await initializeAndOpenProject(path, name);
}
}
} catch (error) {
console.error("[Welcome] Failed to check workspace config:", error);
console.error('[Welcome] Failed to check workspace config:', error);
// Fall back to current behavior on error
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
const name =
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
await initializeAndOpenProject(path, name);
}
}
@@ -224,16 +218,13 @@ export function WelcomeView() {
};
const handleInteractiveMode = () => {
navigate({ to: "/interview" });
navigate({ to: '/interview' });
};
/**
* Create a blank project with just .automaker directory structure
*/
const handleCreateBlankProject = async (
projectName: string,
parentDir: string
) => {
const handleCreateBlankProject = async (projectName: string, parentDir: string) => {
setIsCreating(true);
try {
const api = getElectronAPI();
@@ -242,7 +233,7 @@ export function WelcomeView() {
// Validate that parent directory exists
const parentExists = await api.exists(parentDir);
if (!parentExists) {
toast.error("Parent directory does not exist", {
toast.error('Parent directory does not exist', {
description: `Cannot create project in non-existent directory: ${parentDir}`,
});
return;
@@ -250,8 +241,8 @@ export function WelcomeView() {
// Verify parent is actually a directory
const parentStat = await api.stat(parentDir);
if (parentStat && !parentStat.isDirectory) {
toast.error("Parent path is not a directory", {
if (parentStat && !parentStat.stats?.isDirectory) {
toast.error('Parent path is not a directory', {
description: `${parentDir} is not a directory`,
});
return;
@@ -260,8 +251,8 @@ export function WelcomeView() {
// Create project directory
const mkdirResult = await api.mkdir(projectPath);
if (!mkdirResult.success) {
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
toast.error('Failed to create project directory', {
description: mkdirResult.error || 'Unknown error occurred',
});
return;
}
@@ -270,8 +261,8 @@ export function WelcomeView() {
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
@@ -313,7 +304,7 @@ export function WelcomeView() {
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success("Project created", {
toast.success('Project created', {
description: `Created ${projectName} with .automaker directory`,
});
@@ -326,9 +317,9 @@ export function WelcomeView() {
});
setShowInitDialog(true);
} catch (error) {
console.error("Failed to create project:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
console.error('Failed to create project:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreating(false);
@@ -356,8 +347,8 @@ export function WelcomeView() {
);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone template", {
description: cloneResult.error || "Unknown error occurred",
toast.error('Failed to clone template', {
description: cloneResult.error || 'Unknown error occurred',
});
return;
}
@@ -368,8 +359,8 @@ export function WelcomeView() {
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
@@ -387,15 +378,11 @@ export function WelcomeView() {
</overview>
<technology_stack>
${template.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")}
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
</technology_stack>
<core_capabilities>
${template.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")}
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
</core_capabilities>
<implemented_features>
@@ -415,7 +402,7 @@ export function WelcomeView() {
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success("Project created from template", {
toast.success('Project created from template', {
description: `Created ${projectName} from ${template.name}`,
});
@@ -431,9 +418,9 @@ export function WelcomeView() {
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
console.error("Failed to create project from template:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
console.error('Failed to create project from template:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreating(false);
@@ -454,15 +441,11 @@ export function WelcomeView() {
const api = getElectronAPI();
// Clone the repository
const cloneResult = await httpClient.templates.clone(
repoUrl,
projectName,
parentDir
);
const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir);
if (!cloneResult.success || !cloneResult.projectPath) {
toast.error("Failed to clone repository", {
description: cloneResult.error || "Unknown error occurred",
toast.error('Failed to clone repository', {
description: cloneResult.error || 'Unknown error occurred',
});
return;
}
@@ -473,8 +456,8 @@ export function WelcomeView() {
const initResult = await initializeProject(projectPath);
if (!initResult.success) {
toast.error("Failed to initialize project", {
description: initResult.error || "Unknown error occurred",
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
@@ -516,7 +499,7 @@ export function WelcomeView() {
setCurrentProject(project);
setShowNewProjectModal(false);
toast.success("Project created from repository", {
toast.success('Project created from repository', {
description: `Created ${projectName} from ${repoUrl}`,
});
@@ -532,9 +515,9 @@ export function WelcomeView() {
// Kick off project analysis
analyzeProject(projectPath);
} catch (error) {
console.error("Failed to create project from custom URL:", error);
toast.error("Failed to create project", {
description: error instanceof Error ? error.message : "Unknown error",
console.error('Failed to create project from custom URL:', error);
toast.error('Failed to create project', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCreating(false);
@@ -555,7 +538,7 @@ export function WelcomeView() {
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center gap-4 animate-in fade-in slide-in-from-top-2 duration-500">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shadow-lg shadow-brand-500/10">
<img src="/logo.png" alt="Automaker Logo" className="w-8 h-8" />
</div>
<div>
@@ -580,26 +563,23 @@ export function WelcomeView() {
className="group relative rounded-xl border border-border bg-card/80 backdrop-blur-sm hover:bg-card hover:border-brand-500/30 hover:shadow-xl hover:shadow-brand-500/5 transition-all duration-300 hover:-translate-y-1"
data-testid="new-project-card"
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/5 via-transparent to-purple-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/25 flex items-center justify-center group-hover:scale-105 group-hover:shadow-brand-500/40 transition-all duration-300 shrink-0">
<Plus className="w-6 h-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
New Project
</h3>
<h3 className="text-lg font-semibold text-foreground mb-1.5">New Project</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Create a new project from scratch with AI-powered
development
Create a new project from scratch with AI-powered development
</p>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="w-full mt-5 bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
className="w-full mt-5 bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20 hover:shadow-brand-500/30 transition-all"
data-testid="create-new-project"
>
<Plus className="w-4 h-4 mr-2" />
@@ -608,10 +588,7 @@ export function WelcomeView() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleNewProject}
data-testid="quick-setup-option"
>
<DropdownMenuItem onClick={handleNewProject} data-testid="quick-setup-option">
<Plus className="w-4 h-4 mr-2" />
Quick Setup
</DropdownMenuItem>
@@ -633,16 +610,14 @@ export function WelcomeView() {
onClick={handleOpenProject}
data-testid="open-project-card"
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-blue-500/5 via-transparent to-cyan-600/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative p-6 h-full flex flex-col">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-muted border border-border flex items-center justify-center group-hover:bg-blue-500/10 group-hover:border-blue-500/30 group-hover:scale-105 transition-all duration-300 shrink-0">
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-foreground mb-1.5">
Open Project
</h3>
<h3 className="text-lg font-semibold text-foreground mb-1.5">Open Project</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Open an existing project folder to continue working
</p>
@@ -667,9 +642,7 @@ export function WelcomeView() {
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
<Clock className="w-4 h-4 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold text-foreground">
Recent Projects
</h2>
<h2 className="text-lg font-semibold text-foreground">Recent Projects</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recentProjects.map((project, index) => (
@@ -680,7 +653,7 @@ export function WelcomeView() {
data-testid={`recent-project-${project.id}`}
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
@@ -695,9 +668,7 @@ export function WelcomeView() {
</p>
{project.lastOpened && (
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(
project.lastOpened
).toLocaleDateString()}
{new Date(project.lastOpened).toLocaleDateString()}
</p>
)}
</div>
@@ -715,9 +686,7 @@ export function WelcomeView() {
<div className="w-20 h-20 rounded-2xl bg-muted/50 border border-border flex items-center justify-center mb-5">
<Sparkles className="w-10 h-10 text-muted-foreground/50" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
No projects yet
</h3>
<h3 className="text-xl font-semibold text-foreground mb-2">No projects yet</h3>
<p className="text-sm text-muted-foreground max-w-md leading-relaxed">
Get started by creating a new project or opening an existing one
</p>
@@ -747,9 +716,7 @@ export function WelcomeView() {
<div className="w-8 h-8 rounded-lg bg-brand-500/10 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-brand-500" />
</div>
{initStatus?.isNewProject
? "Project Initialized"
: "Project Updated"}
{initStatus?.isNewProject ? 'Project Initialized' : 'Project Updated'}
</DialogTitle>
<DialogDescription className="text-muted-foreground mt-1">
{initStatus?.isNewProject
@@ -759,9 +726,7 @@ export function WelcomeView() {
</DialogHeader>
<div className="py-4">
<div className="space-y-3">
<p className="text-sm text-foreground font-medium">
Created files:
</p>
<p className="text-sm text-foreground font-medium">Created files:</p>
<ul className="space-y-2">
{initStatus?.createdFiles.map((file) => (
<li
@@ -788,12 +753,12 @@ export function WelcomeView() {
</div>
) : (
<p className="text-sm text-muted-foreground leading-relaxed">
<span className="text-brand-500 font-medium">Tip:</span> Edit the{" "}
<span className="text-brand-500 font-medium">Tip:</span> Edit the{' '}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
app_spec.txt
</code>{" "}
file to describe your project. The AI agent will use this to
understand your project structure.
</code>{' '}
file to describe your project. The AI agent will use this to understand your
project structure.
</p>
)}
</div>
@@ -802,7 +767,7 @@ export function WelcomeView() {
<DialogFooter>
<Button
onClick={() => setShowInitDialog(false)}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white border-0 shadow-md shadow-brand-500/20"
data-testid="close-init-dialog"
>
Get Started
@@ -826,9 +791,7 @@ export function WelcomeView() {
>
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
<p className="text-foreground font-medium">
Initializing project...
</p>
<p className="text-foreground font-medium">Initializing project...</p>
</div>
</div>
)}

View File

@@ -1,5 +1,4 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useState, type ReactNode, type ElementType } from 'react';
import {
ChevronDown,
ChevronRight,
@@ -13,7 +12,6 @@ import {
PlayCircle,
Bot,
LayoutGrid,
FileText,
Terminal,
Palette,
Keyboard,
@@ -23,13 +21,13 @@ import {
TestTube,
Brain,
Users,
} from "lucide-react";
} from 'lucide-react';
interface WikiSection {
id: string;
title: string;
icon: React.ElementType;
content: React.ReactNode;
icon: ElementType;
content: ReactNode;
}
function CollapsibleSection({
@@ -52,9 +50,7 @@ function CollapsibleSection({
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-brand-500/10 text-brand-500">
<Icon className="w-4 h-4" />
</div>
<span className="flex-1 font-medium text-foreground">
{section.title}
</span>
<span className="flex-1 font-medium text-foreground">{section.title}</span>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
@@ -90,7 +86,7 @@ function CodeBlock({ children, title }: { children: string; title?: string }) {
function FeatureList({
items,
}: {
items: { icon: React.ElementType; title: string; description: string }[];
items: { icon: ElementType; title: string; description: string }[];
}) {
return (
<div className="grid gap-3 mt-3">
@@ -105,12 +101,8 @@ function FeatureList({
<ItemIcon className="w-3.5 h-3.5" />
</div>
<div>
<div className="font-medium text-foreground text-sm">
{item.title}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{item.description}
</div>
<div className="font-medium text-foreground text-sm">{item.title}</div>
<div className="text-xs text-muted-foreground mt-0.5">{item.description}</div>
</div>
</div>
);
@@ -120,9 +112,7 @@ function FeatureList({
}
export function WikiView() {
const [openSections, setOpenSections] = useState<Set<string>>(
new Set(["overview"])
);
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['overview']));
const toggleSection = (id: string) => {
setOpenSections((prev) => {
@@ -146,66 +136,66 @@ export function WikiView() {
const sections: WikiSection[] = [
{
id: "overview",
title: "Project Overview",
id: 'overview',
title: 'Project Overview',
icon: Rocket,
content: (
<div className="space-y-3">
<p>
<strong className="text-foreground">Automaker</strong> is an
autonomous AI development studio that helps developers build
software faster using AI agents.
<strong className="text-foreground">Automaker</strong> is an autonomous AI development
studio that helps developers build software faster using AI agents.
</p>
<p>
At its core, Automaker provides a visual Kanban board to manage
features. When you're ready, AI agents automatically implement those
features in your codebase, complete with git worktree isolation for
safe parallel development.
At its core, Automaker provides a visual Kanban board to manage features. When you're
ready, AI agents automatically implement those features in your codebase, complete with
git worktree isolation for safe parallel development.
</p>
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
<p className="text-brand-400 text-sm">
Think of it as having a team of AI developers that can work on
multiple features simultaneously while you focus on the bigger
picture.
Think of it as having a team of AI developers that can work on multiple features
simultaneously while you focus on the bigger picture.
</p>
</div>
</div>
),
},
{
id: "architecture",
title: "Architecture",
id: 'architecture',
title: 'Architecture',
icon: Layers,
content: (
<div className="space-y-3">
<p>Automaker is built as a monorepo with two main applications:</p>
<p>Automaker is built as a monorepo with two main applications and shared libraries:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>
<strong className="text-foreground">apps/ui</strong> - Next.js +
<strong className="text-foreground">apps/ui</strong> - React + TanStack Router +
Electron frontend for the desktop application
</li>
<li>
<strong className="text-foreground">apps/server</strong> - Express
backend handling API requests and agent orchestration
<strong className="text-foreground">apps/server</strong> - Express backend handling
API requests and agent orchestration
</li>
<li>
<strong className="text-foreground">libs/</strong> - Shared packages for types,
utilities, and common logic used across apps
</li>
</ul>
<div className="mt-4 space-y-2">
<p className="font-medium text-foreground">Key Technologies:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Electron wraps Next.js for cross-platform desktop support</li>
<li>
Real-time communication via WebSocket for live agent updates
</li>
<li>Electron + React + TanStack Router for cross-platform desktop support</li>
<li>Real-time communication via WebSocket for live agent updates</li>
<li>State management with Zustand for reactive UI updates</li>
<li>Claude Agent SDK for AI capabilities</li>
<li>Shared monorepo packages (@automaker/*) for code reuse</li>
</ul>
</div>
</div>
),
},
{
id: "features",
title: "Key Features",
id: 'features',
title: 'Key Features',
icon: Sparkles,
content: (
<div>
@@ -213,73 +203,69 @@ export function WikiView() {
items={[
{
icon: LayoutGrid,
title: "Kanban Board",
title: 'Kanban Board',
description:
"4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.",
'4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.',
},
{
icon: Bot,
title: "AI Agent Integration",
title: 'AI Agent Integration',
description:
"Powered by Claude via the Agent SDK with full file, bash, and git access.",
'Powered by Claude via the Agent SDK with full file, bash, and git access.',
},
{
icon: Cpu,
title: "Multi-Model Support",
title: 'Multi-Model Support',
description:
"Claude Haiku/Sonnet/Opus models. Choose the right model for each task.",
'Claude Haiku/Sonnet/Opus models. Choose the right model for each task.',
},
{
icon: Brain,
title: "Extended Thinking",
title: 'Extended Thinking',
description:
"Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.",
'Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.',
},
{
icon: Zap,
title: "Real-time Streaming",
description:
"Watch AI agents work in real-time with live output streaming.",
title: 'Real-time Streaming',
description: 'Watch AI agents work in real-time with live output streaming.',
},
{
icon: GitBranch,
title: "Git Worktree Isolation",
title: 'Git Worktree Isolation',
description:
"Each feature runs in its own git worktree for safe parallel development.",
'Each feature runs in its own git worktree for safe parallel development.',
},
{
icon: Users,
title: "AI Profiles",
title: 'AI Profiles',
description:
"Pre-configured model + thinking level combinations for different task types.",
'Pre-configured model + thinking level combinations for different task types.',
},
{
icon: Terminal,
title: "Integrated Terminal",
description:
"Built-in terminal with tab support and split panes.",
title: 'Integrated Terminal',
description: 'Built-in terminal with tab support and split panes.',
},
{
icon: Keyboard,
title: "Keyboard Shortcuts",
description: "Fully customizable shortcuts for power users.",
title: 'Keyboard Shortcuts',
description: 'Fully customizable shortcuts for power users.',
},
{
icon: Palette,
title: "14 Themes",
description:
"From light to dark, retro to synthwave - pick your style.",
title: '14 Themes',
description: 'From light to dark, retro to synthwave - pick your style.',
},
{
icon: Image,
title: "Image Support",
description: "Attach images to features for visual context.",
title: 'Image Support',
description: 'Attach images to features for visual context.',
},
{
icon: TestTube,
title: "Test Integration",
description:
"Automatic test running and TDD support for quality assurance.",
title: 'Test Integration',
description: 'Automatic test running and TDD support for quality assurance.',
},
]}
/>
@@ -287,26 +273,23 @@ export function WikiView() {
),
},
{
id: "data-flow",
title: "How It Works (Data Flow)",
id: 'data-flow',
title: 'How It Works (Data Flow)',
icon: GitBranch,
content: (
<div className="space-y-3">
<p>
Here's what happens when you use Automaker to implement a feature:
</p>
<p>Here's what happens when you use Automaker to implement a feature:</p>
<ol className="list-decimal list-inside space-y-3 ml-2 mt-4">
<li className="text-foreground">
<strong>Create Feature</strong>
<p className="text-muted-foreground ml-5 mt-1">
Add a new feature card to the Kanban board with description and
steps
Add a new feature card to the Kanban board with description and steps
</p>
</li>
<li className="text-foreground">
<strong>Feature Saved</strong>
<p className="text-muted-foreground ml-5 mt-1">
Feature saved to{" "}
Feature saved to{' '}
<code className="px-1 py-0.5 bg-muted rounded text-xs">
.automaker/features/&#123;id&#125;/feature.json
</code>
@@ -315,15 +298,13 @@ export function WikiView() {
<li className="text-foreground">
<strong>Start Work</strong>
<p className="text-muted-foreground ml-5 mt-1">
Drag to "In Progress" or enable auto mode to start
implementation
Drag to "In Progress" or enable auto mode to start implementation
</p>
</li>
<li className="text-foreground">
<strong>Git Worktree Created</strong>
<p className="text-muted-foreground ml-5 mt-1">
Backend AutoModeService creates isolated git worktree (if
enabled)
Backend AutoModeService creates isolated git worktree (if enabled)
</p>
</li>
<li className="text-foreground">
@@ -355,38 +336,64 @@ export function WikiView() {
),
},
{
id: "structure",
title: "Project Structure",
id: 'structure',
title: 'Project Structure',
icon: FolderTree,
content: (
<div>
<p className="mb-3">
The Automaker codebase is organized as follows:
</p>
<p className="mb-3">The Automaker codebase is organized as follows:</p>
<CodeBlock title="Directory Structure">
{`/automaker/
├─ apps/
├── app/ # Frontend (Next.js + Electron)
├── electron/ # Electron main process
└── src/
├── app/ # Next.js App Router pages
│ ├── components/ # React components
│ ├─ store/ # Zustand state management
│ ├── hooks/ # Custom React hooks
│ └── lib/ # Utilities and helpers
└── server/ # Backend (Express)
└── src/
├── routes/ # API endpoints
└── services/ # Business logic (AutoModeService, etc.)
├── docs/ # Documentation
└── package.json # Workspace root`}
{`automaker/
├─ apps/
├─ ui/ Frontend (React + Electron)
│ │ └─ src/
│ │ ├─ routes/ TanStack Router pages
├─ components/
│ │ ├─ layout/ Layout components (sidebar, etc.)
│ │ ├─ views/ View components (board, agent, etc.)
│ │ ├─ dialogs/ Dialog components
│ │ └─ ui/ shadcn/ui components
│ ├─ store/ Zustand state management
├─ hooks/ Custom React hooks
│ ├─ lib/ Utilities and helpers
├─ config/ App configuration files
│ │ ├─ contexts/ React context providers
│ │ ├─ styles/ CSS styles and theme definitions
│ │ ├─ types/ TypeScript type definitions
│ │ ├─ utils/ Utility functions
│ │ ├─ main.ts Electron main process entry
│ │ ├─ preload.ts Electron preload script
│ │ └─ renderer.tsx React renderer entry
│ │
│ └─ server/ Backend (Express)
│ └─ src/
│ ├─ routes/ API endpoints
│ ├─ services/ Business logic (AutoModeService, etc.)
│ ├─ lib/ Library utilities
│ ├─ middleware/ Express middleware
│ ├─ providers/ AI provider implementations
│ ├─ types/ TypeScript type definitions
│ └─ index.ts Server entry point
├─ libs/ Shared packages (monorepo)
│ ├─ types/ TypeScript type definitions
│ ├─ utils/ Common utilities (logging, errors)
│ ├─ prompts/ AI prompt templates
│ ├─ platform/ Platform & path utilities
│ ├─ model-resolver/ Claude model resolution
│ ├─ dependency-resolver/ Feature dependency ordering
│ └─ git-utils/ Git operations & parsing
├─ docs/ Documentation
└─ package.json Workspace root
`}
</CodeBlock>
</div>
),
},
{
id: "components",
title: "Key Components",
id: 'components',
title: 'Key Components',
icon: Component,
content: (
<div className="space-y-3">
@@ -394,33 +401,36 @@ export function WikiView() {
<div className="grid gap-2 mt-4">
{[
{
file: "sidebar.tsx",
desc: "Main navigation with project picker and view switching",
file: 'layout/sidebar.tsx',
desc: 'Main navigation with project picker and view switching',
},
{
file: "board-view.tsx",
desc: "Kanban board with drag-and-drop cards",
file: 'views/board-view.tsx',
desc: 'Kanban board with drag-and-drop cards',
},
{
file: "agent-view.tsx",
desc: "AI chat interface for conversational development",
},
{ file: "spec-view.tsx", desc: "Project specification editor" },
{
file: "context-view.tsx",
desc: "Context file manager for AI context",
file: 'views/agent-view.tsx',
desc: 'AI chat interface for conversational development',
},
{
file: "terminal-view.tsx",
desc: "Integrated terminal with splits and tabs",
file: 'views/spec-view/',
desc: 'Project specification editor with AI generation',
},
{
file: "profiles-view.tsx",
desc: "AI profile management (model + thinking presets)",
file: 'views/context-view.tsx',
desc: 'Context file manager for AI context',
},
{
file: "app-store.ts",
desc: "Central Zustand state management",
file: 'views/terminal-view/',
desc: 'Integrated terminal with splits and tabs',
},
{
file: 'views/profiles-view.tsx',
desc: 'AI profile management (model + thinking presets)',
},
{
file: 'store/app-store.ts',
desc: 'Central Zustand state management',
},
].map((item) => (
<div
@@ -430,9 +440,7 @@ export function WikiView() {
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">
{item.file}
</code>
<span className="text-xs text-muted-foreground">
{item.desc}
</span>
<span className="text-xs text-muted-foreground">{item.desc}</span>
</div>
))}
</div>
@@ -440,31 +448,28 @@ export function WikiView() {
),
},
{
id: "configuration",
title: "Configuration",
id: 'configuration',
title: 'Configuration',
icon: Settings,
content: (
<div className="space-y-3">
<p>
Automaker stores project configuration in the{" "}
<code className="px-1 py-0.5 bg-muted rounded text-xs">
.automaker/
</code>{" "}
directory:
Automaker stores project configuration in the{' '}
<code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/</code> directory:
</p>
<div className="grid gap-2 mt-4">
{[
{
file: "app_spec.txt",
desc: "Project specification describing your app for AI context",
file: 'app_spec.txt',
desc: 'Project specification describing your app for AI context',
},
{
file: "context/",
desc: "Additional context files (docs, examples) for AI",
file: 'context/',
desc: 'Additional context files (docs, examples) for AI',
},
{
file: "features/",
desc: "Feature definitions with descriptions and steps",
file: 'features/',
desc: 'Feature definitions with descriptions and steps',
},
].map((item) => (
<div
@@ -474,16 +479,12 @@ export function WikiView() {
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">
{item.file}
</code>
<span className="text-xs text-muted-foreground">
{item.desc}
</span>
<span className="text-xs text-muted-foreground">{item.desc}</span>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-sm text-foreground font-medium mb-2">
Tip: App Spec Best Practices
</p>
<p className="text-sm text-foreground font-medium mb-2">Tip: App Spec Best Practices</p>
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
<li>Include your tech stack and key dependencies</li>
<li>Describe the project structure and conventions</li>
@@ -495,8 +496,8 @@ export function WikiView() {
),
},
{
id: "getting-started",
title: "Getting Started",
id: 'getting-started',
title: 'Getting Started',
icon: PlayCircle,
content: (
<div className="space-y-3">
@@ -505,43 +506,38 @@ export function WikiView() {
<li className="text-foreground">
<strong>Create or Open a Project</strong>
<p className="text-muted-foreground ml-5 mt-1">
Use the sidebar to create a new project or open an existing
folder
Use the sidebar to create a new project or open an existing folder
</p>
</li>
<li className="text-foreground">
<strong>Write an App Spec</strong>
<p className="text-muted-foreground ml-5 mt-1">
Go to Spec Editor and describe your project. This helps AI
understand your codebase.
Go to Spec Editor and describe your project. This helps AI understand your codebase.
</p>
</li>
<li className="text-foreground">
<strong>Add Context (Optional)</strong>
<p className="text-muted-foreground ml-5 mt-1">
Add relevant documentation or examples to the Context view for
better AI results
Add relevant documentation or examples to the Context view for better AI results
</p>
</li>
<li className="text-foreground">
<strong>Create Features</strong>
<p className="text-muted-foreground ml-5 mt-1">
Add feature cards to your Kanban board with clear descriptions
and implementation steps
Add feature cards to your Kanban board with clear descriptions and implementation
steps
</p>
</li>
<li className="text-foreground">
<strong>Configure AI Profile</strong>
<p className="text-muted-foreground ml-5 mt-1">
Choose an AI profile or customize model/thinking settings per
feature
Choose an AI profile or customize model/thinking settings per feature
</p>
</li>
<li className="text-foreground">
<strong>Start Implementation</strong>
<p className="text-muted-foreground ml-5 mt-1">
Drag features to "In Progress" or enable auto mode to let AI
work
Drag features to "In Progress" or enable auto mode to let AI work
</p>
</li>
<li className="text-foreground">
@@ -555,16 +551,12 @@ export function WikiView() {
<p className="text-brand-400 text-sm font-medium mb-2">Pro Tips:</p>
<ul className="list-disc list-inside space-y-1 text-xs text-brand-400/80">
<li>
Use keyboard shortcuts for faster navigation (press{" "}
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code>{" "}
to see all)
Use keyboard shortcuts for faster navigation (press{' '}
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)
</li>
<li>Enable git worktree isolation for parallel feature development</li>
<li>
Enable git worktree isolation for parallel feature development
</li>
<li>
Start with "Quick Edit" profile for simple tasks, use "Heavy
Task" for complex work
Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
</li>
<li>Keep your app spec up to date as your project evolves</li>
</ul>

View File

@@ -1,8 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import type { LucideIcon } from "lucide-react";
import type { ApiKeys } from "@/store/app-store";
import type { Dispatch, SetStateAction } from 'react';
import type { ApiKeys } from '@/store/app-store';
export type ProviderKey = "anthropic" | "google";
export type ProviderKey = 'anthropic' | 'google';
export interface ProviderConfig {
key: ProviderKey;
@@ -56,33 +55,32 @@ export interface ProviderConfigParams {
export const buildProviderConfigs = ({
apiKeys,
anthropic,
google,
}: ProviderConfigParams): ProviderConfig[] => [
{
key: "anthropic",
label: "Anthropic API Key",
inputId: "anthropic-key",
placeholder: "sk-ant-...",
key: 'anthropic',
label: 'Anthropic API Key',
inputId: 'anthropic-key',
placeholder: 'sk-ant-...',
value: anthropic.value,
setValue: anthropic.setValue,
showValue: anthropic.show,
setShowValue: anthropic.setShow,
hasStoredKey: apiKeys.anthropic,
inputTestId: "anthropic-api-key-input",
toggleTestId: "toggle-anthropic-visibility",
inputTestId: 'anthropic-api-key-input',
toggleTestId: 'toggle-anthropic-visibility',
testButton: {
onClick: anthropic.onTest,
disabled: !anthropic.value || anthropic.testing,
loading: anthropic.testing,
testId: "test-claude-connection",
testId: 'test-claude-connection',
},
result: anthropic.result,
resultTestId: "test-connection-result",
resultMessageTestId: "test-connection-message",
descriptionPrefix: "Used for Claude AI features. Get your key at",
descriptionLinkHref: "https://console.anthropic.com/account/keys",
descriptionLinkText: "console.anthropic.com",
descriptionSuffix: ".",
resultTestId: 'test-connection-result',
resultMessageTestId: 'test-connection-message',
descriptionPrefix: 'Used for Claude AI features. Get your key at',
descriptionLinkHref: 'https://console.anthropic.com/account/keys',
descriptionLinkText: 'console.anthropic.com',
descriptionSuffix: '.',
},
// {
// key: "google",

View File

@@ -0,0 +1,9 @@
export { useAutoMode } from './use-auto-mode';
export { useBoardBackgroundSettings } from './use-board-background-settings';
export { useElectronAgent } from './use-electron-agent';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
export { useMessageQueue } from './use-message-queue';
export { useResponsiveKanban } from './use-responsive-kanban';
export { useScrollTracking } from './use-scroll-tracking';
export { useSettingsMigration } from './use-settings-migration';
export { useWindowState } from './use-window-state';

View File

@@ -1,10 +1,10 @@
/**
* File Picker Utility for Web Browsers
*
*
* Provides cross-platform file and directory selection using:
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
*
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This implementation extracts directory information and may require
* user confirmation or server-side path resolution.
@@ -22,7 +22,7 @@ export interface DirectoryPickerResult {
/**
* Opens a directory picker dialog
* @returns Promise resolving to directory information, or null if canceled
*
*
* Note: Browsers don't expose absolute file paths for security reasons.
* This function returns directory structure information that the server
* can use to locate the actual directory path.
@@ -31,10 +31,10 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Use webkitdirectory (works on Windows and all modern browsers)
return new Promise<DirectoryPickerResult | null>((resolve) => {
let resolved = false;
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.style.display = "none";
input.style.display = 'none';
const cleanup = () => {
if (input.parentNode) {
@@ -58,62 +58,59 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
}
};
input.addEventListener("change", (e) => {
input.addEventListener('change', (e) => {
changeEventFired = true;
if (focusTimeout) {
clearTimeout(focusTimeout);
focusTimeout = null;
}
console.log("[FilePicker] Change event fired");
console.log('[FilePicker] Change event fired');
const files = input.files;
console.log("[FilePicker] Files selected:", files?.length || 0);
console.log('[FilePicker] Files selected:', files?.length || 0);
if (!files || files.length === 0) {
console.log("[FilePicker] No files selected");
console.log('[FilePicker] No files selected');
safeResolve(null);
return;
}
const firstFile = files[0];
console.log("[FilePicker] First file:", {
console.log('[FilePicker] First file:', {
name: firstFile.name,
webkitRelativePath: firstFile.webkitRelativePath,
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
path: firstFile.path,
});
// Extract directory name from webkitRelativePath
// webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
let directoryName = "Selected Directory";
let directoryName = 'Selected Directory';
// Method 1: Try to get absolute path from File object (non-standard, works in Electron/Chromium)
// @ts-expect-error - path property is non-standard but available in some browsers
if (firstFile.path) {
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
const filePath = firstFile.path as string;
console.log("[FilePicker] Found file.path:", filePath);
console.log('[FilePicker] Found file.path:', filePath);
// Extract directory path (remove filename)
const lastSeparator = Math.max(
filePath.lastIndexOf("\\"),
filePath.lastIndexOf("/")
);
const lastSeparator = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
if (lastSeparator > 0) {
const absolutePath = filePath.substring(0, lastSeparator);
console.log("[FilePicker] Found absolute path:", absolutePath);
console.log('[FilePicker] Found absolute path:', absolutePath);
// Return as directory name for now - server can validate it directly
directoryName = absolutePath;
}
}
// Method 2: Extract directory name from webkitRelativePath
if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
if (directoryName === 'Selected Directory' && firstFile.webkitRelativePath) {
const relativePath = firstFile.webkitRelativePath;
console.log("[FilePicker] Using webkitRelativePath:", relativePath);
const pathParts = relativePath.split("/");
console.log('[FilePicker] Using webkitRelativePath:', relativePath);
const pathParts = relativePath.split('/');
if (pathParts.length > 0) {
directoryName = pathParts[0]; // Top-level directory name
console.log("[FilePicker] Extracted directory name:", directoryName);
console.log('[FilePicker] Extracted directory name:', directoryName);
}
}
@@ -130,7 +127,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
}
}
console.log("[FilePicker] Directory info:", {
console.log('[FilePicker] Directory info:', {
directoryName,
fileCount: files.length,
sampleFiles: sampleFiles.slice(0, 5), // Log first 5
@@ -150,7 +147,7 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Only resolve as canceled if change event hasn't fired after a delay
focusTimeout = setTimeout(() => {
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
console.log("[FilePicker] Dialog canceled (no files after focus and no change event)");
console.log('[FilePicker] Dialog canceled (no files after focus and no change event)');
safeResolve(null);
}
}, 2000); // Increased timeout for Windows - give it time
@@ -158,33 +155,37 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
// Add to DOM temporarily
document.body.appendChild(input);
console.log("[FilePicker] Opening directory picker...");
console.log('[FilePicker] Opening directory picker...');
// Try to show picker programmatically
if ("showPicker" in HTMLInputElement.prototype) {
if ('showPicker' in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
console.log("[FilePicker] Using showPicker()");
console.log('[FilePicker] Using showPicker()');
} catch (error) {
console.log("[FilePicker] showPicker() failed, using click()", error);
console.log('[FilePicker] showPicker() failed, using click()', error);
input.click();
}
} else {
console.log("[FilePicker] Using click()");
console.log('[FilePicker] Using click()');
input.click();
}
// Set up cancellation detection with longer delay
// Only add focus listener if we're not already resolved
window.addEventListener("focus", handleFocus, { once: true });
window.addEventListener('focus', handleFocus, { once: true });
// Also handle blur as a cancellation signal (but with delay)
window.addEventListener("blur", () => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener("focus", handleFocus, { once: true });
}, 100);
}, { once: true });
window.addEventListener(
'blur',
() => {
// Dialog opened, wait for it to close
setTimeout(() => {
window.addEventListener('focus', handleFocus, { once: true });
}, 100);
},
{ once: true }
);
});
}
@@ -193,21 +194,19 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
* @param options Optional configuration (multiple files, file types, etc.)
* @returns Promise resolving to selected file path(s), or null if canceled
*/
export async function openFilePicker(
options?: {
multiple?: boolean;
accept?: string;
}
): Promise<string | string[] | null> {
export async function openFilePicker(options?: {
multiple?: boolean;
accept?: string;
}): Promise<string | string[] | null> {
// Use standard file input (works on all browsers including Windows)
return new Promise<string | string[] | null>((resolve) => {
const input = document.createElement("input");
input.type = "file";
const input = document.createElement('input');
input.type = 'file';
input.multiple = options?.multiple ?? false;
if (options?.accept) {
input.accept = options.accept;
}
input.style.display = "none";
input.style.display = 'none';
const cleanup = () => {
if (input.parentNode) {
@@ -215,7 +214,7 @@ export async function openFilePicker(
}
};
input.addEventListener("change", () => {
input.addEventListener('change', () => {
const files = input.files;
if (!files || files.length === 0) {
cleanup();
@@ -228,7 +227,7 @@ export async function openFilePicker(
// Try to get path from File object (non-standard, but available in some browsers)
// @ts-expect-error - path property is non-standard
if (file.path) {
// @ts-expect-error
// @ts-expect-error - path property is non-standard but available in some browsers
return file.path as string;
}
// Fallback to filename (server will need to resolve)
@@ -262,7 +261,7 @@ export async function openFilePicker(
// Try to show picker programmatically
// Note: showPicker() is available in modern browsers but TypeScript types it as void
// In practice, it may return a Promise in some implementations, but we'll handle errors via try/catch
if ("showPicker" in HTMLInputElement.prototype) {
if ('showPicker' in HTMLInputElement.prototype) {
try {
(input as any).showPicker();
} catch {
@@ -274,6 +273,6 @@ export async function openFilePicker(
}
// Set up cancellation detection
window.addEventListener("focus", handleFocus, { once: true });
window.addEventListener('focus', handleFocus, { once: true });
});
}

View File

@@ -20,41 +20,35 @@ import type {
AutoModeEvent,
SuggestionsEvent,
SpecRegenerationEvent,
FeatureSuggestion,
SuggestionType,
} from "./electron";
import type { Message, SessionListItem } from "@/types/electron";
import type { Feature, ClaudeUsageResponse } from "@/store/app-store";
import type {
WorktreeAPI,
GitAPI,
ModelDefinition,
ProviderStatus,
} from "@/types/electron";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
} from './electron';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
const envUrl = import.meta.env.VITE_SERVER_URL;
if (envUrl) return envUrl;
}
return "http://localhost:3008";
return 'http://localhost:3008';
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
}
return null;
};
type EventType =
| "agent:stream"
| "auto-mode:event"
| "suggestions:event"
| "spec-regeneration:event";
| 'agent:stream'
| 'auto-mode:event'
| 'suggestions:event'
| 'spec-regeneration:event';
type EventCallback = (payload: unknown) => void;
@@ -80,21 +74,18 @@ export class HttpApiClient implements ElectronAPI {
}
private connectWebSocket(): void {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return;
}
this.isConnecting = true;
try {
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log("[HttpApiClient] WebSocket connected");
console.log('[HttpApiClient] WebSocket connected');
this.isConnecting = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -110,15 +101,12 @@ export class HttpApiClient implements ElectronAPI {
callbacks.forEach((cb) => cb(data.payload));
}
} catch (error) {
console.error(
"[HttpApiClient] Failed to parse WebSocket message:",
error
);
console.error('[HttpApiClient] Failed to parse WebSocket message:', error);
}
};
this.ws.onclose = () => {
console.log("[HttpApiClient] WebSocket disconnected");
console.log('[HttpApiClient] WebSocket disconnected');
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
@@ -131,19 +119,16 @@ export class HttpApiClient implements ElectronAPI {
};
this.ws.onerror = (error) => {
console.error("[HttpApiClient] WebSocket error:", error);
console.error('[HttpApiClient] WebSocket error:', error);
this.isConnecting = false;
};
} catch (error) {
console.error("[HttpApiClient] Failed to create WebSocket:", error);
console.error('[HttpApiClient] Failed to create WebSocket:', error);
this.isConnecting = false;
}
}
private subscribeToEvent(
type: EventType,
callback: EventCallback
): () => void {
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
if (!this.eventCallbacks.has(type)) {
this.eventCallbacks.set(type, new Set());
}
@@ -162,18 +147,18 @@ export class HttpApiClient implements ElectronAPI {
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
'Content-Type': 'application/json',
};
const apiKey = getApiKey();
if (apiKey) {
headers["X-API-Key"] = apiKey;
headers['X-API-Key'] = apiKey;
}
return headers;
}
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "POST",
method: 'POST',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
@@ -188,7 +173,7 @@ export class HttpApiClient implements ElectronAPI {
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "PUT",
method: 'PUT',
headers: this.getHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
@@ -197,7 +182,7 @@ export class HttpApiClient implements ElectronAPI {
private async httpDelete<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: "DELETE",
method: 'DELETE',
headers: this.getHeaders(),
});
return response.json();
@@ -205,15 +190,13 @@ export class HttpApiClient implements ElectronAPI {
// Basic operations
async ping(): Promise<string> {
const result = await this.get<{ status: string }>("/api/health");
return result.status === "ok" ? "pong" : "error";
const result = await this.get<{ status: string }>('/api/health');
return result.status === 'ok' ? 'pong' : 'error';
}
async openExternalLink(
url: string
): Promise<{ success: boolean; error?: string }> {
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
// Open in new tab
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, '_blank', 'noopener,noreferrer');
return { success: true };
}
@@ -222,7 +205,7 @@ export class HttpApiClient implements ElectronAPI {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
console.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -237,21 +220,21 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
path?: string;
error?: string;
}>("/api/fs/validate-path", { filePath: path });
}>('/api/fs/validate-path', { filePath: path });
if (result.success && result.path) {
return { canceled: false, filePaths: [result.path] };
}
console.error("Invalid directory:", result.error);
console.error('Invalid directory:', result.error);
return { canceled: true, filePaths: [] };
}
async openFile(options?: object): Promise<DialogResult> {
async openFile(_options?: object): Promise<DialogResult> {
const fileBrowser = getGlobalFileBrowser();
if (!fileBrowser) {
console.error("File browser not initialized");
console.error('File browser not initialized');
return { canceled: true, filePaths: [] };
}
@@ -262,50 +245,48 @@ export class HttpApiClient implements ElectronAPI {
return { canceled: true, filePaths: [] };
}
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath: path }
);
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
filePath: path,
});
if (result.success && result.exists) {
return { canceled: false, filePaths: [path] };
}
console.error("File not found");
console.error('File not found');
return { canceled: true, filePaths: [] };
}
// File system operations
async readFile(filePath: string): Promise<FileResult> {
return this.post("/api/fs/read", { filePath });
return this.post('/api/fs/read', { filePath });
}
async writeFile(filePath: string, content: string): Promise<WriteResult> {
return this.post("/api/fs/write", { filePath, content });
return this.post('/api/fs/write', { filePath, content });
}
async mkdir(dirPath: string): Promise<WriteResult> {
return this.post("/api/fs/mkdir", { dirPath });
return this.post('/api/fs/mkdir', { dirPath });
}
async readdir(dirPath: string): Promise<ReaddirResult> {
return this.post("/api/fs/readdir", { dirPath });
return this.post('/api/fs/readdir', { dirPath });
}
async exists(filePath: string): Promise<boolean> {
const result = await this.post<{ success: boolean; exists: boolean }>(
"/api/fs/exists",
{ filePath }
);
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
filePath,
});
return result.exists;
}
async stat(filePath: string): Promise<StatResult> {
return this.post("/api/fs/stat", { filePath });
return this.post('/api/fs/stat', { filePath });
}
async deleteFile(filePath: string): Promise<WriteResult> {
return this.post("/api/fs/delete", { filePath });
return this.post('/api/fs/delete', { filePath });
}
async trashItem(filePath: string): Promise<WriteResult> {
@@ -315,11 +296,9 @@ export class HttpApiClient implements ElectronAPI {
async getPath(name: string): Promise<string> {
// Server provides data directory
if (name === "userData") {
const result = await this.get<{ dataDir: string }>(
"/api/health/detailed"
);
return result.dataDir || "/data";
if (name === 'userData') {
const result = await this.get<{ dataDir: string }>('/api/health/detailed');
return result.dataDir || '/data';
}
return `/data/${name}`;
}
@@ -330,7 +309,7 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath?: string
): Promise<SaveImageResult> {
return this.post("/api/fs/save-image", {
return this.post('/api/fs/save-image', {
data,
filename,
mimeType,
@@ -344,7 +323,7 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath: string
): Promise<{ success: boolean; path?: string; error?: string }> {
return this.post("/api/fs/save-board-background", {
return this.post('/api/fs/save-board-background', {
data,
filename,
mimeType,
@@ -352,10 +331,8 @@ export class HttpApiClient implements ElectronAPI {
});
}
async deleteBoardBackground(
projectPath: string
): Promise<{ success: boolean; error?: string }> {
return this.post("/api/fs/delete-board-background", { projectPath });
async deleteBoardBackground(projectPath: string): Promise<{ success: boolean; error?: string }> {
return this.post('/api/fs/delete-board-background', { projectPath });
}
// CLI checks - server-side
@@ -374,7 +351,7 @@ export class HttpApiClient implements ElectronAPI {
};
error?: string;
}> {
return this.get("/api/setup/claude-status");
return this.get('/api/setup/claude-status');
}
// Model API
@@ -384,14 +361,14 @@ export class HttpApiClient implements ElectronAPI {
models?: ModelDefinition[];
error?: string;
}> => {
return this.get("/api/models/available");
return this.get('/api/models/available');
},
checkProviders: async (): Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}> => {
return this.get("/api/models/providers");
return this.get('/api/models/providers');
},
};
@@ -417,13 +394,13 @@ export class HttpApiClient implements ElectronAPI {
hasRecentActivity?: boolean;
};
error?: string;
}> => this.get("/api/setup/claude-status"),
}> => this.get('/api/setup/claude-status'),
installClaude: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post("/api/setup/install-claude"),
}> => this.post('/api/setup/install-claude'),
authClaude: (): Promise<{
success: boolean;
@@ -434,7 +411,7 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
message?: string;
output?: string;
}> => this.post("/api/setup/auth-claude"),
}> => this.post('/api/setup/auth-claude'),
storeApiKey: (
provider: string,
@@ -442,7 +419,7 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
}> => this.post('/api/setup/store-api-key', { provider, apiKey }),
deleteApiKey: (
provider: string
@@ -450,13 +427,13 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
error?: string;
message?: string;
}> => this.post("/api/setup/delete-api-key", { provider }),
}> => this.post('/api/setup/delete-api-key', { provider }),
getApiKeys: (): Promise<{
success: boolean;
hasAnthropicKey: boolean;
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
}> => this.get('/api/setup/api-keys'),
getPlatform: (): Promise<{
success: boolean;
@@ -466,15 +443,15 @@ export class HttpApiClient implements ElectronAPI {
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
}> => this.get("/api/setup/platform"),
}> => this.get('/api/setup/platform'),
verifyClaudeAuth: (
authMethod?: "cli" | "api_key"
authMethod?: 'cli' | 'api_key'
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
}> => this.post('/api/setup/verify-claude-auth', { authMethod }),
getGhStatus: (): Promise<{
success: boolean;
@@ -484,76 +461,65 @@ export class HttpApiClient implements ElectronAPI {
path: string | null;
user: string | null;
error?: string;
}> => this.get("/api/setup/gh-status"),
}> => this.get('/api/setup/gh-status'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
return this.subscribeToEvent('agent:stream', callback);
},
onAuthProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent("agent:stream", callback);
return this.subscribeToEvent('agent:stream', callback);
},
};
// Features API
features: FeaturesAPI = {
getAll: (projectPath: string) =>
this.post("/api/features/list", { projectPath }),
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
get: (projectPath: string, featureId: string) =>
this.post("/api/features/get", { projectPath, featureId }),
this.post('/api/features/get', { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
) => this.post("/api/features/update", { projectPath, featureId, updates }),
this.post('/api/features/create', { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post('/api/features/update', { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
this.post('/api/features/delete', { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
this.post("/api/features/agent-output", { projectPath, featureId }),
this.post('/api/features/agent-output', { projectPath, featureId }),
generateTitle: (description: string) =>
this.post("/api/features/generate-title", { description }),
this.post('/api/features/generate-title', { description }),
};
// Auto Mode API
autoMode: AutoModeAPI = {
start: (projectPath: string, maxConcurrency?: number) =>
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
stop: (projectPath: string) =>
this.post("/api/auto-mode/stop", { projectPath }),
stopFeature: (featureId: string) =>
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean,
worktreePath?: string
) =>
this.post("/api/auto-mode/run-feature", {
this.post('/api/auto-mode/run-feature', {
projectPath,
featureId,
useWorktrees,
worktreePath,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/resume-feature", {
this.post('/api/auto-mode/verify-feature', { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
this.post('/api/auto-mode/resume-feature', {
projectPath,
featureId,
useWorktrees,
}),
contextExists: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
this.post('/api/auto-mode/context-exists', { projectPath, featureId }),
analyzeProject: (projectPath: string) =>
this.post("/api/auto-mode/analyze-project", { projectPath }),
this.post('/api/auto-mode/analyze-project', { projectPath }),
followUpFeature: (
projectPath: string,
featureId: string,
@@ -561,19 +527,15 @@ export class HttpApiClient implements ElectronAPI {
imagePaths?: string[],
worktreePath?: string
) =>
this.post("/api/auto-mode/follow-up-feature", {
this.post('/api/auto-mode/follow-up-feature', {
projectPath,
featureId,
prompt,
imagePaths,
worktreePath,
}),
commitFeature: (
projectPath: string,
featureId: string,
worktreePath?: string
) =>
this.post("/api/auto-mode/commit-feature", {
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
this.post('/api/auto-mode/commit-feature', {
projectPath,
featureId,
worktreePath,
@@ -585,7 +547,7 @@ export class HttpApiClient implements ElectronAPI {
editedPlan?: string,
feedback?: string
) =>
this.post("/api/auto-mode/approve-plan", {
this.post('/api/auto-mode/approve-plan', {
projectPath,
featureId,
approved,
@@ -593,10 +555,7 @@ export class HttpApiClient implements ElectronAPI {
feedback,
}),
onEvent: (callback: (event: AutoModeEvent) => void) => {
return this.subscribeToEvent(
"auto-mode:event",
callback as EventCallback
);
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
},
};
@@ -607,7 +566,7 @@ export class HttpApiClient implements ElectronAPI {
enhancementMode: string,
model?: string
): Promise<EnhancePromptResult> =>
this.post("/api/enhance-prompt", {
this.post('/api/enhance-prompt', {
originalText,
enhancementMode,
model,
@@ -617,86 +576,74 @@ export class HttpApiClient implements ElectronAPI {
// Worktree API
worktree: WorktreeAPI = {
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
this.post("/api/worktree/merge", { projectPath, featureId, options }),
this.post('/api/worktree/merge', { projectPath, featureId, options }),
getInfo: (projectPath: string, featureId: string) =>
this.post("/api/worktree/info", { projectPath, featureId }),
this.post('/api/worktree/info', { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) =>
this.post("/api/worktree/status", { projectPath, featureId }),
list: (projectPath: string) =>
this.post("/api/worktree/list", { projectPath }),
this.post('/api/worktree/status', { projectPath, featureId }),
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
listAll: (projectPath: string, includeDetails?: boolean) =>
this.post("/api/worktree/list", { projectPath, includeDetails }),
this.post('/api/worktree/list', { projectPath, includeDetails }),
create: (projectPath: string, branchName: string, baseBranch?: string) =>
this.post("/api/worktree/create", {
this.post('/api/worktree/create', {
projectPath,
branchName,
baseBranch,
}),
delete: (
projectPath: string,
worktreePath: string,
deleteBranch?: boolean
) =>
this.post("/api/worktree/delete", {
delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) =>
this.post('/api/worktree/delete', {
projectPath,
worktreePath,
deleteBranch,
}),
commit: (worktreePath: string, message: string) =>
this.post("/api/worktree/commit", { worktreePath, message }),
this.post('/api/worktree/commit', { worktreePath, message }),
push: (worktreePath: string, force?: boolean) =>
this.post("/api/worktree/push", { worktreePath, force }),
this.post('/api/worktree/push', { worktreePath, force }),
createPR: (worktreePath: string, options?: any) =>
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
this.post('/api/worktree/diffs', { projectPath, featureId }),
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
this.post("/api/worktree/file-diff", {
this.post('/api/worktree/file-diff', {
projectPath,
featureId,
filePath,
}),
pull: (worktreePath: string) =>
this.post("/api/worktree/pull", { worktreePath }),
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
listBranches: (worktreePath: string) =>
this.post("/api/worktree/list-branches", { worktreePath }),
this.post('/api/worktree/list-branches', { worktreePath }),
switchBranch: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
openInEditor: (worktreePath: string) =>
this.post("/api/worktree/open-in-editor", { worktreePath }),
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
initGit: (projectPath: string) =>
this.post("/api/worktree/init-git", { projectPath }),
this.post('/api/worktree/open-in-editor', { worktreePath }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
startDevServer: (projectPath: string, worktreePath: string) =>
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) =>
this.post("/api/worktree/stop-dev", { worktreePath }),
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
};
// Git API
git: GitAPI = {
getDiffs: (projectPath: string) =>
this.post("/api/git/diffs", { projectPath }),
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
getFileDiff: (projectPath: string, filePath: string) =>
this.post("/api/git/file-diff", { projectPath, filePath }),
this.post('/api/git/file-diff', { projectPath, filePath }),
};
// Suggestions API
suggestions: SuggestionsAPI = {
generate: (projectPath: string, suggestionType?: SuggestionType) =>
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
this.post('/api/suggestions/generate', { projectPath, suggestionType }),
stop: () => this.post('/api/suggestions/stop'),
status: () => this.get('/api/suggestions/status'),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent(
"suggestions:event",
callback as EventCallback
);
return this.subscribeToEvent('suggestions:event', callback as EventCallback);
},
};
@@ -709,7 +656,7 @@ export class HttpApiClient implements ElectronAPI {
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/create", {
this.post('/api/spec-regeneration/create', {
projectPath,
projectOverview,
generateFeatures,
@@ -723,7 +670,7 @@ export class HttpApiClient implements ElectronAPI {
analyzeProject?: boolean,
maxFeatures?: number
) =>
this.post("/api/spec-regeneration/generate", {
this.post('/api/spec-regeneration/generate', {
projectPath,
projectDefinition,
generateFeatures,
@@ -731,17 +678,14 @@ export class HttpApiClient implements ElectronAPI {
maxFeatures,
}),
generateFeatures: (projectPath: string, maxFeatures?: number) =>
this.post("/api/spec-regeneration/generate-features", {
this.post('/api/spec-regeneration/generate-features', {
projectPath,
maxFeatures,
}),
stop: () => this.post("/api/spec-regeneration/stop"),
status: () => this.get("/api/spec-regeneration/status"),
stop: () => this.post('/api/spec-regeneration/stop'),
status: () => this.get('/api/spec-regeneration/status'),
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
return this.subscribeToEvent(
"spec-regeneration:event",
callback as EventCallback
);
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
},
};
@@ -757,7 +701,7 @@ export class HttpApiClient implements ElectronAPI {
}>;
totalCount?: number;
error?: string;
}> => this.get("/api/running-agents"),
}> => this.get('/api/running-agents'),
};
// Workspace API
@@ -768,13 +712,13 @@ export class HttpApiClient implements ElectronAPI {
workspaceDir?: string;
defaultDir?: string | null;
error?: string;
}> => this.get("/api/workspace/config"),
}> => this.get('/api/workspace/config'),
getDirectories: (): Promise<{
success: boolean;
directories?: Array<{ name: string; path: string }>;
error?: string;
}> => this.get("/api/workspace/directories"),
}> => this.get('/api/workspace/directories'),
};
// Agent API
@@ -786,7 +730,7 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
messages?: Message[];
error?: string;
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
}> => this.post('/api/agent/start', { sessionId, workingDirectory }),
send: (
sessionId: string,
@@ -795,7 +739,7 @@ export class HttpApiClient implements ElectronAPI {
imagePaths?: string[],
model?: string
): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/send", {
this.post('/api/agent/send', {
sessionId,
message,
workingDirectory,
@@ -810,16 +754,16 @@ export class HttpApiClient implements ElectronAPI {
messages?: Message[];
isRunning?: boolean;
error?: string;
}> => this.post("/api/agent/history", { sessionId }),
}> => this.post('/api/agent/history', { sessionId }),
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/stop", { sessionId }),
this.post('/api/agent/stop', { sessionId }),
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post("/api/agent/clear", { sessionId }),
this.post('/api/agent/clear', { sessionId }),
onStream: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent("agent:stream", callback as EventCallback);
return this.subscribeToEvent('agent:stream', callback as EventCallback);
},
};
@@ -834,8 +778,7 @@ export class HttpApiClient implements ElectronAPI {
projectPath?: string;
projectName?: string;
error?: string;
}> =>
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
}> => this.post('/api/templates/clone', { repoUrl, projectName, parentDir }),
};
// Settings API - persistent file-based settings
@@ -847,7 +790,7 @@ export class HttpApiClient implements ElectronAPI {
hasCredentials: boolean;
dataDir: string;
needsMigration: boolean;
}> => this.get("/api/settings/status"),
}> => this.get('/api/settings/status'),
// Global settings
getGlobal: (): Promise<{
@@ -880,13 +823,15 @@ export class HttpApiClient implements ElectronAPI {
lastSelectedSessionByProject: Record<string, string>;
};
error?: string;
}> => this.get("/api/settings/global"),
}> => this.get('/api/settings/global'),
updateGlobal: (updates: Record<string, unknown>): Promise<{
updateGlobal: (
updates: Record<string, unknown>
): Promise<{
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/global", updates),
}> => this.put('/api/settings/global', updates),
// Credentials (masked for security)
getCredentials: (): Promise<{
@@ -897,7 +842,7 @@ export class HttpApiClient implements ElectronAPI {
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.get("/api/settings/credentials"),
}> => this.get('/api/settings/credentials'),
updateCredentials: (updates: {
apiKeys?: { anthropic?: string; google?: string; openai?: string };
@@ -909,10 +854,12 @@ export class HttpApiClient implements ElectronAPI {
openai: { configured: boolean; masked: string };
};
error?: string;
}> => this.put("/api/settings/credentials", updates),
}> => this.put('/api/settings/credentials', updates),
// Project settings
getProject: (projectPath: string): Promise<{
getProject: (
projectPath: string
): Promise<{
success: boolean;
settings?: {
version: number;
@@ -940,7 +887,7 @@ export class HttpApiClient implements ElectronAPI {
lastSelectedSessionId?: string;
};
error?: string;
}> => this.post("/api/settings/project", { projectPath }),
}> => this.post('/api/settings/project', { projectPath }),
updateProject: (
projectPath: string,
@@ -949,22 +896,22 @@ export class HttpApiClient implements ElectronAPI {
success: boolean;
settings?: Record<string, unknown>;
error?: string;
}> => this.put("/api/settings/project", { projectPath, updates }),
}> => this.put('/api/settings/project', { projectPath, updates }),
// Migration from localStorage
migrate: (data: {
"automaker-storage"?: string;
"automaker-setup"?: string;
"worktree-panel-collapsed"?: string;
"file-browser-recent-folders"?: string;
"automaker:lastProjectDir"?: string;
'automaker-storage'?: string;
'automaker-setup'?: string;
'worktree-panel-collapsed'?: string;
'file-browser-recent-folders'?: string;
'automaker:lastProjectDir'?: string;
}): Promise<{
success: boolean;
migratedGlobalSettings: boolean;
migratedCredentials: boolean;
migratedProjectCount: number;
errors: string[];
}> => this.post("/api/settings/migrate", { data }),
}> => this.post('/api/settings/migrate', { data }),
};
// Sessions API
@@ -992,7 +939,7 @@ export class HttpApiClient implements ElectronAPI {
updatedAt: string;
};
error?: string;
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
}> => this.post('/api/sessions', { name, projectPath, workingDirectory }),
update: (
sessionId: string,
@@ -1001,25 +948,19 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; error?: string }> =>
this.put(`/api/sessions/${sessionId}`, { name, tags }),
archive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/archive`, {}),
unarchive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
delete: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
this.httpDelete(`/api/sessions/${sessionId}`),
};
// Claude API
claude = {
getUsage: (): Promise<ClaudeUsageResponse> => this.get("/api/claude/usage"),
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
};
}

View File

@@ -1,15 +1,15 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { AgentModel } from '@/store/app-store';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
export function modelSupportsThinking(_model?: AgentModel | string): boolean {
// All Claude models support thinking
return true;
}
@@ -19,9 +19,9 @@ export function modelSupportsThinking(model?: AgentModel | string): boolean {
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
haiku: 'Claude Haiku',
sonnet: 'Claude Sonnet',
opus: 'Claude Opus',
};
return displayNames[model] || model;
}
@@ -41,7 +41,7 @@ export function truncateDescription(description: string, maxLength = 50): string
* This is important for cross-platform compatibility (Windows uses backslashes).
*/
export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
return p.replace(/\\/g, '/');
}
/**

View File

@@ -3,13 +3,12 @@
* Centralizes the logic for determining where projects should be created/opened
*/
/* eslint-disable no-undef */
import { getHttpApiClient } from "./http-api-client";
import { getElectronAPI } from "./electron";
import { getItem, setItem } from "./storage";
import path from "path";
import { getHttpApiClient } from './http-api-client';
import { getElectronAPI } from './electron';
import { getItem, setItem } from './storage';
import path from 'path';
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
/**
* Gets the default Documents/Automaker directory path
@@ -18,11 +17,11 @@ const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
async function getDefaultDocumentsPath(): Promise<string | null> {
try {
const api = getElectronAPI();
const documentsPath = await api.getPath("documents");
return path.join(documentsPath, "Automaker");
const documentsPath = await api.getPath('documents');
return path.join(documentsPath, 'Automaker');
} catch (error) {
if (typeof window !== "undefined" && window.console) {
window.console.error("Failed to get documents path:", error);
if (typeof window !== 'undefined' && window.console) {
window.console.error('Failed to get documents path:', error);
}
return null;
}
@@ -82,8 +81,8 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
const documentsPath = await getDefaultDocumentsPath();
return documentsPath;
} catch (error) {
if (typeof window !== "undefined" && window.console) {
window.console.error("Failed to get default workspace directory:", error);
if (typeof window !== 'undefined' && window.console) {
window.console.error('Failed to get default workspace directory:', error);
}
// On error, try last used dir and Documents

View File

@@ -1,8 +1,8 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './app';
createRoot(document.getElementById("app")!).render(
createRoot(document.getElementById('app')!).render(
<StrictMode>
<App />
</StrictMode>

View File

@@ -14,7 +14,7 @@ import { ThemeOption, themeOptions } from '@/config/theme-options';
function RootLayoutContent() {
const location = useLocation();
const { setIpcConnected, theme, currentProject, previewTheme, getEffectiveTheme } = useAppStore();
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
const { setupComplete } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);

11
apps/ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SERVER_URL?: string;
// Add other VITE_ prefixed env vars here as needed
}
// Extend ImportMeta to include env property
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -15,11 +15,11 @@
* so it doesn't make real API calls during CI/CD runs.
*/
import { test, expect } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import {
waitForNetworkIdle,
@@ -29,15 +29,14 @@ import {
setupProjectWithPathNoWorktrees,
waitForBoardView,
clickAddFeature,
fillAddFeatureDialog,
confirmAddFeature,
dragAndDropWithDndKit,
} from "./utils";
} from './utils';
const execAsync = promisify(exec);
// Create unique temp dir for this test run
const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests");
const TEST_TEMP_DIR = createTempDirPath('feature-lifecycle-tests');
interface TestRepo {
path: string;
@@ -45,9 +44,9 @@ interface TestRepo {
}
// Configure all tests to run serially
test.describe.configure({ mode: "serial" });
test.describe.configure({ mode: 'serial' });
test.describe("Feature Lifecycle Tests", () => {
test.describe('Feature Lifecycle Tests', () => {
let testRepo: TestRepo;
let featureId: string;
@@ -76,7 +75,7 @@ test.describe("Feature Lifecycle Tests", () => {
});
// this one fails in github actions for some reason
test.skip("complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete", async ({
test.skip('complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete', async ({
page,
}) => {
// Increase timeout for this comprehensive test
@@ -87,7 +86,7 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await waitForBoardView(page);
@@ -98,18 +97,15 @@ test.describe("Feature Lifecycle Tests", () => {
await clickAddFeature(page);
// Fill in the feature details - requesting a file with "yellow" content
const featureDescription =
"Create a file named yellow.txt that contains the text yellow";
const descriptionInput = page
.locator('[data-testid="add-feature-dialog"] textarea')
.first();
const featureDescription = 'Create a file named yellow.txt that contains the text yellow';
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
await descriptionInput.fill(featureDescription);
// Confirm the feature creation
await confirmAddFeature(page);
// Debug: Check the filesystem to see if feature was created
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featuresDir = path.join(testRepo.path, '.automaker', 'features');
// Wait for the feature to be created in the filesystem
await expect(async () => {
@@ -131,18 +127,14 @@ test.describe("Feature Lifecycle Tests", () => {
featureId = featureDirs[0];
// Now get the actual card element by testid
const featureCardByTestId = page.locator(
`[data-testid="kanban-card-${featureId}"]`
);
const featureCardByTestId = page.locator(`[data-testid="kanban-card-${featureId}"]`);
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
// ==========================================================================
// Step 2: Drag feature to in_progress and wait for agent to finish
// ==========================================================================
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const inProgressColumn = page.locator(
'[data-testid="kanban-column-in_progress"]'
);
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
// Perform the drag and drop using dnd-kit compatible method
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
@@ -151,13 +143,10 @@ test.describe("Feature Lifecycle Tests", () => {
// This helps diagnose if the drag-drop is working or not
await expect(async () => {
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
expect(['in_progress', 'waiting_approval']).toContain(featureData.status);
}).toPass({ timeout: 15000 });
// The mock agent should complete quickly (about 1.3 seconds based on the sleep times)
@@ -165,12 +154,9 @@ test.describe("Feature Lifecycle Tests", () => {
// The status changes are: in_progress -> waiting_approval after agent completes
await expect(async () => {
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
expect(featureData.status).toBe("waiting_approval");
expect(featureData.status).toBe('waiting_approval');
}).toPass({ timeout: 30000 });
// Refresh page to ensure UI reflects the status change
@@ -181,19 +167,17 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Step 3: Verify feature is in waiting_approval (manual review) column
// ==========================================================================
const waitingApprovalColumn = page.locator(
'[data-testid="kanban-column-waiting_approval"]'
);
const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]');
const cardInWaitingApproval = waitingApprovalColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
// Verify the mock agent created the yellow.txt file
const yellowFilePath = path.join(testRepo.path, "yellow.txt");
const yellowFilePath = path.join(testRepo.path, 'yellow.txt');
expect(fs.existsSync(yellowFilePath)).toBe(true);
const yellowContent = fs.readFileSync(yellowFilePath, "utf-8");
expect(yellowContent).toBe("yellow");
const yellowContent = fs.readFileSync(yellowFilePath, 'utf-8');
expect(yellowContent).toBe('yellow');
// ==========================================================================
// Step 4: Click commit and verify git status shows committed changes
@@ -207,18 +191,18 @@ test.describe("Feature Lifecycle Tests", () => {
await page.waitForTimeout(2000);
// Verify git status shows clean (changes committed)
const { stdout: gitStatus } = await execAsync("git status --porcelain", {
const { stdout: gitStatus } = await execAsync('git status --porcelain', {
cwd: testRepo.path,
});
// After commit, the yellow.txt file should be committed, so git status should be clean
// (only .automaker directory might have changes)
expect(gitStatus.includes("yellow.txt")).toBe(false);
expect(gitStatus.includes('yellow.txt')).toBe(false);
// Verify the commit exists in git log
const { stdout: gitLog } = await execAsync("git log --oneline -1", {
const { stdout: gitLog } = await execAsync('git log --oneline -1', {
cwd: testRepo.path,
});
expect(gitLog.toLowerCase()).toContain("yellow");
expect(gitLog.toLowerCase()).toContain('yellow');
// ==========================================================================
// Step 5: Verify feature moved to verified column after commit
@@ -228,21 +212,15 @@ test.describe("Feature Lifecycle Tests", () => {
await waitForNetworkIdle(page);
await waitForBoardView(page);
const verifiedColumn = page.locator(
'[data-testid="kanban-column-verified"]'
);
const cardInVerified = verifiedColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
const cardInVerified = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`);
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
// ==========================================================================
// Step 6: Archive (complete) the feature
// ==========================================================================
// Click the Complete button on the verified card
const completeButton = page.locator(
`[data-testid="complete-${featureId}"]`
);
const completeButton = page.locator(`[data-testid="complete-${featureId}"]`);
await expect(completeButton).toBeVisible({ timeout: 5000 });
await completeButton.click();
@@ -254,39 +232,28 @@ test.describe("Feature Lifecycle Tests", () => {
// Verify feature status is completed in filesystem
const featureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
expect(featureData.status).toBe("completed");
expect(featureData.status).toBe('completed');
// ==========================================================================
// Step 7: Open archive modal and restore the feature
// ==========================================================================
// Click the completed features button to open the archive modal
const completedFeaturesButton = page.locator(
'[data-testid="completed-features-button"]'
);
const completedFeaturesButton = page.locator('[data-testid="completed-features-button"]');
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
await completedFeaturesButton.click();
// Wait for the modal to open
const completedModal = page.locator(
'[data-testid="completed-features-modal"]'
);
const completedModal = page.locator('[data-testid="completed-features-modal"]');
await expect(completedModal).toBeVisible({ timeout: 5000 });
// Verify the archived feature is shown in the modal
const archivedCard = completedModal.locator(
`[data-testid="completed-card-${featureId}"]`
);
const archivedCard = completedModal.locator(`[data-testid="completed-card-${featureId}"]`);
await expect(archivedCard).toBeVisible({ timeout: 5000 });
// Click the restore button
const restoreButton = page.locator(
`[data-testid="unarchive-${featureId}"]`
);
const restoreButton = page.locator(`[data-testid="unarchive-${featureId}"]`);
await expect(restoreButton).toBeVisible({ timeout: 5000 });
await restoreButton.click();
@@ -294,47 +261,34 @@ test.describe("Feature Lifecycle Tests", () => {
await page.waitForTimeout(1000);
// Close the modal - use first() to select the footer Close button, not the X button
const closeButton = completedModal
.locator('button:has-text("Close")')
.first();
const closeButton = completedModal.locator('button:has-text("Close")').first();
await closeButton.click();
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
// Verify the feature is back in the verified column
const restoredCard = verifiedColumn.locator(
`[data-testid="kanban-card-${featureId}"]`
);
const restoredCard = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`);
await expect(restoredCard).toBeVisible({ timeout: 10000 });
// Verify feature status is verified in filesystem
const restoredFeatureData = JSON.parse(
fs.readFileSync(
path.join(featuresDir, featureId, "feature.json"),
"utf-8"
)
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
);
expect(restoredFeatureData.status).toBe("verified");
expect(restoredFeatureData.status).toBe('verified');
// ==========================================================================
// Step 8: Delete the feature and verify it's removed
// ==========================================================================
// Click the delete button on the verified card
const deleteButton = page.locator(
`[data-testid="delete-verified-${featureId}"]`
);
const deleteButton = page.locator(`[data-testid="delete-verified-${featureId}"]`);
await expect(deleteButton).toBeVisible({ timeout: 5000 });
await deleteButton.click();
// Wait for the confirmation dialog
const confirmDialog = page.locator(
'[data-testid="delete-confirmation-dialog"]'
);
const confirmDialog = page.locator('[data-testid="delete-confirmation-dialog"]');
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
// Click the confirm delete button
const confirmDeleteButton = page.locator(
'[data-testid="confirm-delete-button"]'
);
const confirmDeleteButton = page.locator('[data-testid="confirm-delete-button"]');
await confirmDeleteButton.click();
// Wait for the delete action to complete
@@ -361,7 +315,7 @@ test.describe("Feature Lifecycle Tests", () => {
// Step 1: Setup and create a feature in backlog
// ==========================================================================
await setupProjectWithPathNoWorktrees(page, testRepo.path);
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
await waitForBoardView(page);
await page.waitForTimeout(1000);
@@ -370,17 +324,15 @@ test.describe("Feature Lifecycle Tests", () => {
await clickAddFeature(page);
// Fill in the feature details
const featureDescription = "Create a file named test-restart.txt";
const descriptionInput = page
.locator('[data-testid="add-feature-dialog"] textarea')
.first();
const featureDescription = 'Create a file named test-restart.txt';
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
await descriptionInput.fill(featureDescription);
// Confirm the feature creation
await confirmAddFeature(page);
// Wait for the feature to be created in the filesystem
const featuresDir = path.join(testRepo.path, ".automaker", "features");
const featuresDir = path.join(testRepo.path, '.automaker', 'features');
await expect(async () => {
const dirs = fs.readdirSync(featuresDir);
expect(dirs.length).toBeGreaterThan(0);
@@ -396,36 +348,26 @@ test.describe("Feature Lifecycle Tests", () => {
await waitForBoardView(page);
// Wait for the feature card to appear
const featureCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
const featureCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
await expect(featureCard).toBeVisible({ timeout: 10000 });
// ==========================================================================
// Step 2: Drag feature to in_progress (first start)
// ==========================================================================
const dragHandle = page.locator(
`[data-testid="drag-handle-${testFeatureId}"]`
);
const inProgressColumn = page.locator(
'[data-testid="kanban-column-in_progress"]'
);
const dragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
// Verify feature file still exists and is readable
const featureFilePath = path.join(
featuresDir,
testFeatureId,
"feature.json"
);
const featureFilePath = path.join(featuresDir, testFeatureId, 'feature.json');
expect(fs.existsSync(featureFilePath)).toBe(true);
// First verify that the drag succeeded by checking for in_progress status
await expect(async () => {
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
expect(['in_progress', 'waiting_approval']).toContain(featureData.status);
}).toPass({ timeout: 15000 });
// ==========================================================================
@@ -433,19 +375,14 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// The mock agent completes quickly, so we wait for it to finish
await expect(async () => {
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(featureData.status).toBe("waiting_approval");
const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
expect(featureData.status).toBe('waiting_approval');
}).toPass({ timeout: 30000 });
// Verify feature file still exists after completion
expect(fs.existsSync(featureFilePath)).toBe(true);
const featureDataAfterComplete = JSON.parse(
fs.readFileSync(featureFilePath, "utf-8")
);
console.log(
"Feature status after first run:",
featureDataAfterComplete.status
);
const featureDataAfterComplete = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
console.log('Feature status after first run:', featureDataAfterComplete.status);
// Reload to ensure clean state
await page.reload();
@@ -457,12 +394,8 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Feature is in waiting_approval, drag it back to backlog
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
const currentCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
const currentDragHandle = page.locator(
`[data-testid="drag-handle-${testFeatureId}"]`
);
const currentCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
const currentDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
await expect(currentCard).toBeVisible({ timeout: 10000 });
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
@@ -470,8 +403,8 @@ test.describe("Feature Lifecycle Tests", () => {
// Verify feature is in backlog
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(data.status).toBe("backlog");
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
expect(data.status).toBe('backlog');
}).toPass({ timeout: 10000 });
// Reload to ensure clean state
@@ -482,55 +415,45 @@ test.describe("Feature Lifecycle Tests", () => {
// ==========================================================================
// Step 5: Restart the feature (drag to in_progress again)
// ==========================================================================
const restartCard = page.locator(
`[data-testid="kanban-card-${testFeatureId}"]`
);
const restartCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
await expect(restartCard).toBeVisible({ timeout: 10000 });
const restartDragHandle = page.locator(
`[data-testid="drag-handle-${testFeatureId}"]`
);
const inProgressColumnRestart = page.locator(
'[data-testid="kanban-column-in_progress"]'
);
const restartDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
const inProgressColumnRestart = page.locator('[data-testid="kanban-column-in_progress"]');
// Listen for console errors to catch "Feature not found"
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") {
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
// Drag to in_progress to restart
await dragAndDropWithDndKit(
page,
restartDragHandle,
inProgressColumnRestart
);
await dragAndDropWithDndKit(page, restartDragHandle, inProgressColumnRestart);
// Verify the feature file still exists
expect(fs.existsSync(featureFilePath)).toBe(true);
// First verify that the restart drag succeeded by checking for in_progress status
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
expect(["in_progress", "waiting_approval"]).toContain(data.status);
expect(['in_progress', 'waiting_approval']).toContain(data.status);
}).toPass({ timeout: 15000 });
// Verify no "Feature not found" errors in console
const featureNotFoundErrors = consoleErrors.filter(
(err) => err.includes("not found") || err.includes("Feature")
(err) => err.includes('not found') || err.includes('Feature')
);
expect(featureNotFoundErrors).toEqual([]);
// Wait for the mock agent to complete and move to waiting_approval
await expect(async () => {
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
expect(data.status).toBe("waiting_approval");
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
expect(data.status).toBe('waiting_approval');
}).toPass({ timeout: 30000 });
console.log("Feature successfully restarted after stop!");
console.log('Feature successfully restarted after stop!');
});
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from '@playwright/test';
import {
resetFixtureSpec,
setupProjectWithFixture,
@@ -12,9 +12,9 @@ import {
fillInput,
waitForNetworkIdle,
waitForElement,
} from "./utils";
} from './utils';
test.describe("Spec Editor Persistence", () => {
test.describe('Spec Editor Persistence', () => {
test.beforeEach(async () => {
// Reset the fixture spec file to original content before each test
resetFixtureSpec();
@@ -25,7 +25,7 @@ test.describe("Spec Editor Persistence", () => {
resetFixtureSpec();
});
test("should open project, edit spec, save, and persist changes after refresh", async ({
test('should open project, edit spec, save, and persist changes after refresh', async ({
page,
}) => {
// Use the resolved fixture path
@@ -35,33 +35,33 @@ test.describe("Spec Editor Persistence", () => {
await setupProjectWithFixture(page, fixturePath);
// Step 2: Navigate to the app
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
// Step 3: Verify we're on the dashboard with the project loaded
// The sidebar should show the project selector
const sidebar = await getByTestId(page, "sidebar");
await sidebar.waitFor({ state: "visible", timeout: 10000 });
const sidebar = await getByTestId(page, 'sidebar');
await sidebar.waitFor({ state: 'visible', timeout: 10000 });
// Step 4: Click on the Spec Editor in the sidebar
await navigateToSpecEditor(page);
// Step 5: Wait for the spec view to load (not empty state)
await waitForElement(page, "spec-view", { timeout: 10000 });
await waitForElement(page, 'spec-view', { timeout: 10000 });
// Step 6: Wait for the spec editor to load
const specEditor = await getByTestId(page, "spec-editor");
await specEditor.waitFor({ state: "visible", timeout: 10000 });
const specEditor = await getByTestId(page, 'spec-editor');
await specEditor.waitFor({ state: 'visible', timeout: 10000 });
// Step 7: Wait for CodeMirror to initialize (it has a .cm-content element)
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
await specEditor.locator('.cm-content').waitFor({ state: 'visible', timeout: 10000 });
// Step 8: Modify the editor content to "hello world"
await setEditorContent(page, "hello world");
await setEditorContent(page, 'hello world');
// Verify content was set before saving
const contentBeforeSave = await getEditorContent(page);
expect(contentBeforeSave.trim()).toBe("hello world");
expect(contentBeforeSave.trim()).toBe('hello world');
// Step 9: Click the save button and wait for save to complete
await clickSaveButton(page);
@@ -72,14 +72,16 @@ test.describe("Spec Editor Persistence", () => {
// Step 11: Navigate back to the spec editor
// After reload, we need to wait for the app to initialize
await waitForElement(page, "sidebar", { timeout: 10000 });
await waitForElement(page, 'sidebar', { timeout: 10000 });
// Navigate to spec editor again
await navigateToSpecEditor(page);
// Wait for CodeMirror to be ready
const specEditorAfterReload = await getByTestId(page, "spec-editor");
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
const specEditorAfterReload = await getByTestId(page, 'spec-editor');
await specEditorAfterReload
.locator('.cm-content')
.waitFor({ state: 'visible', timeout: 10000 });
// Wait for CodeMirror content to update with the loaded spec
// The spec might need time to load into the editor after page reload
@@ -91,11 +93,11 @@ test.describe("Spec Editor Persistence", () => {
try {
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
const text = await contentElement.textContent();
if (text && text.trim() === "hello world") {
if (text && text.trim() === 'hello world') {
contentMatches = true;
break;
}
} catch (e) {
} catch {
// Element might not be ready yet, continue
}
@@ -111,20 +113,20 @@ test.describe("Spec Editor Persistence", () => {
(expectedContent) => {
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
if (!contentElement) return false;
const text = (contentElement.textContent || "").trim();
const text = (contentElement.textContent || '').trim();
return text === expectedContent;
},
"hello world",
'hello world',
{ timeout: 10000 }
);
}
// Step 12: Verify the content was persisted
const persistedContent = await getEditorContent(page);
expect(persistedContent.trim()).toBe("hello world");
expect(persistedContent.trim()).toBe('hello world');
});
test("should handle opening project via Open Project button and file browser", async ({
test('should handle opening project via Open Project button and file browser', async ({
page,
}) => {
// This test covers the flow of:
@@ -139,49 +141,47 @@ test.describe("Spec Editor Persistence", () => {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
});
// Navigate to the app
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
// Wait for the sidebar to be visible
const sidebar = await getByTestId(page, "sidebar");
await sidebar.waitFor({ state: "visible", timeout: 10000 });
const sidebar = await getByTestId(page, 'sidebar');
await sidebar.waitFor({ state: 'visible', timeout: 10000 });
// Click the Open Project button
const openProjectButton = await getByTestId(page, "open-project-button");
const openProjectButton = await getByTestId(page, 'open-project-button');
// Check if the button is visible (it might not be in collapsed sidebar)
const isButtonVisible = await openProjectButton
.isVisible()
.catch(() => false);
const isButtonVisible = await openProjectButton.isVisible().catch(() => false);
if (isButtonVisible) {
await clickElement(page, "open-project-button");
await clickElement(page, 'open-project-button');
// The file browser dialog should open
// Note: In web mode, this might use the FileBrowserDialog component
@@ -200,10 +200,10 @@ test.describe("Spec Editor Persistence", () => {
// For now, let's verify the dialog appeared and close it
// A full test would navigate through directories
console.log("File browser dialog opened successfully");
console.log('File browser dialog opened successfully');
// Press Escape to close the dialog
await page.keyboard.press("Escape");
await page.keyboard.press('Escape');
}
}
@@ -220,7 +220,7 @@ test.describe("Spec Editor Persistence", () => {
});
});
test.describe("Spec Editor - Full Open Project Flow", () => {
test.describe('Spec Editor - Full Open Project Flow', () => {
test.beforeEach(async () => {
// Reset the fixture spec file to original content before each test
resetFixtureSpec();
@@ -232,11 +232,9 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
});
// Skip in CI - file browser navigation is flaky in headless environments
test.skip("should open project via file browser, edit spec, and persist", async ({
page,
}) => {
test.skip('should open project via file browser, edit spec, and persist', async ({ page }) => {
// Navigate to app first
await page.goto("/");
await page.goto('/');
await waitForNetworkIdle(page);
// Set up localStorage state (without a current project, but mark setup complete)
@@ -247,29 +245,29 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
state: {
projects: [],
currentProject: null,
currentView: "welcome",
theme: "dark",
currentView: 'welcome',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
},
version: 0,
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 0,
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
});
// Reload to apply the localStorage state
@@ -277,69 +275,68 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
await waitForNetworkIdle(page);
// Wait for sidebar
await waitForElement(page, "sidebar", { timeout: 10000 });
await waitForElement(page, 'sidebar', { timeout: 10000 });
// Click the Open Project button
const openProjectButton = await getByTestId(page, "open-project-button");
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
await clickElement(page, "open-project-button");
const openProjectButton = await getByTestId(page, 'open-project-button');
await openProjectButton.waitFor({ state: 'visible', timeout: 10000 });
await clickElement(page, 'open-project-button');
// Wait for the file browser dialog to open
const dialogTitle = page.locator('text="Select Project Directory"');
await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
await dialogTitle.waitFor({ state: 'visible', timeout: 10000 });
// Wait for the dialog to fully load (loading to complete)
await page.waitForFunction(
() => !document.body.textContent?.includes("Loading directories..."),
() => !document.body.textContent?.includes('Loading directories...'),
{ timeout: 10000 }
);
// Use the path input to directly navigate to the fixture directory
const pathInput = await getByTestId(page, "path-input");
await pathInput.waitFor({ state: "visible", timeout: 5000 });
const pathInput = await getByTestId(page, 'path-input');
await pathInput.waitFor({ state: 'visible', timeout: 5000 });
// Clear the input and type the full path to the fixture
await fillInput(page, "path-input", getFixturePath());
await fillInput(page, 'path-input', getFixturePath());
// Click the Go button to navigate to the path
await clickElement(page, "go-to-path-button");
await clickElement(page, 'go-to-path-button');
// Wait for loading to complete
await page.waitForFunction(
() => !document.body.textContent?.includes("Loading directories..."),
() => !document.body.textContent?.includes('Loading directories...'),
{ timeout: 10000 }
);
// Verify we're in the right directory by checking the path display
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
await expect(pathDisplay).toContainText("projectA");
const pathDisplay = page.locator('.font-mono.text-sm.truncate');
await expect(pathDisplay).toContainText('projectA');
// Click "Select Current Folder" button
const selectFolderButton = page.locator(
'button:has-text("Select Current Folder")'
);
const selectFolderButton = page.locator('button:has-text("Select Current Folder")');
await selectFolderButton.click();
// Wait for dialog to close and project to load
await page.waitForFunction(
() => !document.querySelector('[role="dialog"]'),
{ timeout: 10000 }
);
await page.waitForFunction(() => !document.querySelector('[role="dialog"]'), {
timeout: 10000,
});
await page.waitForTimeout(500);
// Navigate to spec editor
const specNav = await getByTestId(page, "nav-spec");
await specNav.waitFor({ state: "visible", timeout: 10000 });
await clickElement(page, "nav-spec");
const specNav = await getByTestId(page, 'nav-spec');
await specNav.waitFor({ state: 'visible', timeout: 10000 });
await clickElement(page, 'nav-spec');
// Wait for spec view with the editor (not the empty state)
await waitForElement(page, "spec-view", { timeout: 10000 });
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
await waitForElement(page, 'spec-view', { timeout: 10000 });
const specEditorForOpenFlow = await getByTestId(page, 'spec-editor');
await specEditorForOpenFlow
.locator('.cm-content')
.waitFor({ state: 'visible', timeout: 10000 });
await page.waitForTimeout(500);
// Edit the content
await setEditorContent(page, "hello world");
await setEditorContent(page, 'hello world');
// Click save button
await clickSaveButton(page);
@@ -349,15 +346,17 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
await waitForNetworkIdle(page);
// Navigate back to spec editor
await specNav.waitFor({ state: "visible", timeout: 10000 });
await clickElement(page, "nav-spec");
await specNav.waitFor({ state: 'visible', timeout: 10000 });
await clickElement(page, 'nav-spec');
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
const specEditorAfterRefresh = await getByTestId(page, 'spec-editor');
await specEditorAfterRefresh
.locator('.cm-content')
.waitFor({ state: 'visible', timeout: 10000 });
await page.waitForTimeout(500);
// Verify the content persisted
const persistedContent = await getEditorContent(page);
expect(persistedContent.trim()).toBe("hello world");
expect(persistedContent.trim()).toBe('hello world');
});
});

View File

@@ -1,5 +1,4 @@
import { Page, Locator } from "@playwright/test";
import { waitForElement } from "../core/waiting";
import { Page, Locator } from '@playwright/test';
/**
* Wait for a toast notification with specific text to appear
@@ -12,7 +11,7 @@ export async function waitForToast(
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
await toast.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
state: 'visible',
});
return toast;
}
@@ -32,19 +31,21 @@ export async function waitForErrorToast(
if (titleText) {
// First try specific error type, then fallback to any toast with text
const errorToast = page.locator(
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
).first();
const errorToast = page
.locator(
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
)
.first();
await errorToast.waitFor({
timeout,
state: "visible",
state: 'visible',
});
return errorToast;
} else {
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
await errorToast.waitFor({
timeout,
state: "visible",
state: 'visible',
});
return errorToast;
}
@@ -53,10 +54,7 @@ export async function waitForErrorToast(
/**
* Check if an error toast is visible
*/
export async function isErrorToastVisible(
page: Page,
titleText?: string
): Promise<boolean> {
export async function isErrorToastVisible(page: Page, titleText?: string): Promise<boolean> {
const toastSelector = titleText
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
: '[data-sonner-toast][data-type="error"]';
@@ -81,7 +79,7 @@ export async function waitForSuccessToast(
const toast = page.locator(toastSelector).first();
await toast.waitFor({
timeout: options?.timeout ?? 5000,
state: "visible",
state: 'visible',
});
return toast;
}

View File

@@ -3,12 +3,12 @@
* Provides helpers for creating test git repos and managing worktrees
*/
import * as fs from "fs";
import * as path from "path";
import { exec } from "child_process";
import { promisify } from "util";
import { Page } from "@playwright/test";
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { Page } from '@playwright/test';
import { sanitizeBranchName, TIMEOUTS } from '../core/constants';
const execAsync = promisify(exec);
@@ -40,8 +40,8 @@ export interface FeatureData {
*/
function getWorkspaceRoot(): string {
const cwd = process.cwd();
if (cwd.includes("apps/ui")) {
return path.resolve(cwd, "../..");
if (cwd.includes('apps/ui')) {
return path.resolve(cwd, '../..');
}
return cwd;
}
@@ -49,9 +49,9 @@ function getWorkspaceRoot(): string {
/**
* Create a unique temp directory path for tests
*/
export function createTempDirPath(prefix: string = "temp-worktree-tests"): string {
export function createTempDirPath(prefix: string = 'temp-worktree-tests'): string {
const uniqueId = `${process.pid}-${Math.random().toString(36).substring(2, 9)}`;
return path.join(getWorkspaceRoot(), "test", `${prefix}-${uniqueId}`);
return path.join(getWorkspaceRoot(), 'test', `${prefix}-${uniqueId}`);
}
/**
@@ -59,7 +59,7 @@ export function createTempDirPath(prefix: string = "temp-worktree-tests"): strin
*/
export function getWorktreePath(projectPath: string, branchName: string): string {
const sanitizedName = sanitizeBranchName(branchName);
return path.join(projectPath, ".worktrees", sanitizedName);
return path.join(projectPath, '.worktrees', sanitizedName);
}
// ============================================================================
@@ -79,25 +79,25 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
fs.mkdirSync(tmpDir, { recursive: true });
// Initialize git repo
await execAsync("git init", { cwd: tmpDir });
await execAsync('git init', { cwd: tmpDir });
await execAsync('git config user.email "test@example.com"', { cwd: tmpDir });
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
// Create initial commit
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
await execAsync("git add .", { cwd: tmpDir });
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n');
await execAsync('git add .', { cwd: tmpDir });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
// Create main branch explicitly
await execAsync("git branch -M main", { cwd: tmpDir });
await execAsync('git branch -M main', { cwd: tmpDir });
// Create .automaker directories
const automakerDir = path.join(tmpDir, ".automaker");
const featuresDir = path.join(automakerDir, "features");
const automakerDir = path.join(tmpDir, '.automaker');
const featuresDir = path.join(automakerDir, 'features');
fs.mkdirSync(featuresDir, { recursive: true });
// Create empty categories.json to avoid ENOENT errors in tests
fs.writeFileSync(path.join(automakerDir, "categories.json"), "[]");
fs.writeFileSync(path.join(automakerDir, 'categories.json'), '[]');
return {
path: tmpDir,
@@ -113,16 +113,16 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
export async function cleanupTestRepo(repoPath: string): Promise<void> {
try {
// Remove all worktrees first
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: repoPath,
}).catch(() => ({ stdout: "" }));
}).catch(() => ({ stdout: '' }));
const worktrees = stdout
.split("\n\n")
.split('\n\n')
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
return pathLine ? pathLine.replace("worktree ", "") : null;
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
return pathLine ? pathLine.replace('worktree ', '') : null;
})
.filter(Boolean);
@@ -139,7 +139,7 @@ export async function cleanupTestRepo(repoPath: string): Promise<void> {
// Remove the repository
fs.rmSync(repoPath, { recursive: true, force: true });
} catch (error) {
console.error("Failed to cleanup test repo:", error);
console.error('Failed to cleanup test repo:', error);
}
}
@@ -171,18 +171,18 @@ export async function gitExec(
*/
export async function listWorktrees(repoPath: string): Promise<string[]> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
const { stdout } = await execAsync('git worktree list --porcelain', {
cwd: repoPath,
});
return stdout
.split("\n\n")
.split('\n\n')
.slice(1) // Skip main worktree
.map((block) => {
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
if (!pathLine) return null;
// Normalize path separators to OS native (git on Windows returns forward slashes)
const worktreePath = pathLine.replace("worktree ", "");
const worktreePath = pathLine.replace('worktree ', '');
return path.normalize(worktreePath);
})
.filter(Boolean) as string[];
@@ -195,10 +195,10 @@ export async function listWorktrees(repoPath: string): Promise<string[]> {
* Get list of git branches
*/
export async function listBranches(repoPath: string): Promise<string[]> {
const { stdout } = await execAsync("git branch --list", { cwd: repoPath });
const { stdout } = await execAsync('git branch --list', { cwd: repoPath });
return stdout
.split("\n")
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
.split('\n')
.map((line) => line.trim().replace(/^[*+]\s*/, ''))
.filter(Boolean);
}
@@ -206,7 +206,7 @@ export async function listBranches(repoPath: string): Promise<string[]> {
* Get the current branch name
*/
export async function getCurrentBranch(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: repoPath });
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath });
return stdout.trim();
}
@@ -233,7 +233,7 @@ export async function createWorktreeDirectly(
worktreePath?: string
): Promise<string> {
const sanitizedName = sanitizeBranchName(branchName);
const targetPath = worktreePath || path.join(repoPath, ".worktrees", sanitizedName);
const targetPath = worktreePath || path.join(repoPath, '.worktrees', sanitizedName);
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
return targetPath;
@@ -257,7 +257,7 @@ export async function commitFile(
* Get the latest commit message
*/
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
const { stdout } = await execAsync("git log --oneline -1", { cwd: repoPath });
const { stdout } = await execAsync('git log --oneline -1', { cwd: repoPath });
return stdout.trim();
}
@@ -268,32 +268,36 @@ export async function getLatestCommitMessage(repoPath: string): Promise<string>
/**
* Create a feature file in the test repo
*/
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
const featuresDir = path.join(repoPath, ".automaker", "features");
export function createTestFeature(
repoPath: string,
featureId: string,
featureData: FeatureData
): void {
const featuresDir = path.join(repoPath, '.automaker', 'features');
const featureDir = path.join(featuresDir, featureId);
fs.mkdirSync(featureDir, { recursive: true });
fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(featureData, null, 2));
fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2));
}
/**
* Read a feature file from the test repo
*/
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
const featureFilePath = path.join(repoPath, ".automaker", "features", featureId, "feature.json");
const featureFilePath = path.join(repoPath, '.automaker', 'features', featureId, 'feature.json');
if (!fs.existsSync(featureFilePath)) {
return null;
}
return JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
return JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
}
/**
* List all feature directories in the test repo
*/
export function listTestFeatures(repoPath: string): string[] {
const featuresDir = path.join(repoPath, ".automaker", "features");
const featuresDir = path.join(repoPath, '.automaker', 'features');
if (!fs.existsSync(featuresDir)) {
return [];
@@ -312,8 +316,8 @@ export function listTestFeatures(repoPath: string): string[] {
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-worktree",
name: "Worktree Test Project",
id: 'test-project-worktree',
name: 'Worktree Test Project',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -322,36 +326,36 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
aiProfiles: [],
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
[pathArg]: { path: null, branch: "main" }, // Initialize to main branch
[pathArg]: { path: null, branch: 'main' }, // Initialize to main branch
},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
@@ -359,11 +363,14 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
* Set up localStorage with a project pointing to a test repo with worktrees DISABLED
* Use this to test scenarios where the worktree feature flag is off
*/
export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: string): Promise<void> {
export async function setupProjectWithPathNoWorktrees(
page: Page,
projectPath: string
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-no-worktree",
name: "Test Project (No Worktrees)",
id: 'test-project-no-worktree',
name: 'Test Project (No Worktrees)',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -372,10 +379,10 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -387,19 +394,19 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
@@ -408,11 +415,14 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
* The currentWorktreeByProject points to a worktree path that no longer exists
* This simulates the scenario where a user previously selected a worktree that was later deleted
*/
export async function setupProjectWithStaleWorktree(page: Page, projectPath: string): Promise<void> {
export async function setupProjectWithStaleWorktree(
page: Page,
projectPath: string
): Promise<void> {
await page.addInitScript((pathArg: string) => {
const mockProject = {
id: "test-project-stale-worktree",
name: "Stale Worktree Test Project",
id: 'test-project-stale-worktree',
name: 'Stale Worktree Test Project',
path: pathArg,
lastOpened: new Date().toISOString(),
};
@@ -421,10 +431,10 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
state: {
projects: [mockProject],
currentProject: mockProject,
currentView: "board",
theme: "dark",
currentView: 'board',
theme: 'dark',
sidebarOpen: true,
apiKeys: { anthropic: "", google: "" },
apiKeys: { anthropic: '', google: '' },
chatSessions: [],
chatHistoryOpen: false,
maxConcurrency: 3,
@@ -432,26 +442,26 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
useWorktrees: true, // Enable worktree feature for tests
currentWorktreeByProject: {
// This is STALE data - pointing to a worktree path that doesn't exist
[pathArg]: { path: "/non/existent/worktree/path", branch: "feature/deleted-branch" },
[pathArg]: { path: '/non/existent/worktree/path', branch: 'feature/deleted-branch' },
},
worktreesByProject: {},
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
// Mark setup as complete to skip the setup wizard
const setupState = {
state: {
isFirstRun: false,
setupComplete: true,
currentStep: "complete",
currentStep: 'complete',
skipClaudeSetup: false,
},
version: 2, // Must match app-store.ts persist version
};
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
}, projectPath);
}
@@ -477,8 +487,6 @@ export async function waitForBoardView(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const boardView = document.querySelector('[data-testid="board-view"]');
const noProject = document.querySelector('[data-testid="board-view-no-project"]');
const loading = document.querySelector('[data-testid="board-view-loading"]');
// Return true only when board-view is visible (store hydrated with project)
return boardView !== null;
},
@@ -490,8 +498,10 @@ export async function waitForBoardView(page: Page): Promise<void> {
* Wait for the worktree selector to be visible
*/
export async function waitForWorktreeSelector(page: Page): Promise<void> {
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
// Fallback: wait for "Branch:" text
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
});
await page
.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium })
.catch(() => {
// Fallback: wait for "Branch:" text
return page.getByText('Branch:').waitFor({ timeout: TIMEOUTS.medium });
});
}

View File

@@ -1,32 +1,23 @@
import { Page, Locator } from "@playwright/test";
import { Page, Locator } from '@playwright/test';
/**
* Get a kanban card by feature ID
*/
export async function getKanbanCard(
page: Page,
featureId: string
): Promise<Locator> {
export async function getKanbanCard(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
}
/**
* Get a kanban column by its ID
*/
export async function getKanbanColumn(
page: Page,
columnId: string
): Promise<Locator> {
export async function getKanbanColumn(page: Page, columnId: string): Promise<Locator> {
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
}
/**
* Get the width of a kanban column
*/
export async function getKanbanColumnWidth(
page: Page,
columnId: string
): Promise<number> {
export async function getKanbanColumnWidth(page: Page, columnId: string): Promise<number> {
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
const box = await column.boundingBox();
return box?.width ?? 0;
@@ -35,19 +26,16 @@ export async function getKanbanColumnWidth(
/**
* Check if a kanban column has CSS columns (masonry) layout
*/
export async function hasKanbanColumnMasonryLayout(
page: Page,
columnId: string
): Promise<boolean> {
export async function hasKanbanColumnMasonryLayout(page: Page, columnId: string): Promise<boolean> {
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
const contentDiv = column.locator("> div").nth(1); // Second child is the content area
const contentDiv = column.locator('> div').nth(1); // Second child is the content area
const columnCount = await contentDiv.evaluate((el) => {
const style = window.getComputedStyle(el);
return style.columnCount;
});
return columnCount === "2";
return columnCount === '2';
}
/**
@@ -58,11 +46,8 @@ export async function dragKanbanCard(
featureId: string,
targetColumnId: string
): Promise<void> {
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
const targetColumn = page.locator(
`[data-testid="kanban-column-${targetColumnId}"]`
);
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
// Perform drag and drop
await dragHandle.dragTo(targetColumn);
@@ -71,15 +56,10 @@ export async function dragKanbanCard(
/**
* Click the view output button on a kanban card
*/
export async function clickViewOutput(
page: Page,
featureId: string
): Promise<void> {
export async function clickViewOutput(page: Page, featureId: string): Promise<void> {
// Try the running version first, then the in-progress version
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
const inProgressBtn = page.locator(
`[data-testid="view-output-inprogress-${featureId}"]`
);
const inProgressBtn = page.locator(`[data-testid="view-output-inprogress-${featureId}"]`);
if (await runningBtn.isVisible()) {
await runningBtn.click();
@@ -104,10 +84,7 @@ export async function isDragHandleVisibleForFeature(
/**
* Get the drag handle element for a specific feature card
*/
export async function getDragHandleForFeature(
page: Page,
featureId: string
): Promise<Locator> {
export async function getDragHandleForFeature(page: Page, featureId: string): Promise<Locator> {
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
}
@@ -134,9 +111,7 @@ export async function fillAddFeatureDialog(
options?: { branch?: string; category?: string }
): Promise<void> {
// Fill description (using the dropzone textarea)
const descriptionInput = page
.locator('[data-testid="add-feature-dialog"] textarea')
.first();
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
await descriptionInput.fill(description);
// Fill branch if provided (it's a combobox autocomplete)
@@ -145,36 +120,34 @@ export async function fillAddFeatureDialog(
const otherBranchRadio = page
.locator('[data-testid="feature-radio-group"]')
.locator('[id="feature-other"]');
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 });
await otherBranchRadio.click();
// Wait for the branch input to appear
await page.waitForTimeout(300);
// Now click on the branch input (autocomplete)
const branchInput = page.locator('[data-testid="feature-input"]');
await branchInput.waitFor({ state: "visible", timeout: 5000 });
await branchInput.waitFor({ state: 'visible', timeout: 5000 });
await branchInput.click();
// Wait for the popover to open
await page.waitForTimeout(300);
// Type in the command input
const commandInput = page.locator("[cmdk-input]");
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.branch);
// Press Enter to select/create the branch
await commandInput.press("Enter");
await commandInput.press('Enter');
// Wait for popover to close
await page.waitForTimeout(200);
}
// Fill category if provided (it's also a combobox autocomplete)
if (options?.category) {
const categoryButton = page.locator(
'[data-testid="feature-category-input"]'
);
const categoryButton = page.locator('[data-testid="feature-category-input"]');
await categoryButton.click();
await page.waitForTimeout(300);
const commandInput = page.locator("[cmdk-input]");
const commandInput = page.locator('[cmdk-input]');
await commandInput.fill(options.category);
await commandInput.press("Enter");
await commandInput.press('Enter');
await page.waitForTimeout(200);
}
}
@@ -185,10 +158,9 @@ export async function fillAddFeatureDialog(
export async function confirmAddFeature(page: Page): Promise<void> {
await page.click('[data-testid="confirm-add-feature"]');
// Wait for dialog to close
await page.waitForFunction(
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
{ timeout: 5000 }
);
await page.waitForFunction(() => !document.querySelector('[data-testid="add-feature-dialog"]'), {
timeout: 5000,
});
}
/**
@@ -218,12 +190,9 @@ export async function getWorktreeSelector(page: Page): Promise<Locator> {
/**
* Click on a branch button in the worktree selector
*/
export async function selectWorktreeBranch(
page: Page,
branchName: string
): Promise<void> {
const branchButton = page.getByRole("button", {
name: new RegExp(branchName, "i"),
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
const branchButton = page.getByRole('button', {
name: new RegExp(branchName, 'i'),
});
await branchButton.click();
await page.waitForTimeout(500); // Wait for UI to update
@@ -232,9 +201,7 @@ export async function selectWorktreeBranch(
/**
* Get the currently selected branch in the worktree selector
*/
export async function getSelectedWorktreeBranch(
page: Page
): Promise<string | null> {
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
// The main branch button has aria-pressed="true" when selected
const selectedButton = page.locator(
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
@@ -246,12 +213,9 @@ export async function getSelectedWorktreeBranch(
/**
* Check if a branch button is visible in the worktree selector
*/
export async function isWorktreeBranchVisible(
page: Page,
branchName: string
): Promise<boolean> {
const branchButton = page.getByRole("button", {
name: new RegExp(branchName, "i"),
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
const branchButton = page.getByRole('button', {
name: new RegExp(branchName, 'i'),
});
return await branchButton.isVisible().catch(() => false);
}

View File

@@ -1,20 +1,19 @@
import { Page, Locator } from "@playwright/test";
import { getByTestId } from "../core/elements";
import { waitForElement } from "../core/waiting";
import { setupFirstRun } from "../project/setup";
import { Page, Locator } from '@playwright/test';
import { getByTestId } from '../core/elements';
import { waitForElement } from '../core/waiting';
/**
* Wait for setup view to be visible
*/
export async function waitForSetupView(page: Page): Promise<Locator> {
return waitForElement(page, "setup-view", { timeout: 10000 });
return waitForElement(page, 'setup-view', { timeout: 10000 });
}
/**
* Click "Get Started" button on setup welcome step
*/
export async function clickSetupGetStarted(page: Page): Promise<void> {
const button = await getByTestId(page, "setup-start-button");
const button = await getByTestId(page, 'setup-start-button');
await button.click();
}
@@ -22,7 +21,7 @@ export async function clickSetupGetStarted(page: Page): Promise<void> {
* Click continue on Claude setup step
*/
export async function clickClaudeContinue(page: Page): Promise<void> {
const button = await getByTestId(page, "claude-next-button");
const button = await getByTestId(page, 'claude-next-button');
await button.click();
}
@@ -30,46 +29,40 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
* Click finish on setup complete step
*/
export async function clickSetupFinish(page: Page): Promise<void> {
const button = await getByTestId(page, "setup-finish-button");
const button = await getByTestId(page, 'setup-finish-button');
await button.click();
}
/**
* Enter Anthropic API key in setup
*/
export async function enterAnthropicApiKey(
page: Page,
apiKey: string
): Promise<void> {
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
// Click "Use Anthropic API Key Instead" button
const useApiKeyButton = await getByTestId(page, "use-api-key-button");
const useApiKeyButton = await getByTestId(page, 'use-api-key-button');
await useApiKeyButton.click();
// Enter the API key
const input = await getByTestId(page, "anthropic-api-key-input");
const input = await getByTestId(page, 'anthropic-api-key-input');
await input.fill(apiKey);
// Click save button
const saveButton = await getByTestId(page, "save-anthropic-key-button");
const saveButton = await getByTestId(page, 'save-anthropic-key-button');
await saveButton.click();
}
/**
* Enter OpenAI API key in setup
*/
export async function enterOpenAIApiKey(
page: Page,
apiKey: string
): Promise<void> {
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
// Click "Enter OpenAI API Key" button
const useApiKeyButton = await getByTestId(page, "use-openai-key-button");
const useApiKeyButton = await getByTestId(page, 'use-openai-key-button');
await useApiKeyButton.click();
// Enter the API key
const input = await getByTestId(page, "openai-api-key-input");
const input = await getByTestId(page, 'openai-api-key-input');
await input.fill(apiKey);
// Click save button
const saveButton = await getByTestId(page, "save-openai-key-button");
const saveButton = await getByTestId(page, 'save-openai-key-button');
await saveButton.click();
}

70
pnpm-lock.yaml generated
View File

@@ -1,70 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
cross-spawn:
specifier: ^7.0.6
version: 7.0.6
tree-kill:
specifier: ^1.2.2
version: 1.2.2
packages:
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
snapshots:
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
isexe@2.0.0: {}
path-key@3.1.1: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
tree-kill@1.2.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0