mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Merge pull request #210 from AutoMaker-Org/refactor/folder-pattern-compliance
refactor: sidebar
This commit is contained in:
644
apps/server/tests/unit/services/claude-usage-service.test.ts
Normal file
644
apps/server/tests/unit/services/claude-usage-service.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,18 @@ import tsParser from "@typescript-eslint/parser";
|
|||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.mjs", "**/*.cjs"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
console: "readonly",
|
||||||
|
process: "readonly",
|
||||||
|
require: "readonly",
|
||||||
|
__dirname: "readonly",
|
||||||
|
__filename: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
files: ["**/*.ts", "**/*.tsx"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
@@ -13,6 +25,70 @@ const eslintConfig = defineConfig([
|
|||||||
ecmaVersion: "latest",
|
ecmaVersion: "latest",
|
||||||
sourceType: "module",
|
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: {
|
plugins: {
|
||||||
"@typescript-eslint": ts,
|
"@typescript-eslint": ts,
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from 'react';
|
||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
import { router } from "./utils/router";
|
import { router } from './utils/router';
|
||||||
import { SplashScreen } from "./components/splash-screen";
|
import { SplashScreen } from './components/splash-screen';
|
||||||
import { useSettingsMigration } from "./hooks/use-settings-migration";
|
import { useSettingsMigration } from './hooks/use-settings-migration';
|
||||||
import "./styles/global.css";
|
import './styles/global.css';
|
||||||
import "./styles/theme-imports";
|
import './styles/theme-imports';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [showSplash, setShowSplash] = useState(() => {
|
const [showSplash, setShowSplash] = useState(() => {
|
||||||
// Only show splash once per session
|
// Only show splash once per session
|
||||||
if (sessionStorage.getItem("automaker-splash-shown")) {
|
if (sessionStorage.getItem('automaker-splash-shown')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -18,11 +18,11 @@ export default function App() {
|
|||||||
// Run settings migration on startup (localStorage -> file storage)
|
// Run settings migration on startup (localStorage -> file storage)
|
||||||
const migrationState = useSettingsMigration();
|
const migrationState = useSettingsMigration();
|
||||||
if (migrationState.migrated) {
|
if (migrationState.migrated) {
|
||||||
console.log("[App] Settings migrated to file storage");
|
console.log('[App] Settings migrated to file storage');
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSplashComplete = useCallback(() => {
|
const handleSplashComplete = useCallback(() => {
|
||||||
sessionStorage.setItem("automaker-splash-shown", "true");
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
setShowSplash(false);
|
setShowSplash(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -6,9 +5,9 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface DeleteAllArchivedSessionsDialogProps {
|
interface DeleteAllArchivedSessionsDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -29,8 +28,7 @@ export function DeleteAllArchivedSessionsDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete All Archived Sessions</DialogTitle>
|
<DialogTitle>Delete All Archived Sessions</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete all archived sessions? This action
|
Are you sure you want to delete all archived sessions? This action cannot be undone.
|
||||||
cannot be undone.
|
|
||||||
{archivedCount > 0 && (
|
{archivedCount > 0 && (
|
||||||
<span className="block mt-2 text-yellow-500">
|
<span className="block mt-2 text-yellow-500">
|
||||||
{archivedCount} session(s) will be deleted.
|
{archivedCount} session(s) will be deleted.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||||
import type { SessionListItem } from "@/types/electron";
|
import type { SessionListItem } from '@/types/electron';
|
||||||
|
|
||||||
interface DeleteSessionDialogProps {
|
interface DeleteSessionDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -38,12 +38,8 @@ export function DeleteSessionDialog({
|
|||||||
<MessageSquare className="w-5 h-5 text-brand-500" />
|
<MessageSquare className="w-5 h-5 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-foreground truncate">
|
<p className="font-medium text-foreground truncate">{session.name}</p>
|
||||||
{session.name}
|
<p className="text-xs text-muted-foreground">{session.messageCount} messages</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{session.messageCount} messages
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Clock,
|
Clock,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,14 +17,11 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from '@/components/ui/input';
|
||||||
import { getJSON, setJSON } from "@/lib/storage";
|
import { getJSON, setJSON } from '@/lib/storage';
|
||||||
import {
|
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||||
getDefaultWorkspaceDirectory,
|
|
||||||
saveLastProjectDirectory,
|
|
||||||
} from "@/lib/workspace-config";
|
|
||||||
|
|
||||||
interface DirectoryEntry {
|
interface DirectoryEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -50,7 +47,7 @@ interface FileBrowserDialogProps {
|
|||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
|
const RECENT_FOLDERS_KEY = 'file-browser-recent-folders';
|
||||||
const MAX_RECENT_FOLDERS = 5;
|
const MAX_RECENT_FOLDERS = 5;
|
||||||
|
|
||||||
function getRecentFolders(): string[] {
|
function getRecentFolders(): string[] {
|
||||||
@@ -76,18 +73,18 @@ export function FileBrowserDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
title = "Select Project Directory",
|
title = 'Select Project Directory',
|
||||||
description = "Navigate to your project folder or paste a path directly",
|
description = 'Navigate to your project folder or paste a path directly',
|
||||||
initialPath,
|
initialPath,
|
||||||
}: FileBrowserDialogProps) {
|
}: FileBrowserDialogProps) {
|
||||||
const [currentPath, setCurrentPath] = useState<string>("");
|
const [currentPath, setCurrentPath] = useState<string>('');
|
||||||
const [pathInput, setPathInput] = useState<string>("");
|
const [pathInput, setPathInput] = useState<string>('');
|
||||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||||
const [drives, setDrives] = useState<string[]>([]);
|
const [drives, setDrives] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState('');
|
||||||
const [warning, setWarning] = useState("");
|
const [warning, setWarning] = useState('');
|
||||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -98,28 +95,24 @@ export function FileBrowserDialog({
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleRemoveRecent = useCallback(
|
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||||
(e: React.MouseEvent, path: string) => {
|
e.stopPropagation();
|
||||||
e.stopPropagation();
|
const updated = removeRecentFolder(path);
|
||||||
const updated = removeRecentFolder(path);
|
setRecentFolders(updated);
|
||||||
setRecentFolders(updated);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const browseDirectory = useCallback(async (dirPath?: string) => {
|
const browseDirectory = useCallback(async (dirPath?: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError('');
|
||||||
setWarning("");
|
setWarning('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get server URL from environment or default
|
// Get server URL from environment or default
|
||||||
const serverUrl =
|
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||||
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
|
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ dirPath }),
|
body: JSON.stringify({ dirPath }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,14 +124,12 @@ export function FileBrowserDialog({
|
|||||||
setParentPath(result.parentPath);
|
setParentPath(result.parentPath);
|
||||||
setDirectories(result.directories);
|
setDirectories(result.directories);
|
||||||
setDrives(result.drives || []);
|
setDrives(result.drives || []);
|
||||||
setWarning(result.warning || "");
|
setWarning(result.warning || '');
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || "Failed to browse directory");
|
setError(result.error || 'Failed to browse directory');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||||
err instanceof Error ? err.message : "Failed to load directories"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -154,12 +145,12 @@ export function FileBrowserDialog({
|
|||||||
// Reset current path when dialog closes
|
// Reset current path when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setCurrentPath("");
|
setCurrentPath('');
|
||||||
setPathInput("");
|
setPathInput('');
|
||||||
setParentPath(null);
|
setParentPath(null);
|
||||||
setDirectories([]);
|
setDirectories([]);
|
||||||
setError("");
|
setError('');
|
||||||
setWarning("");
|
setWarning('');
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -189,7 +180,7 @@ export function FileBrowserDialog({
|
|||||||
// No default directory, browse home directory
|
// No default directory, browse home directory
|
||||||
browseDirectory();
|
browseDirectory();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// If config fetch fails, try initialPath or fall back to home directory
|
// If config fetch fails, try initialPath or fall back to home directory
|
||||||
if (initialPath) {
|
if (initialPath) {
|
||||||
setPathInput(initialPath);
|
setPathInput(initialPath);
|
||||||
@@ -230,7 +221,7 @@ export function FileBrowserDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleGoToPath();
|
handleGoToPath();
|
||||||
}
|
}
|
||||||
@@ -252,7 +243,7 @@ export function FileBrowserDialog({
|
|||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
|
// 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();
|
e.preventDefault();
|
||||||
if (currentPath && !loading) {
|
if (currentPath && !loading) {
|
||||||
handleSelect();
|
handleSelect();
|
||||||
@@ -260,8 +251,8 @@ export function FileBrowserDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [open, currentPath, loading, handleSelect]);
|
}, [open, currentPath, loading, handleSelect]);
|
||||||
|
|
||||||
// Helper to get folder name from path
|
// Helper to get folder name from path
|
||||||
@@ -326,9 +317,7 @@ export function FileBrowserDialog({
|
|||||||
title={folder}
|
title={folder}
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
|
<Folder className="w-3 h-3 text-brand-500 shrink-0" />
|
||||||
<span className="truncate max-w-[120px]">
|
<span className="truncate max-w-[120px]">{getFolderName(folder)}</span>
|
||||||
{getFolderName(folder)}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleRemoveRecent(e, folder)}
|
onClick={(e) => handleRemoveRecent(e, folder)}
|
||||||
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-destructive transition-opacity"
|
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) => (
|
{drives.map((drive) => (
|
||||||
<Button
|
<Button
|
||||||
key={drive}
|
key={drive}
|
||||||
variant={
|
variant={currentPath.startsWith(drive) ? 'default' : 'outline'}
|
||||||
currentPath.startsWith(drive) ? "default" : "outline"
|
|
||||||
}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSelectDrive(drive)}
|
onClick={() => handleSelectDrive(drive)}
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 px-2 text-xs"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{drive.replace("\\", "")}
|
{drive.replace('\\', '')}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -388,7 +375,7 @@ export function FileBrowserDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
||||||
{currentPath || "Loading..."}
|
{currentPath || 'Loading...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -396,9 +383,7 @@ export function FileBrowserDialog({
|
|||||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">Loading directories...</div>
|
||||||
Loading directories...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -416,9 +401,7 @@ export function FileBrowserDialog({
|
|||||||
|
|
||||||
{!loading && !error && !warning && directories.length === 0 && (
|
{!loading && !error && !warning && directories.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">No subdirectories found</div>
|
||||||
No subdirectories found
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -440,8 +423,8 @@ export function FileBrowserDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
Paste a full path above, or click on folders to navigate. Press
|
Paste a full path above, or click on folders to navigate. Press Enter or click Go to
|
||||||
Enter or click Go to jump to a path.
|
jump to a path.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -458,10 +441,9 @@ export function FileBrowserDialog({
|
|||||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Select Current Folder
|
Select Current Folder
|
||||||
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
|
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] bg-background/50 rounded border border-border">
|
||||||
{typeof navigator !== "undefined" &&
|
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
|
||||||
navigator.platform?.includes("Mac")
|
? '⌘'
|
||||||
? "⌘"
|
: 'Ctrl'}
|
||||||
: "Ctrl"}
|
|
||||||
+↵
|
+↵
|
||||||
</kbd>
|
</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
6
apps/ui/src/components/dialogs/index.ts
Normal file
6
apps/ui/src/components/dialogs/index.ts
Normal 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';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from '@/components/ui/label';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -22,15 +22,12 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Link,
|
Link,
|
||||||
Folder,
|
Folder,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
|
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { useFileBrowser } from "@/contexts/file-browser-context";
|
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||||
import {
|
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||||
getDefaultWorkspaceDirectory,
|
|
||||||
saveLastProjectDirectory,
|
|
||||||
} from "@/lib/workspace-config";
|
|
||||||
|
|
||||||
interface ValidationErrors {
|
interface ValidationErrors {
|
||||||
projectName?: boolean;
|
projectName?: boolean;
|
||||||
@@ -42,20 +39,13 @@ interface ValidationErrors {
|
|||||||
interface NewProjectModalProps {
|
interface NewProjectModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCreateBlankProject: (
|
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
|
||||||
projectName: string,
|
|
||||||
parentDir: string
|
|
||||||
) => Promise<void>;
|
|
||||||
onCreateFromTemplate: (
|
onCreateFromTemplate: (
|
||||||
template: StarterTemplate,
|
template: StarterTemplate,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
parentDir: string
|
parentDir: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onCreateFromCustomUrl: (
|
onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise<void>;
|
||||||
repoUrl: string,
|
|
||||||
projectName: string,
|
|
||||||
parentDir: string
|
|
||||||
) => Promise<void>;
|
|
||||||
isCreating: boolean;
|
isCreating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +57,13 @@ export function NewProjectModal({
|
|||||||
onCreateFromCustomUrl,
|
onCreateFromCustomUrl,
|
||||||
isCreating,
|
isCreating,
|
||||||
}: NewProjectModalProps) {
|
}: NewProjectModalProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
|
const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank');
|
||||||
const [projectName, setProjectName] = useState("");
|
const [projectName, setProjectName] = useState('');
|
||||||
const [workspaceDir, setWorkspaceDir] = useState<string>("");
|
const [workspaceDir, setWorkspaceDir] = useState<string>('');
|
||||||
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
||||||
const [selectedTemplate, setSelectedTemplate] =
|
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
|
||||||
useState<StarterTemplate | null>(null);
|
|
||||||
const [useCustomUrl, setUseCustomUrl] = useState(false);
|
const [useCustomUrl, setUseCustomUrl] = useState(false);
|
||||||
const [customUrl, setCustomUrl] = useState("");
|
const [customUrl, setCustomUrl] = useState('');
|
||||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
|
||||||
@@ -89,7 +78,7 @@ export function NewProjectModal({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to get default workspace directory:", error);
|
console.error('Failed to get default workspace directory:', error);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsLoadingWorkspace(false);
|
setIsLoadingWorkspace(false);
|
||||||
@@ -100,11 +89,11 @@ export function NewProjectModal({
|
|||||||
// Reset form when modal closes
|
// Reset form when modal closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setProjectName("");
|
setProjectName('');
|
||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
setUseCustomUrl(false);
|
setUseCustomUrl(false);
|
||||||
setCustomUrl("");
|
setCustomUrl('');
|
||||||
setActiveTab("blank");
|
setActiveTab('blank');
|
||||||
setErrors({});
|
setErrors({});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -117,10 +106,7 @@ export function NewProjectModal({
|
|||||||
}, [projectName, errors.projectName]);
|
}, [projectName, errors.projectName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
|
||||||
(selectedTemplate || (useCustomUrl && customUrl)) &&
|
|
||||||
errors.templateSelection
|
|
||||||
) {
|
|
||||||
setErrors((prev) => ({ ...prev, templateSelection: false }));
|
setErrors((prev) => ({ ...prev, templateSelection: false }));
|
||||||
}
|
}
|
||||||
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
|
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
|
||||||
@@ -145,7 +131,7 @@ export function NewProjectModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check template selection (only for template tab)
|
// Check template selection (only for template tab)
|
||||||
if (activeTab === "template") {
|
if (activeTab === 'template') {
|
||||||
if (useCustomUrl) {
|
if (useCustomUrl) {
|
||||||
if (!customUrl.trim()) {
|
if (!customUrl.trim()) {
|
||||||
newErrors.customUrl = true;
|
newErrors.customUrl = true;
|
||||||
@@ -164,7 +150,7 @@ export function NewProjectModal({
|
|||||||
// Clear errors and proceed
|
// Clear errors and proceed
|
||||||
setErrors({});
|
setErrors({});
|
||||||
|
|
||||||
if (activeTab === "blank") {
|
if (activeTab === 'blank') {
|
||||||
await onCreateBlankProject(projectName, workspaceDir);
|
await onCreateBlankProject(projectName, workspaceDir);
|
||||||
} else if (useCustomUrl && customUrl) {
|
} else if (useCustomUrl && customUrl) {
|
||||||
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
|
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
|
||||||
@@ -181,7 +167,7 @@ export function NewProjectModal({
|
|||||||
const handleSelectTemplate = (template: StarterTemplate) => {
|
const handleSelectTemplate = (template: StarterTemplate) => {
|
||||||
setSelectedTemplate(template);
|
setSelectedTemplate(template);
|
||||||
setUseCustomUrl(false);
|
setUseCustomUrl(false);
|
||||||
setCustomUrl("");
|
setCustomUrl('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleCustomUrl = () => {
|
const handleToggleCustomUrl = () => {
|
||||||
@@ -193,9 +179,8 @@ export function NewProjectModal({
|
|||||||
|
|
||||||
const handleBrowseDirectory = async () => {
|
const handleBrowseDirectory = async () => {
|
||||||
const selectedPath = await openFileBrowser({
|
const selectedPath = await openFileBrowser({
|
||||||
title: "Select Base Project Directory",
|
title: 'Select Base Project Directory',
|
||||||
description:
|
description: 'Choose the parent directory where your project will be created',
|
||||||
"Choose the parent directory where your project will be created",
|
|
||||||
initialPath: workspaceDir || undefined,
|
initialPath: workspaceDir || undefined,
|
||||||
});
|
});
|
||||||
if (selectedPath) {
|
if (selectedPath) {
|
||||||
@@ -211,15 +196,12 @@ export function NewProjectModal({
|
|||||||
|
|
||||||
// Use platform-specific path separator
|
// Use platform-specific path separator
|
||||||
const pathSep =
|
const pathSep =
|
||||||
typeof window !== "undefined" && (window as any).electronAPI
|
typeof window !== 'undefined' && (window as any).electronAPI
|
||||||
? navigator.platform.indexOf("Win") !== -1
|
? navigator.platform.indexOf('Win') !== -1
|
||||||
? "\\"
|
? '\\'
|
||||||
: "/"
|
: '/'
|
||||||
: "/";
|
: '/';
|
||||||
const projectPath =
|
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : '';
|
||||||
workspaceDir && projectName
|
|
||||||
? `${workspaceDir}${pathSep}${projectName}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -228,9 +210,7 @@ export function NewProjectModal({
|
|||||||
data-testid="new-project-modal"
|
data-testid="new-project-modal"
|
||||||
>
|
>
|
||||||
<DialogHeader className="pb-2">
|
<DialogHeader className="pb-2">
|
||||||
<DialogTitle className="text-foreground">
|
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
|
||||||
Create New Project
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Start with a blank project or choose from a starter template.
|
Start with a blank project or choose from a starter template.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -241,13 +221,9 @@ export function NewProjectModal({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
htmlFor="project-name"
|
htmlFor="project-name"
|
||||||
className={cn(
|
className={cn('text-foreground', errors.projectName && 'text-red-500')}
|
||||||
"text-foreground",
|
|
||||||
errors.projectName && "text-red-500"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Project Name{" "}
|
Project Name {errors.projectName && <span className="text-red-500">*</span>}
|
||||||
{errors.projectName && <span className="text-red-500">*</span>}
|
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="project-name"
|
id="project-name"
|
||||||
@@ -255,33 +231,31 @@ export function NewProjectModal({
|
|||||||
value={projectName}
|
value={projectName}
|
||||||
onChange={(e) => setProjectName(e.target.value)}
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
'bg-input text-foreground placeholder:text-muted-foreground',
|
||||||
errors.projectName
|
errors.projectName
|
||||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
|
||||||
: "border-border"
|
: 'border-border'
|
||||||
)}
|
)}
|
||||||
data-testid="project-name-input"
|
data-testid="project-name-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{errors.projectName && (
|
{errors.projectName && <p className="text-xs text-red-500">Project name is required</p>}
|
||||||
<p className="text-xs text-red-500">Project name is required</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workspace Directory Display */}
|
{/* Workspace Directory Display */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 text-sm",
|
'flex items-center gap-2 text-sm',
|
||||||
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
|
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="w-4 h-4 shrink-0" />
|
<Folder className="w-4 h-4 shrink-0" />
|
||||||
<span className="flex-1 min-w-0">
|
<span className="flex-1 min-w-0">
|
||||||
{isLoadingWorkspace ? (
|
{isLoadingWorkspace ? (
|
||||||
"Loading workspace..."
|
'Loading workspace...'
|
||||||
) : workspaceDir ? (
|
) : workspaceDir ? (
|
||||||
<>
|
<>
|
||||||
Will be created at:{" "}
|
Will be created at:{' '}
|
||||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
||||||
{projectPath || workspaceDir}
|
{projectPath || workspaceDir}
|
||||||
</code>
|
</code>
|
||||||
@@ -305,7 +279,7 @@ export function NewProjectModal({
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
|
onValueChange={(v) => setActiveTab(v as 'blank' | 'template')}
|
||||||
className="flex-1 flex flex-col overflow-hidden"
|
className="flex-1 flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full justify-start">
|
<TabsList className="w-full justify-start">
|
||||||
@@ -323,9 +297,8 @@ export function NewProjectModal({
|
|||||||
<TabsContent value="blank" className="mt-0">
|
<TabsContent value="blank" className="mt-0">
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Create an empty project with the standard .automaker directory
|
Create an empty project with the standard .automaker directory structure. Perfect
|
||||||
structure. Perfect for starting from scratch or importing an
|
for starting from scratch or importing an existing codebase.
|
||||||
existing codebase.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -342,18 +315,18 @@ export function NewProjectModal({
|
|||||||
{/* Preset Templates */}
|
{/* Preset Templates */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-3 rounded-lg p-1 -m-1",
|
'space-y-3 rounded-lg p-1 -m-1',
|
||||||
errors.templateSelection && "ring-2 ring-red-500/50"
|
errors.templateSelection && 'ring-2 ring-red-500/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{starterTemplates.map((template) => (
|
{starterTemplates.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className={cn(
|
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
|
selectedTemplate?.id === template.id && !useCustomUrl
|
||||||
? "border-brand-500 bg-brand-500/10"
|
? 'border-brand-500 bg-brand-500/10'
|
||||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSelectTemplate(template)}
|
onClick={() => handleSelectTemplate(template)}
|
||||||
data-testid={`template-${template.id}`}
|
data-testid={`template-${template.id}`}
|
||||||
@@ -361,13 +334,10 @@ export function NewProjectModal({
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h4 className="font-medium text-foreground">
|
<h4 className="font-medium text-foreground">{template.name}</h4>
|
||||||
{template.name}
|
{selectedTemplate?.id === template.id && !useCustomUrl && (
|
||||||
</h4>
|
<Check className="w-4 h-4 text-brand-500" />
|
||||||
{selectedTemplate?.id === template.id &&
|
)}
|
||||||
!useCustomUrl && (
|
|
||||||
<Check className="w-4 h-4 text-brand-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
{template.description}
|
{template.description}
|
||||||
@@ -376,11 +346,7 @@ export function NewProjectModal({
|
|||||||
{/* Tech Stack */}
|
{/* Tech Stack */}
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
{template.techStack.slice(0, 6).map((tech) => (
|
{template.techStack.slice(0, 6).map((tech) => (
|
||||||
<Badge
|
<Badge key={tech} variant="secondary" className="text-xs">
|
||||||
key={tech}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{tech}
|
{tech}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
@@ -394,7 +360,7 @@ export function NewProjectModal({
|
|||||||
{/* Key Features */}
|
{/* Key Features */}
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
<span className="font-medium">Features: </span>
|
<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 &&
|
||||||
` · +${template.features.length - 3} more`}
|
` · +${template.features.length - 3} more`}
|
||||||
</div>
|
</div>
|
||||||
@@ -419,47 +385,38 @@ export function NewProjectModal({
|
|||||||
{/* Custom URL Option */}
|
{/* Custom URL Option */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
'p-4 rounded-lg border cursor-pointer transition-all',
|
||||||
useCustomUrl
|
useCustomUrl
|
||||||
? "border-brand-500 bg-brand-500/10"
|
? 'border-brand-500 bg-brand-500/10'
|
||||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
|
||||||
)}
|
)}
|
||||||
onClick={handleToggleCustomUrl}
|
onClick={handleToggleCustomUrl}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Link className="w-4 h-4 text-muted-foreground" />
|
<Link className="w-4 h-4 text-muted-foreground" />
|
||||||
<h4 className="font-medium text-foreground">
|
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
|
||||||
Custom GitHub URL
|
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
|
||||||
</h4>
|
|
||||||
{useCustomUrl && (
|
|
||||||
<Check className="w-4 h-4 text-brand-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
Clone any public GitHub repository as a starting point.
|
Clone any public GitHub repository as a starting point.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{useCustomUrl && (
|
{useCustomUrl && (
|
||||||
<div
|
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="space-y-1"
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://github.com/username/repository"
|
placeholder="https://github.com/username/repository"
|
||||||
value={customUrl}
|
value={customUrl}
|
||||||
onChange={(e) => setCustomUrl(e.target.value)}
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
'bg-input text-foreground placeholder:text-muted-foreground',
|
||||||
errors.customUrl
|
errors.customUrl
|
||||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
|
||||||
: "border-border"
|
: 'border-border'
|
||||||
)}
|
)}
|
||||||
data-testid="custom-url-input"
|
data-testid="custom-url-input"
|
||||||
/>
|
/>
|
||||||
{errors.customUrl && (
|
{errors.customUrl && (
|
||||||
<p className="text-xs text-red-500">
|
<p className="text-xs text-red-500">GitHub URL is required</p>
|
||||||
GitHub URL is required
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -482,14 +439,14 @@ export function NewProjectModal({
|
|||||||
onClick={validateAndCreate}
|
onClick={validateAndCreate}
|
||||||
disabled={isCreating}
|
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"
|
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}
|
hotkeyActive={open}
|
||||||
data-testid="confirm-create-project"
|
data-testid="confirm-create-project"
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
{activeTab === "template" ? "Cloning..." : "Creating..."}
|
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Create Project</>
|
<>Create Project</>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -7,10 +6,10 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Folder, Loader2, FolderOpen, AlertCircle } from "lucide-react";
|
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
|
||||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
|
||||||
interface WorkspaceDirectory {
|
interface WorkspaceDirectory {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,11 +22,7 @@ interface WorkspacePickerModalProps {
|
|||||||
onSelect: (path: string, name: string) => void;
|
onSelect: (path: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspacePickerModal({
|
export function WorkspacePickerModal({ open, onOpenChange, onSelect }: WorkspacePickerModalProps) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSelect,
|
|
||||||
}: WorkspacePickerModalProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -43,10 +38,10 @@ export function WorkspacePickerModal({
|
|||||||
if (result.success && result.directories) {
|
if (result.success && result.directories) {
|
||||||
setDirectories(result.directories);
|
setDirectories(result.directories);
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || "Failed to load directories");
|
setError(result.error || 'Failed to load directories');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load directories");
|
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -90,12 +85,7 @@ export function WorkspacePickerModal({
|
|||||||
<AlertCircle className="w-6 h-6 text-destructive" />
|
<AlertCircle className="w-6 h-6 text-destructive" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
<Button
|
<Button variant="secondary" size="sm" onClick={loadDirectories} className="mt-2">
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={loadDirectories}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
Try Again
|
Try Again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,9 +118,7 @@ export function WorkspacePickerModal({
|
|||||||
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
|
<p className="font-medium text-foreground truncate group-hover:text-brand-500 transition-colors">
|
||||||
{dir.name}
|
{dir.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70 truncate">
|
<p className="text-xs text-muted-foreground/70 truncate">{dir.path}</p>
|
||||||
{dir.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
1
apps/ui/src/components/layout/index.ts
Normal file
1
apps/ui/src/components/layout/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Sidebar } from './sidebar';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/ui/src/components/layout/sidebar/components/index.ts
Normal file
10
apps/ui/src/components/layout/sidebar/components/index.ts
Normal 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';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
24
apps/ui/src/components/layout/sidebar/constants.ts
Normal file
24
apps/ui/src/components/layout/sidebar/constants.ts
Normal 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;
|
||||||
2
apps/ui/src/components/layout/sidebar/dialogs/index.ts
Normal file
2
apps/ui/src/components/layout/sidebar/dialogs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { TrashDialog } from './trash-dialog';
|
||||||
|
export { OnboardingDialog } from './onboarding-dialog';
|
||||||
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx
Normal file
116
apps/ui/src/components/layout/sidebar/dialogs/trash-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/ui/src/components/layout/sidebar/hooks/index.ts
Normal file
12
apps/ui/src/components/layout/sidebar/hooks/index.ts
Normal 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';
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
211
apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Normal file
211
apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
147
apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts
Normal file
147
apps/ui/src/components/layout/sidebar/hooks/use-setup-dialog.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
apps/ui/src/components/layout/sidebar/types.ts
Normal file
32
apps/ui/src/components/layout/sidebar/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect } from "react";
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -15,66 +14,66 @@ import {
|
|||||||
X,
|
X,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import type { SessionListItem } from "@/types/electron";
|
import type { SessionListItem } from '@/types/electron';
|
||||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
||||||
import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
|
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
|
||||||
|
|
||||||
// Random session name generator
|
// Random session name generator
|
||||||
const adjectives = [
|
const adjectives = [
|
||||||
"Swift",
|
'Swift',
|
||||||
"Bright",
|
'Bright',
|
||||||
"Clever",
|
'Clever',
|
||||||
"Dynamic",
|
'Dynamic',
|
||||||
"Eager",
|
'Eager',
|
||||||
"Focused",
|
'Focused',
|
||||||
"Gentle",
|
'Gentle',
|
||||||
"Happy",
|
'Happy',
|
||||||
"Inventive",
|
'Inventive',
|
||||||
"Jolly",
|
'Jolly',
|
||||||
"Keen",
|
'Keen',
|
||||||
"Lively",
|
'Lively',
|
||||||
"Mighty",
|
'Mighty',
|
||||||
"Noble",
|
'Noble',
|
||||||
"Optimal",
|
'Optimal',
|
||||||
"Peaceful",
|
'Peaceful',
|
||||||
"Quick",
|
'Quick',
|
||||||
"Radiant",
|
'Radiant',
|
||||||
"Smart",
|
'Smart',
|
||||||
"Tranquil",
|
'Tranquil',
|
||||||
"Unique",
|
'Unique',
|
||||||
"Vibrant",
|
'Vibrant',
|
||||||
"Wise",
|
'Wise',
|
||||||
"Zealous",
|
'Zealous',
|
||||||
];
|
];
|
||||||
|
|
||||||
const nouns = [
|
const nouns = [
|
||||||
"Agent",
|
'Agent',
|
||||||
"Builder",
|
'Builder',
|
||||||
"Coder",
|
'Coder',
|
||||||
"Developer",
|
'Developer',
|
||||||
"Explorer",
|
'Explorer',
|
||||||
"Forge",
|
'Forge',
|
||||||
"Garden",
|
'Garden',
|
||||||
"Helper",
|
'Helper',
|
||||||
"Innovator",
|
'Innovator',
|
||||||
"Journey",
|
'Journey',
|
||||||
"Kernel",
|
'Kernel',
|
||||||
"Lighthouse",
|
'Lighthouse',
|
||||||
"Mission",
|
'Mission',
|
||||||
"Navigator",
|
'Navigator',
|
||||||
"Oracle",
|
'Oracle',
|
||||||
"Project",
|
'Project',
|
||||||
"Quest",
|
'Quest',
|
||||||
"Runner",
|
'Runner',
|
||||||
"Spark",
|
'Spark',
|
||||||
"Task",
|
'Task',
|
||||||
"Unicorn",
|
'Unicorn',
|
||||||
"Voyage",
|
'Voyage',
|
||||||
"Workshop",
|
'Workshop',
|
||||||
];
|
];
|
||||||
|
|
||||||
function generateRandomSessionName(): string {
|
function generateRandomSessionName(): string {
|
||||||
@@ -101,19 +100,15 @@ export function SessionManager({
|
|||||||
}: SessionManagerProps) {
|
}: SessionManagerProps) {
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
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 [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState('');
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newSessionName, setNewSessionName] = useState("");
|
const [newSessionName, setNewSessionName] = useState('');
|
||||||
const [runningSessions, setRunningSessions] = useState<Set<string>>(
|
const [runningSessions, setRunningSessions] = useState<Set<string>>(new Set());
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [sessionToDelete, setSessionToDelete] =
|
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||||
useState<SessionListItem | null>(null);
|
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
||||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
// Check running state for all sessions
|
// Check running state for all sessions
|
||||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||||
@@ -131,10 +126,7 @@ export function SessionManager({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore errors for individual session checks
|
// Ignore errors for individual session checks
|
||||||
console.warn(
|
console.warn(`[SessionManager] Failed to check running state for ${session.id}:`, err);
|
||||||
`[SessionManager] Failed to check running state for ${session.id}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,14 +172,10 @@ export function SessionManager({
|
|||||||
|
|
||||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||||
|
|
||||||
const result = await api.sessions.create(
|
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||||
sessionName,
|
|
||||||
projectPath,
|
|
||||||
projectPath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
setNewSessionName("");
|
setNewSessionName('');
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
onSelectSession(result.session.id);
|
onSelectSession(result.session.id);
|
||||||
@@ -201,11 +189,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
const sessionName = generateRandomSessionName();
|
const sessionName = generateRandomSessionName();
|
||||||
|
|
||||||
const result = await api.sessions.create(
|
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||||
sessionName,
|
|
||||||
projectPath,
|
|
||||||
projectPath
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.session?.id) {
|
if (result.success && result.session?.id) {
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
@@ -234,7 +218,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
setEditingName("");
|
setEditingName('');
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -243,7 +227,7 @@ export function SessionManager({
|
|||||||
const handleArchiveSession = async (sessionId: string) => {
|
const handleArchiveSession = async (sessionId: string) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.sessions) {
|
if (!api?.sessions) {
|
||||||
console.error("[SessionManager] Sessions API not available");
|
console.error('[SessionManager] Sessions API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,10 +240,10 @@ export function SessionManager({
|
|||||||
}
|
}
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} else {
|
} else {
|
||||||
console.error("[SessionManager] Archive failed:", result.error);
|
console.error('[SessionManager] Archive failed:', result.error);
|
||||||
}
|
}
|
||||||
} catch (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 handleUnarchiveSession = async (sessionId: string) => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api?.sessions) {
|
if (!api?.sessions) {
|
||||||
console.error("[SessionManager] Sessions API not available");
|
console.error('[SessionManager] Sessions API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,10 +260,10 @@ export function SessionManager({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
} else {
|
} else {
|
||||||
console.error("[SessionManager] Unarchive failed:", result.error);
|
console.error('[SessionManager] Unarchive failed:', result.error);
|
||||||
}
|
}
|
||||||
} catch (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 activeSessions = sessions.filter((s) => !s.isArchived);
|
||||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
const archivedSessions = sessions.filter((s) => s.isArchived);
|
||||||
const displayedSessions =
|
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
|
||||||
activeTab === "active" ? activeSessions : archivedSessions;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full flex flex-col rounded-none">
|
<Card className="h-full flex flex-col rounded-none">
|
||||||
@@ -337,8 +320,8 @@ export function SessionManager({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Switch to active tab if on archived tab
|
// Switch to active tab if on archived tab
|
||||||
if (activeTab === "archived") {
|
if (activeTab === 'archived') {
|
||||||
setActiveTab("active");
|
setActiveTab('active');
|
||||||
}
|
}
|
||||||
handleQuickCreateSession();
|
handleQuickCreateSession();
|
||||||
}}
|
}}
|
||||||
@@ -354,9 +337,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => setActiveTab(value as 'active' | 'archived')}
|
||||||
setActiveTab(value as "active" | "archived")
|
|
||||||
}
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
@@ -372,10 +353,7 @@ export function SessionManager({
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent
|
<CardContent className="flex-1 overflow-y-auto space-y-2" data-testid="session-list">
|
||||||
className="flex-1 overflow-y-auto space-y-2"
|
|
||||||
data-testid="session-list"
|
|
||||||
>
|
|
||||||
{/* Create new session */}
|
{/* Create new session */}
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<div className="p-3 border rounded-lg bg-muted/50">
|
<div className="p-3 border rounded-lg bg-muted/50">
|
||||||
@@ -385,10 +363,10 @@ export function SessionManager({
|
|||||||
value={newSessionName}
|
value={newSessionName}
|
||||||
onChange={(e) => setNewSessionName(e.target.value)}
|
onChange={(e) => setNewSessionName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleCreateSession();
|
if (e.key === 'Enter') handleCreateSession();
|
||||||
if (e.key === "Escape") {
|
if (e.key === 'Escape') {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setNewSessionName("");
|
setNewSessionName('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -401,7 +379,7 @@ export function SessionManager({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setNewSessionName("");
|
setNewSessionName('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
@@ -411,7 +389,7 @@ export function SessionManager({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete All Archived button - shown at the top of archived sessions */}
|
{/* 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">
|
<div className="pb-2 border-b mb-2">
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -431,9 +409,9 @@ export function SessionManager({
|
|||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
|
'p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50',
|
||||||
currentSessionId === session.id && "bg-primary/10 border-primary",
|
currentSessionId === session.id && 'bg-primary/10 border-primary',
|
||||||
session.isArchived && "opacity-60"
|
session.isArchived && 'opacity-60'
|
||||||
)}
|
)}
|
||||||
onClick={() => !session.isArchived && onSelectSession(session.id)}
|
onClick={() => !session.isArchived && onSelectSession(session.id)}
|
||||||
data-testid={`session-item-${session.id}`}
|
data-testid={`session-item-${session.id}`}
|
||||||
@@ -446,10 +424,10 @@ export function SessionManager({
|
|||||||
value={editingName}
|
value={editingName}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleRenameSession(session.id);
|
if (e.key === 'Enter') handleRenameSession(session.id);
|
||||||
if (e.key === "Escape") {
|
if (e.key === 'Escape') {
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
setEditingName("");
|
setEditingName('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -472,7 +450,7 @@ export function SessionManager({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingSessionId(null);
|
setEditingSessionId(null);
|
||||||
setEditingName("");
|
setEditingName('');
|
||||||
}}
|
}}
|
||||||
className="h-7"
|
className="h-7"
|
||||||
>
|
>
|
||||||
@@ -483,16 +461,14 @@ export function SessionManager({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<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) */}
|
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
||||||
{(currentSessionId === session.id &&
|
{(currentSessionId === session.id && isCurrentSessionThinking) ||
|
||||||
isCurrentSessionThinking) ||
|
|
||||||
runningSessions.has(session.id) ? (
|
runningSessions.has(session.id) ? (
|
||||||
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
)}
|
)}
|
||||||
<h3 className="font-medium truncate">{session.name}</h3>
|
<h3 className="font-medium truncate">{session.name}</h3>
|
||||||
{((currentSessionId === session.id &&
|
{((currentSessionId === session.id && isCurrentSessionThinking) ||
|
||||||
isCurrentSessionThinking) ||
|
|
||||||
runningSessions.has(session.id)) && (
|
runningSessions.has(session.id)) && (
|
||||||
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||||
thinking...
|
thinking...
|
||||||
@@ -500,9 +476,7 @@ export function SessionManager({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{session.preview && (
|
{session.preview && (
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">{session.preview}</p>
|
||||||
{session.preview}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -519,10 +493,7 @@ export function SessionManager({
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{!session.isArchived && (
|
{!session.isArchived && (
|
||||||
<div
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
className="flex gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -547,10 +518,7 @@ export function SessionManager({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{session.isArchived && (
|
{session.isArchived && (
|
||||||
<div
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
className="flex gap-1"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -578,14 +546,12 @@ export function SessionManager({
|
|||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{activeTab === "active"
|
{activeTab === 'active' ? 'No active sessions' : 'No archived sessions'}
|
||||||
? "No active sessions"
|
|
||||||
: "No archived sessions"}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs">
|
<p className="text-xs">
|
||||||
{activeTab === "active"
|
{activeTab === 'active'
|
||||||
? "Create your first session to get started"
|
? 'Create your first session to get started'
|
||||||
: "Archive sessions to see them here"}
|
: 'Archive sessions to see them here'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type AccordionType = "single" | "multiple";
|
type AccordionType = 'single' | 'multiple';
|
||||||
|
|
||||||
interface AccordionContextValue {
|
interface AccordionContextValue {
|
||||||
type: AccordionType;
|
type: AccordionType;
|
||||||
@@ -12,12 +13,10 @@ interface AccordionContextValue {
|
|||||||
collapsible?: boolean;
|
collapsible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccordionContext = React.createContext<AccordionContextValue | null>(
|
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
type?: "single" | "multiple";
|
type?: 'single' | 'multiple';
|
||||||
value?: string | string[];
|
value?: string | string[];
|
||||||
defaultValue?: string | string[];
|
defaultValue?: string | string[];
|
||||||
onValueChange?: (value: string | string[]) => void;
|
onValueChange?: (value: string | string[]) => void;
|
||||||
@@ -27,7 +26,7 @@ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
type = "single",
|
type = 'single',
|
||||||
value,
|
value,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
@@ -38,13 +37,11 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const [internalValue, setInternalValue] = React.useState<string | string[]>(
|
const [internalValue, setInternalValue] = React.useState<string | string[]>(() => {
|
||||||
() => {
|
if (value !== undefined) return value;
|
||||||
if (value !== undefined) return value;
|
if (defaultValue !== undefined) return defaultValue;
|
||||||
if (defaultValue !== undefined) return defaultValue;
|
return type === 'single' ? '' : [];
|
||||||
return type === "single" ? "" : [];
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentValue = value !== undefined ? value : internalValue;
|
const currentValue = value !== undefined ? value : internalValue;
|
||||||
|
|
||||||
@@ -52,9 +49,9 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
|||||||
(itemValue: string) => {
|
(itemValue: string) => {
|
||||||
let newValue: string | string[];
|
let newValue: string | string[];
|
||||||
|
|
||||||
if (type === "single") {
|
if (type === 'single') {
|
||||||
if (currentValue === itemValue && collapsible) {
|
if (currentValue === itemValue && collapsible) {
|
||||||
newValue = "";
|
newValue = '';
|
||||||
} else if (currentValue === itemValue && !collapsible) {
|
} else if (currentValue === itemValue && !collapsible) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@@ -91,27 +88,21 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionContext.Provider value={contextValue}>
|
<AccordionContext.Provider value={contextValue}>
|
||||||
<div
|
<div ref={ref} data-slot="accordion" className={cn('w-full', className)} {...props}>
|
||||||
ref={ref}
|
|
||||||
data-slot="accordion"
|
|
||||||
className={cn("w-full", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContext.Provider>
|
</AccordionContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
Accordion.displayName = "Accordion";
|
Accordion.displayName = 'Accordion';
|
||||||
|
|
||||||
interface AccordionItemContextValue {
|
interface AccordionItemContextValue {
|
||||||
value: string;
|
value: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccordionItemContext =
|
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
|
||||||
React.createContext<AccordionItemContextValue | null>(null);
|
|
||||||
|
|
||||||
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -122,25 +113,22 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
|||||||
const accordionContext = React.useContext(AccordionContext);
|
const accordionContext = React.useContext(AccordionContext);
|
||||||
|
|
||||||
if (!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)
|
const isOpen = Array.isArray(accordionContext.value)
|
||||||
? accordionContext.value.includes(value)
|
? accordionContext.value.includes(value)
|
||||||
: accordionContext.value === value;
|
: accordionContext.value === value;
|
||||||
|
|
||||||
const contextValue = React.useMemo(
|
const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]);
|
||||||
() => ({ value, isOpen }),
|
|
||||||
[value, isOpen]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItemContext.Provider value={contextValue}>
|
<AccordionItemContext.Provider value={contextValue}>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-slot="accordion-item"
|
data-slot="accordion-item"
|
||||||
data-state={isOpen ? "open" : "closed"}
|
data-state={isOpen ? 'open' : 'closed'}
|
||||||
className={cn("border-b border-border", className)}
|
className={cn('border-b border-border', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -149,47 +137,45 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
AccordionItem.displayName = "AccordionItem";
|
AccordionItem.displayName = 'AccordionItem';
|
||||||
|
|
||||||
interface AccordionTriggerProps
|
interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||||
HTMLButtonElement,
|
({ className, children, ...props }, ref) => {
|
||||||
AccordionTriggerProps
|
const accordionContext = React.useContext(AccordionContext);
|
||||||
>(({ className, children, ...props }, ref) => {
|
const itemContext = React.useContext(AccordionItemContext);
|
||||||
const accordionContext = React.useContext(AccordionContext);
|
|
||||||
const itemContext = React.useContext(AccordionItemContext);
|
|
||||||
|
|
||||||
if (!accordionContext || !itemContext) {
|
if (!accordionContext || !itemContext) {
|
||||||
throw new Error("AccordionTrigger must be used within an AccordionItem");
|
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;
|
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||||
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";
|
|
||||||
|
|
||||||
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
@@ -200,7 +186,7 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
|
|||||||
const [height, setHeight] = React.useState<number | undefined>(undefined);
|
const [height, setHeight] = React.useState<number | undefined>(undefined);
|
||||||
|
|
||||||
if (!itemContext) {
|
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;
|
const { isOpen } = itemContext;
|
||||||
@@ -220,16 +206,16 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="accordion-content"
|
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"
|
className="overflow-hidden text-sm transition-all duration-200 ease-out"
|
||||||
style={{
|
style={{
|
||||||
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
|
height: isOpen ? (height !== undefined ? `${height}px` : 'auto') : 0,
|
||||||
opacity: isOpen ? 1 : 0,
|
opacity: isOpen ? 1 : 0,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div ref={contentRef}>
|
<div ref={contentRef}>
|
||||||
<div ref={ref} className={cn("pb-4 pt-0", className)}>
|
<div ref={ref} className={cn('pb-4 pt-0', className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,6 +223,6 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
AccordionContent.displayName = "AccordionContent";
|
AccordionContent.displayName = 'AccordionContent';
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
import { ImageIcon, X, Loader2 } from 'lucide-react';
|
||||||
import { ImageIcon, X, Loader2 } from "lucide-react";
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { useAppStore, type FeatureImagePath } from '@/store/app-store';
|
||||||
import { useAppStore, type FeatureImagePath } from "@/store/app-store";
|
|
||||||
|
|
||||||
// Map to store preview data by image ID (persisted across component re-mounts)
|
// Map to store preview data by image ID (persisted across component re-mounts)
|
||||||
export type ImagePreviewMap = Map<string, string>;
|
export type ImagePreviewMap = Map<string, string>;
|
||||||
@@ -26,13 +25,7 @@ interface DescriptionImageDropZoneProps {
|
|||||||
error?: boolean; // Show error state with red border
|
error?: boolean; // Show error state with red border
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = [
|
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 DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
export function DescriptionImageDropZone({
|
export function DescriptionImageDropZone({
|
||||||
@@ -40,7 +33,7 @@ export function DescriptionImageDropZone({
|
|||||||
onChange,
|
onChange,
|
||||||
images,
|
images,
|
||||||
onImagesChange,
|
onImagesChange,
|
||||||
placeholder = "Describe the feature...",
|
placeholder = 'Describe the feature...',
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
maxFiles = 5,
|
maxFiles = 5,
|
||||||
@@ -59,71 +52,76 @@ export function DescriptionImageDropZone({
|
|||||||
|
|
||||||
// Determine which preview map to use - prefer parent-controlled state
|
// Determine which preview map to use - prefer parent-controlled state
|
||||||
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
|
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||||
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
const setPreviewImages = useCallback(
|
||||||
if (onPreviewMapChange) {
|
(updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
||||||
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
if (onPreviewMapChange) {
|
||||||
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||||
onPreviewMapChange(newMap);
|
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
||||||
} else {
|
onPreviewMapChange(newMap);
|
||||||
setLocalPreviewImages((prev) => {
|
} else {
|
||||||
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
setLocalPreviewImages((prev) => {
|
||||||
return newMap;
|
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
||||||
});
|
return newMap;
|
||||||
}
|
});
|
||||||
}, [onPreviewMapChange, previewMap, localPreviewImages]);
|
}
|
||||||
|
},
|
||||||
|
[onPreviewMapChange, previewMap, localPreviewImages]
|
||||||
|
);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
const currentProject = useAppStore((state) => state.currentProject);
|
||||||
|
|
||||||
// Construct server URL for loading saved images
|
// Construct server URL for loading saved images
|
||||||
const getImageServerUrl = useCallback((imagePath: string): string => {
|
const getImageServerUrl = useCallback(
|
||||||
const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
|
(imagePath: string): string => {
|
||||||
const projectPath = currentProject?.path || "";
|
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
|
||||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
const projectPath = currentProject?.path || '';
|
||||||
}, [currentProject?.path]);
|
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||||
|
},
|
||||||
|
[currentProject?.path]
|
||||||
|
);
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> => {
|
const fileToBase64 = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
if (typeof reader.result === "string") {
|
if (typeof reader.result === 'string') {
|
||||||
resolve(reader.result);
|
resolve(reader.result);
|
||||||
} else {
|
} 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);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveImageToTemp = useCallback(async (
|
const saveImageToTemp = useCallback(
|
||||||
base64Data: string,
|
async (base64Data: string, filename: string, mimeType: string): Promise<string | null> => {
|
||||||
filename: string,
|
try {
|
||||||
mimeType: string
|
const api = getElectronAPI();
|
||||||
): Promise<string | null> => {
|
// Check if saveImageToTemp method exists
|
||||||
try {
|
if (!api.saveImageToTemp) {
|
||||||
const api = getElectronAPI();
|
// Fallback path when saveImageToTemp is not available
|
||||||
// Check if saveImageToTemp method exists
|
console.log('[DescriptionImageDropZone] Using fallback path for image');
|
||||||
if (!api.saveImageToTemp) {
|
return `.automaker/images/${Date.now()}_${filename}`;
|
||||||
// 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
|
// Get projectPath from the store if available
|
||||||
const projectPath = currentProject?.path;
|
const projectPath = currentProject?.path;
|
||||||
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
|
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
return 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;
|
[currentProject?.path]
|
||||||
} catch (error) {
|
);
|
||||||
console.error("[DescriptionImageDropZone] Error saving image:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
const processFiles = useCallback(
|
const processFiles = useCallback(
|
||||||
async (files: FileList) => {
|
async (files: FileList) => {
|
||||||
@@ -137,18 +135,14 @@ export function DescriptionImageDropZone({
|
|||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Validate file type
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||||
errors.push(
|
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size
|
// Validate file size
|
||||||
if (file.size > maxFileSize) {
|
if (file.size > maxFileSize) {
|
||||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||||
errors.push(
|
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,13 +170,13 @@ export function DescriptionImageDropZone({
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`${file.name}: Failed to save image.`);
|
errors.push(`${file.name}: Failed to save image.`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
errors.push(`${file.name}: Failed to process image.`);
|
errors.push(`${file.name}: Failed to process image.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.warn("Image upload errors:", errors);
|
console.warn('Image upload errors:', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
if (newImages.length > 0) {
|
||||||
@@ -192,7 +186,16 @@ export function DescriptionImageDropZone({
|
|||||||
|
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
},
|
},
|
||||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
|
[
|
||||||
|
disabled,
|
||||||
|
isProcessing,
|
||||||
|
images,
|
||||||
|
maxFiles,
|
||||||
|
maxFileSize,
|
||||||
|
onImagesChange,
|
||||||
|
previewImages,
|
||||||
|
saveImageToTemp,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
@@ -236,7 +239,7 @@ export function DescriptionImageDropZone({
|
|||||||
}
|
}
|
||||||
// Reset the input so the same file can be selected again
|
// Reset the input so the same file can be selected again
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[processFiles]
|
[processFiles]
|
||||||
@@ -276,17 +279,15 @@ export function DescriptionImageDropZone({
|
|||||||
const item = clipboardItems[i];
|
const item = clipboardItems[i];
|
||||||
|
|
||||||
// Check if the item is an image
|
// Check if the item is an image
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith('image/')) {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
// Generate a filename for pasted images since they don't have one
|
// Generate a filename for pasted images since they don't have one
|
||||||
const extension = item.type.split("/")[1] || "png";
|
const extension = item.type.split('/')[1] || 'png';
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const renamedFile = new File(
|
const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, {
|
||||||
[file],
|
type: file.type,
|
||||||
`pasted-image-${timestamp}.${extension}`,
|
});
|
||||||
{ type: file.type }
|
|
||||||
);
|
|
||||||
imageFiles.push(renamedFile);
|
imageFiles.push(renamedFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,13 +308,13 @@ export function DescriptionImageDropZone({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", className)}>
|
<div className={cn('relative', className)}>
|
||||||
{/* Hidden file input */}
|
{/* Hidden file input */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -325,13 +326,9 @@ export function DescriptionImageDropZone({
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
className={cn(
|
className={cn('relative rounded-md transition-all duration-200', {
|
||||||
"relative rounded-md transition-all duration-200",
|
'ring-2 ring-blue-400 ring-offset-2 ring-offset-background': isDragOver && !disabled,
|
||||||
{
|
})}
|
||||||
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
|
|
||||||
isDragOver && !disabled,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Drag overlay */}
|
{/* Drag overlay */}
|
||||||
{isDragOver && !disabled && (
|
{isDragOver && !disabled && (
|
||||||
@@ -355,17 +352,14 @@ export function DescriptionImageDropZone({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
aria-invalid={error}
|
aria-invalid={error}
|
||||||
className={cn(
|
className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')}
|
||||||
"min-h-[120px]",
|
|
||||||
isProcessing && "opacity-50 pointer-events-none"
|
|
||||||
)}
|
|
||||||
data-testid="feature-description-input"
|
data-testid="feature-description-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hint text */}
|
{/* Hint text */}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Paste, drag and drop images, or{" "}
|
Paste, drag and drop images, or{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowseClick}
|
onClick={handleBrowseClick}
|
||||||
@@ -373,7 +367,7 @@ export function DescriptionImageDropZone({
|
|||||||
disabled={disabled || isProcessing}
|
disabled={disabled || isProcessing}
|
||||||
>
|
>
|
||||||
browse
|
browse
|
||||||
</button>{" "}
|
</button>{' '}
|
||||||
to attach context images
|
to attach context images
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -390,7 +384,7 @@ export function DescriptionImageDropZone({
|
|||||||
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
{images.length} image{images.length > 1 ? "s" : ""} attached
|
{images.length} image{images.length > 1 ? 's' : ''} attached
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -447,9 +441,7 @@ export function DescriptionImageDropZone({
|
|||||||
)}
|
)}
|
||||||
{/* Filename tooltip on hover */}
|
{/* 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">
|
<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">
|
<p className="text-[10px] text-white truncate">{image.filename}</p>
|
||||||
{image.filename}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||||
import { ImageIcon, X, Upload } from "lucide-react";
|
|
||||||
|
|
||||||
export interface FeatureImage {
|
export interface FeatureImage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,13 +19,7 @@ interface FeatureImageUploadProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = [
|
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 DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
export function FeatureImageUpload({
|
export function FeatureImageUpload({
|
||||||
@@ -45,13 +38,13 @@ export function FeatureImageUpload({
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
if (typeof reader.result === "string") {
|
if (typeof reader.result === 'string') {
|
||||||
resolve(reader.result);
|
resolve(reader.result);
|
||||||
} else {
|
} 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);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -67,18 +60,14 @@ export function FeatureImageUpload({
|
|||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Validate file type
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||||
errors.push(
|
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size
|
// Validate file size
|
||||||
if (file.size > maxFileSize) {
|
if (file.size > maxFileSize) {
|
||||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||||
errors.push(
|
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +87,13 @@ export function FeatureImageUpload({
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
};
|
};
|
||||||
newImages.push(imageAttachment);
|
newImages.push(imageAttachment);
|
||||||
} catch (error) {
|
} catch {
|
||||||
errors.push(`${file.name}: Failed to process image.`);
|
errors.push(`${file.name}: Failed to process image.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.warn("Image upload errors:", errors);
|
console.warn('Image upload errors:', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
if (newImages.length > 0) {
|
||||||
@@ -157,7 +146,7 @@ export function FeatureImageUpload({
|
|||||||
}
|
}
|
||||||
// Reset the input so the same file can be selected again
|
// Reset the input so the same file can be selected again
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = "";
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[processFiles]
|
[processFiles]
|
||||||
@@ -180,22 +169,14 @@ export function FeatureImageUpload({
|
|||||||
onImagesChange([]);
|
onImagesChange([]);
|
||||||
}, [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 (
|
return (
|
||||||
<div className={cn("relative", className)}>
|
<div className={cn('relative', className)}>
|
||||||
{/* Hidden file input */}
|
{/* Hidden file input */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -209,13 +190,12 @@ export function FeatureImageUpload({
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onClick={handleBrowseClick}
|
onClick={handleBrowseClick}
|
||||||
className={cn(
|
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":
|
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
|
||||||
isDragOver && !disabled,
|
'border-muted-foreground/25': !isDragOver && !disabled,
|
||||||
"border-muted-foreground/25": !isDragOver && !disabled,
|
'border-muted-foreground/10 opacity-50 cursor-not-allowed': 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':
|
||||||
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
|
|
||||||
!disabled && !isDragOver,
|
!disabled && !isDragOver,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
@@ -224,10 +204,8 @@ export function FeatureImageUpload({
|
|||||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full p-2 mb-2",
|
'rounded-full p-2 mb-2',
|
||||||
isDragOver && !disabled
|
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
|
||||||
? "bg-blue-100 dark:bg-blue-900/30"
|
|
||||||
: "bg-muted"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
@@ -237,13 +215,10 @@ export function FeatureImageUpload({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isDragOver && !disabled
|
{isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}
|
||||||
? "Drop images here"
|
|
||||||
: "Click or drag images here"}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Up to {maxFiles} images, max{" "}
|
Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each
|
||||||
{Math.round(maxFileSize / (1024 * 1024))}MB each
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +228,7 @@ export function FeatureImageUpload({
|
|||||||
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
|
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
{images.length} image{images.length > 1 ? "s" : ""} selected
|
{images.length} image{images.length > 1 ? 's' : ''} selected
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -295,9 +270,7 @@ export function FeatureImageUpload({
|
|||||||
)}
|
)}
|
||||||
{/* Filename tooltip on hover */}
|
{/* 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">
|
<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">
|
<p className="text-[10px] text-white truncate">{image.filename}</p>
|
||||||
{image.filename}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import { cn } from '@/lib/utils';
|
||||||
import { cn } from "@/lib/utils";
|
import { ImageIcon, X, Upload } from 'lucide-react';
|
||||||
import { ImageIcon, X, Upload } from "lucide-react";
|
import type { ImageAttachment } from '@/store/app-store';
|
||||||
import type { ImageAttachment } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface ImageDropZoneProps {
|
interface ImageDropZoneProps {
|
||||||
onImagesSelected: (images: ImageAttachment[]) => void;
|
onImagesSelected: (images: ImageAttachment[]) => void;
|
||||||
@@ -35,88 +34,100 @@ export function ImageDropZone({
|
|||||||
const selectedImages = images ?? internalImages;
|
const selectedImages = images ?? internalImages;
|
||||||
|
|
||||||
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
|
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
|
||||||
const updateImages = useCallback((newImages: ImageAttachment[]) => {
|
const updateImages = useCallback(
|
||||||
if (images === undefined) {
|
(newImages: ImageAttachment[]) => {
|
||||||
setInternalImages(newImages);
|
if (images === undefined) {
|
||||||
}
|
setInternalImages(newImages);
|
||||||
onImagesSelected(newImages);
|
}
|
||||||
}, [images, onImagesSelected]);
|
onImagesSelected(newImages);
|
||||||
|
},
|
||||||
|
[images, onImagesSelected]
|
||||||
|
);
|
||||||
|
|
||||||
const processFiles = useCallback(async (files: FileList) => {
|
const processFiles = useCallback(
|
||||||
if (disabled || isProcessing) return;
|
async (files: FileList) => {
|
||||||
|
if (disabled || isProcessing) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const newImages: ImageAttachment[] = [];
|
const newImages: ImageAttachment[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Validate file type
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(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;
|
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 (errors.length > 0) {
|
||||||
if (file.size > maxFileSize) {
|
console.warn('Image upload errors:', errors);
|
||||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
// You could show these errors to the user via a toast or notification
|
||||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've reached max files
|
if (newImages.length > 0) {
|
||||||
if (newImages.length + selectedImages.length >= maxFiles) {
|
const allImages = [...selectedImages, ...newImages];
|
||||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
updateImages(allImages);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
setIsProcessing(false);
|
||||||
const base64 = await fileToBase64(file);
|
},
|
||||||
const imageAttachment: ImageAttachment = {
|
[disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]
|
||||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
);
|
||||||
data: base64,
|
|
||||||
mimeType: file.type,
|
const handleDrop = useCallback(
|
||||||
filename: file.name,
|
(e: React.DragEvent) => {
|
||||||
size: file.size,
|
e.preventDefault();
|
||||||
};
|
e.stopPropagation();
|
||||||
newImages.push(imageAttachment);
|
setIsDragOver(false);
|
||||||
} catch (error) {
|
|
||||||
errors.push(`${file.name}: Failed to process image.`);
|
if (disabled) return;
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFiles(files);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[disabled, processFiles]
|
||||||
|
);
|
||||||
|
|
||||||
if (errors.length > 0) {
|
const handleDragOver = useCallback(
|
||||||
console.warn('Image upload errors:', errors);
|
(e: React.DragEvent) => {
|
||||||
// You could show these errors to the user via a toast or notification
|
e.preventDefault();
|
||||||
}
|
e.stopPropagation();
|
||||||
|
if (!disabled) {
|
||||||
if (newImages.length > 0) {
|
setIsDragOver(true);
|
||||||
const allImages = [...selectedImages, ...newImages];
|
}
|
||||||
updateImages(allImages);
|
},
|
||||||
}
|
[disabled]
|
||||||
|
);
|
||||||
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 handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -124,16 +135,19 @@ export function ImageDropZone({
|
|||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = useCallback(
|
||||||
const files = e.target.files;
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (files && files.length > 0) {
|
const files = e.target.files;
|
||||||
processFiles(files);
|
if (files && files.length > 0) {
|
||||||
}
|
processFiles(files);
|
||||||
// Reset the input so the same file can be selected again
|
}
|
||||||
if (fileInputRef.current) {
|
// Reset the input so the same file can be selected again
|
||||||
fileInputRef.current.value = '';
|
if (fileInputRef.current) {
|
||||||
}
|
fileInputRef.current.value = '';
|
||||||
}, [processFiles]);
|
}
|
||||||
|
},
|
||||||
|
[processFiles]
|
||||||
|
);
|
||||||
|
|
||||||
const handleBrowseClick = useCallback(() => {
|
const handleBrowseClick = useCallback(() => {
|
||||||
if (!disabled && fileInputRef.current) {
|
if (!disabled && fileInputRef.current) {
|
||||||
@@ -141,17 +155,20 @@ export function ImageDropZone({
|
|||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
const removeImage = useCallback((imageId: string) => {
|
const removeImage = useCallback(
|
||||||
const updated = selectedImages.filter(img => img.id !== imageId);
|
(imageId: string) => {
|
||||||
updateImages(updated);
|
const updated = selectedImages.filter((img) => img.id !== imageId);
|
||||||
}, [selectedImages, updateImages]);
|
updateImages(updated);
|
||||||
|
},
|
||||||
|
[selectedImages, updateImages]
|
||||||
|
);
|
||||||
|
|
||||||
const clearAllImages = useCallback(() => {
|
const clearAllImages = useCallback(() => {
|
||||||
updateImages([]);
|
updateImages([]);
|
||||||
}, [updateImages]);
|
}, [updateImages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative", className)}>
|
<div className={cn('relative', className)}>
|
||||||
{/* Hidden file input */}
|
{/* Hidden file input */}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -168,22 +185,22 @@ export function ImageDropZone({
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
className={cn(
|
className={cn('relative rounded-lg border-2 border-dashed transition-all duration-200', {
|
||||||
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
|
||||||
{
|
'border-muted-foreground/25': !isDragOver && !disabled,
|
||||||
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
|
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled,
|
||||||
"border-muted-foreground/25": !isDragOver && !disabled,
|
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10':
|
||||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
|
!disabled && !isDragOver,
|
||||||
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
|
})}
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{children || (
|
{children || (
|
||||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||||
<div className={cn(
|
<div
|
||||||
"rounded-full p-3 mb-4",
|
className={cn(
|
||||||
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
|
'rounded-full p-3 mb-4',
|
||||||
)}>
|
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
@@ -191,10 +208,13 @@ export function ImageDropZone({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-foreground mb-1">
|
<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>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button
|
<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"
|
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
|
||||||
>
|
>
|
||||||
{/* Image thumbnail */}
|
{/* 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
|
<img
|
||||||
src={image.data}
|
src={image.data}
|
||||||
alt={image.filename}
|
alt={image.filename}
|
||||||
@@ -240,13 +260,9 @@ export function ImageDropZone({
|
|||||||
</div>
|
</div>
|
||||||
{/* Image info */}
|
{/* Image info */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium text-foreground truncate">
|
<p className="text-xs font-medium text-foreground truncate">{image.filename}</p>
|
||||||
{image.filename}
|
|
||||||
</p>
|
|
||||||
{image.size !== undefined && (
|
{image.size !== undefined && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{formatFileSize(image.size)}</p>
|
||||||
{formatFileSize(image.size)}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Remove button */}
|
{/* Remove button */}
|
||||||
@@ -288,4 +304,4 @@ function formatFileSize(bytes: number): string {
|
|||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from 'react';
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTrigger({
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetClose({
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetPortal({
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,13 +28,13 @@ interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
|
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
|
||||||
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
|
const Overlay = SheetPrimitive.Overlay as React.ComponentType<
|
||||||
SheetOverlayProps & { "data-slot": string }
|
SheetOverlayProps & { 'data-slot': string }
|
||||||
>;
|
>;
|
||||||
return (
|
return (
|
||||||
<Overlay
|
<Overlay
|
||||||
data-slot="sheet-overlay"
|
data-slot="sheet-overlay"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -48,21 +43,16 @@ const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
forceMount?: true;
|
forceMount?: true;
|
||||||
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
onEscapeKeyDown?: (event: KeyboardEvent) => void;
|
||||||
onPointerDownOutside?: (event: PointerEvent) => void;
|
onPointerDownOutside?: (event: PointerEvent) => void;
|
||||||
onInteractOutside?: (event: Event) => void;
|
onInteractOutside?: (event: Event) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SheetContent = ({
|
const SheetContent = ({ className, children, side = 'right', ...props }: SheetContentProps) => {
|
||||||
className,
|
|
||||||
children,
|
|
||||||
side = "right",
|
|
||||||
...props
|
|
||||||
}: SheetContentProps) => {
|
|
||||||
const Content = SheetPrimitive.Content as React.ComponentType<
|
const Content = SheetPrimitive.Content as React.ComponentType<
|
||||||
SheetContentProps & { "data-slot": string }
|
SheetContentProps & { 'data-slot': string }
|
||||||
>;
|
>;
|
||||||
const Close = SheetPrimitive.Close as React.ComponentType<{
|
const Close = SheetPrimitive.Close as React.ComponentType<{
|
||||||
className: string;
|
className: string;
|
||||||
@@ -75,15 +65,15 @@ const SheetContent = ({
|
|||||||
<Content
|
<Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
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",
|
'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" &&
|
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",
|
'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" &&
|
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",
|
'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" &&
|
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",
|
'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" &&
|
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",
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -98,21 +88,21 @@ const SheetContent = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-header"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-footer"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -122,28 +112,27 @@ interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
|
|||||||
|
|
||||||
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
|
const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
|
||||||
const Title = SheetPrimitive.Title as React.ComponentType<
|
const Title = SheetPrimitive.Title as React.ComponentType<
|
||||||
SheetTitleProps & { "data-slot": string }
|
SheetTitleProps & { 'data-slot': string }
|
||||||
>;
|
>;
|
||||||
return (
|
return (
|
||||||
<Title
|
<Title
|
||||||
data-slot="sheet-title"
|
data-slot="sheet-title"
|
||||||
className={cn("text-foreground font-semibold", className)}
|
className={cn('text-foreground font-semibold', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SheetDescriptionProps
|
interface SheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
||||||
extends React.HTMLAttributes<HTMLParagraphElement> {}
|
|
||||||
|
|
||||||
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
|
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
|
||||||
const Description = SheetPrimitive.Description as React.ComponentType<
|
const Description = SheetPrimitive.Description as React.ComponentType<
|
||||||
SheetDescriptionProps & { "data-slot": string }
|
SheetDescriptionProps & { 'data-slot': string }
|
||||||
>;
|
>;
|
||||||
return (
|
return (
|
||||||
<Description
|
<Description
|
||||||
data-slot="sheet-description"
|
data-slot="sheet-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import { useAppStore, type AgentModel } from '@/store/app-store';
|
||||||
import { useAppStore, type AgentModel } from "@/store/app-store";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Input } from "@/components/ui/input";
|
import { ImageDropZone } from '@/components/ui/image-drop-zone';
|
||||||
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
Send,
|
Send,
|
||||||
User,
|
User,
|
||||||
Loader2,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Wrench,
|
Wrench,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -18,37 +16,36 @@ import {
|
|||||||
X,
|
X,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
import { useElectronAgent } from '@/hooks/use-electron-agent';
|
||||||
import { SessionManager } from "@/components/session-manager";
|
import { SessionManager } from '@/components/session-manager';
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from '@/components/ui/markdown';
|
||||||
import type { ImageAttachment } from "@/store/app-store";
|
import type { ImageAttachment } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from '@/hooks/use-keyboard-shortcuts';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { CLAUDE_MODELS } from "@/components/views/board-view/shared/model-constants";
|
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||||
|
|
||||||
export function AgentView() {
|
export function AgentView() {
|
||||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } =
|
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
||||||
useAppStore();
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState('');
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
const [showSessionManager, setShowSessionManager] = useState(true);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<AgentModel>("sonnet");
|
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
|
||||||
|
|
||||||
// Track if initial session has been loaded
|
// Track if initial session has been loaded
|
||||||
const initialSessionLoadedRef = useRef(false);
|
const initialSessionLoadedRef = useRef(false);
|
||||||
@@ -72,7 +69,7 @@ export function AgentView() {
|
|||||||
clearHistory,
|
clearHistory,
|
||||||
error: agentError,
|
error: agentError,
|
||||||
} = useElectronAgent({
|
} = useElectronAgent({
|
||||||
sessionId: currentSessionId || "",
|
sessionId: currentSessionId || '',
|
||||||
workingDirectory: currentProject?.path,
|
workingDirectory: currentProject?.path,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
onToolUse: (toolName) => {
|
onToolUse: (toolName) => {
|
||||||
@@ -108,10 +105,7 @@ export function AgentView() {
|
|||||||
|
|
||||||
const lastSessionId = getLastSelectedSession(currentProject.path);
|
const lastSessionId = getLastSelectedSession(currentProject.path);
|
||||||
if (lastSessionId) {
|
if (lastSessionId) {
|
||||||
console.log(
|
console.log('[AgentView] Restoring last selected session:', lastSessionId);
|
||||||
"[AgentView] Restoring last selected session:",
|
|
||||||
lastSessionId
|
|
||||||
);
|
|
||||||
setCurrentSessionId(lastSessionId);
|
setCurrentSessionId(lastSessionId);
|
||||||
}
|
}
|
||||||
}, [currentProject?.path, getLastSelectedSession]);
|
}, [currentProject?.path, getLastSelectedSession]);
|
||||||
@@ -127,7 +121,7 @@ export function AgentView() {
|
|||||||
const messageContent = input;
|
const messageContent = input;
|
||||||
const messageImages = selectedImages;
|
const messageImages = selectedImages;
|
||||||
|
|
||||||
setInput("");
|
setInput('');
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
setShowImageDropZone(false);
|
setShowImageDropZone(false);
|
||||||
|
|
||||||
@@ -147,13 +141,13 @@ export function AgentView() {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
if (typeof reader.result === "string") {
|
if (typeof reader.result === 'string') {
|
||||||
resolve(reader.result);
|
resolve(reader.result);
|
||||||
} else {
|
} 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);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -164,11 +158,11 @@ export function AgentView() {
|
|||||||
if (isProcessing) return;
|
if (isProcessing) return;
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = [
|
const ACCEPTED_IMAGE_TYPES = [
|
||||||
"image/jpeg",
|
'image/jpeg',
|
||||||
"image/jpg",
|
'image/jpg',
|
||||||
"image/png",
|
'image/png',
|
||||||
"image/gif",
|
'image/gif',
|
||||||
"image/webp",
|
'image/webp',
|
||||||
];
|
];
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const MAX_FILES = 5;
|
const MAX_FILES = 5;
|
||||||
@@ -179,18 +173,14 @@ export function AgentView() {
|
|||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// Validate file type
|
// Validate file type
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||||
errors.push(
|
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate file size
|
// Validate file size
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
|
||||||
errors.push(
|
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +206,7 @@ export function AgentView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.warn("Image upload errors:", errors);
|
console.warn('Image upload errors:', errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
if (newImages.length > 0) {
|
||||||
@@ -239,7 +229,7 @@ export function AgentView() {
|
|||||||
if (isProcessing || !isConnected) return;
|
if (isProcessing || !isConnected) return;
|
||||||
|
|
||||||
// Check if dragged items contain files
|
// Check if dragged items contain files
|
||||||
if (e.dataTransfer.types.includes("Files")) {
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -285,7 +275,7 @@ export function AgentView() {
|
|||||||
if (items && items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
if (item.kind === "file") {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
const dataTransfer = new DataTransfer();
|
const dataTransfer = new DataTransfer();
|
||||||
@@ -309,9 +299,9 @@ export function AgentView() {
|
|||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
|
|
||||||
if (item.kind === "file") {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file && file.type.startsWith("image/")) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
e.preventDefault(); // Prevent default paste of file path
|
e.preventDefault(); // Prevent default paste of file path
|
||||||
files.push(file);
|
files.push(file);
|
||||||
}
|
}
|
||||||
@@ -329,14 +319,14 @@ export function AgentView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearChat = async () => {
|
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();
|
await clearHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,14 +337,13 @@ export function AgentView() {
|
|||||||
|
|
||||||
const threshold = 50; // 50px threshold for "near bottom"
|
const threshold = 50; // 50px threshold for "near bottom"
|
||||||
const isAtBottom =
|
const isAtBottom =
|
||||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
|
||||||
threshold;
|
|
||||||
|
|
||||||
setIsUserAtBottom(isAtBottom);
|
setIsUserAtBottom(isAtBottom);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Scroll to bottom function
|
// Scroll to bottom function
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
@@ -375,7 +364,7 @@ export function AgentView() {
|
|||||||
if (isUserAtBottom && messages.length > 0) {
|
if (isUserAtBottom && messages.length > 0) {
|
||||||
// Use a small delay to ensure DOM is updated
|
// Use a small delay to ensure DOM is updated
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottom("smooth");
|
scrollToBottom('smooth');
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [messages, isUserAtBottom, scrollToBottom]);
|
}, [messages, isUserAtBottom, scrollToBottom]);
|
||||||
@@ -385,7 +374,7 @@ export function AgentView() {
|
|||||||
if (currentSessionId && messages.length > 0) {
|
if (currentSessionId && messages.length > 0) {
|
||||||
// Scroll immediately without animation when switching sessions
|
// Scroll immediately without animation when switching sessions
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToBottom("auto");
|
scrollToBottom('auto');
|
||||||
setIsUserAtBottom(true);
|
setIsUserAtBottom(true);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
@@ -414,7 +403,7 @@ export function AgentView() {
|
|||||||
quickCreateSessionRef.current();
|
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">
|
<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" />
|
<Sparkles className="w-8 h-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-3 text-foreground">
|
<h2 className="text-xl font-semibold mb-3 text-foreground">No Project Selected</h2>
|
||||||
No Project Selected
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground leading-relaxed">
|
<p className="text-muted-foreground leading-relaxed">
|
||||||
Open or create a project to start working with the AI agent.
|
Open or create a project to start working with the AI agent.
|
||||||
</p>
|
</p>
|
||||||
@@ -450,8 +437,8 @@ export function AgentView() {
|
|||||||
messages.length === 0
|
messages.length === 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "welcome",
|
id: 'welcome',
|
||||||
role: "assistant" as const,
|
role: 'assistant' as const,
|
||||||
content:
|
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?",
|
"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(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -460,10 +447,7 @@ export function AgentView() {
|
|||||||
: messages;
|
: messages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
|
||||||
className="flex-1 flex overflow-hidden bg-background"
|
|
||||||
data-testid="agent-view"
|
|
||||||
>
|
|
||||||
{/* Session Manager Sidebar */}
|
{/* Session Manager Sidebar */}
|
||||||
{showSessionManager && currentProject && (
|
{showSessionManager && currentProject && (
|
||||||
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
|
<div className="w-80 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" />
|
<Bot className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-foreground">
|
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
|
||||||
AI Agent
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{currentProject.name}
|
{currentProject.name}
|
||||||
{currentSessionId && !isConnected && " - Connecting..."}
|
{currentSessionId && !isConnected && ' - Connecting...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,7 +503,10 @@ export function AgentView() {
|
|||||||
data-testid="model-selector"
|
data-testid="model-selector"
|
||||||
>
|
>
|
||||||
<Bot className="w-3.5 h-3.5" />
|
<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" />
|
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -530,17 +515,12 @@ export function AgentView() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
onClick={() => setSelectedModel(model.id)}
|
onClick={() => setSelectedModel(model.id)}
|
||||||
className={cn(
|
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
|
||||||
"cursor-pointer",
|
|
||||||
selectedModel === model.id && "bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid={`model-option-${model.id}`}
|
data-testid={`model-option-${model.id}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{model.label}</span>
|
<span className="font-medium">{model.label}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{model.description}</span>
|
||||||
{model.description}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -554,9 +534,7 @@ export function AgentView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{agentError && (
|
{agentError && (
|
||||||
<span className="text-xs text-destructive font-medium">
|
<span className="text-xs text-destructive font-medium">{agentError}</span>
|
||||||
{agentError}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{currentSessionId && messages.length > 0 && (
|
{currentSessionId && messages.length > 0 && (
|
||||||
<Button
|
<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">
|
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
|
||||||
<Bot className="w-8 h-8 text-muted-foreground" />
|
<Bot className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold mb-3 text-foreground">
|
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
|
||||||
No Session Selected
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
|
||||||
Create or select a session to start chatting with the AI agent
|
Create or select a session to start chatting with the AI agent
|
||||||
</p>
|
</p>
|
||||||
@@ -595,7 +571,7 @@ export function AgentView() {
|
|||||||
className="gap-2"
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<PanelLeft className="w-4 h-4" />
|
<PanelLeft className="w-4 h-4" />
|
||||||
{showSessionManager ? "View" : "Show"} Sessions
|
{showSessionManager ? 'View' : 'Show'} Sessions
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -610,20 +586,20 @@ export function AgentView() {
|
|||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-4 max-w-4xl",
|
'flex gap-4 max-w-4xl',
|
||||||
message.role === "user" ? "flex-row-reverse ml-auto" : ""
|
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
|
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
|
||||||
message.role === "assistant"
|
message.role === 'assistant'
|
||||||
? "bg-primary/10 ring-1 ring-primary/20"
|
? 'bg-primary/10 ring-1 ring-primary/20'
|
||||||
: "bg-muted ring-1 ring-border"
|
: 'bg-muted ring-1 ring-border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{message.role === "assistant" ? (
|
{message.role === 'assistant' ? (
|
||||||
<Bot className="w-4 h-4 text-primary" />
|
<Bot className="w-4 h-4 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -633,76 +609,67 @@ export function AgentView() {
|
|||||||
{/* Message Bubble */}
|
{/* Message Bubble */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
|
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
|
||||||
message.role === "user"
|
message.role === 'user'
|
||||||
? "bg-primary text-primary-foreground"
|
? 'bg-primary text-primary-foreground'
|
||||||
: "bg-card border border-border"
|
: '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">
|
<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}
|
{message.content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Display attached images for user messages */}
|
{/* Display attached images for user messages */}
|
||||||
{message.role === "user" &&
|
{message.role === 'user' && message.images && message.images.length > 0 && (
|
||||||
message.images &&
|
<div className="mt-3 space-y-2">
|
||||||
message.images.length > 0 && (
|
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
||||||
<div className="mt-3 space-y-2">
|
<ImageIcon className="w-3 h-3" />
|
||||||
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
|
<span>
|
||||||
<ImageIcon className="w-3 h-3" />
|
{message.images.length} image
|
||||||
<span>
|
{message.images.length > 1 ? 's' : ''} attached
|
||||||
{message.images.length} image
|
</span>
|
||||||
{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>
|
</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
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[11px] mt-2 font-medium",
|
'text-[11px] mt-2 font-medium',
|
||||||
message.role === "user"
|
message.role === 'user'
|
||||||
? "text-primary-foreground/70"
|
? 'text-primary-foreground/70'
|
||||||
: "text-muted-foreground"
|
: 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: '2-digit',
|
||||||
minute: "2-digit",
|
minute: '2-digit',
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,20 +687,18 @@ export function AgentView() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||||
style={{ animationDelay: "0ms" }}
|
style={{ animationDelay: '0ms' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||||
style={{ animationDelay: "150ms" }}
|
style={{ animationDelay: '150ms' }}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
className="w-2 h-2 rounded-full bg-primary animate-pulse"
|
||||||
style={{ animationDelay: "300ms" }}
|
style={{ animationDelay: '300ms' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">Thinking...</span>
|
||||||
Thinking...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -761,7 +726,7 @@ export function AgentView() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-medium text-foreground">
|
<p className="text-xs font-medium text-foreground">
|
||||||
{selectedImages.length} image
|
{selectedImages.length} image
|
||||||
{selectedImages.length > 1 ? "s" : ""} attached
|
{selectedImages.length > 1 ? 's' : ''} attached
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedImages([])}
|
onClick={() => setSelectedImages([])}
|
||||||
@@ -815,8 +780,8 @@ export function AgentView() {
|
|||||||
{/* Text Input and Controls */}
|
{/* Text Input and Controls */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-2 transition-all duration-200 rounded-xl p-1",
|
'flex gap-2 transition-all duration-200 rounded-xl p-1',
|
||||||
isDragOver && "bg-primary/5 ring-2 ring-primary/30"
|
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
|
||||||
)}
|
)}
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -827,9 +792,7 @@ export function AgentView() {
|
|||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={
|
placeholder={
|
||||||
isDragOver
|
isDragOver ? 'Drop your images here...' : 'Describe what you want to build...'
|
||||||
? "Drop your images here..."
|
|
||||||
: "Describe what you want to build..."
|
|
||||||
}
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
@@ -838,16 +801,16 @@ export function AgentView() {
|
|||||||
disabled={isProcessing || !isConnected}
|
disabled={isProcessing || !isConnected}
|
||||||
data-testid="agent-input"
|
data-testid="agent-input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
|
'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",
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
|
||||||
selectedImages.length > 0 && "border-primary/30",
|
selectedImages.length > 0 && 'border-primary/30',
|
||||||
isDragOver && "border-primary bg-primary/5"
|
isDragOver && 'border-primary bg-primary/5'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{selectedImages.length > 0 && !isDragOver && (
|
{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">
|
<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} image
|
||||||
{selectedImages.length > 1 ? "s" : ""}
|
{selectedImages.length > 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isDragOver && (
|
{isDragOver && (
|
||||||
@@ -865,10 +828,9 @@ export function AgentView() {
|
|||||||
onClick={toggleImageDropZone}
|
onClick={toggleImageDropZone}
|
||||||
disabled={isProcessing || !isConnected}
|
disabled={isProcessing || !isConnected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 w-11 rounded-xl border-border",
|
'h-11 w-11 rounded-xl border-border',
|
||||||
showImageDropZone &&
|
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
|
||||||
"bg-primary/10 text-primary border-primary/30",
|
selectedImages.length > 0 && 'border-primary/30 text-primary'
|
||||||
selectedImages.length > 0 && "border-primary/30 text-primary"
|
|
||||||
)}
|
)}
|
||||||
title="Attach images"
|
title="Attach images"
|
||||||
>
|
>
|
||||||
@@ -879,9 +841,7 @@ export function AgentView() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={
|
disabled={
|
||||||
(!input.trim() && selectedImages.length === 0) ||
|
(!input.trim() && selectedImages.length === 0) || isProcessing || !isConnected
|
||||||
isProcessing ||
|
|
||||||
!isConnected
|
|
||||||
}
|
}
|
||||||
className="h-11 px-4 rounded-xl"
|
className="h-11 px-4 rounded-xl"
|
||||||
data-testid="send-message"
|
data-testid="send-message"
|
||||||
@@ -892,11 +852,9 @@ export function AgentView() {
|
|||||||
|
|
||||||
{/* Keyboard hint */}
|
{/* Keyboard hint */}
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
<p className="text-[11px] text-muted-foreground mt-2 text-center">
|
||||||
Press{" "}
|
Press{' '}
|
||||||
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
|
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
|
||||||
Enter
|
send
|
||||||
</kbd>{" "}
|
|
||||||
to send
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -907,9 +865,9 @@ export function AgentView() {
|
|||||||
|
|
||||||
// Helper function to format file size
|
// Helper function to format file size
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
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));
|
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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
import { useCallback, useState } from "react";
|
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
|
||||||
import {
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
useAppStore,
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
FileTreeNode,
|
import { Button } from '@/components/ui/button';
|
||||||
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 {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -30,29 +18,29 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const IGNORE_PATTERNS = [
|
const IGNORE_PATTERNS = [
|
||||||
"node_modules",
|
'node_modules',
|
||||||
".git",
|
'.git',
|
||||||
".next",
|
'.next',
|
||||||
"dist",
|
'dist',
|
||||||
"build",
|
'build',
|
||||||
".DS_Store",
|
'.DS_Store',
|
||||||
"*.log",
|
'*.log',
|
||||||
".cache",
|
'.cache',
|
||||||
"coverage",
|
'coverage',
|
||||||
"__pycache__",
|
'__pycache__',
|
||||||
".pytest_cache",
|
'.pytest_cache',
|
||||||
".venv",
|
'.venv',
|
||||||
"venv",
|
'venv',
|
||||||
".env",
|
'.env',
|
||||||
];
|
];
|
||||||
|
|
||||||
const shouldIgnore = (name: string) => {
|
const shouldIgnore = (name: string) => {
|
||||||
return IGNORE_PATTERNS.some((pattern) => {
|
return IGNORE_PATTERNS.some((pattern) => {
|
||||||
if (pattern.startsWith("*")) {
|
if (pattern.startsWith('*')) {
|
||||||
return name.endsWith(pattern.slice(1));
|
return name.endsWith(pattern.slice(1));
|
||||||
}
|
}
|
||||||
return name === pattern;
|
return name === pattern;
|
||||||
@@ -60,8 +48,8 @@ const shouldIgnore = (name: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getExtension = (filename: string): string => {
|
const getExtension = (filename: string): string => {
|
||||||
const parts = filename.split(".");
|
const parts = filename.split('.');
|
||||||
return parts.length > 1 ? parts.pop() || "" : "";
|
return parts.length > 1 ? parts.pop() || '' : '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AnalysisView() {
|
export function AnalysisView() {
|
||||||
@@ -74,9 +62,7 @@ export function AnalysisView() {
|
|||||||
clearAnalysis,
|
clearAnalysis,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
|
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
|
||||||
const [specGenerated, setSpecGenerated] = useState(false);
|
const [specGenerated, setSpecGenerated] = useState(false);
|
||||||
const [specError, setSpecError] = useState<string | null>(null);
|
const [specError, setSpecError] = useState<string | null>(null);
|
||||||
@@ -123,7 +109,7 @@ export function AnalysisView() {
|
|||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to scan directory:", path, error);
|
console.error('Failed to scan directory:', path, error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -148,7 +134,7 @@ export function AnalysisView() {
|
|||||||
if (item.extension) {
|
if (item.extension) {
|
||||||
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
|
byExt[item.extension] = (byExt[item.extension] || 0) + 1;
|
||||||
} else {
|
} 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);
|
setProjectAnalysis(analysis);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Analysis failed:", error);
|
console.error('Analysis failed:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsAnalyzing(false);
|
setIsAnalyzing(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]);
|
||||||
currentProject,
|
|
||||||
setIsAnalyzing,
|
|
||||||
clearAnalysis,
|
|
||||||
scanDirectory,
|
|
||||||
setProjectAnalysis,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Generate app_spec.txt from analysis
|
// Generate app_spec.txt from analysis
|
||||||
const generateSpec = useCallback(async () => {
|
const generateSpec = useCallback(async () => {
|
||||||
@@ -204,7 +184,7 @@ export function AnalysisView() {
|
|||||||
|
|
||||||
// Read key files to understand the project better
|
// Read key files to understand the project better
|
||||||
const fileContents: Record<string, string> = {};
|
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
|
// Collect file paths from analysis
|
||||||
const collectFilePaths = (
|
const collectFilePaths = (
|
||||||
@@ -217,15 +197,13 @@ export function AnalysisView() {
|
|||||||
if (!node.isDirectory) {
|
if (!node.isDirectory) {
|
||||||
paths.push(node.path);
|
paths.push(node.path);
|
||||||
} else if (node.children && currentDepth < maxDepth) {
|
} else if (node.children && currentDepth < maxDepth) {
|
||||||
paths.push(
|
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1));
|
||||||
...collectFilePaths(node.children, maxDepth, currentDepth + 1)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return paths;
|
return paths;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
|
collectFilePaths(projectAnalysis.fileTree);
|
||||||
|
|
||||||
// Try to read key configuration files
|
// Try to read key configuration files
|
||||||
for (const keyFile of keyFiles) {
|
for (const keyFile of keyFiles) {
|
||||||
@@ -245,40 +223,34 @@ export function AnalysisView() {
|
|||||||
const extensions = projectAnalysis.filesByExtension;
|
const extensions = projectAnalysis.filesByExtension;
|
||||||
|
|
||||||
// Check package.json for dependencies
|
// Check package.json for dependencies
|
||||||
if (fileContents["package.json"]) {
|
if (fileContents['package.json']) {
|
||||||
try {
|
try {
|
||||||
const pkg = JSON.parse(fileContents["package.json"]);
|
const pkg = JSON.parse(fileContents['package.json']);
|
||||||
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"])
|
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React');
|
||||||
stack.push("React");
|
if (pkg.dependencies?.next) stack.push('Next.js');
|
||||||
if (pkg.dependencies?.next) stack.push("Next.js");
|
if (pkg.dependencies?.vue) stack.push('Vue');
|
||||||
if (pkg.dependencies?.vue) stack.push("Vue");
|
if (pkg.dependencies?.angular) stack.push('Angular');
|
||||||
if (pkg.dependencies?.angular) stack.push("Angular");
|
if (pkg.dependencies?.express) stack.push('Express');
|
||||||
if (pkg.dependencies?.express) stack.push("Express");
|
if (pkg.dependencies?.electron) stack.push('Electron');
|
||||||
if (pkg.dependencies?.electron) stack.push("Electron");
|
|
||||||
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
|
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
|
||||||
stack.push("TypeScript");
|
stack.push('TypeScript');
|
||||||
if (
|
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss)
|
||||||
pkg.devDependencies?.tailwindcss ||
|
stack.push('Tailwind CSS');
|
||||||
pkg.dependencies?.tailwindcss
|
|
||||||
)
|
|
||||||
stack.push("Tailwind CSS");
|
|
||||||
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
|
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
|
||||||
stack.push("Playwright");
|
stack.push('Playwright');
|
||||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
|
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest');
|
||||||
stack.push("Jest");
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect by file extensions
|
// Detect by file extensions
|
||||||
if (extensions["ts"] || extensions["tsx"]) stack.push("TypeScript");
|
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript');
|
||||||
if (extensions["py"]) stack.push("Python");
|
if (extensions['py']) stack.push('Python');
|
||||||
if (extensions["go"]) stack.push("Go");
|
if (extensions['go']) stack.push('Go');
|
||||||
if (extensions["rs"]) stack.push("Rust");
|
if (extensions['rs']) stack.push('Rust');
|
||||||
if (extensions["java"]) stack.push("Java");
|
if (extensions['java']) stack.push('Java');
|
||||||
if (extensions["css"] || extensions["scss"] || extensions["sass"])
|
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS');
|
||||||
stack.push("CSS/SCSS");
|
|
||||||
|
|
||||||
// Remove duplicates
|
// Remove duplicates
|
||||||
return [...new Set(stack)];
|
return [...new Set(stack)];
|
||||||
@@ -286,9 +258,9 @@ export function AnalysisView() {
|
|||||||
|
|
||||||
// Get project name from package.json or folder name
|
// Get project name from package.json or folder name
|
||||||
const getProjectName = () => {
|
const getProjectName = () => {
|
||||||
if (fileContents["package.json"]) {
|
if (fileContents['package.json']) {
|
||||||
try {
|
try {
|
||||||
const pkg = JSON.parse(fileContents["package.json"]);
|
const pkg = JSON.parse(fileContents['package.json']);
|
||||||
if (pkg.name) return pkg.name;
|
if (pkg.name) return pkg.name;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
@@ -300,30 +272,30 @@ export function AnalysisView() {
|
|||||||
|
|
||||||
// Get project description from package.json or README
|
// Get project description from package.json or README
|
||||||
const getProjectDescription = () => {
|
const getProjectDescription = () => {
|
||||||
if (fileContents["package.json"]) {
|
if (fileContents['package.json']) {
|
||||||
try {
|
try {
|
||||||
const pkg = JSON.parse(fileContents["package.json"]);
|
const pkg = JSON.parse(fileContents['package.json']);
|
||||||
if (pkg.description) return pkg.description;
|
if (pkg.description) return pkg.description;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors
|
// Ignore JSON parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fileContents["README.md"]) {
|
if (fileContents['README.md']) {
|
||||||
// Extract first paragraph from README
|
// Extract first paragraph from README
|
||||||
const lines = fileContents["README.md"].split("\n");
|
const lines = fileContents['README.md'].split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (
|
if (
|
||||||
trimmed &&
|
trimmed &&
|
||||||
!trimmed.startsWith("#") &&
|
!trimmed.startsWith('#') &&
|
||||||
!trimmed.startsWith("!") &&
|
!trimmed.startsWith('!') &&
|
||||||
trimmed.length > 20
|
trimmed.length > 20
|
||||||
) {
|
) {
|
||||||
return trimmed.substring(0, 200);
|
return trimmed.substring(0, 200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "A software project";
|
return 'A software project';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group files by directory for structure analysis
|
// Group files by directory for structure analysis
|
||||||
@@ -336,7 +308,7 @@ export function AnalysisView() {
|
|||||||
for (const dir of topLevelDirs) {
|
for (const dir of topLevelDirs) {
|
||||||
structure.push(` <directory name="${dir}" />`);
|
structure.push(` <directory name="${dir}" />`);
|
||||||
}
|
}
|
||||||
return structure.join("\n");
|
return structure.join('\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectName = getProjectName();
|
const projectName = getProjectName();
|
||||||
@@ -356,20 +328,15 @@ export function AnalysisView() {
|
|||||||
<languages>
|
<languages>
|
||||||
${Object.entries(projectAnalysis.filesByExtension)
|
${Object.entries(projectAnalysis.filesByExtension)
|
||||||
.filter(([ext]: [string, number]) =>
|
.filter(([ext]: [string, number]) =>
|
||||||
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
|
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext)
|
||||||
ext
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map(
|
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
|
||||||
([ext, count]: [string, number]) =>
|
.join('\n')}
|
||||||
` <language ext=".${ext}" count="${count}" />`
|
|
||||||
)
|
|
||||||
.join("\n")}
|
|
||||||
</languages>
|
</languages>
|
||||||
<frameworks>
|
<frameworks>
|
||||||
${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
|
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')}
|
||||||
</frameworks>
|
</frameworks>
|
||||||
</technology_stack>
|
</technology_stack>
|
||||||
|
|
||||||
@@ -387,11 +354,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map(
|
.map(
|
||||||
([ext, count]: [string, number]) =>
|
([ext, count]: [string, number]) =>
|
||||||
` <extension type="${
|
` <extension type="${ext.startsWith('(') ? ext : '.' + ext}" count="${count}" />`
|
||||||
ext.startsWith("(") ? ext : "." + ext
|
|
||||||
}" count="${count}" />`
|
|
||||||
)
|
)
|
||||||
.join("\n")}
|
.join('\n')}
|
||||||
</file_breakdown>
|
</file_breakdown>
|
||||||
|
|
||||||
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
|
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
|
||||||
@@ -405,13 +370,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
if (writeResult.success) {
|
if (writeResult.success) {
|
||||||
setSpecGenerated(true);
|
setSpecGenerated(true);
|
||||||
} else {
|
} else {
|
||||||
setSpecError(writeResult.error || "Failed to write spec file");
|
setSpecError(writeResult.error || 'Failed to write spec file');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to generate spec:", error);
|
console.error('Failed to generate spec:', error);
|
||||||
setSpecError(
|
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec');
|
||||||
error instanceof Error ? error.message : "Failed to generate spec"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingSpec(false);
|
setIsGeneratingSpec(false);
|
||||||
}
|
}
|
||||||
@@ -430,7 +393,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
// Read key files to understand the project
|
// Read key files to understand the project
|
||||||
const fileContents: Record<string, string> = {};
|
const fileContents: Record<string, string> = {};
|
||||||
const keyFiles = ["package.json", "README.md"];
|
const keyFiles = ['package.json', 'README.md'];
|
||||||
|
|
||||||
// Try to read key configuration files
|
// Try to read key configuration files
|
||||||
for (const keyFile of keyFiles) {
|
for (const keyFile of keyFiles) {
|
||||||
@@ -481,21 +444,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
// Check for test directories and files
|
// Check for test directories and files
|
||||||
const hasTests =
|
const hasTests =
|
||||||
topLevelDirs.includes("tests") ||
|
topLevelDirs.includes('tests') ||
|
||||||
topLevelDirs.includes("test") ||
|
topLevelDirs.includes('test') ||
|
||||||
topLevelDirs.includes("__tests__") ||
|
topLevelDirs.includes('__tests__') ||
|
||||||
allFilePaths.some(
|
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.'));
|
||||||
(p) => p.includes(".spec.") || p.includes(".test.")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasTests) {
|
if (hasTests) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Testing",
|
category: 'Testing',
|
||||||
description: "Automated test suite",
|
description: 'Automated test suite',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Tests directory exists",
|
'Step 1: Tests directory exists',
|
||||||
"Step 2: Test files are present",
|
'Step 2: Test files are present',
|
||||||
"Step 3: Run test suite",
|
'Step 3: Run test suite',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -503,50 +464,50 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
// Check for components directory (UI components)
|
// Check for components directory (UI components)
|
||||||
const hasComponents =
|
const hasComponents =
|
||||||
topLevelDirs.includes("components") ||
|
topLevelDirs.includes('components') ||
|
||||||
allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
|
allFilePaths.some((p) => p.toLowerCase().includes('/components/'));
|
||||||
|
|
||||||
if (hasComponents) {
|
if (hasComponents) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "UI/Design",
|
category: 'UI/Design',
|
||||||
description: "Component-based UI architecture",
|
description: 'Component-based UI architecture',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Components directory exists",
|
'Step 1: Components directory exists',
|
||||||
"Step 2: UI components are defined",
|
'Step 2: UI components are defined',
|
||||||
"Step 3: Components are reusable",
|
'Step 3: Components are reusable',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for src directory (organized source code)
|
// Check for src directory (organized source code)
|
||||||
if (topLevelDirs.includes("src")) {
|
if (topLevelDirs.includes('src')) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Project Structure",
|
category: 'Project Structure',
|
||||||
description: "Organized source code structure",
|
description: 'Organized source code structure',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Source directory exists",
|
'Step 1: Source directory exists',
|
||||||
"Step 2: Code is properly organized",
|
'Step 2: Code is properly organized',
|
||||||
"Step 3: Follows best practices",
|
'Step 3: Follows best practices',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check package.json for dependencies and detect features
|
// Check package.json for dependencies and detect features
|
||||||
if (fileContents["package.json"]) {
|
if (fileContents['package.json']) {
|
||||||
try {
|
try {
|
||||||
const pkg = JSON.parse(fileContents["package.json"]);
|
const pkg = JSON.parse(fileContents['package.json']);
|
||||||
|
|
||||||
// React/Next.js app detection
|
// React/Next.js app detection
|
||||||
if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"]) {
|
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Frontend",
|
category: 'Frontend',
|
||||||
description: "React-based user interface",
|
description: 'React-based user interface',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: React is installed",
|
'Step 1: React is installed',
|
||||||
"Step 2: Components render correctly",
|
'Step 2: Components render correctly',
|
||||||
"Step 3: State management works",
|
'Step 3: State management works',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -554,12 +515,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
if (pkg.dependencies?.next) {
|
if (pkg.dependencies?.next) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Framework",
|
category: 'Framework',
|
||||||
description: "Next.js framework integration",
|
description: 'Next.js framework integration',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Next.js is configured",
|
'Step 1: Next.js is configured',
|
||||||
"Step 2: Pages/routes are defined",
|
'Step 2: Pages/routes are defined',
|
||||||
"Step 3: Server-side rendering works",
|
'Step 3: Server-side rendering works',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -569,33 +530,30 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
if (
|
if (
|
||||||
pkg.devDependencies?.typescript ||
|
pkg.devDependencies?.typescript ||
|
||||||
pkg.dependencies?.typescript ||
|
pkg.dependencies?.typescript ||
|
||||||
extensions["ts"] ||
|
extensions['ts'] ||
|
||||||
extensions["tsx"]
|
extensions['tsx']
|
||||||
) {
|
) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Developer Experience",
|
category: 'Developer Experience',
|
||||||
description: "TypeScript type safety",
|
description: 'TypeScript type safety',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: TypeScript is configured",
|
'Step 1: TypeScript is configured',
|
||||||
"Step 2: Type definitions exist",
|
'Step 2: Type definitions exist',
|
||||||
"Step 3: Code compiles without errors",
|
'Step 3: Code compiles without errors',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tailwind CSS
|
// Tailwind CSS
|
||||||
if (
|
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) {
|
||||||
pkg.devDependencies?.tailwindcss ||
|
|
||||||
pkg.dependencies?.tailwindcss
|
|
||||||
) {
|
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "UI/Design",
|
category: 'UI/Design',
|
||||||
description: "Tailwind CSS styling",
|
description: 'Tailwind CSS styling',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Tailwind is configured",
|
'Step 1: Tailwind is configured',
|
||||||
"Step 2: Styles are applied",
|
'Step 2: Styles are applied',
|
||||||
"Step 3: Responsive design works",
|
'Step 3: Responsive design works',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -604,12 +562,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
// ESLint/Prettier (code quality)
|
// ESLint/Prettier (code quality)
|
||||||
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
|
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Developer Experience",
|
category: 'Developer Experience',
|
||||||
description: "Code quality tools",
|
description: 'Code quality tools',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Linter is configured",
|
'Step 1: Linter is configured',
|
||||||
"Step 2: Code passes lint checks",
|
'Step 2: Code passes lint checks',
|
||||||
"Step 3: Formatting is consistent",
|
'Step 3: Formatting is consistent',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -618,29 +576,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
// Electron (desktop app)
|
// Electron (desktop app)
|
||||||
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
|
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Platform",
|
category: 'Platform',
|
||||||
description: "Electron desktop application",
|
description: 'Electron desktop application',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Electron is configured",
|
'Step 1: Electron is configured',
|
||||||
"Step 2: Main process runs",
|
'Step 2: Main process runs',
|
||||||
"Step 3: Renderer process loads",
|
'Step 3: Renderer process loads',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playwright testing
|
// Playwright testing
|
||||||
if (
|
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) {
|
||||||
pkg.devDependencies?.playwright ||
|
|
||||||
pkg.devDependencies?.["@playwright/test"]
|
|
||||||
) {
|
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Testing",
|
category: 'Testing',
|
||||||
description: "Playwright end-to-end testing",
|
description: 'Playwright end-to-end testing',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Playwright is configured",
|
'Step 1: Playwright is configured',
|
||||||
"Step 2: E2E tests are defined",
|
'Step 2: E2E tests are defined',
|
||||||
"Step 3: Tests pass successfully",
|
'Step 3: Tests pass successfully',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -651,17 +606,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for documentation
|
// Check for documentation
|
||||||
if (
|
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) {
|
||||||
topLevelFiles.includes("readme.md") ||
|
|
||||||
topLevelDirs.includes("docs")
|
|
||||||
) {
|
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Documentation",
|
category: 'Documentation',
|
||||||
description: "Project documentation",
|
description: 'Project documentation',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: README exists",
|
'Step 1: README exists',
|
||||||
"Step 2: Documentation is comprehensive",
|
'Step 2: Documentation is comprehensive',
|
||||||
"Step 3: Setup instructions are clear",
|
'Step 3: Setup instructions are clear',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -669,18 +621,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
// Check for CI/CD configuration
|
// Check for CI/CD configuration
|
||||||
const hasCICD =
|
const hasCICD =
|
||||||
topLevelDirs.includes(".github") ||
|
topLevelDirs.includes('.github') ||
|
||||||
topLevelFiles.includes(".gitlab-ci.yml") ||
|
topLevelFiles.includes('.gitlab-ci.yml') ||
|
||||||
topLevelFiles.includes(".travis.yml");
|
topLevelFiles.includes('.travis.yml');
|
||||||
|
|
||||||
if (hasCICD) {
|
if (hasCICD) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "DevOps",
|
category: 'DevOps',
|
||||||
description: "CI/CD pipeline configuration",
|
description: 'CI/CD pipeline configuration',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: CI config exists",
|
'Step 1: CI config exists',
|
||||||
"Step 2: Pipeline runs on push",
|
'Step 2: Pipeline runs on push',
|
||||||
"Step 3: Automated checks pass",
|
'Step 3: Automated checks pass',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -688,20 +640,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
// Check for API routes (Next.js API or Express)
|
// Check for API routes (Next.js API or Express)
|
||||||
const hasAPIRoutes = allFilePaths.some(
|
const hasAPIRoutes = allFilePaths.some(
|
||||||
(p) =>
|
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/')
|
||||||
p.includes("/api/") ||
|
|
||||||
p.includes("/routes/") ||
|
|
||||||
p.includes("/endpoints/")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasAPIRoutes) {
|
if (hasAPIRoutes) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Backend",
|
category: 'Backend',
|
||||||
description: "API endpoints",
|
description: 'API endpoints',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: API routes are defined",
|
'Step 1: API routes are defined',
|
||||||
"Step 2: Endpoints respond correctly",
|
'Step 2: Endpoints respond correctly',
|
||||||
"Step 3: Error handling is implemented",
|
'Step 3: Error handling is implemented',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -710,37 +659,34 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
// Check for state management
|
// Check for state management
|
||||||
const hasStateManagement = allFilePaths.some(
|
const hasStateManagement = allFilePaths.some(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.includes("/store/") ||
|
p.includes('/store/') ||
|
||||||
p.includes("/stores/") ||
|
p.includes('/stores/') ||
|
||||||
p.includes("/redux/") ||
|
p.includes('/redux/') ||
|
||||||
p.includes("/context/")
|
p.includes('/context/')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasStateManagement) {
|
if (hasStateManagement) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Architecture",
|
category: 'Architecture',
|
||||||
description: "State management system",
|
description: 'State management system',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Store is configured",
|
'Step 1: Store is configured',
|
||||||
"Step 2: State updates correctly",
|
'Step 2: State updates correctly',
|
||||||
"Step 3: Components access state",
|
'Step 3: Components access state',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for configuration files
|
// Check for configuration files
|
||||||
if (
|
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) {
|
||||||
topLevelFiles.includes("tsconfig.json") ||
|
|
||||||
topLevelFiles.includes("package.json")
|
|
||||||
) {
|
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Configuration",
|
category: 'Configuration',
|
||||||
description: "Project configuration files",
|
description: 'Project configuration files',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Config files exist",
|
'Step 1: Config files exist',
|
||||||
"Step 2: Configuration is valid",
|
'Step 2: Configuration is valid',
|
||||||
"Step 3: Build process works",
|
'Step 3: Build process works',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -752,12 +698,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
// If no features were detected, add a default feature
|
// If no features were detected, add a default feature
|
||||||
if (detectedFeatures.length === 0) {
|
if (detectedFeatures.length === 0) {
|
||||||
detectedFeatures.push({
|
detectedFeatures.push({
|
||||||
category: "Core",
|
category: 'Core',
|
||||||
description: "Basic project structure",
|
description: 'Basic project structure',
|
||||||
steps: [
|
steps: [
|
||||||
"Step 1: Project directory exists",
|
'Step 1: Project directory exists',
|
||||||
"Step 2: Files are present",
|
'Step 2: Files are present',
|
||||||
"Step 3: Project can be loaded",
|
'Step 3: Project can be loaded',
|
||||||
],
|
],
|
||||||
passes: true,
|
passes: true,
|
||||||
});
|
});
|
||||||
@@ -765,7 +711,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
|
|
||||||
// Create each feature using the features API
|
// Create each feature using the features API
|
||||||
if (!api.features) {
|
if (!api.features) {
|
||||||
throw new Error("Features API not available");
|
throw new Error('Features API not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const detectedFeature of detectedFeatures) {
|
for (const detectedFeature of detectedFeatures) {
|
||||||
@@ -774,17 +720,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
category: detectedFeature.category,
|
category: detectedFeature.category,
|
||||||
description: detectedFeature.description,
|
description: detectedFeature.description,
|
||||||
steps: detectedFeature.steps,
|
steps: detectedFeature.steps,
|
||||||
status: "backlog",
|
status: 'backlog',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setFeatureListGenerated(true);
|
setFeatureListGenerated(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to generate feature list:", error);
|
console.error('Failed to generate feature list:', error);
|
||||||
setFeatureListError(
|
setFeatureListError(
|
||||||
error instanceof Error
|
error instanceof Error ? error.message : 'Failed to generate feature list'
|
||||||
? error.message
|
|
||||||
: "Failed to generate feature list"
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingFeatureList(false);
|
setIsGeneratingFeatureList(false);
|
||||||
@@ -810,7 +754,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
|
<div key={node.path} data-testid={`analysis-node-${node.name}`}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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` }}
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -840,17 +784,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
)}
|
)}
|
||||||
<span className="truncate">{node.name}</span>
|
<span className="truncate">{node.name}</span>
|
||||||
{node.extension && (
|
{node.extension && (
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span>
|
||||||
.{node.extension}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.isDirectory && isExpanded && node.children && (
|
{node.isDirectory && isExpanded && node.children && (
|
||||||
<div>
|
<div>{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}</div>
|
||||||
{node.children.map((child: FileTreeNode) =>
|
|
||||||
renderNode(child, depth + 1)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -868,26 +806,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="analysis-view">
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
||||||
data-testid="analysis-view"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* 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 justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Search className="w-5 h-5 text-muted-foreground" />
|
<Search className="w-5 h-5 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">Project Analysis</h1>
|
<h1 className="text-xl font-bold">Project Analysis</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||||
{currentProject.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
|
||||||
onClick={runAnalysis}
|
|
||||||
disabled={isAnalyzing}
|
|
||||||
data-testid="analyze-project-button"
|
|
||||||
>
|
|
||||||
{isAnalyzing ? (
|
{isAnalyzing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<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" />
|
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
|
||||||
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
|
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||||
Click "Analyze Project" to scan your codebase and get
|
Click "Analyze Project" to scan your codebase and get insights about its
|
||||||
insights about its structure.
|
structure.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button onClick={runAnalysis} data-testid="analyze-project-button-empty">
|
||||||
onClick={runAnalysis}
|
|
||||||
data-testid="analyze-project-button-empty"
|
|
||||||
>
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
<Search className="w-4 h-4 mr-2" />
|
||||||
Start Analysis
|
Start Analysis
|
||||||
</Button>
|
</Button>
|
||||||
@@ -936,27 +862,19 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
Statistics
|
Statistics
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Analyzed{" "}
|
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()}
|
||||||
{new Date(projectAnalysis.analyzedAt).toLocaleString()}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">Total Files</span>
|
||||||
Total Files
|
|
||||||
</span>
|
|
||||||
<span className="font-medium" data-testid="total-files">
|
<span className="font-medium" data-testid="total-files">
|
||||||
{projectAnalysis.totalFiles}
|
{projectAnalysis.totalFiles}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">Total Directories</span>
|
||||||
Total Directories
|
<span className="font-medium" data-testid="total-directories">
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-medium"
|
|
||||||
data-testid="total-directories"
|
|
||||||
>
|
|
||||||
{projectAnalysis.totalDirectories}
|
{projectAnalysis.totalDirectories}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -973,15 +891,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(projectAnalysis.filesByExtension)
|
{Object.entries(projectAnalysis.filesByExtension)
|
||||||
.sort(
|
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
|
||||||
(a: [string, number], b: [string, number]) =>
|
|
||||||
b[1] - a[1]
|
|
||||||
)
|
|
||||||
.slice(0, 15)
|
.slice(0, 15)
|
||||||
.map(([ext, count]: [string, number]) => (
|
.map(([ext, count]: [string, number]) => (
|
||||||
<div key={ext} className="flex justify-between text-sm">
|
<div key={ext} className="flex justify-between text-sm">
|
||||||
<span className="text-muted-foreground font-mono">
|
<span className="text-muted-foreground font-mono">
|
||||||
{ext.startsWith("(") ? ext : `.${ext}`}
|
{ext.startsWith('(') ? ext : `.${ext}`}
|
||||||
</span>
|
</span>
|
||||||
<span>{count}</span>
|
<span>{count}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -997,14 +912,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
Generate Specification
|
Generate Specification
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Create app_spec.txt from analysis</CardDescription>
|
||||||
Create app_spec.txt from analysis
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Generate a project specification file based on the analyzed
|
Generate a project specification file based on the analyzed codebase structure
|
||||||
codebase structure and detected technologies.
|
and detected technologies.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={generateSpec}
|
onClick={generateSpec}
|
||||||
@@ -1052,15 +965,12 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
<ListChecks className="w-4 h-4" />
|
<ListChecks className="w-4 h-4" />
|
||||||
Generate Feature List
|
Generate Feature List
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Create features from analysis</CardDescription>
|
||||||
Create features from analysis
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Automatically detect and generate a feature list based on
|
Automatically detect and generate a feature list based on the analyzed codebase
|
||||||
the analyzed codebase structure, dependencies, and project
|
structure, dependencies, and project configuration.
|
||||||
configuration.
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={generateFeatureList}
|
onClick={generateFeatureList}
|
||||||
@@ -1110,18 +1020,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
|
|||||||
File Tree
|
File Tree
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{projectAnalysis.totalFiles} files in{" "}
|
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '}
|
||||||
{projectAnalysis.totalDirectories} directories
|
directories
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent
|
<CardContent className="p-0 overflow-y-auto h-full" data-testid="analysis-file-tree">
|
||||||
className="p-0 overflow-y-auto h-full"
|
|
||||||
data-testid="analysis-file-tree"
|
|
||||||
>
|
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{projectAnalysis.fileTree.map((node: FileTreeNode) =>
|
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
|
||||||
renderNode(node)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Plus, Bot } from 'lucide-react';
|
||||||
import { Label } from "@/components/ui/label";
|
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { Plus, Bot } from "lucide-react";
|
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
|
||||||
import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { ClaudeUsagePopover } from "@/components/claude-usage-popover";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface BoardHeaderProps {
|
interface BoardHeaderProps {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -36,7 +34,8 @@ export function BoardHeader({
|
|||||||
|
|
||||||
// Hide usage tracking when using API key (only show for Claude Code CLI users)
|
// 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)
|
// 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;
|
const showUsageTracking = !apiKeys.anthropic && !isWindows;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,10 +77,7 @@ export function BoardHeader({
|
|||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary border border-border">
|
||||||
<Label
|
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
|
||||||
htmlFor="auto-mode-toggle"
|
|
||||||
className="text-sm font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
Auto Mode
|
Auto Mode
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from 'react';
|
||||||
import { Feature, ThinkingLevel, useAppStore } from "@/store/app-store";
|
import { Feature, ThinkingLevel, useAppStore } from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
AgentTaskInfo,
|
AgentTaskInfo,
|
||||||
parseAgentContext,
|
parseAgentContext,
|
||||||
formatModelName,
|
formatModelName,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
} from "@/lib/agent-context-parser";
|
} from '@/lib/agent-context-parser';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Cpu,
|
||||||
Brain,
|
Brain,
|
||||||
@@ -17,21 +17,21 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { SummaryDialog } from "./summary-dialog";
|
import { SummaryDialog } from './summary-dialog';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats thinking level for compact display
|
* Formats thinking level for compact display
|
||||||
*/
|
*/
|
||||||
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||||
if (!level || level === "none") return "";
|
if (!level || level === 'none') return '';
|
||||||
const labels: Record<ThinkingLevel, string> = {
|
const labels: Record<ThinkingLevel, string> = {
|
||||||
none: "",
|
none: '',
|
||||||
low: "Low",
|
low: 'Low',
|
||||||
medium: "Med",
|
medium: 'Med',
|
||||||
high: "High",
|
high: 'High',
|
||||||
ultrathink: "Ultra",
|
ultrathink: 'Ultra',
|
||||||
};
|
};
|
||||||
return labels[level];
|
return labels[level];
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export function AgentInfoPanel({
|
|||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
|
|
||||||
const showAgentInfo = kanbanCardDetailLevel === "detailed";
|
const showAgentInfo = kanbanCardDetailLevel === 'detailed';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadContext = async () => {
|
const loadContext = async () => {
|
||||||
@@ -63,22 +63,18 @@ export function AgentInfoPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feature.status === "backlog") {
|
if (feature.status === 'backlog') {
|
||||||
setAgentInfo(null);
|
setAgentInfo(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-undef
|
|
||||||
const currentProject = (window as any).__currentProject;
|
const currentProject = (window as any).__currentProject;
|
||||||
if (!currentProject?.path) return;
|
if (!currentProject?.path) return;
|
||||||
|
|
||||||
if (api.features) {
|
if (api.features) {
|
||||||
const result = await api.features.getAgentOutput(
|
const result = await api.features.getAgentOutput(currentProject.path, feature.id);
|
||||||
currentProject.path,
|
|
||||||
feature.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.content) {
|
if (result.success && result.content) {
|
||||||
const info = parseAgentContext(result.content);
|
const info = parseAgentContext(result.content);
|
||||||
@@ -94,68 +90,61 @@ export function AgentInfoPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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();
|
loadContext();
|
||||||
|
|
||||||
if (isCurrentAutoTask) {
|
if (isCurrentAutoTask) {
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
const interval = setInterval(loadContext, 3000);
|
const interval = setInterval(loadContext, 3000);
|
||||||
return () => {
|
return () => {
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
}, [feature.id, feature.status, contextContent, isCurrentAutoTask]);
|
||||||
// Model/Preset Info for Backlog Cards
|
// Model/Preset Info for Backlog Cards
|
||||||
if (showAgentInfo && feature.status === "backlog") {
|
if (showAgentInfo && feature.status === 'backlog') {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<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-2 text-[11px] flex-wrap">
|
||||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||||
<Cpu className="w-3 h-3" />
|
<Cpu className="w-3 h-3" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
|
{feature.thinkingLevel && feature.thinkingLevel !== 'none' ? (
|
||||||
<div className="flex items-center gap-1 text-purple-400">
|
<div className="flex items-center gap-1 text-purple-400">
|
||||||
<Brain className="w-3 h-3" />
|
<Brain className="w-3 h-3" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{formatThinkingLevel(feature.thinkingLevel)}
|
{formatThinkingLevel(feature.thinkingLevel as ThinkingLevel)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent Info Panel for non-backlog cards
|
// Agent Info Panel for non-backlog cards
|
||||||
if (showAgentInfo && feature.status !== "backlog" && agentInfo) {
|
if (showAgentInfo && feature.status !== 'backlog' && agentInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 space-y-2 overflow-hidden">
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
{/* Model & Phase */}
|
{/* Model & Phase */}
|
||||||
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
<div className="flex items-center gap-2 text-[11px] flex-wrap">
|
||||||
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
<div className="flex items-center gap-1 text-[var(--status-info)]">
|
||||||
<Cpu className="w-3 h-3" />
|
<Cpu className="w-3 h-3" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">{formatModelName(feature.model ?? DEFAULT_MODEL)}</span>
|
||||||
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{agentInfo.currentPhase && (
|
{agentInfo.currentPhase && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-1.5 py-0.5 rounded-md text-[10px] font-medium",
|
'px-1.5 py-0.5 rounded-md text-[10px] font-medium',
|
||||||
agentInfo.currentPhase === "planning" &&
|
agentInfo.currentPhase === 'planning' &&
|
||||||
"bg-[var(--status-info-bg)] text-[var(--status-info)]",
|
'bg-[var(--status-info-bg)] text-[var(--status-info)]',
|
||||||
agentInfo.currentPhase === "action" &&
|
agentInfo.currentPhase === 'action' &&
|
||||||
"bg-[var(--status-warning-bg)] text-[var(--status-warning)]",
|
'bg-[var(--status-warning-bg)] text-[var(--status-warning)]',
|
||||||
agentInfo.currentPhase === "verification" &&
|
agentInfo.currentPhase === 'verification' &&
|
||||||
"bg-[var(--status-success-bg)] text-[var(--status-success)]"
|
'bg-[var(--status-success-bg)] text-[var(--status-success)]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{agentInfo.currentPhase}
|
{agentInfo.currentPhase}
|
||||||
@@ -169,31 +158,26 @@ export function AgentInfoPanel({
|
|||||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
|
||||||
<ListTodo className="w-3 h-3" />
|
<ListTodo className="w-3 h-3" />
|
||||||
<span>
|
<span>
|
||||||
{agentInfo.todos.filter((t) => t.status === "completed").length}
|
{agentInfo.todos.filter((t) => t.status === 'completed').length}/
|
||||||
/{agentInfo.todos.length} tasks
|
{agentInfo.todos.length} tasks
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
<div className="space-y-0.5 max-h-16 overflow-y-auto">
|
||||||
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
{agentInfo.todos.slice(0, 3).map((todo, idx) => (
|
||||||
<div
|
<div key={idx} className="flex items-center gap-1.5 text-[10px]">
|
||||||
key={idx}
|
{todo.status === 'completed' ? (
|
||||||
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" />
|
<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" />
|
<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" />
|
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"break-words hyphens-auto line-clamp-2 leading-relaxed",
|
'break-words hyphens-auto line-clamp-2 leading-relaxed',
|
||||||
todo.status === "completed" &&
|
todo.status === 'completed' && 'text-muted-foreground/60 line-through',
|
||||||
"text-muted-foreground/60 line-through",
|
todo.status === 'in_progress' && 'text-[var(--status-warning)]',
|
||||||
todo.status === "in_progress" &&
|
todo.status === 'pending' && 'text-muted-foreground/80'
|
||||||
"text-[var(--status-warning)]",
|
|
||||||
todo.status === "pending" && "text-muted-foreground/80"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{todo.content}
|
{todo.content}
|
||||||
@@ -210,8 +194,7 @@ export function AgentInfoPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Summary for waiting_approval and verified */}
|
{/* Summary for waiting_approval and verified */}
|
||||||
{(feature.status === "waiting_approval" ||
|
{(feature.status === 'waiting_approval' || feature.status === 'verified') && (
|
||||||
feature.status === "verified") && (
|
|
||||||
<>
|
<>
|
||||||
{(feature.summary || summary || agentInfo.summary) && (
|
{(feature.summary || summary || agentInfo.summary) && (
|
||||||
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
<div className="space-y-1.5 pt-2 border-t border-border/30 overflow-hidden">
|
||||||
@@ -238,27 +221,20 @@ export function AgentInfoPanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!feature.summary &&
|
{!feature.summary && !summary && !agentInfo.summary && agentInfo.toolCallCount > 0 && (
|
||||||
!summary &&
|
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
||||||
!agentInfo.summary &&
|
<span className="flex items-center gap-1">
|
||||||
agentInfo.toolCallCount > 0 && (
|
<Wrench className="w-2.5 h-2.5" />
|
||||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/60 pt-2 border-t border-border/30">
|
{agentInfo.toolCallCount} tool calls
|
||||||
|
</span>
|
||||||
|
{agentInfo.todos.length > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Wrench className="w-2.5 h-2.5" />
|
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)]" />
|
||||||
{agentInfo.toolCallCount} tool calls
|
{agentInfo.todos.filter((t) => t.status === 'completed').length} tasks done
|
||||||
</span>
|
</span>
|
||||||
{agentInfo.todos.length > 0 && (
|
)}
|
||||||
<span className="flex items-center gap-1">
|
</div>
|
||||||
<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>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Loader2, List, FileText, GitBranch } from "lucide-react";
|
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { LogViewer } from "@/components/ui/log-viewer";
|
import { LogViewer } from '@/components/ui/log-viewer';
|
||||||
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
|
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||||
import { TaskProgressPanel } from "@/components/ui/task-progress-panel";
|
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { AutoModeEvent } from "@/types/electron";
|
import type { AutoModeEvent } from '@/types/electron';
|
||||||
|
|
||||||
interface AgentOutputModalProps {
|
interface AgentOutputModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -26,7 +25,7 @@ interface AgentOutputModalProps {
|
|||||||
onNumberKeyPress?: (key: string) => void;
|
onNumberKeyPress?: (key: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = "parsed" | "raw" | "changes";
|
type ViewMode = 'parsed' | 'raw' | 'changes';
|
||||||
|
|
||||||
export function AgentOutputModal({
|
export function AgentOutputModal({
|
||||||
open,
|
open,
|
||||||
@@ -36,13 +35,13 @@ export function AgentOutputModal({
|
|||||||
featureStatus,
|
featureStatus,
|
||||||
onNumberKeyPress,
|
onNumberKeyPress,
|
||||||
}: AgentOutputModalProps) {
|
}: AgentOutputModalProps) {
|
||||||
const [output, setOutput] = useState<string>("");
|
const [output, setOutput] = useState<string>('');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
|
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
|
||||||
const [projectPath, setProjectPath] = useState<string>("");
|
const [projectPath, setProjectPath] = useState<string>('');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollRef = useRef(true);
|
const autoScrollRef = useRef(true);
|
||||||
const projectPathRef = useRef<string>("");
|
const projectPathRef = useRef<string>('');
|
||||||
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
const useWorktrees = useAppStore((state) => state.useWorktrees);
|
||||||
|
|
||||||
// Auto-scroll to bottom when output changes
|
// Auto-scroll to bottom when output changes
|
||||||
@@ -75,22 +74,19 @@ export function AgentOutputModal({
|
|||||||
|
|
||||||
// Use features API to get agent output
|
// Use features API to get agent output
|
||||||
if (api.features) {
|
if (api.features) {
|
||||||
const result = await api.features.getAgentOutput(
|
const result = await api.features.getAgentOutput(currentProject.path, featureId);
|
||||||
currentProject.path,
|
|
||||||
featureId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOutput(result.content || "");
|
setOutput(result.content || '');
|
||||||
} else {
|
} else {
|
||||||
setOutput("");
|
setOutput('');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setOutput("");
|
setOutput('');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load output:", error);
|
console.error('Failed to load output:', error);
|
||||||
setOutput("");
|
setOutput('');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -108,38 +104,32 @@ export function AgentOutputModal({
|
|||||||
|
|
||||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||||
// Filter events for this specific feature only (skip events without featureId)
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newContent = "";
|
let newContent = '';
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "auto_mode_progress":
|
case 'auto_mode_progress':
|
||||||
newContent = event.content || "";
|
newContent = event.content || '';
|
||||||
break;
|
break;
|
||||||
case "auto_mode_tool":
|
case 'auto_mode_tool': {
|
||||||
const toolName = event.tool || "Unknown Tool";
|
const toolName = event.tool || 'Unknown Tool';
|
||||||
const toolInput = event.input
|
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
|
||||||
? JSON.stringify(event.input, null, 2)
|
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
|
||||||
: "";
|
|
||||||
newContent = `\n🔧 Tool: ${toolName}\n${
|
|
||||||
toolInput ? `Input: ${toolInput}\n` : ""
|
|
||||||
}`;
|
|
||||||
break;
|
break;
|
||||||
case "auto_mode_phase":
|
}
|
||||||
|
case 'auto_mode_phase': {
|
||||||
const phaseEmoji =
|
const phaseEmoji =
|
||||||
event.phase === "planning"
|
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
|
||||||
? "📋"
|
|
||||||
: event.phase === "action"
|
|
||||||
? "⚡"
|
|
||||||
: "✅";
|
|
||||||
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
newContent = `\n${phaseEmoji} ${event.message}\n`;
|
||||||
break;
|
break;
|
||||||
case "auto_mode_error":
|
}
|
||||||
|
case 'auto_mode_error':
|
||||||
newContent = `\n❌ Error: ${event.error}\n`;
|
newContent = `\n❌ Error: ${event.error}\n`;
|
||||||
break;
|
break;
|
||||||
case "auto_mode_ultrathink_preparation":
|
case 'auto_mode_ultrathink_preparation': {
|
||||||
// Format thinking level preparation information
|
// Format thinking level preparation information
|
||||||
let prepContent = `\n🧠 Ultrathink Preparation\n`;
|
let prepContent = `\n🧠 Ultrathink Preparation\n`;
|
||||||
|
|
||||||
@@ -169,66 +159,74 @@ export function AgentOutputModal({
|
|||||||
|
|
||||||
newContent = prepContent;
|
newContent = prepContent;
|
||||||
break;
|
break;
|
||||||
case "planning_started":
|
}
|
||||||
|
case 'planning_started': {
|
||||||
// Show when planning mode begins
|
// Show when planning mode begins
|
||||||
if ("mode" in event && "message" in event) {
|
if ('mode' in event && 'message' in event) {
|
||||||
const modeLabel =
|
const modeLabel =
|
||||||
event.mode === "lite"
|
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
|
||||||
? "Lite"
|
|
||||||
: event.mode === "spec"
|
|
||||||
? "Spec"
|
|
||||||
: "Full";
|
|
||||||
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
|
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "plan_approval_required":
|
}
|
||||||
|
case 'plan_approval_required':
|
||||||
// Show when plan requires approval
|
// Show when plan requires approval
|
||||||
if ("planningMode" in event) {
|
if ('planningMode' in event) {
|
||||||
newContent = `\n⏸️ Plan generated - waiting for your approval...\n`;
|
newContent = `\n⏸️ Plan generated - waiting for your approval...\n`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "plan_approved":
|
case 'plan_approved':
|
||||||
// Show when plan is manually approved
|
// Show when plan is manually approved
|
||||||
if ("hasEdits" in event) {
|
if ('hasEdits' in event) {
|
||||||
newContent = event.hasEdits
|
newContent = event.hasEdits
|
||||||
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
|
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
|
||||||
: `\n✅ Plan approved - continuing to implementation...\n`;
|
: `\n✅ Plan approved - continuing to implementation...\n`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "plan_auto_approved":
|
case 'plan_auto_approved':
|
||||||
// Show when plan is auto-approved
|
// Show when plan is auto-approved
|
||||||
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
|
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
|
||||||
break;
|
break;
|
||||||
case "plan_revision_requested":
|
case 'plan_revision_requested': {
|
||||||
// Show when user requests plan revision
|
// Show when user requests plan revision
|
||||||
if ("planVersion" in event) {
|
if ('planVersion' in event) {
|
||||||
const revisionEvent = event as Extract<AutoModeEvent, { type: "plan_revision_requested" }>;
|
const revisionEvent = event as Extract<
|
||||||
|
AutoModeEvent,
|
||||||
|
{ type: 'plan_revision_requested' }
|
||||||
|
>;
|
||||||
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
|
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "auto_mode_task_started":
|
}
|
||||||
|
case 'auto_mode_task_started': {
|
||||||
// Show when a task starts
|
// Show when a task starts
|
||||||
if ("taskId" in event && "taskDescription" in event) {
|
if ('taskId' in event && 'taskDescription' in event) {
|
||||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_started" }>;
|
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
|
||||||
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
|
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "auto_mode_task_complete":
|
}
|
||||||
|
case 'auto_mode_task_complete': {
|
||||||
// Show task completion progress
|
// Show task completion progress
|
||||||
if ("taskId" in event && "tasksCompleted" in event && "tasksTotal" in event) {
|
if ('taskId' in event && 'tasksCompleted' in event && 'tasksTotal' in event) {
|
||||||
const taskEvent = event as Extract<AutoModeEvent, { type: "auto_mode_task_complete" }>;
|
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
|
||||||
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
|
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "auto_mode_phase_complete":
|
}
|
||||||
|
case 'auto_mode_phase_complete': {
|
||||||
// Show phase completion for full mode
|
// Show phase completion for full mode
|
||||||
if ("phaseNumber" in event) {
|
if ('phaseNumber' in event) {
|
||||||
const phaseEvent = event as Extract<AutoModeEvent, { type: "auto_mode_phase_complete" }>;
|
const phaseEvent = event as Extract<
|
||||||
|
AutoModeEvent,
|
||||||
|
{ type: 'auto_mode_phase_complete' }
|
||||||
|
>;
|
||||||
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
|
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
|
||||||
}
|
}
|
||||||
break;
|
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`;
|
newContent = `\n${emoji} Task completed: ${event.message}\n`;
|
||||||
|
|
||||||
// Close the modal when the feature is verified (passes = true)
|
// Close the modal when the feature is verified (passes = true)
|
||||||
@@ -239,6 +237,7 @@ export function AgentOutputModal({
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newContent) {
|
if (newContent) {
|
||||||
@@ -267,20 +266,15 @@ export function AgentOutputModal({
|
|||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
// Check if a number key (0-9) was pressed without modifiers
|
// Check if a number key (0-9) was pressed without modifiers
|
||||||
if (
|
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
|
||||||
!event.ctrlKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
/^[0-9]$/.test(event.key)
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onNumberKeyPress(event.key);
|
onNumberKeyPress(event.key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [open, onNumberKeyPress]);
|
}, [open, onNumberKeyPress]);
|
||||||
|
|
||||||
@@ -293,19 +287,18 @@ export function AgentOutputModal({
|
|||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{featureStatus !== "verified" &&
|
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||||
featureStatus !== "waiting_approval" && (
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
)}
|
||||||
)}
|
|
||||||
Agent Output
|
Agent Output
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||||
<button
|
<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 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
viewMode === "parsed"
|
viewMode === 'parsed'
|
||||||
? "bg-primary/20 text-primary shadow-sm"
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
data-testid="view-mode-parsed"
|
data-testid="view-mode-parsed"
|
||||||
>
|
>
|
||||||
@@ -313,11 +306,11 @@ export function AgentOutputModal({
|
|||||||
Logs
|
Logs
|
||||||
</button>
|
</button>
|
||||||
<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 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
viewMode === "changes"
|
viewMode === 'changes'
|
||||||
? "bg-primary/20 text-primary shadow-sm"
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
data-testid="view-mode-changes"
|
data-testid="view-mode-changes"
|
||||||
>
|
>
|
||||||
@@ -325,11 +318,11 @@ export function AgentOutputModal({
|
|||||||
Changes
|
Changes
|
||||||
</button>
|
</button>
|
||||||
<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 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
viewMode === "raw"
|
viewMode === 'raw'
|
||||||
? "bg-primary/20 text-primary shadow-sm"
|
? 'bg-primary/20 text-primary shadow-sm'
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
data-testid="view-mode-raw"
|
data-testid="view-mode-raw"
|
||||||
>
|
>
|
||||||
@@ -353,7 +346,7 @@ export function AgentOutputModal({
|
|||||||
className="flex-shrink-0 mx-1"
|
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">
|
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||||
{projectPath ? (
|
{projectPath ? (
|
||||||
<GitDiffPanel
|
<GitDiffPanel
|
||||||
@@ -386,19 +379,17 @@ export function AgentOutputModal({
|
|||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
No output yet. The agent will stream output here as it works.
|
No output yet. The agent will stream output here as it works.
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === "parsed" ? (
|
) : viewMode === 'parsed' ? (
|
||||||
<LogViewer output={output} />
|
<LogViewer output={output} />
|
||||||
) : (
|
) : (
|
||||||
<div className="whitespace-pre-wrap break-words text-zinc-300">
|
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
|
||||||
{output}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||||
{autoScrollRef.current
|
{autoScrollRef.current
|
||||||
? "Auto-scrolling enabled"
|
? 'Auto-scrolling enabled'
|
||||||
: "Scroll to bottom to enable auto-scroll"}
|
: 'Scroll to bottom to enable auto-scroll'}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -7,30 +6,29 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from '@/components/ui/label';
|
||||||
import { CategoryAutocomplete } from "@/components/ui/category-autocomplete";
|
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
FeatureImagePath as DescriptionImagePath,
|
FeatureImagePath as DescriptionImagePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from "@/components/ui/description-image-dropzone";
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Settings2,
|
Settings2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
FlaskConical,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { toast } from "sonner";
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { modelSupportsThinking } from "@/lib/utils";
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
AgentModel,
|
AgentModel,
|
||||||
@@ -38,7 +36,7 @@ import {
|
|||||||
AIProfile,
|
AIProfile,
|
||||||
useAppStore,
|
useAppStore,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
} from "@/store/app-store";
|
} from '@/store/app-store';
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
ModelSelector,
|
||||||
ThinkingLevelSelector,
|
ThinkingLevelSelector,
|
||||||
@@ -47,14 +45,14 @@ import {
|
|||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
BranchSelector,
|
BranchSelector,
|
||||||
PlanningModeSelector,
|
PlanningModeSelector,
|
||||||
} from "../shared";
|
} from '../shared';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { DependencyTreeDialog } from "./dependency-tree-dialog";
|
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||||
|
|
||||||
interface EditFeatureDialogProps {
|
interface EditFeatureDialogProps {
|
||||||
feature: Feature | null;
|
feature: Feature | null;
|
||||||
@@ -104,16 +102,19 @@ export function EditFeatureDialog({
|
|||||||
// If feature has no branchName, default to using current branch
|
// If feature has no branchName, default to using current branch
|
||||||
return !feature?.branchName;
|
return !feature?.branchName;
|
||||||
});
|
});
|
||||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] =
|
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||||
useState<ImagePreviewMap>(() => new Map());
|
() => new Map()
|
||||||
|
);
|
||||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
"improve" | "technical" | "simplify" | "acceptance"
|
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||||
>("improve");
|
>('improve');
|
||||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
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
|
// Get enhancement model and worktrees setting from store
|
||||||
const { enhancementModel, useWorktrees } = useAppStore();
|
const { enhancementModel, useWorktrees } = useAppStore();
|
||||||
@@ -135,33 +136,31 @@ export function EditFeatureDialog({
|
|||||||
if (!editingFeature) return;
|
if (!editingFeature) return;
|
||||||
|
|
||||||
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
// Validate branch selection when "other branch" is selected and branch selector is enabled
|
||||||
const isBranchSelectorEnabled = editingFeature.status === "backlog";
|
const isBranchSelectorEnabled = editingFeature.status === 'backlog';
|
||||||
if (
|
if (
|
||||||
useWorktrees &&
|
useWorktrees &&
|
||||||
isBranchSelectorEnabled &&
|
isBranchSelectorEnabled &&
|
||||||
!useCurrentBranch &&
|
!useCurrentBranch &&
|
||||||
!editingFeature.branchName?.trim()
|
!editingFeature.branchName?.trim()
|
||||||
) {
|
) {
|
||||||
toast.error("Please select a branch name");
|
toast.error('Please select a branch name');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
|
const selectedModel = (editingFeature.model ?? 'opus') as AgentModel;
|
||||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(
|
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
||||||
selectedModel
|
? (editingFeature.thinkingLevel ?? 'none')
|
||||||
)
|
: 'none';
|
||||||
? editingFeature.thinkingLevel ?? "none"
|
|
||||||
: "none";
|
|
||||||
|
|
||||||
// Use current branch if toggle is on
|
// Use current branch if toggle is on
|
||||||
// If currentBranch is provided (non-primary worktree), use it
|
// If currentBranch is provided (non-primary worktree), use it
|
||||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
||||||
const finalBranchName = useCurrentBranch
|
const finalBranchName = useCurrentBranch
|
||||||
? (currentBranch || "")
|
? currentBranch || ''
|
||||||
: editingFeature.branchName || "";
|
: editingFeature.branchName || '';
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
title: editingFeature.title ?? "",
|
title: editingFeature.title ?? '',
|
||||||
category: editingFeature.category,
|
category: editingFeature.category,
|
||||||
description: editingFeature.description,
|
description: editingFeature.description,
|
||||||
steps: editingFeature.steps,
|
steps: editingFeature.steps,
|
||||||
@@ -192,16 +191,11 @@ export function EditFeatureDialog({
|
|||||||
setEditingFeature({
|
setEditingFeature({
|
||||||
...editingFeature,
|
...editingFeature,
|
||||||
model,
|
model,
|
||||||
thinkingLevel: modelSupportsThinking(model)
|
thinkingLevel: modelSupportsThinking(model) ? editingFeature.thinkingLevel : 'none',
|
||||||
? editingFeature.thinkingLevel
|
|
||||||
: "none",
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfileSelect = (
|
const handleProfileSelect = (model: AgentModel, thinkingLevel: ThinkingLevel) => {
|
||||||
model: AgentModel,
|
|
||||||
thinkingLevel: ThinkingLevel
|
|
||||||
) => {
|
|
||||||
if (!editingFeature) return;
|
if (!editingFeature) return;
|
||||||
setEditingFeature({
|
setEditingFeature({
|
||||||
...editingFeature,
|
...editingFeature,
|
||||||
@@ -224,16 +218,14 @@ export function EditFeatureDialog({
|
|||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
if (result?.success && result.enhancedText) {
|
||||||
const enhancedText = result.enhancedText;
|
const enhancedText = result.enhancedText;
|
||||||
setEditingFeature((prev) =>
|
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
|
||||||
prev ? { ...prev, description: enhancedText } : prev
|
toast.success('Description enhanced!');
|
||||||
);
|
|
||||||
toast.success("Description enhanced!");
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(result?.error || "Failed to enhance description");
|
toast.error(result?.error || 'Failed to enhance description');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Enhancement failed:", error);
|
console.error('Enhancement failed:', error);
|
||||||
toast.error("Failed to enhance description");
|
toast.error('Failed to enhance description');
|
||||||
} finally {
|
} finally {
|
||||||
setIsEnhancing(false);
|
setIsEnhancing(false);
|
||||||
}
|
}
|
||||||
@@ -267,10 +259,7 @@ export function EditFeatureDialog({
|
|||||||
<DialogTitle>Edit Feature</DialogTitle>
|
<DialogTitle>Edit Feature</DialogTitle>
|
||||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs
|
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
||||||
defaultValue="prompt"
|
|
||||||
className="py-4 flex-1 min-h-0 flex flex-col"
|
|
||||||
>
|
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
<TabsList className="w-full grid grid-cols-3 mb-4">
|
||||||
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
<MessageSquare className="w-4 h-4 mr-2" />
|
||||||
@@ -287,10 +276,7 @@ export function EditFeatureDialog({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
{/* Prompt Tab */}
|
||||||
<TabsContent
|
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
||||||
value="prompt"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
@@ -318,7 +304,7 @@ export function EditFeatureDialog({
|
|||||||
<Label htmlFor="edit-title">Title (optional)</Label>
|
<Label htmlFor="edit-title">Title (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="edit-title"
|
id="edit-title"
|
||||||
value={editingFeature.title ?? ""}
|
value={editingFeature.title ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditingFeature({
|
setEditingFeature({
|
||||||
...editingFeature,
|
...editingFeature,
|
||||||
@@ -332,38 +318,25 @@ export function EditFeatureDialog({
|
|||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" size="sm" className="w-[180px] justify-between">
|
||||||
variant="outline"
|
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||||
size="sm"
|
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||||
className="w-[180px] justify-between"
|
{enhancementMode === 'simplify' && 'Simplify'}
|
||||||
>
|
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||||
{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" />
|
<ChevronDown className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||||
onClick={() => setEnhancementMode("improve")}
|
|
||||||
>
|
|
||||||
Improve Clarity
|
Improve Clarity
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||||
onClick={() => setEnhancementMode("technical")}
|
|
||||||
>
|
|
||||||
Add Technical Details
|
Add Technical Details
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||||
onClick={() => setEnhancementMode("simplify")}
|
|
||||||
>
|
|
||||||
Simplify
|
Simplify
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||||
onClick={() => setEnhancementMode("acceptance")}
|
|
||||||
>
|
|
||||||
Add Acceptance Criteria
|
Add Acceptance Criteria
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -400,7 +373,7 @@ export function EditFeatureDialog({
|
|||||||
<BranchSelector
|
<BranchSelector
|
||||||
useCurrentBranch={useCurrentBranch}
|
useCurrentBranch={useCurrentBranch}
|
||||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||||
branchName={editingFeature.branchName ?? ""}
|
branchName={editingFeature.branchName ?? ''}
|
||||||
onBranchNameChange={(value) =>
|
onBranchNameChange={(value) =>
|
||||||
setEditingFeature({
|
setEditingFeature({
|
||||||
...editingFeature,
|
...editingFeature,
|
||||||
@@ -410,7 +383,7 @@ export function EditFeatureDialog({
|
|||||||
branchSuggestions={branchSuggestions}
|
branchSuggestions={branchSuggestions}
|
||||||
branchCardCounts={branchCardCounts}
|
branchCardCounts={branchCardCounts}
|
||||||
currentBranch={currentBranch}
|
currentBranch={currentBranch}
|
||||||
disabled={editingFeature.status !== "backlog"}
|
disabled={editingFeature.status !== 'backlog'}
|
||||||
testIdPrefix="edit-feature"
|
testIdPrefix="edit-feature"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -429,17 +402,12 @@ export function EditFeatureDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Model Tab */}
|
{/* Model Tab */}
|
||||||
<TabsContent
|
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
||||||
value="model"
|
|
||||||
className="space-y-4 overflow-y-auto cursor-default"
|
|
||||||
>
|
|
||||||
{/* Show Advanced Options Toggle */}
|
{/* Show Advanced Options Toggle */}
|
||||||
{showProfilesOnly && (
|
{showProfilesOnly && (
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
|
||||||
Simple Mode Active
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
Only showing AI profiles. Advanced model tweaking is hidden.
|
||||||
</p>
|
</p>
|
||||||
@@ -447,13 +415,11 @@ export function EditFeatureDialog({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
|
||||||
setShowEditAdvancedOptions(!showEditAdvancedOptions)
|
|
||||||
}
|
|
||||||
data-testid="edit-show-advanced-options-toggle"
|
data-testid="edit-show-advanced-options-toggle"
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
{showEditAdvancedOptions ? "Hide" : "Show"} Advanced
|
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -461,29 +427,28 @@ export function EditFeatureDialog({
|
|||||||
{/* Quick Select Profile Section */}
|
{/* Quick Select Profile Section */}
|
||||||
<ProfileQuickSelect
|
<ProfileQuickSelect
|
||||||
profiles={aiProfiles}
|
profiles={aiProfiles}
|
||||||
selectedModel={editingFeature.model ?? "opus"}
|
selectedModel={editingFeature.model ?? 'opus'}
|
||||||
selectedThinkingLevel={editingFeature.thinkingLevel ?? "none"}
|
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
|
||||||
onSelect={handleProfileSelect}
|
onSelect={handleProfileSelect}
|
||||||
testIdPrefix="edit-profile-quick-select"
|
testIdPrefix="edit-profile-quick-select"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
{aiProfiles.length > 0 &&
|
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
(!showProfilesOnly || showEditAdvancedOptions) && (
|
<div className="border-t border-border" />
|
||||||
<div className="border-t border-border" />
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section */}
|
{/* Claude Models Section */}
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
||||||
<>
|
<>
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
selectedModel={(editingFeature.model ?? "opus") as AgentModel}
|
selectedModel={(editingFeature.model ?? 'opus') as AgentModel}
|
||||||
onModelSelect={handleModelSelect}
|
onModelSelect={handleModelSelect}
|
||||||
testIdPrefix="edit-model-select"
|
testIdPrefix="edit-model-select"
|
||||||
/>
|
/>
|
||||||
{editModelAllowsThinking && (
|
{editModelAllowsThinking && (
|
||||||
<ThinkingLevelSelector
|
<ThinkingLevelSelector
|
||||||
selectedLevel={editingFeature.thinkingLevel ?? "none"}
|
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
|
||||||
onLevelSelect={(level) =>
|
onLevelSelect={(level) =>
|
||||||
setEditingFeature({
|
setEditingFeature({
|
||||||
...editingFeature,
|
...editingFeature,
|
||||||
@@ -515,13 +480,9 @@ export function EditFeatureDialog({
|
|||||||
{/* Testing Section */}
|
{/* Testing Section */}
|
||||||
<TestingTabContent
|
<TestingTabContent
|
||||||
skipTests={editingFeature.skipTests ?? false}
|
skipTests={editingFeature.skipTests ?? false}
|
||||||
onSkipTestsChange={(skipTests) =>
|
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
|
||||||
setEditingFeature({ ...editingFeature, skipTests })
|
|
||||||
}
|
|
||||||
steps={editingFeature.steps}
|
steps={editingFeature.steps}
|
||||||
onStepsChange={(steps) =>
|
onStepsChange={(steps) => setEditingFeature({ ...editingFeature, steps })}
|
||||||
setEditingFeature({ ...editingFeature, steps })
|
|
||||||
}
|
|
||||||
testIdPrefix="edit"
|
testIdPrefix="edit"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -541,12 +502,12 @@ export function EditFeatureDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<HotkeyButton
|
<HotkeyButton
|
||||||
onClick={handleUpdate}
|
onClick={handleUpdate}
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||||
hotkeyActive={!!editingFeature}
|
hotkeyActive={!!editingFeature}
|
||||||
data-testid="confirm-edit-feature"
|
data-testid="confirm-edit-feature"
|
||||||
disabled={
|
disabled={
|
||||||
useWorktrees &&
|
useWorktrees &&
|
||||||
editingFeature.status === "backlog" &&
|
editingFeature.status === 'backlog' &&
|
||||||
!useCurrentBranch &&
|
!useCurrentBranch &&
|
||||||
!editingFeature.branchName?.trim()
|
!editingFeature.branchName?.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
|
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
import {
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
DndContext,
|
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
DragOverlay,
|
import { Button } from '@/components/ui/button';
|
||||||
} from "@dnd-kit/core";
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import {
|
import { KanbanColumn, KanbanCard } from './components';
|
||||||
SortableContext,
|
import { Feature } from '@/store/app-store';
|
||||||
verticalListSortingStrategy,
|
import { FastForward, Lightbulb, Archive } from 'lucide-react';
|
||||||
} from "@dnd-kit/sortable";
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
|
||||||
import { Button } from "@/components/ui/button";
|
import { COLUMNS, ColumnId } from './constants';
|
||||||
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";
|
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
sensors: any;
|
sensors: any;
|
||||||
@@ -93,10 +85,7 @@ export function KanbanBoard({
|
|||||||
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
const { columnWidth } = useResponsiveKanban(COLUMNS.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex-1 overflow-x-auto px-4 pb-4 relative" style={backgroundImageStyle}>
|
||||||
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
|
||||||
style={backgroundImageStyle}
|
|
||||||
>
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={collisionDetectionStrategy}
|
collisionDetection={collisionDetectionStrategy}
|
||||||
@@ -118,8 +107,7 @@ export function KanbanBoard({
|
|||||||
showBorder={backgroundSettings.columnBorderEnabled}
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
hideScrollbar={backgroundSettings.hideScrollbar}
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
headerAction={
|
headerAction={
|
||||||
column.id === "verified" &&
|
column.id === 'verified' && columnFeatures.length > 0 ? (
|
||||||
columnFeatures.length > 0 ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -130,7 +118,7 @@ export function KanbanBoard({
|
|||||||
<Archive className="w-3 h-3 mr-1" />
|
<Archive className="w-3 h-3 mr-1" />
|
||||||
Archive All
|
Archive All
|
||||||
</Button>
|
</Button>
|
||||||
) : column.id === "backlog" ? (
|
) : column.id === 'backlog' ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -175,9 +163,8 @@ export function KanbanBoard({
|
|||||||
{columnFeatures.map((feature, index) => {
|
{columnFeatures.map((feature, index) => {
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||||
let shortcutKey: string | undefined;
|
let shortcutKey: string | undefined;
|
||||||
if (column.id === "in_progress" && index < 10) {
|
if (column.id === 'in_progress' && index < 10) {
|
||||||
shortcutKey =
|
shortcutKey = index === 9 ? '0' : String(index + 1);
|
||||||
index === 9 ? "0" : String(index + 1);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<KanbanCard
|
<KanbanCard
|
||||||
@@ -190,29 +177,19 @@ export function KanbanBoard({
|
|||||||
onResume={() => onResume(feature)}
|
onResume={() => onResume(feature)}
|
||||||
onForceStop={() => onForceStop(feature)}
|
onForceStop={() => onForceStop(feature)}
|
||||||
onManualVerify={() => onManualVerify(feature)}
|
onManualVerify={() => onManualVerify(feature)}
|
||||||
onMoveBackToInProgress={() =>
|
onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
|
||||||
onMoveBackToInProgress(feature)
|
|
||||||
}
|
|
||||||
onFollowUp={() => onFollowUp(feature)}
|
onFollowUp={() => onFollowUp(feature)}
|
||||||
onComplete={() => onComplete(feature)}
|
onComplete={() => onComplete(feature)}
|
||||||
onImplement={() => onImplement(feature)}
|
onImplement={() => onImplement(feature)}
|
||||||
onViewPlan={() => onViewPlan(feature)}
|
onViewPlan={() => onViewPlan(feature)}
|
||||||
onApprovePlan={() => onApprovePlan(feature)}
|
onApprovePlan={() => onApprovePlan(feature)}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(
|
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||||
feature.id
|
|
||||||
)}
|
|
||||||
shortcutKey={shortcutKey}
|
shortcutKey={shortcutKey}
|
||||||
opacity={backgroundSettings.cardOpacity}
|
opacity={backgroundSettings.cardOpacity}
|
||||||
glassmorphism={
|
glassmorphism={backgroundSettings.cardGlassmorphism}
|
||||||
backgroundSettings.cardGlassmorphism
|
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||||
}
|
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||||
cardBorderEnabled={
|
|
||||||
backgroundSettings.cardBorderEnabled
|
|
||||||
}
|
|
||||||
cardBorderOpacity={
|
|
||||||
backgroundSettings.cardBorderOpacity
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -225,7 +202,7 @@ export function KanbanBoard({
|
|||||||
<DragOverlay
|
<DragOverlay
|
||||||
dropAnimation={{
|
dropAnimation={{
|
||||||
duration: 200,
|
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 && (
|
{activeFeature && (
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
import { Card } from '@/components/ui/card';
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -14,17 +13,16 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Upload,
|
Upload,
|
||||||
File,
|
File,
|
||||||
X,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
Eye,
|
Eye,
|
||||||
Pencil,
|
Pencil,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from '@/hooks/use-keyboard-shortcuts';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -32,15 +30,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
import { Markdown } from "../ui/markdown";
|
import { Markdown } from '../ui/markdown';
|
||||||
|
|
||||||
interface ContextFile {
|
interface ContextFile {
|
||||||
name: string;
|
name: string;
|
||||||
type: "text" | "image";
|
type: 'text' | 'image';
|
||||||
content?: string;
|
content?: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
@@ -53,17 +51,15 @@ export function ContextView() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [editedContent, setEditedContent] = useState("");
|
const [editedContent, setEditedContent] = useState('');
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||||
const [renameFileName, setRenameFileName] = useState("");
|
const [renameFileName, setRenameFileName] = useState('');
|
||||||
const [newFileName, setNewFileName] = useState("");
|
const [newFileName, setNewFileName] = useState('');
|
||||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
const [newFileType, setNewFileType] = useState<'text' | 'image'>('text');
|
||||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
const [uploadedImageData, setUploadedImageData] = useState<string | null>(null);
|
||||||
null
|
const [newFileContent, setNewFileContent] = useState('');
|
||||||
);
|
|
||||||
const [newFileContent, setNewFileContent] = useState("");
|
|
||||||
const [isDropHovering, setIsDropHovering] = useState(false);
|
const [isDropHovering, setIsDropHovering] = useState(false);
|
||||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||||
|
|
||||||
@@ -73,7 +69,7 @@ export function ContextView() {
|
|||||||
{
|
{
|
||||||
key: shortcuts.addContextFile,
|
key: shortcuts.addContextFile,
|
||||||
action: () => setIsAddDialogOpen(true),
|
action: () => setIsAddDialogOpen(true),
|
||||||
description: "Add new context file",
|
description: 'Add new context file',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[shortcuts]
|
[shortcuts]
|
||||||
@@ -87,22 +83,14 @@ export function ContextView() {
|
|||||||
}, [currentProject]);
|
}, [currentProject]);
|
||||||
|
|
||||||
const isMarkdownFile = (filename: string): boolean => {
|
const isMarkdownFile = (filename: string): boolean => {
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
return ext === ".md" || ext === ".markdown";
|
return ext === '.md' || ext === '.markdown';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if a file is an image based on extension
|
// Determine if a file is an image based on extension
|
||||||
const isImageFile = (filename: string): boolean => {
|
const isImageFile = (filename: string): boolean => {
|
||||||
const imageExtensions = [
|
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||||
".png",
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".gif",
|
|
||||||
".webp",
|
|
||||||
".svg",
|
|
||||||
".bmp",
|
|
||||||
];
|
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
|
||||||
return imageExtensions.includes(ext);
|
return imageExtensions.includes(ext);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,13 +113,13 @@ export function ContextView() {
|
|||||||
.filter((entry) => entry.isFile)
|
.filter((entry) => entry.isFile)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
type: isImageFile(entry.name) ? "image" : "text",
|
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||||
path: `${contextPath}/${entry.name}`,
|
path: `${contextPath}/${entry.name}`,
|
||||||
}));
|
}));
|
||||||
setContextFiles(files);
|
setContextFiles(files);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load context files:", error);
|
console.error('Failed to load context files:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -152,7 +140,7 @@ export function ContextView() {
|
|||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 });
|
setSelectedFile({ ...selectedFile, content: editedContent });
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save file:", error);
|
console.error('Failed to save file:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -198,32 +186,32 @@ export function ContextView() {
|
|||||||
let filename = newFileName.trim();
|
let filename = newFileName.trim();
|
||||||
|
|
||||||
// Add default extension if not provided
|
// Add default extension if not provided
|
||||||
if (newFileType === "text" && !filename.includes(".")) {
|
if (newFileType === 'text' && !filename.includes('.')) {
|
||||||
filename += ".md";
|
filename += '.md';
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = `${contextPath}/${filename}`;
|
const filePath = `${contextPath}/${filename}`;
|
||||||
|
|
||||||
if (newFileType === "image" && uploadedImageData) {
|
if (newFileType === 'image' && uploadedImageData) {
|
||||||
// Write image data
|
// Write image data
|
||||||
await api.writeFile(filePath, uploadedImageData);
|
await api.writeFile(filePath, uploadedImageData);
|
||||||
} else {
|
} else {
|
||||||
// Write text file with content (or empty if no content)
|
// Write text file with content (or empty if no content)
|
||||||
await api.writeFile(filePath, newFileContent);
|
await api.writeFile(filePath, newFileContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only reload files on success
|
// Only reload files on success
|
||||||
await loadContextFiles();
|
await loadContextFiles();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add file:", error);
|
console.error('Failed to add file:', error);
|
||||||
// Optionally show error toast to user here
|
// Optionally show error toast to user here
|
||||||
} finally {
|
} finally {
|
||||||
// Close dialog and reset state
|
// Close dialog and reset state
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
setNewFileName("");
|
setNewFileName('');
|
||||||
setNewFileType("text");
|
setNewFileType('text');
|
||||||
setUploadedImageData(null);
|
setUploadedImageData(null);
|
||||||
setNewFileContent("");
|
setNewFileContent('');
|
||||||
setIsDropHovering(false);
|
setIsDropHovering(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -238,11 +226,11 @@ export function ContextView() {
|
|||||||
|
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setEditedContent("");
|
setEditedContent('');
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
await loadContextFiles();
|
await loadContextFiles();
|
||||||
} catch (error) {
|
} 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
|
// Check if file with new name already exists
|
||||||
const exists = await api.exists(newPath);
|
const exists = await api.exists(newPath);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
console.error("A file with this name already exists");
|
console.error('A file with this name already exists');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read current file content
|
// Read current file content
|
||||||
const result = await api.readFile(selectedFile.path);
|
const result = await api.readFile(selectedFile.path);
|
||||||
if (!result.success || result.content === undefined) {
|
if (!result.success || result.content === undefined) {
|
||||||
console.error("Failed to read file for rename");
|
console.error('Failed to read file for rename');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +270,7 @@ export function ContextView() {
|
|||||||
await api.deleteFile(selectedFile.path);
|
await api.deleteFile(selectedFile.path);
|
||||||
|
|
||||||
setIsRenameDialogOpen(false);
|
setIsRenameDialogOpen(false);
|
||||||
setRenameFileName("");
|
setRenameFileName('');
|
||||||
|
|
||||||
// Reload files and select the renamed file
|
// Reload files and select the renamed file
|
||||||
await loadContextFiles();
|
await loadContextFiles();
|
||||||
@@ -290,13 +278,13 @@ export function ContextView() {
|
|||||||
// Update selected file with new name and path
|
// Update selected file with new name and path
|
||||||
const renamedFile: ContextFile = {
|
const renamedFile: ContextFile = {
|
||||||
name: newName,
|
name: newName,
|
||||||
type: isImageFile(newName) ? "image" : "text",
|
type: isImageFile(newName) ? 'image' : 'text',
|
||||||
path: newPath,
|
path: newPath,
|
||||||
content: result.content,
|
content: result.content,
|
||||||
};
|
};
|
||||||
setSelectedFile(renamedFile);
|
setSelectedFile(renamedFile);
|
||||||
} catch (error) {
|
} 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
|
// Handle drag and drop for .txt and .md files in the add context dialog textarea
|
||||||
const handleTextAreaDrop = async (
|
const handleTextAreaDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||||
e: React.DragEvent<HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDropHovering(false);
|
setIsDropHovering(false);
|
||||||
@@ -366,8 +352,8 @@ export function ContextView() {
|
|||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
|
|
||||||
// Only accept .txt and .md files
|
// Only accept .txt and .md files
|
||||||
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
|
if (!fileName.endsWith('.txt') && !fileName.endsWith('.md')) {
|
||||||
console.warn("Only .txt and .md files are supported for drag and drop");
|
console.warn('Only .txt and .md files are supported for drag and drop');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,20 +395,14 @@ export function ContextView() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="context-view-loading"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="context-view">
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
||||||
data-testid="context-view"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -462,10 +442,7 @@ export function ContextView() {
|
|||||||
Context Files ({contextFiles.length})
|
Context Files ({contextFiles.length})
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex-1 overflow-y-auto p-2" data-testid="context-file-list">
|
||||||
className="flex-1 overflow-y-auto p-2"
|
|
||||||
data-testid="context-file-list"
|
|
||||||
>
|
|
||||||
{contextFiles.length === 0 ? (
|
{contextFiles.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
<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" />
|
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
||||||
@@ -481,10 +458,10 @@ export function ContextView() {
|
|||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
className={cn(
|
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
|
selectedFile?.path === file.path
|
||||||
? "bg-primary/20 text-foreground border border-primary/30"
|
? 'bg-primary/20 text-foreground border border-primary/30'
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -492,7 +469,7 @@ export function ContextView() {
|
|||||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||||
data-testid={`context-file-${file.name}`}
|
data-testid={`context-file-${file.name}`}
|
||||||
>
|
>
|
||||||
{file.type === "image" ? (
|
{file.type === 'image' ? (
|
||||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<FileText 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 */}
|
{/* File toolbar */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedFile.type === "image" ? (
|
{selectedFile.type === 'image' ? (
|
||||||
<ImageIcon className="w-4 h-4 text-muted-foreground" />
|
<ImageIcon className="w-4 h-4 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">{selectedFile.name}</span>
|
||||||
{selectedFile.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedFile.type === "text" &&
|
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||||
isMarkdownFile(selectedFile.name) && (
|
<Button
|
||||||
<Button
|
variant={'outline'}
|
||||||
variant={"outline"}
|
size="sm"
|
||||||
size="sm"
|
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
data-testid="toggle-preview-mode"
|
||||||
data-testid="toggle-preview-mode"
|
>
|
||||||
>
|
{isPreviewMode ? (
|
||||||
{isPreviewMode ? (
|
<>
|
||||||
<>
|
<EditIcon className="w-4 h-4 mr-2" />
|
||||||
<EditIcon className="w-4 h-4 mr-2" />
|
Edit
|
||||||
Edit
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
<Eye className="w-4 h-4 mr-2" />
|
Preview
|
||||||
Preview
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
{selectedFile.type === 'text' && (
|
||||||
{selectedFile.type === "text" && (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={saveFile}
|
onClick={saveFile}
|
||||||
@@ -564,7 +538,7 @@ export function ContextView() {
|
|||||||
data-testid="save-context-file"
|
data-testid="save-context-file"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4 mr-2" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
|
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -581,7 +555,7 @@ export function ContextView() {
|
|||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 overflow-hidden p-4">
|
<div className="flex-1 overflow-hidden p-4">
|
||||||
{selectedFile.type === "image" ? (
|
{selectedFile.type === 'image' ? (
|
||||||
<div
|
<div
|
||||||
className="h-full flex items-center justify-center bg-card rounded-lg"
|
className="h-full flex items-center justify-center bg-card rounded-lg"
|
||||||
data-testid="image-preview"
|
data-testid="image-preview"
|
||||||
@@ -614,12 +588,8 @@ export function ContextView() {
|
|||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||||
<p className="text-foreground-secondary">
|
<p className="text-foreground-secondary">Select a file to view or edit</p>
|
||||||
Select a file to view or edit
|
<p className="text-muted-foreground text-sm mt-1">Or drop files here to add them</p>
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
|
||||||
Or drop files here to add them
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -634,25 +604,23 @@ export function ContextView() {
|
|||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Context File</DialogTitle>
|
<DialogTitle>Add Context File</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Add a new text or image file to the context.</DialogDescription>
|
||||||
Add a new text or image file to the context.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={newFileType === "text" ? "default" : "outline"}
|
variant={newFileType === 'text' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setNewFileType("text")}
|
onClick={() => setNewFileType('text')}
|
||||||
data-testid="add-text-type"
|
data-testid="add-text-type"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
Text
|
Text
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={newFileType === "image" ? "default" : "outline"}
|
variant={newFileType === 'image' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setNewFileType("image")}
|
onClick={() => setNewFileType('image')}
|
||||||
data-testid="add-image-type"
|
data-testid="add-image-type"
|
||||||
>
|
>
|
||||||
<ImageIcon className="w-4 h-4 mr-2" />
|
<ImageIcon className="w-4 h-4 mr-2" />
|
||||||
@@ -666,20 +634,18 @@ export function ContextView() {
|
|||||||
id="filename"
|
id="filename"
|
||||||
value={newFileName}
|
value={newFileName}
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
onChange={(e) => setNewFileName(e.target.value)}
|
||||||
placeholder={
|
placeholder={newFileType === 'text' ? 'context.md' : 'image.png'}
|
||||||
newFileType === "text" ? "context.md" : "image.png"
|
|
||||||
}
|
|
||||||
data-testid="new-file-name"
|
data-testid="new-file-name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{newFileType === "text" && (
|
{newFileType === 'text' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="context-content">Context Content</Label>
|
<Label htmlFor="context-content">Context Content</Label>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-lg transition-colors",
|
'relative rounded-lg transition-colors',
|
||||||
isDropHovering && "ring-2 ring-primary"
|
isDropHovering && 'ring-2 ring-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -691,8 +657,8 @@ export function ContextView() {
|
|||||||
onDragLeave={handleTextAreaDragLeave}
|
onDragLeave={handleTextAreaDragLeave}
|
||||||
placeholder="Enter context content here or drag & drop a .txt or .md file..."
|
placeholder="Enter context content here or drag & drop a .txt or .md file..."
|
||||||
className={cn(
|
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",
|
'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"
|
isDropHovering && 'border-primary bg-primary/10'
|
||||||
)}
|
)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-testid="new-file-content"
|
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="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">
|
<div className="flex flex-col items-center text-primary">
|
||||||
<Upload className="w-8 h-8 mb-2" />
|
<Upload className="w-8 h-8 mb-2" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">Drop .txt or .md file here</span>
|
||||||
Drop .txt or .md file here
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -714,7 +678,7 @@ export function ContextView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{newFileType === "image" && (
|
{newFileType === 'image' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Upload Image</Label>
|
<Label>Upload Image</Label>
|
||||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
|
<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" />
|
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{uploadedImageData
|
{uploadedImageData ? 'Click to change' : 'Click to upload'}
|
||||||
? "Click to change"
|
|
||||||
: "Click to upload"}
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -754,9 +716,9 @@ export function ContextView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
setNewFileName("");
|
setNewFileName('');
|
||||||
setUploadedImageData(null);
|
setUploadedImageData(null);
|
||||||
setNewFileContent("");
|
setNewFileContent('');
|
||||||
setIsDropHovering(false);
|
setIsDropHovering(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -764,11 +726,8 @@ export function ContextView() {
|
|||||||
</Button>
|
</Button>
|
||||||
<HotkeyButton
|
<HotkeyButton
|
||||||
onClick={handleAddFile}
|
onClick={handleAddFile}
|
||||||
disabled={
|
disabled={!newFileName.trim() || (newFileType === 'image' && !uploadedImageData)}
|
||||||
!newFileName.trim() ||
|
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||||
(newFileType === "image" && !uploadedImageData)
|
|
||||||
}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={isAddDialogOpen}
|
hotkeyActive={isAddDialogOpen}
|
||||||
data-testid="confirm-add-file"
|
data-testid="confirm-add-file"
|
||||||
>
|
>
|
||||||
@@ -784,15 +743,11 @@ export function ContextView() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Context File</DialogTitle>
|
<DialogTitle>Delete Context File</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete "{selectedFile?.name}"? This
|
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
|
||||||
action cannot be undone.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsDeleteDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -812,9 +767,7 @@ export function ContextView() {
|
|||||||
<DialogContent data-testid="rename-context-dialog">
|
<DialogContent data-testid="rename-context-dialog">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Rename Context File</DialogTitle>
|
<DialogTitle>Rename Context File</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
|
||||||
Enter a new name for "{selectedFile?.name}".
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -826,7 +779,7 @@ export function ContextView() {
|
|||||||
placeholder="Enter new filename"
|
placeholder="Enter new filename"
|
||||||
data-testid="rename-file-input"
|
data-testid="rename-file-input"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && renameFileName.trim()) {
|
if (e.key === 'Enter' && renameFileName.trim()) {
|
||||||
handleRenameFile();
|
handleRenameFile();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -838,7 +791,7 @@ export function ContextView() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRenameDialogOpen(false);
|
setIsRenameDialogOpen(false);
|
||||||
setRenameFileName("");
|
setRenameFileName('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import { CheckCircle2, AlertCircle, Info, Terminal } from 'lucide-react';
|
||||||
CheckCircle2,
|
import type { ClaudeAuthStatus } from '@/store/setup-store';
|
||||||
AlertCircle,
|
|
||||||
Info,
|
|
||||||
Terminal,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ClaudeAuthStatus } from "@/store/setup-store";
|
|
||||||
|
|
||||||
interface AuthenticationStatusDisplayProps {
|
interface AuthenticationStatusDisplayProps {
|
||||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||||
@@ -39,35 +33,29 @@ export function AuthenticationStatusDisplay({
|
|||||||
<div className="p-3 rounded-lg bg-card border border-border">
|
<div className="p-3 rounded-lg bg-card border border-border">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<Terminal className="w-4 h-4 text-brand-500" />
|
<Terminal className="w-4 h-4 text-brand-500" />
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">Claude (Anthropic)</span>
|
||||||
Claude (Anthropic)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 text-xs min-h-12">
|
<div className="space-y-1.5 text-xs min-h-12">
|
||||||
{claudeAuthStatus?.authenticated ? (
|
{claudeAuthStatus?.authenticated ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
||||||
<span className="text-green-400 font-medium">
|
<span className="text-green-400 font-medium">Authenticated</span>
|
||||||
Authenticated
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
<Info className="w-3 h-3 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{claudeAuthStatus.method === "oauth_token"
|
{claudeAuthStatus.method === 'oauth_token'
|
||||||
? "Using stored OAuth token (subscription)"
|
? 'Using stored OAuth token (subscription)'
|
||||||
: claudeAuthStatus.method === "api_key_env"
|
: claudeAuthStatus.method === 'api_key_env'
|
||||||
? "Using ANTHROPIC_API_KEY"
|
? 'Using ANTHROPIC_API_KEY'
|
||||||
: claudeAuthStatus.method === "api_key"
|
: claudeAuthStatus.method === 'api_key'
|
||||||
? "Using stored API key"
|
? 'Using stored API key'
|
||||||
: claudeAuthStatus.method === "credentials_file"
|
: claudeAuthStatus.method === 'credentials_file'
|
||||||
? "Using credentials file"
|
? 'Using credentials file'
|
||||||
: claudeAuthStatus.method === "cli_authenticated"
|
: claudeAuthStatus.method === 'cli_authenticated'
|
||||||
? "Using Claude CLI authentication"
|
? 'Using Claude CLI authentication'
|
||||||
: `Using ${
|
: `Using ${claudeAuthStatus.method || 'detected'} authentication`}
|
||||||
claudeAuthStatus.method || "detected"
|
|
||||||
} authentication`}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useApiKeyManagement } from './use-api-key-management';
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AppearanceSection } from './appearance-section';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AudioSection } from './audio-section';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ClaudeCliStatus } from './claude-cli-status';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Folder, Trash2 } from "lucide-react";
|
import { Folder } from 'lucide-react';
|
||||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||||
import type { Project } from "@/lib/electron";
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
interface DeleteProjectDialogProps {
|
interface DeleteProjectDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -39,18 +39,13 @@ export function DeleteProjectDialog({
|
|||||||
<Folder className="w-5 h-5 text-brand-500" />
|
<Folder className="w-5 h-5 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-foreground truncate">
|
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||||
{project.name}
|
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
|
||||||
{project.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The folder will remain on disk until you permanently delete it from
|
The folder will remain on disk until you permanently delete it from Trash.
|
||||||
Trash.
|
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { NAV_ITEMS } from './navigation';
|
||||||
|
export type { NavigationItem } from './navigation';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DangerZoneSection } from './danger-zone-section';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FeatureDefaultsSection } from './feature-defaults-section';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { KeyboardShortcutsSection } from './keyboard-shortcuts-section';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { Theme } from '@/config/theme-options';
|
||||||
|
export type { CliStatus, KanbanDetailLevel, Project, ApiKeys } from './types';
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Input } from '@/components/ui/input';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Label } from '@/components/ui/label';
|
||||||
import { Label } from "@/components/ui/label";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from '@/components/ui/accordion';
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -31,14 +24,13 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Download,
|
Download,
|
||||||
Info,
|
Info,
|
||||||
AlertTriangle,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
XCircle,
|
XCircle,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { toast } from "sonner";
|
import { toast } from 'sonner';
|
||||||
import { StatusBadge, TerminalOutput } from "../components";
|
import { StatusBadge, TerminalOutput } from '../components';
|
||||||
import { useCliStatus, useCliInstallation, useTokenSave } from "../hooks";
|
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
|
||||||
|
|
||||||
interface ClaudeSetupStepProps {
|
interface ClaudeSetupStepProps {
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
@@ -46,17 +38,13 @@ interface ClaudeSetupStepProps {
|
|||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VerificationStatus = "idle" | "verifying" | "verified" | "error";
|
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
|
||||||
|
|
||||||
// Claude Setup Step
|
// Claude Setup Step
|
||||||
// Users can either:
|
// Users can either:
|
||||||
// 1. Have Claude CLI installed and authenticated (verified by running a test query)
|
// 1. Have Claude CLI installed and authenticated (verified by running a test query)
|
||||||
// 2. Provide an Anthropic API key manually
|
// 2. Provide an Anthropic API key manually
|
||||||
export function ClaudeSetupStep({
|
export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps) {
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
onSkip,
|
|
||||||
}: ClaudeSetupStepProps) {
|
|
||||||
const {
|
const {
|
||||||
claudeCliStatus,
|
claudeCliStatus,
|
||||||
claudeAuthStatus,
|
claudeAuthStatus,
|
||||||
@@ -66,21 +54,16 @@ export function ClaudeSetupStep({
|
|||||||
} = useSetupStore();
|
} = useSetupStore();
|
||||||
const { setApiKeys, apiKeys } = useAppStore();
|
const { setApiKeys, apiKeys } = useAppStore();
|
||||||
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
|
||||||
// CLI Verification state
|
// CLI Verification state
|
||||||
const [cliVerificationStatus, setCliVerificationStatus] =
|
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
|
||||||
useState<VerificationStatus>("idle");
|
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
|
||||||
const [cliVerificationError, setCliVerificationError] = useState<
|
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
// API Key Verification state
|
// API Key Verification state
|
||||||
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
|
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
|
||||||
useState<VerificationStatus>("idle");
|
useState<VerificationStatus>('idle');
|
||||||
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<
|
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(null);
|
||||||
string | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
// Delete API Key state
|
// Delete API Key state
|
||||||
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
|
const [isDeletingApiKey, setIsDeletingApiKey] = useState(false);
|
||||||
@@ -96,14 +79,11 @@ export function ClaudeSetupStep({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getStoreState = useCallback(
|
const getStoreState = useCallback(() => useSetupStore.getState().claudeCliStatus, []);
|
||||||
() => useSetupStore.getState().claudeCliStatus,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hooks
|
// Use custom hooks
|
||||||
const { isChecking, checkStatus } = useCliStatus({
|
const { isChecking, checkStatus } = useCliStatus({
|
||||||
cliType: "claude",
|
cliType: 'claude',
|
||||||
statusApi,
|
statusApi,
|
||||||
setCliStatus: setClaudeCliStatus,
|
setCliStatus: setClaudeCliStatus,
|
||||||
setAuthStatus: setClaudeAuthStatus,
|
setAuthStatus: setClaudeAuthStatus,
|
||||||
@@ -114,120 +94,114 @@ export function ClaudeSetupStep({
|
|||||||
}, [checkStatus]);
|
}, [checkStatus]);
|
||||||
|
|
||||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
const { isInstalling, installProgress, install } = useCliInstallation({
|
||||||
cliType: "claude",
|
cliType: 'claude',
|
||||||
installApi,
|
installApi,
|
||||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
||||||
onSuccess: onInstallSuccess,
|
onSuccess: onInstallSuccess,
|
||||||
getStoreState,
|
getStoreState,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave(
|
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
||||||
{
|
provider: 'anthropic',
|
||||||
provider: "anthropic",
|
onSuccess: () => {
|
||||||
onSuccess: () => {
|
setClaudeAuthStatus({
|
||||||
setClaudeAuthStatus({
|
authenticated: true,
|
||||||
authenticated: true,
|
method: 'api_key',
|
||||||
method: "api_key",
|
hasCredentialsFile: false,
|
||||||
hasCredentialsFile: false,
|
apiKeyValid: true,
|
||||||
apiKeyValid: true,
|
});
|
||||||
});
|
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
||||||
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
toast.success('API key saved successfully!');
|
||||||
toast.success("API key saved successfully!");
|
},
|
||||||
},
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify CLI authentication by running a test query (uses CLI credentials only, not API key)
|
// Verify CLI authentication by running a test query (uses CLI credentials only, not API key)
|
||||||
const verifyCliAuth = useCallback(async () => {
|
const verifyCliAuth = useCallback(async () => {
|
||||||
setCliVerificationStatus("verifying");
|
setCliVerificationStatus('verifying');
|
||||||
setCliVerificationError(null);
|
setCliVerificationError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.setup?.verifyClaudeAuth) {
|
if (!api.setup?.verifyClaudeAuth) {
|
||||||
setCliVerificationStatus("error");
|
setCliVerificationStatus('error');
|
||||||
setCliVerificationError("Verification API not available");
|
setCliVerificationError('Verification API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass "cli" to verify CLI authentication only (ignores any API key)
|
// 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
|
// Check for "Limit reached" error - treat as unverified
|
||||||
const hasLimitReachedError =
|
const hasLimitReachedError =
|
||||||
result.error?.toLowerCase().includes("limit reached") ||
|
result.error?.toLowerCase().includes('limit reached') ||
|
||||||
result.error?.toLowerCase().includes("rate limit");
|
result.error?.toLowerCase().includes('rate limit');
|
||||||
|
|
||||||
if (result.authenticated && !hasLimitReachedError) {
|
if (result.authenticated && !hasLimitReachedError) {
|
||||||
setCliVerificationStatus("verified");
|
setCliVerificationStatus('verified');
|
||||||
setClaudeAuthStatus({
|
setClaudeAuthStatus({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
method: "cli_authenticated",
|
method: 'cli_authenticated',
|
||||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||||
});
|
});
|
||||||
toast.success("Claude CLI authentication verified!");
|
toast.success('Claude CLI authentication verified!');
|
||||||
} else {
|
} else {
|
||||||
setCliVerificationStatus("error");
|
setCliVerificationStatus('error');
|
||||||
setCliVerificationError(
|
setCliVerificationError(
|
||||||
hasLimitReachedError
|
hasLimitReachedError
|
||||||
? "Rate limit reached. Please try again later."
|
? 'Rate limit reached. Please try again later.'
|
||||||
: result.error || "Authentication failed"
|
: result.error || 'Authentication failed'
|
||||||
);
|
);
|
||||||
setClaudeAuthStatus({
|
setClaudeAuthStatus({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
method: "none",
|
method: 'none',
|
||||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||||
error instanceof Error ? error.message : "Verification failed";
|
|
||||||
// Also check for limit reached in caught errors
|
// Also check for limit reached in caught errors
|
||||||
const isLimitError =
|
const isLimitError =
|
||||||
errorMessage.toLowerCase().includes("limit reached") ||
|
errorMessage.toLowerCase().includes('limit reached') ||
|
||||||
errorMessage.toLowerCase().includes("rate limit");
|
errorMessage.toLowerCase().includes('rate limit');
|
||||||
setCliVerificationStatus("error");
|
setCliVerificationStatus('error');
|
||||||
setCliVerificationError(
|
setCliVerificationError(
|
||||||
isLimitError
|
isLimitError ? 'Rate limit reached. Please try again later.' : errorMessage
|
||||||
? "Rate limit reached. Please try again later."
|
|
||||||
: errorMessage
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [claudeAuthStatus, setClaudeAuthStatus]);
|
}, [claudeAuthStatus, setClaudeAuthStatus]);
|
||||||
|
|
||||||
// Verify API Key authentication (uses API key only)
|
// Verify API Key authentication (uses API key only)
|
||||||
const verifyApiKeyAuth = useCallback(async () => {
|
const verifyApiKeyAuth = useCallback(async () => {
|
||||||
setApiKeyVerificationStatus("verifying");
|
setApiKeyVerificationStatus('verifying');
|
||||||
setApiKeyVerificationError(null);
|
setApiKeyVerificationError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.setup?.verifyClaudeAuth) {
|
if (!api.setup?.verifyClaudeAuth) {
|
||||||
setApiKeyVerificationStatus("error");
|
setApiKeyVerificationStatus('error');
|
||||||
setApiKeyVerificationError("Verification API not available");
|
setApiKeyVerificationError('Verification API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass "api_key" to verify API key authentication only
|
// 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) {
|
if (result.authenticated) {
|
||||||
setApiKeyVerificationStatus("verified");
|
setApiKeyVerificationStatus('verified');
|
||||||
setClaudeAuthStatus({
|
setClaudeAuthStatus({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
method: "api_key",
|
method: 'api_key',
|
||||||
hasCredentialsFile: false,
|
hasCredentialsFile: false,
|
||||||
apiKeyValid: true,
|
apiKeyValid: true,
|
||||||
});
|
});
|
||||||
toast.success("API key authentication verified!");
|
toast.success('API key authentication verified!');
|
||||||
} else {
|
} else {
|
||||||
setApiKeyVerificationStatus("error");
|
setApiKeyVerificationStatus('error');
|
||||||
setApiKeyVerificationError(result.error || "Authentication failed");
|
setApiKeyVerificationError(result.error || 'Authentication failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage = error instanceof Error ? error.message : 'Verification failed';
|
||||||
error instanceof Error ? error.message : "Verification failed";
|
setApiKeyVerificationStatus('error');
|
||||||
setApiKeyVerificationStatus("error");
|
|
||||||
setApiKeyVerificationError(errorMessage);
|
setApiKeyVerificationError(errorMessage);
|
||||||
}
|
}
|
||||||
}, [setClaudeAuthStatus]);
|
}, [setClaudeAuthStatus]);
|
||||||
@@ -238,29 +212,28 @@ export function ClaudeSetupStep({
|
|||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
if (!api.setup?.deleteApiKey) {
|
if (!api.setup?.deleteApiKey) {
|
||||||
toast.error("Delete API not available");
|
toast.error('Delete API not available');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.setup.deleteApiKey("anthropic");
|
const result = await api.setup.deleteApiKey('anthropic');
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Clear local state
|
// Clear local state
|
||||||
setApiKey("");
|
setApiKey('');
|
||||||
setApiKeys({ ...apiKeys, anthropic: "" });
|
setApiKeys({ ...apiKeys, anthropic: '' });
|
||||||
setApiKeyVerificationStatus("idle");
|
setApiKeyVerificationStatus('idle');
|
||||||
setApiKeyVerificationError(null);
|
setApiKeyVerificationError(null);
|
||||||
setClaudeAuthStatus({
|
setClaudeAuthStatus({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
method: "none",
|
method: 'none',
|
||||||
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
|
||||||
});
|
});
|
||||||
toast.success("API key deleted successfully");
|
toast.success('API key deleted successfully');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Failed to delete API key");
|
toast.error(result.error || 'Failed to delete API key');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key';
|
||||||
error instanceof Error ? error.message : "Failed to delete API key";
|
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeletingApiKey(false);
|
setIsDeletingApiKey(false);
|
||||||
@@ -282,30 +255,30 @@ export function ClaudeSetupStep({
|
|||||||
|
|
||||||
const copyCommand = (command: string) => {
|
const copyCommand = (command: string) => {
|
||||||
navigator.clipboard.writeText(command);
|
navigator.clipboard.writeText(command);
|
||||||
toast.success("Command copied to clipboard");
|
toast.success('Command copied to clipboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
// User is ready if either method is verified
|
// User is ready if either method is verified
|
||||||
const hasApiKey =
|
const hasApiKey =
|
||||||
!!apiKeys.anthropic ||
|
!!apiKeys.anthropic ||
|
||||||
claudeAuthStatus?.method === "api_key" ||
|
claudeAuthStatus?.method === 'api_key' ||
|
||||||
claudeAuthStatus?.method === "api_key_env";
|
claudeAuthStatus?.method === 'api_key_env';
|
||||||
const isCliVerified = cliVerificationStatus === "verified";
|
const isCliVerified = cliVerificationStatus === 'verified';
|
||||||
const isApiKeyVerified = apiKeyVerificationStatus === "verified";
|
const isApiKeyVerified = apiKeyVerificationStatus === 'verified';
|
||||||
const isReady = isCliVerified || isApiKeyVerified;
|
const isReady = isCliVerified || isApiKeyVerified;
|
||||||
|
|
||||||
const getAuthMethodLabel = () => {
|
const getAuthMethodLabel = () => {
|
||||||
if (isApiKeyVerified) return "API Key";
|
if (isApiKeyVerified) return 'API Key';
|
||||||
if (isCliVerified) return "Claude CLI";
|
if (isCliVerified) return 'Claude CLI';
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to get status badge for CLI
|
// Helper to get status badge for CLI
|
||||||
const getCliStatusBadge = () => {
|
const getCliStatusBadge = () => {
|
||||||
if (cliVerificationStatus === "verified") {
|
if (cliVerificationStatus === 'verified') {
|
||||||
return <StatusBadge status="authenticated" label="Verified" />;
|
return <StatusBadge status="authenticated" label="Verified" />;
|
||||||
}
|
}
|
||||||
if (cliVerificationStatus === "error") {
|
if (cliVerificationStatus === 'error') {
|
||||||
return <StatusBadge status="error" label="Error" />;
|
return <StatusBadge status="error" label="Error" />;
|
||||||
}
|
}
|
||||||
if (isChecking) {
|
if (isChecking) {
|
||||||
@@ -320,10 +293,10 @@ export function ClaudeSetupStep({
|
|||||||
|
|
||||||
// Helper to get status badge for API Key
|
// Helper to get status badge for API Key
|
||||||
const getApiKeyStatusBadge = () => {
|
const getApiKeyStatusBadge = () => {
|
||||||
if (apiKeyVerificationStatus === "verified") {
|
if (apiKeyVerificationStatus === 'verified') {
|
||||||
return <StatusBadge status="authenticated" label="Verified" />;
|
return <StatusBadge status="authenticated" label="Verified" />;
|
||||||
}
|
}
|
||||||
if (apiKeyVerificationStatus === "error") {
|
if (apiKeyVerificationStatus === 'error') {
|
||||||
return <StatusBadge status="error" label="Error" />;
|
return <StatusBadge status="error" label="Error" />;
|
||||||
}
|
}
|
||||||
if (hasApiKey) {
|
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">
|
<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" />
|
<Terminal className="w-8 h-8 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
<h2 className="text-2xl font-bold text-foreground mb-2">API Key Setup</h2>
|
||||||
API Key Setup
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">Configure for code generation</p>
|
<p className="text-muted-foreground">Configure for code generation</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,15 +324,8 @@ export function ClaudeSetupStep({
|
|||||||
<Info className="w-5 h-5" />
|
<Info className="w-5 h-5" />
|
||||||
Authentication Methods
|
Authentication Methods
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||||
variant="ghost"
|
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||||
size="sm"
|
|
||||||
onClick={checkStatus}
|
|
||||||
disabled={isChecking}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -377,16 +341,14 @@ export function ClaudeSetupStep({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Terminal
|
<Terminal
|
||||||
className={`w-5 h-5 ${
|
className={`w-5 h-5 ${
|
||||||
cliVerificationStatus === "verified"
|
cliVerificationStatus === 'verified'
|
||||||
? "text-green-500"
|
? 'text-green-500'
|
||||||
: "text-muted-foreground"
|
: 'text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-foreground">Claude CLI</p>
|
<p className="font-medium text-foreground">Claude CLI</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Use Claude Code subscription</p>
|
||||||
Use Claude Code subscription
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{getCliStatusBadge()}
|
{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="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="w-4 h-4 text-muted-foreground" />
|
<Download className="w-4 h-4 text-muted-foreground" />
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">Install Claude CLI</p>
|
||||||
Install Claude CLI
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm text-muted-foreground">
|
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
|
||||||
macOS / Linux
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
<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
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
@@ -415,9 +373,7 @@ export function ClaudeSetupStep({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyCommand(
|
copyCommand('curl -fsSL https://claude.ai/install.sh | bash')
|
||||||
"curl -fsSL https://claude.ai/install.sh | bash"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
@@ -426,9 +382,7 @@ export function ClaudeSetupStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm text-muted-foreground">
|
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||||
Windows
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||||
irm https://claude.ai/install.ps1 | iex
|
irm https://claude.ai/install.ps1 | iex
|
||||||
@@ -436,20 +390,14 @@ export function ClaudeSetupStep({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() =>
|
onClick={() => copyCommand('irm https://claude.ai/install.ps1 | iex')}
|
||||||
copyCommand(
|
|
||||||
"irm https://claude.ai/install.ps1 | iex"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isInstalling && (
|
{isInstalling && <TerminalOutput lines={installProgress.output} />}
|
||||||
<TerminalOutput lines={installProgress.output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={install}
|
onClick={install}
|
||||||
@@ -480,27 +428,21 @@ export function ClaudeSetupStep({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CLI Verification Status */}
|
{/* 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">
|
<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" />
|
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
|
||||||
Verifying CLI authentication...
|
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Running a test query
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">CLI Authentication verified!</p>
|
||||||
CLI Authentication verified!
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Your Claude CLI is working correctly.
|
Your Claude CLI is working correctly.
|
||||||
</p>
|
</p>
|
||||||
@@ -508,17 +450,13 @@ export function ClaudeSetupStep({
|
|||||||
</div>
|
</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">
|
<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" />
|
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">Verification failed</p>
|
||||||
Verification failed
|
<p className="text-sm text-red-400 mt-1">{cliVerificationError}</p>
|
||||||
</p>
|
{cliVerificationError.includes('login') && (
|
||||||
<p className="text-sm text-red-400 mt-1">
|
|
||||||
{cliVerificationError}
|
|
||||||
</p>
|
|
||||||
{cliVerificationError.includes("login") && (
|
|
||||||
<div className="mt-3 p-3 rounded bg-muted/50">
|
<div className="mt-3 p-3 rounded bg-muted/50">
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
Run this command in your terminal:
|
Run this command in your terminal:
|
||||||
@@ -530,7 +468,7 @@ export function ClaudeSetupStep({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => copyCommand("claude login")}
|
onClick={() => copyCommand('claude login')}
|
||||||
>
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -542,22 +480,19 @@ export function ClaudeSetupStep({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CLI Verify Button - Hide if CLI is verified */}
|
{/* CLI Verify Button - Hide if CLI is verified */}
|
||||||
{cliVerificationStatus !== "verified" && (
|
{cliVerificationStatus !== 'verified' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={verifyCliAuth}
|
onClick={verifyCliAuth}
|
||||||
disabled={
|
disabled={cliVerificationStatus === 'verifying' || !claudeCliStatus?.installed}
|
||||||
cliVerificationStatus === "verifying" ||
|
|
||||||
!claudeCliStatus?.installed
|
|
||||||
}
|
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||||
data-testid="verify-cli-button"
|
data-testid="verify-cli-button"
|
||||||
>
|
>
|
||||||
{cliVerificationStatus === "verifying" ? (
|
{cliVerificationStatus === 'verifying' ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Verifying...
|
Verifying...
|
||||||
</>
|
</>
|
||||||
) : cliVerificationStatus === "error" ? (
|
) : cliVerificationStatus === 'error' ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Retry Verification
|
Retry Verification
|
||||||
@@ -580,15 +515,13 @@ export function ClaudeSetupStep({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Key
|
<Key
|
||||||
className={`w-5 h-5 ${
|
className={`w-5 h-5 ${
|
||||||
apiKeyVerificationStatus === "verified"
|
apiKeyVerificationStatus === 'verified'
|
||||||
? "text-green-500"
|
? 'text-green-500'
|
||||||
: "text-muted-foreground"
|
: 'text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">Anthropic API Key</p>
|
||||||
Anthropic API Key
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Pay-per-use with your own API key
|
Pay-per-use with your own API key
|
||||||
</p>
|
</p>
|
||||||
@@ -614,7 +547,7 @@ export function ClaudeSetupStep({
|
|||||||
data-testid="anthropic-api-key-input"
|
data-testid="anthropic-api-key-input"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Don't have an API key?{" "}
|
Don't have an API key?{' '}
|
||||||
<a
|
<a
|
||||||
href="https://console.anthropic.com/settings/keys"
|
href="https://console.anthropic.com/settings/keys"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -640,7 +573,7 @@ export function ClaudeSetupStep({
|
|||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Save API Key"
|
'Save API Key'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{hasApiKey && (
|
{hasApiKey && (
|
||||||
@@ -662,27 +595,21 @@ export function ClaudeSetupStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* API Key Verification Status */}
|
{/* 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">
|
<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" />
|
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">Verifying API key...</p>
|
||||||
Verifying API key...
|
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Running a test query
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-medium text-foreground">API Key verified!</p>
|
||||||
API Key verified!
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Your API key is working correctly.
|
Your API key is working correctly.
|
||||||
</p>
|
</p>
|
||||||
@@ -690,37 +617,30 @@ export function ClaudeSetupStep({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{apiKeyVerificationStatus === "error" &&
|
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
|
||||||
apiKeyVerificationError && (
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||||
<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" />
|
||||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<p className="font-medium text-foreground">Verification failed</p>
|
||||||
<p className="font-medium text-foreground">
|
<p className="text-sm text-red-400 mt-1">{apiKeyVerificationError}</p>
|
||||||
Verification failed
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-400 mt-1">
|
|
||||||
{apiKeyVerificationError}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* API Key Verify Button - Hide if API key is verified */}
|
{/* API Key Verify Button - Hide if API key is verified */}
|
||||||
{apiKeyVerificationStatus !== "verified" && (
|
{apiKeyVerificationStatus !== 'verified' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={verifyApiKeyAuth}
|
onClick={verifyApiKeyAuth}
|
||||||
disabled={
|
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
|
||||||
apiKeyVerificationStatus === "verifying" || !hasApiKey
|
|
||||||
}
|
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||||
data-testid="verify-api-key-button"
|
data-testid="verify-api-key-button"
|
||||||
>
|
>
|
||||||
{apiKeyVerificationStatus === "verifying" ? (
|
{apiKeyVerificationStatus === 'verifying' ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Verifying...
|
Verifying...
|
||||||
</>
|
</>
|
||||||
) : apiKeyVerificationStatus === "error" ? (
|
) : apiKeyVerificationStatus === 'error' ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Retry Verification
|
Retry Verification
|
||||||
@@ -741,20 +661,12 @@ export function ClaudeSetupStep({
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<Button
|
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
|
||||||
variant="ghost"
|
|
||||||
onClick={onBack}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
|
||||||
variant="ghost"
|
|
||||||
onClick={onSkip}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
Skip for now
|
Skip for now
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,40 +1,28 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { CheckCircle2, Sparkles } from 'lucide-react';
|
||||||
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
interface CompleteStepProps {
|
interface CompleteStepProps {
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||||
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
|
|
||||||
const { apiKeys } = useAppStore();
|
|
||||||
|
|
||||||
const claudeReady =
|
|
||||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
|
||||||
apiKeys.anthropic;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center space-y-6">
|
<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" />
|
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
<h2 className="text-3xl font-bold text-foreground mb-3">Setup Complete!</h2>
|
||||||
Setup Complete!
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
Your development environment is configured. You're ready to start
|
Your development environment is configured. You're ready to start building with
|
||||||
building with AI-powered assistance.
|
AI-powered assistance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
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}
|
onClick={onFinish}
|
||||||
data-testid="setup-finish-button"
|
data-testid="setup-finish-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { Terminal, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface WelcomeStepProps {
|
interface WelcomeStepProps {
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
@@ -10,17 +9,14 @@ export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center space-y-6">
|
<div className="text-center space-y-6">
|
||||||
<div className="flex items-center justify-center mx-auto">
|
<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" />
|
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
<h2 className="text-3xl font-bold text-foreground mb-3">Welcome to Automaker</h2>
|
||||||
Welcome to Automaker
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
<p className="text-muted-foreground max-w-md mx-auto">
|
||||||
To get started, we'll need to verify either claude code cli is
|
To get started, we'll need to verify either claude code cli is installed or you have
|
||||||
installed or you have Anthropic api keys
|
Anthropic api keys
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
||||||
import {
|
import {
|
||||||
Terminal as TerminalIcon,
|
Terminal as TerminalIcon,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -12,17 +11,13 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
X,
|
X,
|
||||||
SquarePlus,
|
SquarePlus,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store";
|
import { useAppStore, type TerminalPanelContent, type TerminalTab } from '@/store/app-store';
|
||||||
import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
Panel,
|
import { TerminalPanel } from './terminal-view/terminal-panel';
|
||||||
PanelGroup,
|
|
||||||
PanelResizeHandle,
|
|
||||||
} from "react-resizable-panels";
|
|
||||||
import { TerminalPanel } from "./terminal-view/terminal-panel";
|
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -34,8 +29,8 @@ import {
|
|||||||
closestCenter,
|
closestCenter,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
} from "@dnd-kit/core";
|
} from '@dnd-kit/core';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface TerminalStatus {
|
interface TerminalStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -64,18 +59,18 @@ function TerminalTabButton({
|
|||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: `tab-${tab.id}`,
|
id: `tab-${tab.id}`,
|
||||||
data: { type: "tab", tabId: tab.id },
|
data: { type: 'tab', tabId: tab.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "bg-background border-brand-500 text-foreground"
|
? 'bg-background border-brand-500 text-foreground'
|
||||||
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
|
: 'bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||||
isOver && isDropTarget && "ring-2 ring-green-500"
|
isOver && isDropTarget && 'ring-2 ring-green-500'
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@@ -97,18 +92,18 @@ function TerminalTabButton({
|
|||||||
// New tab drop zone
|
// New tab drop zone
|
||||||
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
|
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: "new-tab-zone",
|
id: 'new-tab-zone',
|
||||||
data: { type: "new-tab" },
|
data: { type: 'new-tab' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
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
|
isOver && isDropTarget
|
||||||
? "border-green-500 bg-green-500/10 text-green-500"
|
? 'border-green-500 bg-green-500/10 text-green-500'
|
||||||
: "border-transparent text-muted-foreground hover:border-border"
|
: 'border-transparent text-muted-foreground hover:border-border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SquarePlus className="h-4 w-4" />
|
<SquarePlus className="h-4 w-4" />
|
||||||
@@ -135,7 +130,7 @@ export function TerminalView() {
|
|||||||
const [status, setStatus] = useState<TerminalStatus | null>(null);
|
const [status, setStatus] = useState<TerminalStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState('');
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||||
@@ -143,7 +138,7 @@ export function TerminalView() {
|
|||||||
const lastCreateTimeRef = useRef<number>(0);
|
const lastCreateTimeRef = useRef<number>(0);
|
||||||
const isCreatingRef = useRef<boolean>(false);
|
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
|
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
|
||||||
|
|
||||||
// Helper to check if terminal creation should be debounced
|
// Helper to check if terminal creation should be debounced
|
||||||
@@ -159,7 +154,7 @@ export function TerminalView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get active tab
|
// 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
|
// DnD sensors with activation constraint to avoid accidental drags
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -178,43 +173,46 @@ export function TerminalView() {
|
|||||||
// Handle drag over - track which tab we're hovering
|
// Handle drag over - track which tab we're hovering
|
||||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
const { over } = event;
|
const { over } = event;
|
||||||
if (over?.data?.current?.type === "tab") {
|
if (over?.data?.current?.type === 'tab') {
|
||||||
setDragOverTabId(over.data.current.tabId);
|
setDragOverTabId(over.data.current.tabId);
|
||||||
} else if (over?.data?.current?.type === "new-tab") {
|
} else if (over?.data?.current?.type === 'new-tab') {
|
||||||
setDragOverTabId("new");
|
setDragOverTabId('new');
|
||||||
} else {
|
} else {
|
||||||
setDragOverTabId(null);
|
setDragOverTabId(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle drag end
|
// Handle drag end
|
||||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
const handleDragEnd = useCallback(
|
||||||
const { active, over } = event;
|
(event: DragEndEvent) => {
|
||||||
setActiveDragId(null);
|
const { active, over } = event;
|
||||||
setDragOverTabId(null);
|
setActiveDragId(null);
|
||||||
|
setDragOverTabId(null);
|
||||||
|
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
const activeId = active.id as string;
|
const activeId = active.id as string;
|
||||||
const overData = over.data?.current;
|
const overData = over.data?.current;
|
||||||
|
|
||||||
// If dropped on a tab, move terminal to that tab
|
// If dropped on a tab, move terminal to that tab
|
||||||
if (overData?.type === "tab") {
|
if (overData?.type === 'tab') {
|
||||||
moveTerminalToTab(activeId, overData.tabId);
|
moveTerminalToTab(activeId, overData.tabId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If dropped on new tab zone, create new tab with this terminal
|
// If dropped on new tab zone, create new tab with this terminal
|
||||||
if (overData?.type === "new-tab") {
|
if (overData?.type === 'new-tab') {
|
||||||
moveTerminalToTab(activeId, "new");
|
moveTerminalToTab(activeId, 'new');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, swap terminals within current tab
|
// Otherwise, swap terminals within current tab
|
||||||
if (active.id !== over.id) {
|
if (active.id !== over.id) {
|
||||||
swapTerminals(activeId, over.id as string);
|
swapTerminals(activeId, over.id as string);
|
||||||
}
|
}
|
||||||
}, [swapTerminals, moveTerminalToTab]);
|
},
|
||||||
|
[swapTerminals, moveTerminalToTab]
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch terminal status
|
// Fetch terminal status
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
@@ -229,11 +227,11 @@ export function TerminalView() {
|
|||||||
setTerminalUnlocked(true);
|
setTerminalUnlocked(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(data.error || "Failed to get terminal status");
|
setError(data.error || 'Failed to get terminal status');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to connect to server");
|
setError('Failed to connect to server');
|
||||||
console.error("[Terminal] Status fetch error:", err);
|
console.error('[Terminal] Status fetch error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -251,21 +249,21 @@ export function TerminalView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
|
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setTerminalUnlocked(true, data.data.token);
|
setTerminalUnlocked(true, data.data.token);
|
||||||
setPassword("");
|
setPassword('');
|
||||||
} else {
|
} else {
|
||||||
setAuthError(data.error || "Authentication failed");
|
setAuthError(data.error || 'Authentication failed');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAuthError("Failed to authenticate");
|
setAuthError('Failed to authenticate');
|
||||||
console.error("[Terminal] Auth error:", err);
|
console.error('[Terminal] Auth error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setAuthLoading(false);
|
setAuthLoading(false);
|
||||||
}
|
}
|
||||||
@@ -273,21 +271,24 @@ export function TerminalView() {
|
|||||||
|
|
||||||
// Create a new terminal session
|
// Create a new terminal session
|
||||||
// targetSessionId: the terminal to split (if splitting an existing terminal)
|
// targetSessionId: the terminal to split (if splitting an existing terminal)
|
||||||
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
|
const createTerminal = async (
|
||||||
if (!canCreateTerminal("[Terminal] Debounced terminal creation")) {
|
direction?: 'horizontal' | 'vertical',
|
||||||
|
targetSessionId?: string
|
||||||
|
) => {
|
||||||
|
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers["X-Terminal-Token"] = terminalState.authToken;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
cwd: currentProject?.path || undefined,
|
cwd: currentProject?.path || undefined,
|
||||||
@@ -300,10 +301,10 @@ export function TerminalView() {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
addTerminalToLayout(data.data.id, direction, targetSessionId);
|
addTerminalToLayout(data.data.id, direction, targetSessionId);
|
||||||
} else {
|
} else {
|
||||||
console.error("[Terminal] Failed to create session:", data.error);
|
console.error('[Terminal] Failed to create session:', data.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Terminal] Create session error:", err);
|
console.error('[Terminal] Create session error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
isCreatingRef.current = false;
|
isCreatingRef.current = false;
|
||||||
}
|
}
|
||||||
@@ -311,21 +312,21 @@ export function TerminalView() {
|
|||||||
|
|
||||||
// Create terminal in new tab
|
// Create terminal in new tab
|
||||||
const createTerminalInNewTab = async () => {
|
const createTerminalInNewTab = async () => {
|
||||||
if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
|
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabId = addTerminalTab();
|
const tabId = addTerminalTab();
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers["X-Terminal-Token"] = terminalState.authToken;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
cwd: currentProject?.path || undefined,
|
cwd: currentProject?.path || undefined,
|
||||||
@@ -341,7 +342,7 @@ export function TerminalView() {
|
|||||||
addTerminalToTab(data.data.id, tabId);
|
addTerminalToTab(data.data.id, tabId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Terminal] Create session error:", err);
|
console.error('[Terminal] Create session error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
isCreatingRef.current = false;
|
isCreatingRef.current = false;
|
||||||
}
|
}
|
||||||
@@ -352,16 +353,16 @@ export function TerminalView() {
|
|||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (terminalState.authToken) {
|
if (terminalState.authToken) {
|
||||||
headers["X-Terminal-Token"] = terminalState.authToken;
|
headers['X-Terminal-Token'] = terminalState.authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||||
method: "DELETE",
|
method: 'DELETE',
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
removeTerminalFromLayout(sessionId);
|
removeTerminalFromLayout(sessionId);
|
||||||
} catch (err) {
|
} 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 shiftMatches = needsShift ? e.shiftKey : !e.shiftKey;
|
||||||
const altMatches = needsAlt ? e.altKey : !e.altKey;
|
const altMatches = needsAlt ? e.altKey : !e.altKey;
|
||||||
|
|
||||||
return (
|
return e.key.toLowerCase() === key && cmdMatches && shiftMatches && altMatches;
|
||||||
e.key.toLowerCase() === key &&
|
|
||||||
cmdMatches &&
|
|
||||||
shiftMatches &&
|
|
||||||
altMatches
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Split terminal right (Cmd+D / Ctrl+D)
|
// Split terminal right (Cmd+D / Ctrl+D)
|
||||||
if (matchesShortcut(shortcuts.splitTerminalRight)) {
|
if (matchesShortcut(shortcuts.splitTerminalRight)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createTerminal("horizontal", terminalState.activeSessionId);
|
createTerminal('horizontal', terminalState.activeSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
|
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
|
||||||
if (matchesShortcut(shortcuts.splitTerminalDown)) {
|
if (matchesShortcut(shortcuts.splitTerminalDown)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createTerminal("vertical", terminalState.activeSessionId);
|
createTerminal('vertical', terminalState.activeSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +423,7 @@ export function TerminalView() {
|
|||||||
|
|
||||||
// Collect all terminal IDs from a panel tree in order
|
// Collect all terminal IDs from a panel tree in order
|
||||||
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
|
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
|
||||||
if (panel.type === "terminal") {
|
if (panel.type === 'terminal') {
|
||||||
return [panel.sessionId];
|
return [panel.sessionId];
|
||||||
}
|
}
|
||||||
return panel.panels.flatMap(getTerminalIds);
|
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
|
// Get a STABLE key for a panel - based only on terminal IDs, not tree structure
|
||||||
// This prevents unnecessary remounts when layout structure changes
|
// This prevents unnecessary remounts when layout structure changes
|
||||||
const getPanelKey = (panel: TerminalPanelContent): string => {
|
const getPanelKey = (panel: TerminalPanelContent): string => {
|
||||||
if (panel.type === "terminal") {
|
if (panel.type === 'terminal') {
|
||||||
return panel.sessionId;
|
return panel.sessionId;
|
||||||
}
|
}
|
||||||
// Use joined terminal IDs - stable regardless of nesting depth
|
// Use joined terminal IDs - stable regardless of nesting depth
|
||||||
return `group-${getTerminalIds(panel).join("-")}`;
|
return `group-${getTerminalIds(panel).join('-')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render panel content recursively
|
// Render panel content recursively
|
||||||
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
|
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
|
||||||
if (content.type === "terminal") {
|
if (content.type === 'terminal') {
|
||||||
// Use per-terminal fontSize or fall back to default
|
// Use per-terminal fontSize or fall back to default
|
||||||
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
||||||
return (
|
return (
|
||||||
@@ -456,8 +452,8 @@ export function TerminalView() {
|
|||||||
isActive={terminalState.activeSessionId === content.sessionId}
|
isActive={terminalState.activeSessionId === content.sessionId}
|
||||||
onFocus={() => setActiveTerminalSession(content.sessionId)}
|
onFocus={() => setActiveTerminalSession(content.sessionId)}
|
||||||
onClose={() => killTerminal(content.sessionId)}
|
onClose={() => killTerminal(content.sessionId)}
|
||||||
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
|
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)}
|
||||||
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
|
onSplitVertical={() => createTerminal('vertical', content.sessionId)}
|
||||||
isDragging={activeDragId === content.sessionId}
|
isDragging={activeDragId === content.sessionId}
|
||||||
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
||||||
fontSize={terminalFontSize}
|
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;
|
const defaultSizePerPanel = 100 / content.panels.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelGroup direction={content.direction}>
|
<PanelGroup direction={content.direction}>
|
||||||
{content.panels.map((panel, index) => {
|
{content.panels.map((panel, index) => {
|
||||||
const panelSize = panel.type === "terminal" && panel.size
|
const panelSize =
|
||||||
? panel.size
|
panel.type === 'terminal' && panel.size ? panel.size : defaultSizePerPanel;
|
||||||
: defaultSizePerPanel;
|
|
||||||
|
|
||||||
const panelKey = getPanelKey(panel);
|
const panelKey = getPanelKey(panel);
|
||||||
return (
|
return (
|
||||||
@@ -484,8 +479,8 @@ export function TerminalView() {
|
|||||||
key={`handle-${panelKey}`}
|
key={`handle-${panelKey}`}
|
||||||
className={
|
className={
|
||||||
isHorizontal
|
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"
|
? '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"
|
: '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>
|
</div>
|
||||||
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
|
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
|
||||||
<p className="text-muted-foreground max-w-md">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -561,9 +558,7 @@ export function TerminalView() {
|
|||||||
disabled={authLoading}
|
disabled={authLoading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{authError && (
|
{authError && <p className="text-sm text-destructive">{authError}</p>}
|
||||||
<p className="text-sm text-destructive">{authError}</p>
|
|
||||||
)}
|
|
||||||
<Button type="submit" className="w-full" disabled={authLoading || !password}>
|
<Button type="submit" className="w-full" disabled={authLoading || !password}>
|
||||||
{authLoading ? (
|
{authLoading ? (
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
@@ -577,8 +572,8 @@ export function TerminalView() {
|
|||||||
{status.platform && (
|
{status.platform && (
|
||||||
<p className="text-xs text-muted-foreground mt-6">
|
<p className="text-xs text-muted-foreground mt-6">
|
||||||
Platform: {status.platform.platform}
|
Platform: {status.platform.platform}
|
||||||
{status.platform.isWSL && " (WSL)"}
|
{status.platform.isWSL && ' (WSL)'}
|
||||||
{" | "}Shell: {status.platform.defaultShell}
|
{' | '}Shell: {status.platform.defaultShell}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -597,7 +592,8 @@ export function TerminalView() {
|
|||||||
Create a new terminal session to start executing commands.
|
Create a new terminal session to start executing commands.
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<span className="block mt-2 text-sm">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -610,8 +606,8 @@ export function TerminalView() {
|
|||||||
{status?.platform && (
|
{status?.platform && (
|
||||||
<p className="text-xs text-muted-foreground mt-6">
|
<p className="text-xs text-muted-foreground mt-6">
|
||||||
Platform: {status.platform.platform}
|
Platform: {status.platform.platform}
|
||||||
{status.platform.isWSL && " (WSL)"}
|
{status.platform.isWSL && ' (WSL)'}
|
||||||
{" | "}Shell: {status.platform.defaultShell}
|
{' | '}Shell: {status.platform.defaultShell}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -644,9 +640,7 @@ export function TerminalView() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* New tab drop zone (visible when dragging) */}
|
{/* New tab drop zone (visible when dragging) */}
|
||||||
{activeDragId && (
|
{activeDragId && <NewTabDropZone isDropTarget={true} />}
|
||||||
<NewTabDropZone isDropTarget={true} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New tab button */}
|
{/* New tab button */}
|
||||||
<button
|
<button
|
||||||
@@ -664,7 +658,7 @@ export function TerminalView() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => createTerminal("horizontal")}
|
onClick={() => createTerminal('horizontal')}
|
||||||
title="Split Right"
|
title="Split Right"
|
||||||
>
|
>
|
||||||
<SplitSquareHorizontal className="h-4 w-4" />
|
<SplitSquareHorizontal className="h-4 w-4" />
|
||||||
@@ -673,7 +667,7 @@ export function TerminalView() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => createTerminal("vertical")}
|
onClick={() => createTerminal('vertical')}
|
||||||
title="Split Down"
|
title="Split Down"
|
||||||
>
|
>
|
||||||
<SplitSquareVertical className="h-4 w-4" />
|
<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">
|
<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>
|
<p className="text-muted-foreground mb-4">This tab is empty</p>
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => createTerminal()}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => createTerminal()}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
New Terminal
|
New Terminal
|
||||||
</Button>
|
</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">
|
<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" />
|
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
|
||||||
<span className="text-sm font-medium text-foreground whitespace-nowrap">
|
<span className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||||
{dragOverTabId === "new"
|
{dragOverTabId === 'new' ? 'New tab' : dragOverTabId ? 'Move to tab' : 'Terminal'}
|
||||||
? "New tab"
|
|
||||||
: dragOverTabId
|
|
||||||
? "Move to tab"
|
|
||||||
: "Terminal"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { useState, useCallback } from "react";
|
import { Button } from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -8,10 +7,10 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from '@/components/ui/dialog';
|
||||||
import { useAppStore, type ThemeMode } from "@/store/app-store";
|
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||||
import { getElectronAPI, type Project } from "@/lib/electron";
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { initializeProject } from "@/lib/project-init";
|
import { initializeProject } from '@/lib/project-init';
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -21,19 +20,19 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { toast } from "sonner";
|
import { toast } from 'sonner';
|
||||||
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
import { WorkspacePickerModal } from '@/components/dialogs/workspace-picker-modal';
|
||||||
import { NewProjectModal } from "@/components/new-project-modal";
|
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
|
||||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import type { StarterTemplate } from "@/lib/templates";
|
import type { StarterTemplate } from '@/lib/templates';
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
export function WelcomeView() {
|
export function WelcomeView() {
|
||||||
const {
|
const {
|
||||||
@@ -66,24 +65,24 @@ export function WelcomeView() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
|
||||||
if (!api.autoMode?.analyzeProject) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
try {
|
try {
|
||||||
console.log("[Welcome] Starting project analysis for:", projectPath);
|
console.log('[Welcome] Starting project analysis for:', projectPath);
|
||||||
const result = await api.autoMode.analyzeProject(projectPath);
|
const result = await api.autoMode.analyzeProject(projectPath);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Project analyzed", {
|
toast.success('Project analyzed', {
|
||||||
description: "AI agent has analyzed your project structure",
|
description: 'AI agent has analyzed your project structure',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error("[Welcome] Project analysis failed:", result.error);
|
console.error('[Welcome] Project analysis failed:', result.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Welcome] Failed to analyze project:", error);
|
console.error('[Welcome] Failed to analyze project:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsAnalyzing(false);
|
setIsAnalyzing(false);
|
||||||
}
|
}
|
||||||
@@ -100,8 +99,8 @@ export function WelcomeView() {
|
|||||||
const initResult = await initializeProject(path);
|
const initResult = await initializeProject(path);
|
||||||
|
|
||||||
if (!initResult.success) {
|
if (!initResult.success) {
|
||||||
toast.error("Failed to initialize project", {
|
toast.error('Failed to initialize project', {
|
||||||
description: initResult.error || "Unknown error occurred",
|
description: initResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,7 @@ export function WelcomeView() {
|
|||||||
(trashedProject?.theme as ThemeMode | undefined) ||
|
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||||
(currentProject?.theme as ThemeMode | undefined) ||
|
(currentProject?.theme as ThemeMode | undefined) ||
|
||||||
globalTheme;
|
globalTheme;
|
||||||
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
|
upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||||
|
|
||||||
// Show initialization dialog if files were created
|
// Show initialization dialog if files were created
|
||||||
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
||||||
@@ -126,26 +125,23 @@ export function WelcomeView() {
|
|||||||
setShowInitDialog(true);
|
setShowInitDialog(true);
|
||||||
|
|
||||||
// Kick off agent to analyze the project and update app_spec.txt
|
// Kick off agent to analyze the project and update app_spec.txt
|
||||||
console.log(
|
console.log('[Welcome] Project initialized, created files:', initResult.createdFiles);
|
||||||
"[Welcome] Project initialized, created files:",
|
console.log('[Welcome] Kicking off project analysis agent...');
|
||||||
initResult.createdFiles
|
|
||||||
);
|
|
||||||
console.log("[Welcome] Kicking off project analysis agent...");
|
|
||||||
|
|
||||||
// Start analysis in background (don't await, let it run async)
|
// Start analysis in background (don't await, let it run async)
|
||||||
analyzeProject(path);
|
analyzeProject(path);
|
||||||
} else {
|
} else {
|
||||||
toast.success("Project opened", {
|
toast.success('Project opened', {
|
||||||
description: `Opened ${name}`,
|
description: `Opened ${name}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to the board view
|
// Navigate to the board view
|
||||||
navigate({ to: "/board" });
|
navigate({ to: '/board' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Welcome] Failed to open project:", error);
|
console.error('[Welcome] Failed to open project:', error);
|
||||||
toast.error("Failed to open project", {
|
toast.error('Failed to open project', {
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsOpening(false);
|
setIsOpening(false);
|
||||||
@@ -178,21 +174,19 @@ export function WelcomeView() {
|
|||||||
if (!result.canceled && result.filePaths[0]) {
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
const path = result.filePaths[0];
|
const path = result.filePaths[0];
|
||||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
// Extract folder name from path (works on both Windows and Mac/Linux)
|
||||||
const name =
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||||
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
|
||||||
await initializeAndOpenProject(path, name);
|
await initializeAndOpenProject(path, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Fall back to current behavior on error
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const result = await api.openDirectory();
|
const result = await api.openDirectory();
|
||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
const path = result.filePaths[0];
|
const path = result.filePaths[0];
|
||||||
const name =
|
const name = path.split(/[/\\]/).filter(Boolean).pop() || 'Untitled Project';
|
||||||
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
|
||||||
await initializeAndOpenProject(path, name);
|
await initializeAndOpenProject(path, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,16 +218,13 @@ export function WelcomeView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInteractiveMode = () => {
|
const handleInteractiveMode = () => {
|
||||||
navigate({ to: "/interview" });
|
navigate({ to: '/interview' });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a blank project with just .automaker directory structure
|
* Create a blank project with just .automaker directory structure
|
||||||
*/
|
*/
|
||||||
const handleCreateBlankProject = async (
|
const handleCreateBlankProject = async (projectName: string, parentDir: string) => {
|
||||||
projectName: string,
|
|
||||||
parentDir: string
|
|
||||||
) => {
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -242,7 +233,7 @@ export function WelcomeView() {
|
|||||||
// Validate that parent directory exists
|
// Validate that parent directory exists
|
||||||
const parentExists = await api.exists(parentDir);
|
const parentExists = await api.exists(parentDir);
|
||||||
if (!parentExists) {
|
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}`,
|
description: `Cannot create project in non-existent directory: ${parentDir}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -250,8 +241,8 @@ export function WelcomeView() {
|
|||||||
|
|
||||||
// Verify parent is actually a directory
|
// Verify parent is actually a directory
|
||||||
const parentStat = await api.stat(parentDir);
|
const parentStat = await api.stat(parentDir);
|
||||||
if (parentStat && !parentStat.isDirectory) {
|
if (parentStat && !parentStat.stats?.isDirectory) {
|
||||||
toast.error("Parent path is not a directory", {
|
toast.error('Parent path is not a directory', {
|
||||||
description: `${parentDir} is not a directory`,
|
description: `${parentDir} is not a directory`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -260,8 +251,8 @@ export function WelcomeView() {
|
|||||||
// Create project directory
|
// Create project directory
|
||||||
const mkdirResult = await api.mkdir(projectPath);
|
const mkdirResult = await api.mkdir(projectPath);
|
||||||
if (!mkdirResult.success) {
|
if (!mkdirResult.success) {
|
||||||
toast.error("Failed to create project directory", {
|
toast.error('Failed to create project directory', {
|
||||||
description: mkdirResult.error || "Unknown error occurred",
|
description: mkdirResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -270,8 +261,8 @@ export function WelcomeView() {
|
|||||||
const initResult = await initializeProject(projectPath);
|
const initResult = await initializeProject(projectPath);
|
||||||
|
|
||||||
if (!initResult.success) {
|
if (!initResult.success) {
|
||||||
toast.error("Failed to initialize project", {
|
toast.error('Failed to initialize project', {
|
||||||
description: initResult.error || "Unknown error occurred",
|
description: initResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -313,7 +304,7 @@ export function WelcomeView() {
|
|||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
setShowNewProjectModal(false);
|
setShowNewProjectModal(false);
|
||||||
|
|
||||||
toast.success("Project created", {
|
toast.success('Project created', {
|
||||||
description: `Created ${projectName} with .automaker directory`,
|
description: `Created ${projectName} with .automaker directory`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,9 +317,9 @@ export function WelcomeView() {
|
|||||||
});
|
});
|
||||||
setShowInitDialog(true);
|
setShowInitDialog(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create project:", error);
|
console.error('Failed to create project:', error);
|
||||||
toast.error("Failed to create project", {
|
toast.error('Failed to create project', {
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -356,8 +347,8 @@ export function WelcomeView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||||
toast.error("Failed to clone template", {
|
toast.error('Failed to clone template', {
|
||||||
description: cloneResult.error || "Unknown error occurred",
|
description: cloneResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -368,8 +359,8 @@ export function WelcomeView() {
|
|||||||
const initResult = await initializeProject(projectPath);
|
const initResult = await initializeProject(projectPath);
|
||||||
|
|
||||||
if (!initResult.success) {
|
if (!initResult.success) {
|
||||||
toast.error("Failed to initialize project", {
|
toast.error('Failed to initialize project', {
|
||||||
description: initResult.error || "Unknown error occurred",
|
description: initResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -387,15 +378,11 @@ export function WelcomeView() {
|
|||||||
</overview>
|
</overview>
|
||||||
|
|
||||||
<technology_stack>
|
<technology_stack>
|
||||||
${template.techStack
|
${template.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')}
|
||||||
.map((tech) => `<technology>${tech}</technology>`)
|
|
||||||
.join("\n ")}
|
|
||||||
</technology_stack>
|
</technology_stack>
|
||||||
|
|
||||||
<core_capabilities>
|
<core_capabilities>
|
||||||
${template.features
|
${template.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')}
|
||||||
.map((feature) => `<capability>${feature}</capability>`)
|
|
||||||
.join("\n ")}
|
|
||||||
</core_capabilities>
|
</core_capabilities>
|
||||||
|
|
||||||
<implemented_features>
|
<implemented_features>
|
||||||
@@ -415,7 +402,7 @@ export function WelcomeView() {
|
|||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
setShowNewProjectModal(false);
|
setShowNewProjectModal(false);
|
||||||
|
|
||||||
toast.success("Project created from template", {
|
toast.success('Project created from template', {
|
||||||
description: `Created ${projectName} from ${template.name}`,
|
description: `Created ${projectName} from ${template.name}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -431,9 +418,9 @@ export function WelcomeView() {
|
|||||||
// Kick off project analysis
|
// Kick off project analysis
|
||||||
analyzeProject(projectPath);
|
analyzeProject(projectPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create project from template:", error);
|
console.error('Failed to create project from template:', error);
|
||||||
toast.error("Failed to create project", {
|
toast.error('Failed to create project', {
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -454,15 +441,11 @@ export function WelcomeView() {
|
|||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
|
|
||||||
// Clone the repository
|
// Clone the repository
|
||||||
const cloneResult = await httpClient.templates.clone(
|
const cloneResult = await httpClient.templates.clone(repoUrl, projectName, parentDir);
|
||||||
repoUrl,
|
|
||||||
projectName,
|
|
||||||
parentDir
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!cloneResult.success || !cloneResult.projectPath) {
|
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||||
toast.error("Failed to clone repository", {
|
toast.error('Failed to clone repository', {
|
||||||
description: cloneResult.error || "Unknown error occurred",
|
description: cloneResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -473,8 +456,8 @@ export function WelcomeView() {
|
|||||||
const initResult = await initializeProject(projectPath);
|
const initResult = await initializeProject(projectPath);
|
||||||
|
|
||||||
if (!initResult.success) {
|
if (!initResult.success) {
|
||||||
toast.error("Failed to initialize project", {
|
toast.error('Failed to initialize project', {
|
||||||
description: initResult.error || "Unknown error occurred",
|
description: initResult.error || 'Unknown error occurred',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -516,7 +499,7 @@ export function WelcomeView() {
|
|||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
setShowNewProjectModal(false);
|
setShowNewProjectModal(false);
|
||||||
|
|
||||||
toast.success("Project created from repository", {
|
toast.success('Project created from repository', {
|
||||||
description: `Created ${projectName} from ${repoUrl}`,
|
description: `Created ${projectName} from ${repoUrl}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,9 +515,9 @@ export function WelcomeView() {
|
|||||||
// Kick off project analysis
|
// Kick off project analysis
|
||||||
analyzeProject(projectPath);
|
analyzeProject(projectPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create project from custom URL:", error);
|
console.error('Failed to create project from custom URL:', error);
|
||||||
toast.error("Failed to create project", {
|
toast.error('Failed to create project', {
|
||||||
description: error instanceof Error ? error.message : "Unknown error",
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
@@ -555,7 +538,7 @@ export function WelcomeView() {
|
|||||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||||
<div className="px-8 py-6">
|
<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="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" />
|
<img src="/logo.png" alt="Automaker Logo" className="w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
<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"
|
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"
|
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="relative p-6 h-full flex flex-col">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<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" />
|
<Plus className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
<h3 className="text-lg font-semibold text-foreground mb-1.5">New Project</h3>
|
||||||
New Project
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
Create a new project from scratch with AI-powered
|
Create a new project from scratch with AI-powered development
|
||||||
development
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<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"
|
data-testid="create-new-project"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -608,10 +588,7 @@ export function WelcomeView() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={handleNewProject} data-testid="quick-setup-option">
|
||||||
onClick={handleNewProject}
|
|
||||||
data-testid="quick-setup-option"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Quick Setup
|
Quick Setup
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -633,16 +610,14 @@ export function WelcomeView() {
|
|||||||
onClick={handleOpenProject}
|
onClick={handleOpenProject}
|
||||||
data-testid="open-project-card"
|
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="relative p-6 h-full flex flex-col">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<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">
|
<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" />
|
<FolderOpen className="w-6 h-6 text-muted-foreground group-hover:text-blue-500 transition-colors duration-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-1.5">
|
<h3 className="text-lg font-semibold text-foreground mb-1.5">Open Project</h3>
|
||||||
Open Project
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
Open an existing project folder to continue working
|
Open an existing project folder to continue working
|
||||||
</p>
|
</p>
|
||||||
@@ -667,9 +642,7 @@ export function WelcomeView() {
|
|||||||
<div className="w-8 h-8 rounded-lg bg-muted/50 flex items-center justify-center">
|
<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" />
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">Recent Projects</h2>
|
||||||
Recent Projects
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{recentProjects.map((project, index) => (
|
{recentProjects.map((project, index) => (
|
||||||
@@ -680,7 +653,7 @@ export function WelcomeView() {
|
|||||||
data-testid={`recent-project-${project.id}`}
|
data-testid={`recent-project-${project.id}`}
|
||||||
style={{ animationDelay: `${index * 50}ms` }}
|
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="relative p-4">
|
||||||
<div className="flex items-start gap-3">
|
<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">
|
<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>
|
</p>
|
||||||
{project.lastOpened && (
|
{project.lastOpened && (
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
{new Date(
|
{new Date(project.lastOpened).toLocaleDateString()}
|
||||||
project.lastOpened
|
|
||||||
).toLocaleDateString()}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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" />
|
<Sparkles className="w-10 h-10 text-muted-foreground/50" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
<h3 className="text-xl font-semibold text-foreground mb-2">No projects yet</h3>
|
||||||
No projects yet
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground max-w-md leading-relaxed">
|
<p className="text-sm text-muted-foreground max-w-md leading-relaxed">
|
||||||
Get started by creating a new project or opening an existing one
|
Get started by creating a new project or opening an existing one
|
||||||
</p>
|
</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">
|
<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" />
|
<Sparkles className="w-4 h-4 text-brand-500" />
|
||||||
</div>
|
</div>
|
||||||
{initStatus?.isNewProject
|
{initStatus?.isNewProject ? 'Project Initialized' : 'Project Updated'}
|
||||||
? "Project Initialized"
|
|
||||||
: "Project Updated"}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground mt-1">
|
<DialogDescription className="text-muted-foreground mt-1">
|
||||||
{initStatus?.isNewProject
|
{initStatus?.isNewProject
|
||||||
@@ -759,9 +726,7 @@ export function WelcomeView() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-foreground font-medium">
|
<p className="text-sm text-foreground font-medium">Created files:</p>
|
||||||
Created files:
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{initStatus?.createdFiles.map((file) => (
|
{initStatus?.createdFiles.map((file) => (
|
||||||
<li
|
<li
|
||||||
@@ -788,12 +753,12 @@ export function WelcomeView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<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">
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
|
||||||
app_spec.txt
|
app_spec.txt
|
||||||
</code>{" "}
|
</code>{' '}
|
||||||
file to describe your project. The AI agent will use this to
|
file to describe your project. The AI agent will use this to understand your
|
||||||
understand your project structure.
|
project structure.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -802,7 +767,7 @@ export function WelcomeView() {
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowInitDialog(false)}
|
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"
|
data-testid="close-init-dialog"
|
||||||
>
|
>
|
||||||
Get Started
|
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">
|
<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" />
|
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
|
||||||
<p className="text-foreground font-medium">
|
<p className="text-foreground font-medium">Initializing project...</p>
|
||||||
Initializing project...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, type ReactNode, type ElementType } from 'react';
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
PlayCircle,
|
PlayCircle,
|
||||||
Bot,
|
Bot,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
FileText,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
Palette,
|
Palette,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
@@ -23,13 +21,13 @@ import {
|
|||||||
TestTube,
|
TestTube,
|
||||||
Brain,
|
Brain,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface WikiSection {
|
interface WikiSection {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ElementType;
|
icon: ElementType;
|
||||||
content: React.ReactNode;
|
content: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleSection({
|
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">
|
<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" />
|
<Icon className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 font-medium text-foreground">
|
<span className="flex-1 font-medium text-foreground">{section.title}</span>
|
||||||
{section.title}
|
|
||||||
</span>
|
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
@@ -90,7 +86,7 @@ function CodeBlock({ children, title }: { children: string; title?: string }) {
|
|||||||
function FeatureList({
|
function FeatureList({
|
||||||
items,
|
items,
|
||||||
}: {
|
}: {
|
||||||
items: { icon: React.ElementType; title: string; description: string }[];
|
items: { icon: ElementType; title: string; description: string }[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 mt-3">
|
<div className="grid gap-3 mt-3">
|
||||||
@@ -105,12 +101,8 @@ function FeatureList({
|
|||||||
<ItemIcon className="w-3.5 h-3.5" />
|
<ItemIcon className="w-3.5 h-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-foreground text-sm">
|
<div className="font-medium text-foreground text-sm">{item.title}</div>
|
||||||
{item.title}
|
<div className="text-xs text-muted-foreground mt-0.5">{item.description}</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -120,9 +112,7 @@ function FeatureList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WikiView() {
|
export function WikiView() {
|
||||||
const [openSections, setOpenSections] = useState<Set<string>>(
|
const [openSections, setOpenSections] = useState<Set<string>>(new Set(['overview']));
|
||||||
new Set(["overview"])
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSection = (id: string) => {
|
const toggleSection = (id: string) => {
|
||||||
setOpenSections((prev) => {
|
setOpenSections((prev) => {
|
||||||
@@ -146,66 +136,66 @@ export function WikiView() {
|
|||||||
|
|
||||||
const sections: WikiSection[] = [
|
const sections: WikiSection[] = [
|
||||||
{
|
{
|
||||||
id: "overview",
|
id: 'overview',
|
||||||
title: "Project Overview",
|
title: 'Project Overview',
|
||||||
icon: Rocket,
|
icon: Rocket,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>
|
<p>
|
||||||
<strong className="text-foreground">Automaker</strong> is an
|
<strong className="text-foreground">Automaker</strong> is an autonomous AI development
|
||||||
autonomous AI development studio that helps developers build
|
studio that helps developers build software faster using AI agents.
|
||||||
software faster using AI agents.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
At its core, Automaker provides a visual Kanban board to manage
|
At its core, Automaker provides a visual Kanban board to manage features. When you're
|
||||||
features. When you're ready, AI agents automatically implement those
|
ready, AI agents automatically implement those features in your codebase, complete with
|
||||||
features in your codebase, complete with git worktree isolation for
|
git worktree isolation for safe parallel development.
|
||||||
safe parallel development.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
|
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
|
||||||
<p className="text-brand-400 text-sm">
|
<p className="text-brand-400 text-sm">
|
||||||
Think of it as having a team of AI developers that can work on
|
Think of it as having a team of AI developers that can work on multiple features
|
||||||
multiple features simultaneously while you focus on the bigger
|
simultaneously while you focus on the bigger picture.
|
||||||
picture.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "architecture",
|
id: 'architecture',
|
||||||
title: "Architecture",
|
title: 'Architecture',
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-3">
|
<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">
|
<ul className="list-disc list-inside space-y-2 ml-2">
|
||||||
<li>
|
<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
|
Electron frontend for the desktop application
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-foreground">apps/server</strong> - Express
|
<strong className="text-foreground">apps/server</strong> - Express backend handling
|
||||||
backend handling API requests and agent orchestration
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<p className="font-medium text-foreground">Key Technologies:</p>
|
<p className="font-medium text-foreground">Key Technologies:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||||
<li>Electron wraps Next.js for cross-platform desktop support</li>
|
<li>Electron + React + TanStack Router for cross-platform desktop support</li>
|
||||||
<li>
|
<li>Real-time communication via WebSocket for live agent updates</li>
|
||||||
Real-time communication via WebSocket for live agent updates
|
|
||||||
</li>
|
|
||||||
<li>State management with Zustand for reactive UI updates</li>
|
<li>State management with Zustand for reactive UI updates</li>
|
||||||
<li>Claude Agent SDK for AI capabilities</li>
|
<li>Claude Agent SDK for AI capabilities</li>
|
||||||
|
<li>Shared monorepo packages (@automaker/*) for code reuse</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "features",
|
id: 'features',
|
||||||
title: "Key Features",
|
title: 'Key Features',
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
@@ -213,73 +203,69 @@ export function WikiView() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
title: "Kanban Board",
|
title: 'Kanban Board',
|
||||||
description:
|
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,
|
icon: Bot,
|
||||||
title: "AI Agent Integration",
|
title: 'AI Agent Integration',
|
||||||
description:
|
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,
|
icon: Cpu,
|
||||||
title: "Multi-Model Support",
|
title: 'Multi-Model Support',
|
||||||
description:
|
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,
|
icon: Brain,
|
||||||
title: "Extended Thinking",
|
title: 'Extended Thinking',
|
||||||
description:
|
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,
|
icon: Zap,
|
||||||
title: "Real-time Streaming",
|
title: 'Real-time Streaming',
|
||||||
description:
|
description: 'Watch AI agents work in real-time with live output streaming.',
|
||||||
"Watch AI agents work in real-time with live output streaming.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
title: "Git Worktree Isolation",
|
title: 'Git Worktree Isolation',
|
||||||
description:
|
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,
|
icon: Users,
|
||||||
title: "AI Profiles",
|
title: 'AI Profiles',
|
||||||
description:
|
description:
|
||||||
"Pre-configured model + thinking level combinations for different task types.",
|
'Pre-configured model + thinking level combinations for different task types.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Terminal,
|
icon: Terminal,
|
||||||
title: "Integrated Terminal",
|
title: 'Integrated Terminal',
|
||||||
description:
|
description: 'Built-in terminal with tab support and split panes.',
|
||||||
"Built-in terminal with tab support and split panes.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Keyboard,
|
icon: Keyboard,
|
||||||
title: "Keyboard Shortcuts",
|
title: 'Keyboard Shortcuts',
|
||||||
description: "Fully customizable shortcuts for power users.",
|
description: 'Fully customizable shortcuts for power users.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
title: "14 Themes",
|
title: '14 Themes',
|
||||||
description:
|
description: 'From light to dark, retro to synthwave - pick your style.',
|
||||||
"From light to dark, retro to synthwave - pick your style.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Image,
|
icon: Image,
|
||||||
title: "Image Support",
|
title: 'Image Support',
|
||||||
description: "Attach images to features for visual context.",
|
description: 'Attach images to features for visual context.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: TestTube,
|
icon: TestTube,
|
||||||
title: "Test Integration",
|
title: 'Test Integration',
|
||||||
description:
|
description: 'Automatic test running and TDD support for quality assurance.',
|
||||||
"Automatic test running and TDD support for quality assurance.",
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -287,26 +273,23 @@ export function WikiView() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "data-flow",
|
id: 'data-flow',
|
||||||
title: "How It Works (Data Flow)",
|
title: 'How It Works (Data Flow)',
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>
|
<p>Here's what happens when you use Automaker to implement a feature:</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">
|
<ol className="list-decimal list-inside space-y-3 ml-2 mt-4">
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Create Feature</strong>
|
<strong>Create Feature</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Add a new feature card to the Kanban board with description and
|
Add a new feature card to the Kanban board with description and steps
|
||||||
steps
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Feature Saved</strong>
|
<strong>Feature Saved</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<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">
|
<code className="px-1 py-0.5 bg-muted rounded text-xs">
|
||||||
.automaker/features/{id}/feature.json
|
.automaker/features/{id}/feature.json
|
||||||
</code>
|
</code>
|
||||||
@@ -315,15 +298,13 @@ export function WikiView() {
|
|||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Start Work</strong>
|
<strong>Start Work</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Drag to "In Progress" or enable auto mode to start
|
Drag to "In Progress" or enable auto mode to start implementation
|
||||||
implementation
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Git Worktree Created</strong>
|
<strong>Git Worktree Created</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Backend AutoModeService creates isolated git worktree (if
|
Backend AutoModeService creates isolated git worktree (if enabled)
|
||||||
enabled)
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
@@ -355,38 +336,64 @@ export function WikiView() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "structure",
|
id: 'structure',
|
||||||
title: "Project Structure",
|
title: 'Project Structure',
|
||||||
icon: FolderTree,
|
icon: FolderTree,
|
||||||
content: (
|
content: (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3">
|
<p className="mb-3">The Automaker codebase is organized as follows:</p>
|
||||||
The Automaker codebase is organized as follows:
|
|
||||||
</p>
|
|
||||||
<CodeBlock title="Directory Structure">
|
<CodeBlock title="Directory Structure">
|
||||||
{`/automaker/
|
{`automaker/
|
||||||
├── apps/
|
├─ apps/
|
||||||
│ ├── app/ # Frontend (Next.js + Electron)
|
│ ├─ ui/ Frontend (React + Electron)
|
||||||
│ │ ├── electron/ # Electron main process
|
│ │ └─ src/
|
||||||
│ │ └── src/
|
│ │ ├─ routes/ TanStack Router pages
|
||||||
│ │ ├── app/ # Next.js App Router pages
|
│ │ ├─ components/
|
||||||
│ │ ├── components/ # React components
|
│ │ │ ├─ layout/ Layout components (sidebar, etc.)
|
||||||
│ │ ├── store/ # Zustand state management
|
│ │ │ ├─ views/ View components (board, agent, etc.)
|
||||||
│ │ ├── hooks/ # Custom React hooks
|
│ │ │ ├─ dialogs/ Dialog components
|
||||||
│ │ └── lib/ # Utilities and helpers
|
│ │ │ └─ ui/ shadcn/ui components
|
||||||
│ └── server/ # Backend (Express)
|
│ │ ├─ store/ Zustand state management
|
||||||
│ └── src/
|
│ │ ├─ hooks/ Custom React hooks
|
||||||
│ ├── routes/ # API endpoints
|
│ │ ├─ lib/ Utilities and helpers
|
||||||
│ └── services/ # Business logic (AutoModeService, etc.)
|
│ │ ├─ config/ App configuration files
|
||||||
├── docs/ # Documentation
|
│ │ ├─ contexts/ React context providers
|
||||||
└── package.json # Workspace root`}
|
│ │ ├─ 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>
|
</CodeBlock>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "components",
|
id: 'components',
|
||||||
title: "Key Components",
|
title: 'Key Components',
|
||||||
icon: Component,
|
icon: Component,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -394,33 +401,36 @@ export function WikiView() {
|
|||||||
<div className="grid gap-2 mt-4">
|
<div className="grid gap-2 mt-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
file: "sidebar.tsx",
|
file: 'layout/sidebar.tsx',
|
||||||
desc: "Main navigation with project picker and view switching",
|
desc: 'Main navigation with project picker and view switching',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "board-view.tsx",
|
file: 'views/board-view.tsx',
|
||||||
desc: "Kanban board with drag-and-drop cards",
|
desc: 'Kanban board with drag-and-drop cards',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "agent-view.tsx",
|
file: 'views/agent-view.tsx',
|
||||||
desc: "AI chat interface for conversational development",
|
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: "terminal-view.tsx",
|
file: 'views/spec-view/',
|
||||||
desc: "Integrated terminal with splits and tabs",
|
desc: 'Project specification editor with AI generation',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "profiles-view.tsx",
|
file: 'views/context-view.tsx',
|
||||||
desc: "AI profile management (model + thinking presets)",
|
desc: 'Context file manager for AI context',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "app-store.ts",
|
file: 'views/terminal-view/',
|
||||||
desc: "Central Zustand state management",
|
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) => (
|
].map((item) => (
|
||||||
<div
|
<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">
|
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">
|
||||||
{item.file}
|
{item.file}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{item.desc}</span>
|
||||||
{item.desc}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -440,31 +448,28 @@ export function WikiView() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "configuration",
|
id: 'configuration',
|
||||||
title: "Configuration",
|
title: 'Configuration',
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>
|
<p>
|
||||||
Automaker stores project configuration in the{" "}
|
Automaker stores project configuration in the{' '}
|
||||||
<code className="px-1 py-0.5 bg-muted rounded text-xs">
|
<code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/</code> directory:
|
||||||
.automaker/
|
|
||||||
</code>{" "}
|
|
||||||
directory:
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 mt-4">
|
<div className="grid gap-2 mt-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
file: "app_spec.txt",
|
file: 'app_spec.txt',
|
||||||
desc: "Project specification describing your app for AI context",
|
desc: 'Project specification describing your app for AI context',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "context/",
|
file: 'context/',
|
||||||
desc: "Additional context files (docs, examples) for AI",
|
desc: 'Additional context files (docs, examples) for AI',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: "features/",
|
file: 'features/',
|
||||||
desc: "Feature definitions with descriptions and steps",
|
desc: 'Feature definitions with descriptions and steps',
|
||||||
},
|
},
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<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">
|
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">
|
||||||
{item.file}
|
{item.file}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">{item.desc}</span>
|
||||||
{item.desc}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 p-3 rounded-lg bg-muted/30 border border-border/50">
|
<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">
|
<p className="text-sm text-foreground font-medium mb-2">Tip: App Spec Best Practices</p>
|
||||||
Tip: App Spec Best Practices
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
|
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
|
||||||
<li>Include your tech stack and key dependencies</li>
|
<li>Include your tech stack and key dependencies</li>
|
||||||
<li>Describe the project structure and conventions</li>
|
<li>Describe the project structure and conventions</li>
|
||||||
@@ -495,8 +496,8 @@ export function WikiView() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "getting-started",
|
id: 'getting-started',
|
||||||
title: "Getting Started",
|
title: 'Getting Started',
|
||||||
icon: PlayCircle,
|
icon: PlayCircle,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -505,43 +506,38 @@ export function WikiView() {
|
|||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Create or Open a Project</strong>
|
<strong>Create or Open a Project</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Use the sidebar to create a new project or open an existing
|
Use the sidebar to create a new project or open an existing folder
|
||||||
folder
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Write an App Spec</strong>
|
<strong>Write an App Spec</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Go to Spec Editor and describe your project. This helps AI
|
Go to Spec Editor and describe your project. This helps AI understand your codebase.
|
||||||
understand your codebase.
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Add Context (Optional)</strong>
|
<strong>Add Context (Optional)</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Add relevant documentation or examples to the Context view for
|
Add relevant documentation or examples to the Context view for better AI results
|
||||||
better AI results
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Create Features</strong>
|
<strong>Create Features</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Add feature cards to your Kanban board with clear descriptions
|
Add feature cards to your Kanban board with clear descriptions and implementation
|
||||||
and implementation steps
|
steps
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Configure AI Profile</strong>
|
<strong>Configure AI Profile</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Choose an AI profile or customize model/thinking settings per
|
Choose an AI profile or customize model/thinking settings per feature
|
||||||
feature
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<li className="text-foreground">
|
||||||
<strong>Start Implementation</strong>
|
<strong>Start Implementation</strong>
|
||||||
<p className="text-muted-foreground ml-5 mt-1">
|
<p className="text-muted-foreground ml-5 mt-1">
|
||||||
Drag features to "In Progress" or enable auto mode to let AI
|
Drag features to "In Progress" or enable auto mode to let AI work
|
||||||
work
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="text-foreground">
|
<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>
|
<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">
|
<ul className="list-disc list-inside space-y-1 text-xs text-brand-400/80">
|
||||||
<li>
|
<li>
|
||||||
Use keyboard shortcuts for faster navigation (press{" "}
|
Use keyboard shortcuts for faster navigation (press{' '}
|
||||||
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code>{" "}
|
<code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)
|
||||||
to see all)
|
|
||||||
</li>
|
</li>
|
||||||
|
<li>Enable git worktree isolation for parallel feature development</li>
|
||||||
<li>
|
<li>
|
||||||
Enable git worktree isolation for parallel feature development
|
Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Start with "Quick Edit" profile for simple tasks, use "Heavy
|
|
||||||
Task" for complex work
|
|
||||||
</li>
|
</li>
|
||||||
<li>Keep your app spec up to date as your project evolves</li>
|
<li>Keep your app spec up to date as your project evolves</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { ApiKeys } from '@/store/app-store';
|
||||||
import type { ApiKeys } from "@/store/app-store";
|
|
||||||
|
|
||||||
export type ProviderKey = "anthropic" | "google";
|
export type ProviderKey = 'anthropic' | 'google';
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
key: ProviderKey;
|
key: ProviderKey;
|
||||||
@@ -56,33 +55,32 @@ export interface ProviderConfigParams {
|
|||||||
export const buildProviderConfigs = ({
|
export const buildProviderConfigs = ({
|
||||||
apiKeys,
|
apiKeys,
|
||||||
anthropic,
|
anthropic,
|
||||||
google,
|
|
||||||
}: ProviderConfigParams): ProviderConfig[] => [
|
}: ProviderConfigParams): ProviderConfig[] => [
|
||||||
{
|
{
|
||||||
key: "anthropic",
|
key: 'anthropic',
|
||||||
label: "Anthropic API Key",
|
label: 'Anthropic API Key',
|
||||||
inputId: "anthropic-key",
|
inputId: 'anthropic-key',
|
||||||
placeholder: "sk-ant-...",
|
placeholder: 'sk-ant-...',
|
||||||
value: anthropic.value,
|
value: anthropic.value,
|
||||||
setValue: anthropic.setValue,
|
setValue: anthropic.setValue,
|
||||||
showValue: anthropic.show,
|
showValue: anthropic.show,
|
||||||
setShowValue: anthropic.setShow,
|
setShowValue: anthropic.setShow,
|
||||||
hasStoredKey: apiKeys.anthropic,
|
hasStoredKey: apiKeys.anthropic,
|
||||||
inputTestId: "anthropic-api-key-input",
|
inputTestId: 'anthropic-api-key-input',
|
||||||
toggleTestId: "toggle-anthropic-visibility",
|
toggleTestId: 'toggle-anthropic-visibility',
|
||||||
testButton: {
|
testButton: {
|
||||||
onClick: anthropic.onTest,
|
onClick: anthropic.onTest,
|
||||||
disabled: !anthropic.value || anthropic.testing,
|
disabled: !anthropic.value || anthropic.testing,
|
||||||
loading: anthropic.testing,
|
loading: anthropic.testing,
|
||||||
testId: "test-claude-connection",
|
testId: 'test-claude-connection',
|
||||||
},
|
},
|
||||||
result: anthropic.result,
|
result: anthropic.result,
|
||||||
resultTestId: "test-connection-result",
|
resultTestId: 'test-connection-result',
|
||||||
resultMessageTestId: "test-connection-message",
|
resultMessageTestId: 'test-connection-message',
|
||||||
descriptionPrefix: "Used for Claude AI features. Get your key at",
|
descriptionPrefix: 'Used for Claude AI features. Get your key at',
|
||||||
descriptionLinkHref: "https://console.anthropic.com/account/keys",
|
descriptionLinkHref: 'https://console.anthropic.com/account/keys',
|
||||||
descriptionLinkText: "console.anthropic.com",
|
descriptionLinkText: 'console.anthropic.com',
|
||||||
descriptionSuffix: ".",
|
descriptionSuffix: '.',
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// key: "google",
|
// key: "google",
|
||||||
|
|||||||
9
apps/ui/src/hooks/index.ts
Normal file
9
apps/ui/src/hooks/index.ts
Normal 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';
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* File Picker Utility for Web Browsers
|
* File Picker Utility for Web Browsers
|
||||||
*
|
*
|
||||||
* Provides cross-platform file and directory selection using:
|
* Provides cross-platform file and directory selection using:
|
||||||
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
|
* 1. HTML5 webkitdirectory input - primary method (works on Windows)
|
||||||
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
|
* 2. File System Access API (showDirectoryPicker) - fallback for modern browsers
|
||||||
*
|
*
|
||||||
* Note: Browsers don't expose absolute file paths for security reasons.
|
* Note: Browsers don't expose absolute file paths for security reasons.
|
||||||
* This implementation extracts directory information and may require
|
* This implementation extracts directory information and may require
|
||||||
* user confirmation or server-side path resolution.
|
* user confirmation or server-side path resolution.
|
||||||
@@ -22,7 +22,7 @@ export interface DirectoryPickerResult {
|
|||||||
/**
|
/**
|
||||||
* Opens a directory picker dialog
|
* Opens a directory picker dialog
|
||||||
* @returns Promise resolving to directory information, or null if canceled
|
* @returns Promise resolving to directory information, or null if canceled
|
||||||
*
|
*
|
||||||
* Note: Browsers don't expose absolute file paths for security reasons.
|
* Note: Browsers don't expose absolute file paths for security reasons.
|
||||||
* This function returns directory structure information that the server
|
* This function returns directory structure information that the server
|
||||||
* can use to locate the actual directory path.
|
* 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)
|
// Use webkitdirectory (works on Windows and all modern browsers)
|
||||||
return new Promise<DirectoryPickerResult | null>((resolve) => {
|
return new Promise<DirectoryPickerResult | null>((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const input = document.createElement("input");
|
const input = document.createElement('input');
|
||||||
input.type = "file";
|
input.type = 'file';
|
||||||
input.webkitdirectory = true;
|
input.webkitdirectory = true;
|
||||||
input.style.display = "none";
|
input.style.display = 'none';
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (input.parentNode) {
|
if (input.parentNode) {
|
||||||
@@ -58,62 +58,59 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener("change", (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
changeEventFired = true;
|
changeEventFired = true;
|
||||||
if (focusTimeout) {
|
if (focusTimeout) {
|
||||||
clearTimeout(focusTimeout);
|
clearTimeout(focusTimeout);
|
||||||
focusTimeout = null;
|
focusTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[FilePicker] Change event fired");
|
console.log('[FilePicker] Change event fired');
|
||||||
const files = input.files;
|
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) {
|
if (!files || files.length === 0) {
|
||||||
console.log("[FilePicker] No files selected");
|
console.log('[FilePicker] No files selected');
|
||||||
safeResolve(null);
|
safeResolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstFile = files[0];
|
const firstFile = files[0];
|
||||||
console.log("[FilePicker] First file:", {
|
console.log('[FilePicker] First file:', {
|
||||||
name: firstFile.name,
|
name: firstFile.name,
|
||||||
webkitRelativePath: firstFile.webkitRelativePath,
|
webkitRelativePath: firstFile.webkitRelativePath,
|
||||||
// @ts-expect-error
|
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||||
path: firstFile.path,
|
path: firstFile.path,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract directory name from webkitRelativePath
|
// Extract directory name from webkitRelativePath
|
||||||
// webkitRelativePath format: "directoryName/subfolder/file.txt" or "directoryName/file.txt"
|
// 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)
|
// 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
|
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||||
if (firstFile.path) {
|
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;
|
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)
|
// Extract directory path (remove filename)
|
||||||
const lastSeparator = Math.max(
|
const lastSeparator = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
|
||||||
filePath.lastIndexOf("\\"),
|
|
||||||
filePath.lastIndexOf("/")
|
|
||||||
);
|
|
||||||
if (lastSeparator > 0) {
|
if (lastSeparator > 0) {
|
||||||
const absolutePath = filePath.substring(0, lastSeparator);
|
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
|
// Return as directory name for now - server can validate it directly
|
||||||
directoryName = absolutePath;
|
directoryName = absolutePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 2: Extract directory name from webkitRelativePath
|
// Method 2: Extract directory name from webkitRelativePath
|
||||||
if (directoryName === "Selected Directory" && firstFile.webkitRelativePath) {
|
if (directoryName === 'Selected Directory' && firstFile.webkitRelativePath) {
|
||||||
const relativePath = firstFile.webkitRelativePath;
|
const relativePath = firstFile.webkitRelativePath;
|
||||||
console.log("[FilePicker] Using webkitRelativePath:", relativePath);
|
console.log('[FilePicker] Using webkitRelativePath:', relativePath);
|
||||||
const pathParts = relativePath.split("/");
|
const pathParts = relativePath.split('/');
|
||||||
if (pathParts.length > 0) {
|
if (pathParts.length > 0) {
|
||||||
directoryName = pathParts[0]; // Top-level directory name
|
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,
|
directoryName,
|
||||||
fileCount: files.length,
|
fileCount: files.length,
|
||||||
sampleFiles: sampleFiles.slice(0, 5), // Log first 5
|
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
|
// Only resolve as canceled if change event hasn't fired after a delay
|
||||||
focusTimeout = setTimeout(() => {
|
focusTimeout = setTimeout(() => {
|
||||||
if (!resolved && !changeEventFired && (!input.files || input.files.length === 0)) {
|
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);
|
safeResolve(null);
|
||||||
}
|
}
|
||||||
}, 2000); // Increased timeout for Windows - give it time
|
}, 2000); // Increased timeout for Windows - give it time
|
||||||
@@ -158,33 +155,37 @@ export async function openDirectoryPicker(): Promise<DirectoryPickerResult | nul
|
|||||||
|
|
||||||
// Add to DOM temporarily
|
// Add to DOM temporarily
|
||||||
document.body.appendChild(input);
|
document.body.appendChild(input);
|
||||||
console.log("[FilePicker] Opening directory picker...");
|
console.log('[FilePicker] Opening directory picker...');
|
||||||
|
|
||||||
// Try to show picker programmatically
|
// Try to show picker programmatically
|
||||||
if ("showPicker" in HTMLInputElement.prototype) {
|
if ('showPicker' in HTMLInputElement.prototype) {
|
||||||
try {
|
try {
|
||||||
(input as any).showPicker();
|
(input as any).showPicker();
|
||||||
console.log("[FilePicker] Using showPicker()");
|
console.log('[FilePicker] Using showPicker()');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[FilePicker] showPicker() failed, using click()", error);
|
console.log('[FilePicker] showPicker() failed, using click()', error);
|
||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("[FilePicker] Using click()");
|
console.log('[FilePicker] Using click()');
|
||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up cancellation detection with longer delay
|
// Set up cancellation detection with longer delay
|
||||||
// Only add focus listener if we're not already resolved
|
// 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)
|
// Also handle blur as a cancellation signal (but with delay)
|
||||||
window.addEventListener("blur", () => {
|
window.addEventListener(
|
||||||
// Dialog opened, wait for it to close
|
'blur',
|
||||||
setTimeout(() => {
|
() => {
|
||||||
window.addEventListener("focus", handleFocus, { once: true });
|
// Dialog opened, wait for it to close
|
||||||
}, 100);
|
setTimeout(() => {
|
||||||
}, { once: true });
|
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.)
|
* @param options Optional configuration (multiple files, file types, etc.)
|
||||||
* @returns Promise resolving to selected file path(s), or null if canceled
|
* @returns Promise resolving to selected file path(s), or null if canceled
|
||||||
*/
|
*/
|
||||||
export async function openFilePicker(
|
export async function openFilePicker(options?: {
|
||||||
options?: {
|
multiple?: boolean;
|
||||||
multiple?: boolean;
|
accept?: string;
|
||||||
accept?: string;
|
}): Promise<string | string[] | null> {
|
||||||
}
|
|
||||||
): Promise<string | string[] | null> {
|
|
||||||
// Use standard file input (works on all browsers including Windows)
|
// Use standard file input (works on all browsers including Windows)
|
||||||
return new Promise<string | string[] | null>((resolve) => {
|
return new Promise<string | string[] | null>((resolve) => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement('input');
|
||||||
input.type = "file";
|
input.type = 'file';
|
||||||
input.multiple = options?.multiple ?? false;
|
input.multiple = options?.multiple ?? false;
|
||||||
if (options?.accept) {
|
if (options?.accept) {
|
||||||
input.accept = options.accept;
|
input.accept = options.accept;
|
||||||
}
|
}
|
||||||
input.style.display = "none";
|
input.style.display = 'none';
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (input.parentNode) {
|
if (input.parentNode) {
|
||||||
@@ -215,7 +214,7 @@ export async function openFilePicker(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener('change', () => {
|
||||||
const files = input.files;
|
const files = input.files;
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -228,7 +227,7 @@ export async function openFilePicker(
|
|||||||
// Try to get path from File object (non-standard, but available in some browsers)
|
// Try to get path from File object (non-standard, but available in some browsers)
|
||||||
// @ts-expect-error - path property is non-standard
|
// @ts-expect-error - path property is non-standard
|
||||||
if (file.path) {
|
if (file.path) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error - path property is non-standard but available in some browsers
|
||||||
return file.path as string;
|
return file.path as string;
|
||||||
}
|
}
|
||||||
// Fallback to filename (server will need to resolve)
|
// Fallback to filename (server will need to resolve)
|
||||||
@@ -262,7 +261,7 @@ export async function openFilePicker(
|
|||||||
// Try to show picker programmatically
|
// Try to show picker programmatically
|
||||||
// Note: showPicker() is available in modern browsers but TypeScript types it as void
|
// 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
|
// 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 {
|
try {
|
||||||
(input as any).showPicker();
|
(input as any).showPicker();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -274,6 +273,6 @@ export async function openFilePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up cancellation detection
|
// Set up cancellation detection
|
||||||
window.addEventListener("focus", handleFocus, { once: true });
|
window.addEventListener('focus', handleFocus, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,41 +20,35 @@ import type {
|
|||||||
AutoModeEvent,
|
AutoModeEvent,
|
||||||
SuggestionsEvent,
|
SuggestionsEvent,
|
||||||
SpecRegenerationEvent,
|
SpecRegenerationEvent,
|
||||||
FeatureSuggestion,
|
|
||||||
SuggestionType,
|
SuggestionType,
|
||||||
} from "./electron";
|
} from './electron';
|
||||||
import type { Message, SessionListItem } from "@/types/electron";
|
import type { Message, SessionListItem } from '@/types/electron';
|
||||||
import type { Feature, ClaudeUsageResponse } from "@/store/app-store";
|
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
|
||||||
import type {
|
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||||
WorktreeAPI,
|
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||||
GitAPI,
|
|
||||||
ModelDefinition,
|
|
||||||
ProviderStatus,
|
|
||||||
} from "@/types/electron";
|
|
||||||
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
|
|
||||||
|
|
||||||
// Server URL - configurable via environment variable
|
// Server URL - configurable via environment variable
|
||||||
const getServerUrl = (): string => {
|
const getServerUrl = (): string => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== 'undefined') {
|
||||||
const envUrl = import.meta.env.VITE_SERVER_URL;
|
const envUrl = import.meta.env.VITE_SERVER_URL;
|
||||||
if (envUrl) return envUrl;
|
if (envUrl) return envUrl;
|
||||||
}
|
}
|
||||||
return "http://localhost:3008";
|
return 'http://localhost:3008';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get API key from environment variable
|
// Get API key from environment variable
|
||||||
const getApiKey = (): string | null => {
|
const getApiKey = (): string | null => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== 'undefined') {
|
||||||
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
|
return import.meta.env.VITE_AUTOMAKER_API_KEY || null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventType =
|
type EventType =
|
||||||
| "agent:stream"
|
| 'agent:stream'
|
||||||
| "auto-mode:event"
|
| 'auto-mode:event'
|
||||||
| "suggestions:event"
|
| 'suggestions:event'
|
||||||
| "spec-regeneration:event";
|
| 'spec-regeneration:event';
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -80,21 +74,18 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(): void {
|
||||||
if (
|
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||||
this.isConnecting ||
|
|
||||||
(this.ws && this.ws.readyState === WebSocket.OPEN)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
|
|
||||||
try {
|
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 = new WebSocket(wsUrl);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
console.log("[HttpApiClient] WebSocket connected");
|
console.log('[HttpApiClient] WebSocket connected');
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
@@ -110,15 +101,12 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
callbacks.forEach((cb) => cb(data.payload));
|
callbacks.forEach((cb) => cb(data.payload));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error('[HttpApiClient] Failed to parse WebSocket message:', error);
|
||||||
"[HttpApiClient] Failed to parse WebSocket message:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
console.log("[HttpApiClient] WebSocket disconnected");
|
console.log('[HttpApiClient] WebSocket disconnected');
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
// Attempt to reconnect after 5 seconds
|
// Attempt to reconnect after 5 seconds
|
||||||
@@ -131,19 +119,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
this.ws.onerror = (error) => {
|
||||||
console.error("[HttpApiClient] WebSocket error:", error);
|
console.error('[HttpApiClient] WebSocket error:', error);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[HttpApiClient] Failed to create WebSocket:", error);
|
console.error('[HttpApiClient] Failed to create WebSocket:', error);
|
||||||
this.isConnecting = false;
|
this.isConnecting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToEvent(
|
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
|
||||||
type: EventType,
|
|
||||||
callback: EventCallback
|
|
||||||
): () => void {
|
|
||||||
if (!this.eventCallbacks.has(type)) {
|
if (!this.eventCallbacks.has(type)) {
|
||||||
this.eventCallbacks.set(type, new Set());
|
this.eventCallbacks.set(type, new Set());
|
||||||
}
|
}
|
||||||
@@ -162,18 +147,18 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
private getHeaders(): Record<string, string> {
|
private getHeaders(): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
const apiKey = getApiKey();
|
const apiKey = getApiKey();
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
headers["X-API-Key"] = apiKey;
|
headers['X-API-Key'] = apiKey;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
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> {
|
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
method: "PUT",
|
method: 'PUT',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
@@ -197,7 +182,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
method: "DELETE",
|
method: 'DELETE',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
});
|
});
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -205,15 +190,13 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
// Basic operations
|
// Basic operations
|
||||||
async ping(): Promise<string> {
|
async ping(): Promise<string> {
|
||||||
const result = await this.get<{ status: string }>("/api/health");
|
const result = await this.get<{ status: string }>('/api/health');
|
||||||
return result.status === "ok" ? "pong" : "error";
|
return result.status === 'ok' ? 'pong' : 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
async openExternalLink(
|
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
||||||
url: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
// Open in new tab
|
// Open in new tab
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +205,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const fileBrowser = getGlobalFileBrowser();
|
const fileBrowser = getGlobalFileBrowser();
|
||||||
|
|
||||||
if (!fileBrowser) {
|
if (!fileBrowser) {
|
||||||
console.error("File browser not initialized");
|
console.error('File browser not initialized');
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,21 +220,21 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>("/api/fs/validate-path", { filePath: path });
|
}>('/api/fs/validate-path', { filePath: path });
|
||||||
|
|
||||||
if (result.success && result.path) {
|
if (result.success && result.path) {
|
||||||
return { canceled: false, filePaths: [result.path] };
|
return { canceled: false, filePaths: [result.path] };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Invalid directory:", result.error);
|
console.error('Invalid directory:', result.error);
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
async openFile(options?: object): Promise<DialogResult> {
|
async openFile(_options?: object): Promise<DialogResult> {
|
||||||
const fileBrowser = getGlobalFileBrowser();
|
const fileBrowser = getGlobalFileBrowser();
|
||||||
|
|
||||||
if (!fileBrowser) {
|
if (!fileBrowser) {
|
||||||
console.error("File browser not initialized");
|
console.error('File browser not initialized');
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,50 +245,48 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
|
||||||
"/api/fs/exists",
|
filePath: path,
|
||||||
{ filePath: path }
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.exists) {
|
if (result.success && result.exists) {
|
||||||
return { canceled: false, filePaths: [path] };
|
return { canceled: false, filePaths: [path] };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("File not found");
|
console.error('File not found');
|
||||||
return { canceled: true, filePaths: [] };
|
return { canceled: true, filePaths: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// File system operations
|
// File system operations
|
||||||
async readFile(filePath: string): Promise<FileResult> {
|
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> {
|
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> {
|
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> {
|
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> {
|
async exists(filePath: string): Promise<boolean> {
|
||||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
const result = await this.post<{ success: boolean; exists: boolean }>('/api/fs/exists', {
|
||||||
"/api/fs/exists",
|
filePath,
|
||||||
{ filePath }
|
});
|
||||||
);
|
|
||||||
return result.exists;
|
return result.exists;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stat(filePath: string): Promise<StatResult> {
|
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> {
|
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> {
|
async trashItem(filePath: string): Promise<WriteResult> {
|
||||||
@@ -315,11 +296,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
async getPath(name: string): Promise<string> {
|
async getPath(name: string): Promise<string> {
|
||||||
// Server provides data directory
|
// Server provides data directory
|
||||||
if (name === "userData") {
|
if (name === 'userData') {
|
||||||
const result = await this.get<{ dataDir: string }>(
|
const result = await this.get<{ dataDir: string }>('/api/health/detailed');
|
||||||
"/api/health/detailed"
|
return result.dataDir || '/data';
|
||||||
);
|
|
||||||
return result.dataDir || "/data";
|
|
||||||
}
|
}
|
||||||
return `/data/${name}`;
|
return `/data/${name}`;
|
||||||
}
|
}
|
||||||
@@ -330,7 +309,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
mimeType: string,
|
mimeType: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
): Promise<SaveImageResult> {
|
): Promise<SaveImageResult> {
|
||||||
return this.post("/api/fs/save-image", {
|
return this.post('/api/fs/save-image', {
|
||||||
data,
|
data,
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
@@ -344,7 +323,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
mimeType: string,
|
mimeType: string,
|
||||||
projectPath: string
|
projectPath: string
|
||||||
): Promise<{ success: boolean; path?: string; error?: 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,
|
data,
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
@@ -352,10 +331,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteBoardBackground(
|
async deleteBoardBackground(projectPath: string): Promise<{ success: boolean; error?: string }> {
|
||||||
projectPath: string
|
return this.post('/api/fs/delete-board-background', { projectPath });
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
return this.post("/api/fs/delete-board-background", { projectPath });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI checks - server-side
|
// CLI checks - server-side
|
||||||
@@ -374,7 +351,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
return this.get("/api/setup/claude-status");
|
return this.get('/api/setup/claude-status');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model API
|
// Model API
|
||||||
@@ -384,14 +361,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
models?: ModelDefinition[];
|
models?: ModelDefinition[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => {
|
}> => {
|
||||||
return this.get("/api/models/available");
|
return this.get('/api/models/available');
|
||||||
},
|
},
|
||||||
checkProviders: async (): Promise<{
|
checkProviders: async (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
providers?: Record<string, ProviderStatus>;
|
providers?: Record<string, ProviderStatus>;
|
||||||
error?: string;
|
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;
|
hasRecentActivity?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/setup/claude-status"),
|
}> => this.get('/api/setup/claude-status'),
|
||||||
|
|
||||||
installClaude: (): Promise<{
|
installClaude: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/install-claude"),
|
}> => this.post('/api/setup/install-claude'),
|
||||||
|
|
||||||
authClaude: (): Promise<{
|
authClaude: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -434,7 +411,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
output?: string;
|
output?: string;
|
||||||
}> => this.post("/api/setup/auth-claude"),
|
}> => this.post('/api/setup/auth-claude'),
|
||||||
|
|
||||||
storeApiKey: (
|
storeApiKey: (
|
||||||
provider: string,
|
provider: string,
|
||||||
@@ -442,7 +419,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
}> => this.post('/api/setup/store-api-key', { provider, apiKey }),
|
||||||
|
|
||||||
deleteApiKey: (
|
deleteApiKey: (
|
||||||
provider: string
|
provider: string
|
||||||
@@ -450,13 +427,13 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}> => this.post("/api/setup/delete-api-key", { provider }),
|
}> => this.post('/api/setup/delete-api-key', { provider }),
|
||||||
|
|
||||||
getApiKeys: (): Promise<{
|
getApiKeys: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}> => this.get("/api/setup/api-keys"),
|
}> => this.get('/api/setup/api-keys'),
|
||||||
|
|
||||||
getPlatform: (): Promise<{
|
getPlatform: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -466,15 +443,15 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
isWindows: boolean;
|
isWindows: boolean;
|
||||||
isMac: boolean;
|
isMac: boolean;
|
||||||
isLinux: boolean;
|
isLinux: boolean;
|
||||||
}> => this.get("/api/setup/platform"),
|
}> => this.get('/api/setup/platform'),
|
||||||
|
|
||||||
verifyClaudeAuth: (
|
verifyClaudeAuth: (
|
||||||
authMethod?: "cli" | "api_key"
|
authMethod?: 'cli' | 'api_key'
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
|
}> => this.post('/api/setup/verify-claude-auth', { authMethod }),
|
||||||
|
|
||||||
getGhStatus: (): Promise<{
|
getGhStatus: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -484,76 +461,65 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
path: string | null;
|
path: string | null;
|
||||||
user: string | null;
|
user: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/setup/gh-status"),
|
}> => this.get('/api/setup/gh-status'),
|
||||||
|
|
||||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent("agent:stream", callback);
|
return this.subscribeToEvent('agent:stream', callback);
|
||||||
},
|
},
|
||||||
|
|
||||||
onAuthProgress: (callback: (progress: unknown) => void) => {
|
onAuthProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent("agent:stream", callback);
|
return this.subscribeToEvent('agent:stream', callback);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Features API
|
// Features API
|
||||||
features: FeaturesAPI = {
|
features: FeaturesAPI = {
|
||||||
getAll: (projectPath: string) =>
|
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
|
||||||
this.post("/api/features/list", { projectPath }),
|
|
||||||
get: (projectPath: string, featureId: string) =>
|
get: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/features/get", { projectPath, featureId }),
|
this.post('/api/features/get', { projectPath, featureId }),
|
||||||
create: (projectPath: string, feature: Feature) =>
|
create: (projectPath: string, feature: Feature) =>
|
||||||
this.post("/api/features/create", { projectPath, feature }),
|
this.post('/api/features/create', { projectPath, feature }),
|
||||||
update: (
|
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
|
||||||
projectPath: string,
|
this.post('/api/features/update', { projectPath, featureId, updates }),
|
||||||
featureId: string,
|
|
||||||
updates: Partial<Feature>
|
|
||||||
) => this.post("/api/features/update", { projectPath, featureId, updates }),
|
|
||||||
delete: (projectPath: string, featureId: string) =>
|
delete: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/features/delete", { projectPath, featureId }),
|
this.post('/api/features/delete', { projectPath, featureId }),
|
||||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/features/agent-output", { projectPath, featureId }),
|
this.post('/api/features/agent-output', { projectPath, featureId }),
|
||||||
generateTitle: (description: string) =>
|
generateTitle: (description: string) =>
|
||||||
this.post("/api/features/generate-title", { description }),
|
this.post('/api/features/generate-title', { description }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto Mode API
|
// Auto Mode API
|
||||||
autoMode: AutoModeAPI = {
|
autoMode: AutoModeAPI = {
|
||||||
start: (projectPath: string, maxConcurrency?: number) =>
|
start: (projectPath: string, maxConcurrency?: number) =>
|
||||||
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
|
this.post('/api/auto-mode/start', { projectPath, maxConcurrency }),
|
||||||
stop: (projectPath: string) =>
|
stop: (projectPath: string) => this.post('/api/auto-mode/stop', { projectPath }),
|
||||||
this.post("/api/auto-mode/stop", { projectPath }),
|
stopFeature: (featureId: string) => this.post('/api/auto-mode/stop-feature', { featureId }),
|
||||||
stopFeature: (featureId: string) =>
|
status: (projectPath?: string) => this.post('/api/auto-mode/status', { projectPath }),
|
||||||
this.post("/api/auto-mode/stop-feature", { featureId }),
|
|
||||||
status: (projectPath?: string) =>
|
|
||||||
this.post("/api/auto-mode/status", { projectPath }),
|
|
||||||
runFeature: (
|
runFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
useWorktrees?: boolean,
|
useWorktrees?: boolean,
|
||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/auto-mode/run-feature", {
|
this.post('/api/auto-mode/run-feature', {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
}),
|
}),
|
||||||
verifyFeature: (projectPath: string, featureId: string) =>
|
verifyFeature: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
this.post('/api/auto-mode/verify-feature', { projectPath, featureId }),
|
||||||
resumeFeature: (
|
resumeFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
|
||||||
projectPath: string,
|
this.post('/api/auto-mode/resume-feature', {
|
||||||
featureId: string,
|
|
||||||
useWorktrees?: boolean
|
|
||||||
) =>
|
|
||||||
this.post("/api/auto-mode/resume-feature", {
|
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
useWorktrees,
|
useWorktrees,
|
||||||
}),
|
}),
|
||||||
contextExists: (projectPath: string, featureId: string) =>
|
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) =>
|
analyzeProject: (projectPath: string) =>
|
||||||
this.post("/api/auto-mode/analyze-project", { projectPath }),
|
this.post('/api/auto-mode/analyze-project', { projectPath }),
|
||||||
followUpFeature: (
|
followUpFeature: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
featureId: string,
|
featureId: string,
|
||||||
@@ -561,19 +527,15 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/auto-mode/follow-up-feature", {
|
this.post('/api/auto-mode/follow-up-feature', {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
prompt,
|
prompt,
|
||||||
imagePaths,
|
imagePaths,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
}),
|
}),
|
||||||
commitFeature: (
|
commitFeature: (projectPath: string, featureId: string, worktreePath?: string) =>
|
||||||
projectPath: string,
|
this.post('/api/auto-mode/commit-feature', {
|
||||||
featureId: string,
|
|
||||||
worktreePath?: string
|
|
||||||
) =>
|
|
||||||
this.post("/api/auto-mode/commit-feature", {
|
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
@@ -585,7 +547,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
editedPlan?: string,
|
editedPlan?: string,
|
||||||
feedback?: string
|
feedback?: string
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/auto-mode/approve-plan", {
|
this.post('/api/auto-mode/approve-plan', {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
approved,
|
approved,
|
||||||
@@ -593,10 +555,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
feedback,
|
feedback,
|
||||||
}),
|
}),
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
return this.subscribeToEvent(
|
return this.subscribeToEvent('auto-mode:event', callback as EventCallback);
|
||||||
"auto-mode:event",
|
|
||||||
callback as EventCallback
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -607,7 +566,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
enhancementMode: string,
|
enhancementMode: string,
|
||||||
model?: string
|
model?: string
|
||||||
): Promise<EnhancePromptResult> =>
|
): Promise<EnhancePromptResult> =>
|
||||||
this.post("/api/enhance-prompt", {
|
this.post('/api/enhance-prompt', {
|
||||||
originalText,
|
originalText,
|
||||||
enhancementMode,
|
enhancementMode,
|
||||||
model,
|
model,
|
||||||
@@ -617,86 +576,74 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
// Worktree API
|
// Worktree API
|
||||||
worktree: WorktreeAPI = {
|
worktree: WorktreeAPI = {
|
||||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
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) =>
|
getInfo: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/worktree/info", { projectPath, featureId }),
|
this.post('/api/worktree/info', { projectPath, featureId }),
|
||||||
getStatus: (projectPath: string, featureId: string) =>
|
getStatus: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/worktree/status", { projectPath, featureId }),
|
this.post('/api/worktree/status', { projectPath, featureId }),
|
||||||
list: (projectPath: string) =>
|
list: (projectPath: string) => this.post('/api/worktree/list', { projectPath }),
|
||||||
this.post("/api/worktree/list", { projectPath }),
|
|
||||||
listAll: (projectPath: string, includeDetails?: boolean) =>
|
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) =>
|
create: (projectPath: string, branchName: string, baseBranch?: string) =>
|
||||||
this.post("/api/worktree/create", {
|
this.post('/api/worktree/create', {
|
||||||
projectPath,
|
projectPath,
|
||||||
branchName,
|
branchName,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
}),
|
}),
|
||||||
delete: (
|
delete: (projectPath: string, worktreePath: string, deleteBranch?: boolean) =>
|
||||||
projectPath: string,
|
this.post('/api/worktree/delete', {
|
||||||
worktreePath: string,
|
|
||||||
deleteBranch?: boolean
|
|
||||||
) =>
|
|
||||||
this.post("/api/worktree/delete", {
|
|
||||||
projectPath,
|
projectPath,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
}),
|
}),
|
||||||
commit: (worktreePath: string, message: string) =>
|
commit: (worktreePath: string, message: string) =>
|
||||||
this.post("/api/worktree/commit", { worktreePath, message }),
|
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||||
push: (worktreePath: string, force?: boolean) =>
|
push: (worktreePath: string, force?: boolean) =>
|
||||||
this.post("/api/worktree/push", { worktreePath, force }),
|
this.post('/api/worktree/push', { worktreePath, force }),
|
||||||
createPR: (worktreePath: string, options?: any) =>
|
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) =>
|
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) =>
|
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
||||||
this.post("/api/worktree/file-diff", {
|
this.post('/api/worktree/file-diff', {
|
||||||
projectPath,
|
projectPath,
|
||||||
featureId,
|
featureId,
|
||||||
filePath,
|
filePath,
|
||||||
}),
|
}),
|
||||||
pull: (worktreePath: string) =>
|
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
|
||||||
this.post("/api/worktree/pull", { worktreePath }),
|
|
||||||
checkoutBranch: (worktreePath: string, branchName: string) =>
|
checkoutBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
|
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
|
||||||
listBranches: (worktreePath: string) =>
|
listBranches: (worktreePath: string) =>
|
||||||
this.post("/api/worktree/list-branches", { worktreePath }),
|
this.post('/api/worktree/list-branches', { worktreePath }),
|
||||||
switchBranch: (worktreePath: string, branchName: string) =>
|
switchBranch: (worktreePath: string, branchName: string) =>
|
||||||
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
|
this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
|
||||||
openInEditor: (worktreePath: string) =>
|
openInEditor: (worktreePath: string) =>
|
||||||
this.post("/api/worktree/open-in-editor", { worktreePath }),
|
this.post('/api/worktree/open-in-editor', { worktreePath }),
|
||||||
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
|
getDefaultEditor: () => this.get('/api/worktree/default-editor'),
|
||||||
initGit: (projectPath: string) =>
|
initGit: (projectPath: string) => this.post('/api/worktree/init-git', { projectPath }),
|
||||||
this.post("/api/worktree/init-git", { projectPath }),
|
|
||||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||||
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
|
this.post('/api/worktree/start-dev', { projectPath, worktreePath }),
|
||||||
stopDevServer: (worktreePath: string) =>
|
stopDevServer: (worktreePath: string) => this.post('/api/worktree/stop-dev', { worktreePath }),
|
||||||
this.post("/api/worktree/stop-dev", { worktreePath }),
|
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
|
||||||
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
|
||||||
getPRInfo: (worktreePath: string, branchName: string) =>
|
getPRInfo: (worktreePath: string, branchName: string) =>
|
||||||
this.post("/api/worktree/pr-info", { worktreePath, branchName }),
|
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Git API
|
// Git API
|
||||||
git: GitAPI = {
|
git: GitAPI = {
|
||||||
getDiffs: (projectPath: string) =>
|
getDiffs: (projectPath: string) => this.post('/api/git/diffs', { projectPath }),
|
||||||
this.post("/api/git/diffs", { projectPath }),
|
|
||||||
getFileDiff: (projectPath: string, filePath: string) =>
|
getFileDiff: (projectPath: string, filePath: string) =>
|
||||||
this.post("/api/git/file-diff", { projectPath, filePath }),
|
this.post('/api/git/file-diff', { projectPath, filePath }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suggestions API
|
// Suggestions API
|
||||||
suggestions: SuggestionsAPI = {
|
suggestions: SuggestionsAPI = {
|
||||||
generate: (projectPath: string, suggestionType?: SuggestionType) =>
|
generate: (projectPath: string, suggestionType?: SuggestionType) =>
|
||||||
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
|
this.post('/api/suggestions/generate', { projectPath, suggestionType }),
|
||||||
stop: () => this.post("/api/suggestions/stop"),
|
stop: () => this.post('/api/suggestions/stop'),
|
||||||
status: () => this.get("/api/suggestions/status"),
|
status: () => this.get('/api/suggestions/status'),
|
||||||
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
||||||
return this.subscribeToEvent(
|
return this.subscribeToEvent('suggestions:event', callback as EventCallback);
|
||||||
"suggestions:event",
|
|
||||||
callback as EventCallback
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -709,7 +656,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
analyzeProject?: boolean,
|
analyzeProject?: boolean,
|
||||||
maxFeatures?: number
|
maxFeatures?: number
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/spec-regeneration/create", {
|
this.post('/api/spec-regeneration/create', {
|
||||||
projectPath,
|
projectPath,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
@@ -723,7 +670,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
analyzeProject?: boolean,
|
analyzeProject?: boolean,
|
||||||
maxFeatures?: number
|
maxFeatures?: number
|
||||||
) =>
|
) =>
|
||||||
this.post("/api/spec-regeneration/generate", {
|
this.post('/api/spec-regeneration/generate', {
|
||||||
projectPath,
|
projectPath,
|
||||||
projectDefinition,
|
projectDefinition,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
@@ -731,17 +678,14 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
maxFeatures,
|
maxFeatures,
|
||||||
}),
|
}),
|
||||||
generateFeatures: (projectPath: string, maxFeatures?: number) =>
|
generateFeatures: (projectPath: string, maxFeatures?: number) =>
|
||||||
this.post("/api/spec-regeneration/generate-features", {
|
this.post('/api/spec-regeneration/generate-features', {
|
||||||
projectPath,
|
projectPath,
|
||||||
maxFeatures,
|
maxFeatures,
|
||||||
}),
|
}),
|
||||||
stop: () => this.post("/api/spec-regeneration/stop"),
|
stop: () => this.post('/api/spec-regeneration/stop'),
|
||||||
status: () => this.get("/api/spec-regeneration/status"),
|
status: () => this.get('/api/spec-regeneration/status'),
|
||||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||||
return this.subscribeToEvent(
|
return this.subscribeToEvent('spec-regeneration:event', callback as EventCallback);
|
||||||
"spec-regeneration:event",
|
|
||||||
callback as EventCallback
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -757,7 +701,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}>;
|
}>;
|
||||||
totalCount?: number;
|
totalCount?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/running-agents"),
|
}> => this.get('/api/running-agents'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Workspace API
|
// Workspace API
|
||||||
@@ -768,13 +712,13 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
defaultDir?: string | null;
|
defaultDir?: string | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/workspace/config"),
|
}> => this.get('/api/workspace/config'),
|
||||||
|
|
||||||
getDirectories: (): Promise<{
|
getDirectories: (): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
directories?: Array<{ name: string; path: string }>;
|
directories?: Array<{ name: string; path: string }>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/workspace/directories"),
|
}> => this.get('/api/workspace/directories'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agent API
|
// Agent API
|
||||||
@@ -786,7 +730,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
|
}> => this.post('/api/agent/start', { sessionId, workingDirectory }),
|
||||||
|
|
||||||
send: (
|
send: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -795,7 +739,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
imagePaths?: string[],
|
imagePaths?: string[],
|
||||||
model?: string
|
model?: string
|
||||||
): Promise<{ success: boolean; error?: string }> =>
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.post("/api/agent/send", {
|
this.post('/api/agent/send', {
|
||||||
sessionId,
|
sessionId,
|
||||||
message,
|
message,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
@@ -810,16 +754,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/agent/history", { sessionId }),
|
}> => this.post('/api/agent/history', { sessionId }),
|
||||||
|
|
||||||
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
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 }> =>
|
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) => {
|
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;
|
projectPath?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
error?: 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
|
// Settings API - persistent file-based settings
|
||||||
@@ -847,7 +790,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
hasCredentials: boolean;
|
hasCredentials: boolean;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
needsMigration: boolean;
|
needsMigration: boolean;
|
||||||
}> => this.get("/api/settings/status"),
|
}> => this.get('/api/settings/status'),
|
||||||
|
|
||||||
// Global settings
|
// Global settings
|
||||||
getGlobal: (): Promise<{
|
getGlobal: (): Promise<{
|
||||||
@@ -880,13 +823,15 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
lastSelectedSessionByProject: Record<string, string>;
|
lastSelectedSessionByProject: Record<string, string>;
|
||||||
};
|
};
|
||||||
error?: 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;
|
success: boolean;
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.put("/api/settings/global", updates),
|
}> => this.put('/api/settings/global', updates),
|
||||||
|
|
||||||
// Credentials (masked for security)
|
// Credentials (masked for security)
|
||||||
getCredentials: (): Promise<{
|
getCredentials: (): Promise<{
|
||||||
@@ -897,7 +842,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
openai: { configured: boolean; masked: string };
|
openai: { configured: boolean; masked: string };
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get("/api/settings/credentials"),
|
}> => this.get('/api/settings/credentials'),
|
||||||
|
|
||||||
updateCredentials: (updates: {
|
updateCredentials: (updates: {
|
||||||
apiKeys?: { anthropic?: string; google?: string; openai?: string };
|
apiKeys?: { anthropic?: string; google?: string; openai?: string };
|
||||||
@@ -909,10 +854,12 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
openai: { configured: boolean; masked: string };
|
openai: { configured: boolean; masked: string };
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.put("/api/settings/credentials", updates),
|
}> => this.put('/api/settings/credentials', updates),
|
||||||
|
|
||||||
// Project settings
|
// Project settings
|
||||||
getProject: (projectPath: string): Promise<{
|
getProject: (
|
||||||
|
projectPath: string
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
settings?: {
|
settings?: {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -940,7 +887,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
lastSelectedSessionId?: string;
|
lastSelectedSessionId?: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/settings/project", { projectPath }),
|
}> => this.post('/api/settings/project', { projectPath }),
|
||||||
|
|
||||||
updateProject: (
|
updateProject: (
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
@@ -949,22 +896,22 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.put("/api/settings/project", { projectPath, updates }),
|
}> => this.put('/api/settings/project', { projectPath, updates }),
|
||||||
|
|
||||||
// Migration from localStorage
|
// Migration from localStorage
|
||||||
migrate: (data: {
|
migrate: (data: {
|
||||||
"automaker-storage"?: string;
|
'automaker-storage'?: string;
|
||||||
"automaker-setup"?: string;
|
'automaker-setup'?: string;
|
||||||
"worktree-panel-collapsed"?: string;
|
'worktree-panel-collapsed'?: string;
|
||||||
"file-browser-recent-folders"?: string;
|
'file-browser-recent-folders'?: string;
|
||||||
"automaker:lastProjectDir"?: string;
|
'automaker:lastProjectDir'?: string;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
migratedGlobalSettings: boolean;
|
migratedGlobalSettings: boolean;
|
||||||
migratedCredentials: boolean;
|
migratedCredentials: boolean;
|
||||||
migratedProjectCount: number;
|
migratedProjectCount: number;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}> => this.post("/api/settings/migrate", { data }),
|
}> => this.post('/api/settings/migrate', { data }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sessions API
|
// Sessions API
|
||||||
@@ -992,7 +939,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
|
}> => this.post('/api/sessions', { name, projectPath, workingDirectory }),
|
||||||
|
|
||||||
update: (
|
update: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -1001,25 +948,19 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
): Promise<{ success: boolean; error?: string }> =>
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
||||||
|
|
||||||
archive: (
|
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
sessionId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> =>
|
|
||||||
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
||||||
|
|
||||||
unarchive: (
|
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
sessionId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> =>
|
|
||||||
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
||||||
|
|
||||||
delete: (
|
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||||
sessionId: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> =>
|
|
||||||
this.httpDelete(`/api/sessions/${sessionId}`),
|
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Claude API
|
// Claude API
|
||||||
claude = {
|
claude = {
|
||||||
getUsage: (): Promise<ClaudeUsageResponse> => this.get("/api/claude/usage"),
|
getUsage: (): Promise<ClaudeUsageResponse> => this.get('/api/claude/usage'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { AgentModel } from "@/store/app-store"
|
import type { AgentModel } from '@/store/app-store';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the current model supports extended thinking controls
|
* 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
|
// All Claude models support thinking
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -19,9 +19,9 @@ export function modelSupportsThinking(model?: AgentModel | string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function getModelDisplayName(model: AgentModel | string): string {
|
export function getModelDisplayName(model: AgentModel | string): string {
|
||||||
const displayNames: Record<string, string> = {
|
const displayNames: Record<string, string> = {
|
||||||
haiku: "Claude Haiku",
|
haiku: 'Claude Haiku',
|
||||||
sonnet: "Claude Sonnet",
|
sonnet: 'Claude Sonnet',
|
||||||
opus: "Claude Opus",
|
opus: 'Claude Opus',
|
||||||
};
|
};
|
||||||
return displayNames[model] || model;
|
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).
|
* This is important for cross-platform compatibility (Windows uses backslashes).
|
||||||
*/
|
*/
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
return p.replace(/\\/g, "/");
|
return p.replace(/\\/g, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
* Centralizes the logic for determining where projects should be created/opened
|
* Centralizes the logic for determining where projects should be created/opened
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable no-undef */
|
import { getHttpApiClient } from './http-api-client';
|
||||||
import { getHttpApiClient } from "./http-api-client";
|
import { getElectronAPI } from './electron';
|
||||||
import { getElectronAPI } from "./electron";
|
import { getItem, setItem } from './storage';
|
||||||
import { getItem, setItem } from "./storage";
|
import path from 'path';
|
||||||
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
|
* Gets the default Documents/Automaker directory path
|
||||||
@@ -18,11 +17,11 @@ const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
|
|||||||
async function getDefaultDocumentsPath(): Promise<string | null> {
|
async function getDefaultDocumentsPath(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const documentsPath = await api.getPath("documents");
|
const documentsPath = await api.getPath('documents');
|
||||||
return path.join(documentsPath, "Automaker");
|
return path.join(documentsPath, 'Automaker');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof window !== "undefined" && window.console) {
|
if (typeof window !== 'undefined' && window.console) {
|
||||||
window.console.error("Failed to get documents path:", error);
|
window.console.error('Failed to get documents path:', error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -82,8 +81,8 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
|
|||||||
const documentsPath = await getDefaultDocumentsPath();
|
const documentsPath = await getDefaultDocumentsPath();
|
||||||
return documentsPath;
|
return documentsPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof window !== "undefined" && window.console) {
|
if (typeof window !== 'undefined' && window.console) {
|
||||||
window.console.error("Failed to get default workspace directory:", error);
|
window.console.error('Failed to get default workspace directory:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On error, try last used dir and Documents
|
// On error, try last used dir and Documents
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from "./App";
|
import App from './app';
|
||||||
|
|
||||||
createRoot(document.getElementById("app")!).render(
|
createRoot(document.getElementById('app')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ThemeOption, themeOptions } from '@/config/theme-options';
|
|||||||
|
|
||||||
function RootLayoutContent() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { setIpcConnected, theme, currentProject, previewTheme, getEffectiveTheme } = useAppStore();
|
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
|
||||||
const { setupComplete } = useSetupStore();
|
const { setupComplete } = useSetupStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|||||||
11
apps/ui/src/vite-env.d.ts
vendored
Normal file
11
apps/ui/src/vite-env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
* so it doesn't make real API calls during CI/CD runs.
|
* so it doesn't make real API calls during CI/CD runs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from '@playwright/test';
|
||||||
import * as fs from "fs";
|
import * as fs from 'fs';
|
||||||
import * as path from "path";
|
import * as path from 'path';
|
||||||
import { exec } from "child_process";
|
import { exec } from 'child_process';
|
||||||
import { promisify } from "util";
|
import { promisify } from 'util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
@@ -29,15 +29,14 @@ import {
|
|||||||
setupProjectWithPathNoWorktrees,
|
setupProjectWithPathNoWorktrees,
|
||||||
waitForBoardView,
|
waitForBoardView,
|
||||||
clickAddFeature,
|
clickAddFeature,
|
||||||
fillAddFeatureDialog,
|
|
||||||
confirmAddFeature,
|
confirmAddFeature,
|
||||||
dragAndDropWithDndKit,
|
dragAndDropWithDndKit,
|
||||||
} from "./utils";
|
} from './utils';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
// Create unique temp dir for this test run
|
// 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 {
|
interface TestRepo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -45,9 +44,9 @@ interface TestRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure all tests to run serially
|
// 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 testRepo: TestRepo;
|
||||||
let featureId: string;
|
let featureId: string;
|
||||||
|
|
||||||
@@ -76,7 +75,7 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// this one fails in github actions for some reason
|
// 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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Increase timeout for this comprehensive test
|
// 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
|
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
|
||||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
@@ -98,18 +97,15 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
await clickAddFeature(page);
|
await clickAddFeature(page);
|
||||||
|
|
||||||
// Fill in the feature details - requesting a file with "yellow" content
|
// Fill in the feature details - requesting a file with "yellow" content
|
||||||
const featureDescription =
|
const featureDescription = 'Create a file named yellow.txt that contains the text yellow';
|
||||||
"Create a file named yellow.txt that contains the text yellow";
|
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(featureDescription);
|
await descriptionInput.fill(featureDescription);
|
||||||
|
|
||||||
// Confirm the feature creation
|
// Confirm the feature creation
|
||||||
await confirmAddFeature(page);
|
await confirmAddFeature(page);
|
||||||
|
|
||||||
// Debug: Check the filesystem to see if feature was created
|
// 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
|
// Wait for the feature to be created in the filesystem
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
@@ -131,18 +127,14 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
featureId = featureDirs[0];
|
featureId = featureDirs[0];
|
||||||
|
|
||||||
// Now get the actual card element by testid
|
// Now get the actual card element by testid
|
||||||
const featureCardByTestId = page.locator(
|
const featureCardByTestId = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
`[data-testid="kanban-card-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
|
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Step 2: Drag feature to in_progress and wait for agent to finish
|
// Step 2: Drag feature to in_progress and wait for agent to finish
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||||
const inProgressColumn = page.locator(
|
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||||
'[data-testid="kanban-column-in_progress"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Perform the drag and drop using dnd-kit compatible method
|
// Perform the drag and drop using dnd-kit compatible method
|
||||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
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
|
// This helps diagnose if the drag-drop is working or not
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const featureData = JSON.parse(
|
const featureData = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||||
path.join(featuresDir, featureId, "feature.json"),
|
|
||||||
"utf-8"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
// 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 });
|
}).toPass({ timeout: 15000 });
|
||||||
|
|
||||||
// The mock agent should complete quickly (about 1.3 seconds based on the sleep times)
|
// 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
|
// The status changes are: in_progress -> waiting_approval after agent completes
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const featureData = JSON.parse(
|
const featureData = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||||
path.join(featuresDir, featureId, "feature.json"),
|
|
||||||
"utf-8"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
expect(featureData.status).toBe("waiting_approval");
|
expect(featureData.status).toBe('waiting_approval');
|
||||||
}).toPass({ timeout: 30000 });
|
}).toPass({ timeout: 30000 });
|
||||||
|
|
||||||
// Refresh page to ensure UI reflects the status change
|
// 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
|
// Step 3: Verify feature is in waiting_approval (manual review) column
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
const waitingApprovalColumn = page.locator(
|
const waitingApprovalColumn = page.locator('[data-testid="kanban-column-waiting_approval"]');
|
||||||
'[data-testid="kanban-column-waiting_approval"]'
|
|
||||||
);
|
|
||||||
const cardInWaitingApproval = waitingApprovalColumn.locator(
|
const cardInWaitingApproval = waitingApprovalColumn.locator(
|
||||||
`[data-testid="kanban-card-${featureId}"]`
|
`[data-testid="kanban-card-${featureId}"]`
|
||||||
);
|
);
|
||||||
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
|
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Verify the mock agent created the yellow.txt file
|
// 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);
|
expect(fs.existsSync(yellowFilePath)).toBe(true);
|
||||||
const yellowContent = fs.readFileSync(yellowFilePath, "utf-8");
|
const yellowContent = fs.readFileSync(yellowFilePath, 'utf-8');
|
||||||
expect(yellowContent).toBe("yellow");
|
expect(yellowContent).toBe('yellow');
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Step 4: Click commit and verify git status shows committed changes
|
// Step 4: Click commit and verify git status shows committed changes
|
||||||
@@ -207,18 +191,18 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Verify git status shows clean (changes committed)
|
// 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,
|
cwd: testRepo.path,
|
||||||
});
|
});
|
||||||
// After commit, the yellow.txt file should be committed, so git status should be clean
|
// After commit, the yellow.txt file should be committed, so git status should be clean
|
||||||
// (only .automaker directory might have changes)
|
// (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
|
// 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,
|
cwd: testRepo.path,
|
||||||
});
|
});
|
||||||
expect(gitLog.toLowerCase()).toContain("yellow");
|
expect(gitLog.toLowerCase()).toContain('yellow');
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Step 5: Verify feature moved to verified column after commit
|
// Step 5: Verify feature moved to verified column after commit
|
||||||
@@ -228,21 +212,15 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
const verifiedColumn = page.locator(
|
const verifiedColumn = page.locator('[data-testid="kanban-column-verified"]');
|
||||||
'[data-testid="kanban-column-verified"]'
|
const cardInVerified = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
);
|
|
||||||
const cardInVerified = verifiedColumn.locator(
|
|
||||||
`[data-testid="kanban-card-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
|
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Step 6: Archive (complete) the feature
|
// Step 6: Archive (complete) the feature
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Click the Complete button on the verified card
|
// Click the Complete button on the verified card
|
||||||
const completeButton = page.locator(
|
const completeButton = page.locator(`[data-testid="complete-${featureId}"]`);
|
||||||
`[data-testid="complete-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(completeButton).toBeVisible({ timeout: 5000 });
|
await expect(completeButton).toBeVisible({ timeout: 5000 });
|
||||||
await completeButton.click();
|
await completeButton.click();
|
||||||
|
|
||||||
@@ -254,39 +232,28 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
|
|
||||||
// Verify feature status is completed in filesystem
|
// Verify feature status is completed in filesystem
|
||||||
const featureData = JSON.parse(
|
const featureData = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||||
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
|
// Step 7: Open archive modal and restore the feature
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Click the completed features button to open the archive modal
|
// Click the completed features button to open the archive modal
|
||||||
const completedFeaturesButton = page.locator(
|
const completedFeaturesButton = page.locator('[data-testid="completed-features-button"]');
|
||||||
'[data-testid="completed-features-button"]'
|
|
||||||
);
|
|
||||||
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
|
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
|
||||||
await completedFeaturesButton.click();
|
await completedFeaturesButton.click();
|
||||||
|
|
||||||
// Wait for the modal to open
|
// Wait for the modal to open
|
||||||
const completedModal = page.locator(
|
const completedModal = page.locator('[data-testid="completed-features-modal"]');
|
||||||
'[data-testid="completed-features-modal"]'
|
|
||||||
);
|
|
||||||
await expect(completedModal).toBeVisible({ timeout: 5000 });
|
await expect(completedModal).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Verify the archived feature is shown in the modal
|
// Verify the archived feature is shown in the modal
|
||||||
const archivedCard = completedModal.locator(
|
const archivedCard = completedModal.locator(`[data-testid="completed-card-${featureId}"]`);
|
||||||
`[data-testid="completed-card-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(archivedCard).toBeVisible({ timeout: 5000 });
|
await expect(archivedCard).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Click the restore button
|
// Click the restore button
|
||||||
const restoreButton = page.locator(
|
const restoreButton = page.locator(`[data-testid="unarchive-${featureId}"]`);
|
||||||
`[data-testid="unarchive-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(restoreButton).toBeVisible({ timeout: 5000 });
|
await expect(restoreButton).toBeVisible({ timeout: 5000 });
|
||||||
await restoreButton.click();
|
await restoreButton.click();
|
||||||
|
|
||||||
@@ -294,47 +261,34 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// Close the modal - use first() to select the footer Close button, not the X button
|
// Close the modal - use first() to select the footer Close button, not the X button
|
||||||
const closeButton = completedModal
|
const closeButton = completedModal.locator('button:has-text("Close")').first();
|
||||||
.locator('button:has-text("Close")')
|
|
||||||
.first();
|
|
||||||
await closeButton.click();
|
await closeButton.click();
|
||||||
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
|
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Verify the feature is back in the verified column
|
// Verify the feature is back in the verified column
|
||||||
const restoredCard = verifiedColumn.locator(
|
const restoredCard = verifiedColumn.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
`[data-testid="kanban-card-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(restoredCard).toBeVisible({ timeout: 10000 });
|
await expect(restoredCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Verify feature status is verified in filesystem
|
// Verify feature status is verified in filesystem
|
||||||
const restoredFeatureData = JSON.parse(
|
const restoredFeatureData = JSON.parse(
|
||||||
fs.readFileSync(
|
fs.readFileSync(path.join(featuresDir, featureId, 'feature.json'), 'utf-8')
|
||||||
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
|
// Step 8: Delete the feature and verify it's removed
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Click the delete button on the verified card
|
// Click the delete button on the verified card
|
||||||
const deleteButton = page.locator(
|
const deleteButton = page.locator(`[data-testid="delete-verified-${featureId}"]`);
|
||||||
`[data-testid="delete-verified-${featureId}"]`
|
|
||||||
);
|
|
||||||
await expect(deleteButton).toBeVisible({ timeout: 5000 });
|
await expect(deleteButton).toBeVisible({ timeout: 5000 });
|
||||||
await deleteButton.click();
|
await deleteButton.click();
|
||||||
|
|
||||||
// Wait for the confirmation dialog
|
// Wait for the confirmation dialog
|
||||||
const confirmDialog = page.locator(
|
const confirmDialog = page.locator('[data-testid="delete-confirmation-dialog"]');
|
||||||
'[data-testid="delete-confirmation-dialog"]'
|
|
||||||
);
|
|
||||||
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Click the confirm delete button
|
// Click the confirm delete button
|
||||||
const confirmDeleteButton = page.locator(
|
const confirmDeleteButton = page.locator('[data-testid="confirm-delete-button"]');
|
||||||
'[data-testid="confirm-delete-button"]'
|
|
||||||
);
|
|
||||||
await confirmDeleteButton.click();
|
await confirmDeleteButton.click();
|
||||||
|
|
||||||
// Wait for the delete action to complete
|
// 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
|
// Step 1: Setup and create a feature in backlog
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
@@ -370,17 +324,15 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
await clickAddFeature(page);
|
await clickAddFeature(page);
|
||||||
|
|
||||||
// Fill in the feature details
|
// Fill in the feature details
|
||||||
const featureDescription = "Create a file named test-restart.txt";
|
const featureDescription = 'Create a file named test-restart.txt';
|
||||||
const descriptionInput = page
|
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
|
||||||
.first();
|
|
||||||
await descriptionInput.fill(featureDescription);
|
await descriptionInput.fill(featureDescription);
|
||||||
|
|
||||||
// Confirm the feature creation
|
// Confirm the feature creation
|
||||||
await confirmAddFeature(page);
|
await confirmAddFeature(page);
|
||||||
|
|
||||||
// Wait for the feature to be created in the filesystem
|
// 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 () => {
|
await expect(async () => {
|
||||||
const dirs = fs.readdirSync(featuresDir);
|
const dirs = fs.readdirSync(featuresDir);
|
||||||
expect(dirs.length).toBeGreaterThan(0);
|
expect(dirs.length).toBeGreaterThan(0);
|
||||||
@@ -396,36 +348,26 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
await waitForBoardView(page);
|
await waitForBoardView(page);
|
||||||
|
|
||||||
// Wait for the feature card to appear
|
// Wait for the feature card to appear
|
||||||
const featureCard = page.locator(
|
const featureCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
|
||||||
);
|
|
||||||
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Step 2: Drag feature to in_progress (first start)
|
// Step 2: Drag feature to in_progress (first start)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
const dragHandle = page.locator(
|
const dragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
const inProgressColumn = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||||
);
|
|
||||||
const inProgressColumn = page.locator(
|
|
||||||
'[data-testid="kanban-column-in_progress"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||||
|
|
||||||
// Verify feature file still exists and is readable
|
// Verify feature file still exists and is readable
|
||||||
const featureFilePath = path.join(
|
const featureFilePath = path.join(featuresDir, testFeatureId, 'feature.json');
|
||||||
featuresDir,
|
|
||||||
testFeatureId,
|
|
||||||
"feature.json"
|
|
||||||
);
|
|
||||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||||
|
|
||||||
// First verify that the drag succeeded by checking for in_progress status
|
// First verify that the drag succeeded by checking for in_progress status
|
||||||
await expect(async () => {
|
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)
|
// 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 });
|
}).toPass({ timeout: 15000 });
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -433,19 +375,14 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// The mock agent completes quickly, so we wait for it to finish
|
// The mock agent completes quickly, so we wait for it to finish
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
const featureData = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||||
expect(featureData.status).toBe("waiting_approval");
|
expect(featureData.status).toBe('waiting_approval');
|
||||||
}).toPass({ timeout: 30000 });
|
}).toPass({ timeout: 30000 });
|
||||||
|
|
||||||
// Verify feature file still exists after completion
|
// Verify feature file still exists after completion
|
||||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||||
const featureDataAfterComplete = JSON.parse(
|
const featureDataAfterComplete = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||||
fs.readFileSync(featureFilePath, "utf-8")
|
console.log('Feature status after first run:', featureDataAfterComplete.status);
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"Feature status after first run:",
|
|
||||||
featureDataAfterComplete.status
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reload to ensure clean state
|
// Reload to ensure clean state
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -457,12 +394,8 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Feature is in waiting_approval, drag it back to backlog
|
// Feature is in waiting_approval, drag it back to backlog
|
||||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||||
const currentCard = page.locator(
|
const currentCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
const currentDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||||
);
|
|
||||||
const currentDragHandle = page.locator(
|
|
||||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(currentCard).toBeVisible({ timeout: 10000 });
|
await expect(currentCard).toBeVisible({ timeout: 10000 });
|
||||||
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
|
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
|
||||||
@@ -470,8 +403,8 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
|
|
||||||
// Verify feature is in backlog
|
// Verify feature is in backlog
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||||
expect(data.status).toBe("backlog");
|
expect(data.status).toBe('backlog');
|
||||||
}).toPass({ timeout: 10000 });
|
}).toPass({ timeout: 10000 });
|
||||||
|
|
||||||
// Reload to ensure clean state
|
// Reload to ensure clean state
|
||||||
@@ -482,55 +415,45 @@ test.describe("Feature Lifecycle Tests", () => {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Step 5: Restart the feature (drag to in_progress again)
|
// Step 5: Restart the feature (drag to in_progress again)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
const restartCard = page.locator(
|
const restartCard = page.locator(`[data-testid="kanban-card-${testFeatureId}"]`);
|
||||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
|
||||||
);
|
|
||||||
await expect(restartCard).toBeVisible({ timeout: 10000 });
|
await expect(restartCard).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
const restartDragHandle = page.locator(
|
const restartDragHandle = page.locator(`[data-testid="drag-handle-${testFeatureId}"]`);
|
||||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
const inProgressColumnRestart = page.locator('[data-testid="kanban-column-in_progress"]');
|
||||||
);
|
|
||||||
const inProgressColumnRestart = page.locator(
|
|
||||||
'[data-testid="kanban-column-in_progress"]'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Listen for console errors to catch "Feature not found"
|
// Listen for console errors to catch "Feature not found"
|
||||||
const consoleErrors: string[] = [];
|
const consoleErrors: string[] = [];
|
||||||
page.on("console", (msg) => {
|
page.on('console', (msg) => {
|
||||||
if (msg.type() === "error") {
|
if (msg.type() === 'error') {
|
||||||
consoleErrors.push(msg.text());
|
consoleErrors.push(msg.text());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag to in_progress to restart
|
// Drag to in_progress to restart
|
||||||
await dragAndDropWithDndKit(
|
await dragAndDropWithDndKit(page, restartDragHandle, inProgressColumnRestart);
|
||||||
page,
|
|
||||||
restartDragHandle,
|
|
||||||
inProgressColumnRestart
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the feature file still exists
|
// Verify the feature file still exists
|
||||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||||
|
|
||||||
// First verify that the restart drag succeeded by checking for in_progress status
|
// First verify that the restart drag succeeded by checking for in_progress status
|
||||||
await expect(async () => {
|
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)
|
// 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 });
|
}).toPass({ timeout: 15000 });
|
||||||
|
|
||||||
// Verify no "Feature not found" errors in console
|
// Verify no "Feature not found" errors in console
|
||||||
const featureNotFoundErrors = consoleErrors.filter(
|
const featureNotFoundErrors = consoleErrors.filter(
|
||||||
(err) => err.includes("not found") || err.includes("Feature")
|
(err) => err.includes('not found') || err.includes('Feature')
|
||||||
);
|
);
|
||||||
expect(featureNotFoundErrors).toEqual([]);
|
expect(featureNotFoundErrors).toEqual([]);
|
||||||
|
|
||||||
// Wait for the mock agent to complete and move to waiting_approval
|
// Wait for the mock agent to complete and move to waiting_approval
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(featureFilePath, 'utf-8'));
|
||||||
expect(data.status).toBe("waiting_approval");
|
expect(data.status).toBe('waiting_approval');
|
||||||
}).toPass({ timeout: 30000 });
|
}).toPass({ timeout: 30000 });
|
||||||
|
|
||||||
console.log("Feature successfully restarted after stop!");
|
console.log('Feature successfully restarted after stop!');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
resetFixtureSpec,
|
resetFixtureSpec,
|
||||||
setupProjectWithFixture,
|
setupProjectWithFixture,
|
||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
fillInput,
|
fillInput,
|
||||||
waitForNetworkIdle,
|
waitForNetworkIdle,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
} from "./utils";
|
} from './utils';
|
||||||
|
|
||||||
test.describe("Spec Editor Persistence", () => {
|
test.describe('Spec Editor Persistence', () => {
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
// Reset the fixture spec file to original content before each test
|
// Reset the fixture spec file to original content before each test
|
||||||
resetFixtureSpec();
|
resetFixtureSpec();
|
||||||
@@ -25,7 +25,7 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
resetFixtureSpec();
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Use the resolved fixture path
|
// Use the resolved fixture path
|
||||||
@@ -35,33 +35,33 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
await setupProjectWithFixture(page, fixturePath);
|
await setupProjectWithFixture(page, fixturePath);
|
||||||
|
|
||||||
// Step 2: Navigate to the app
|
// Step 2: Navigate to the app
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Step 3: Verify we're on the dashboard with the project loaded
|
// Step 3: Verify we're on the dashboard with the project loaded
|
||||||
// The sidebar should show the project selector
|
// The sidebar should show the project selector
|
||||||
const sidebar = await getByTestId(page, "sidebar");
|
const sidebar = await getByTestId(page, 'sidebar');
|
||||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
await sidebar.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
// Step 4: Click on the Spec Editor in the sidebar
|
// Step 4: Click on the Spec Editor in the sidebar
|
||||||
await navigateToSpecEditor(page);
|
await navigateToSpecEditor(page);
|
||||||
|
|
||||||
// Step 5: Wait for the spec view to load (not empty state)
|
// 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
|
// Step 6: Wait for the spec editor to load
|
||||||
const specEditor = await getByTestId(page, "spec-editor");
|
const specEditor = await getByTestId(page, 'spec-editor');
|
||||||
await specEditor.waitFor({ state: "visible", timeout: 10000 });
|
await specEditor.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
// Step 7: Wait for CodeMirror to initialize (it has a .cm-content element)
|
// 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"
|
// 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
|
// Verify content was set before saving
|
||||||
const contentBeforeSave = await getEditorContent(page);
|
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
|
// Step 9: Click the save button and wait for save to complete
|
||||||
await clickSaveButton(page);
|
await clickSaveButton(page);
|
||||||
@@ -72,14 +72,16 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
|
|
||||||
// Step 11: Navigate back to the spec editor
|
// Step 11: Navigate back to the spec editor
|
||||||
// After reload, we need to wait for the app to initialize
|
// 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
|
// Navigate to spec editor again
|
||||||
await navigateToSpecEditor(page);
|
await navigateToSpecEditor(page);
|
||||||
|
|
||||||
// Wait for CodeMirror to be ready
|
// Wait for CodeMirror to be ready
|
||||||
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
const specEditorAfterReload = await getByTestId(page, 'spec-editor');
|
||||||
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
await specEditorAfterReload
|
||||||
|
.locator('.cm-content')
|
||||||
|
.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
// Wait for CodeMirror content to update with the loaded spec
|
// Wait for CodeMirror content to update with the loaded spec
|
||||||
// The spec might need time to load into the editor after page reload
|
// The spec might need time to load into the editor after page reload
|
||||||
@@ -91,11 +93,11 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
try {
|
try {
|
||||||
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
|
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
|
||||||
const text = await contentElement.textContent();
|
const text = await contentElement.textContent();
|
||||||
if (text && text.trim() === "hello world") {
|
if (text && text.trim() === 'hello world') {
|
||||||
contentMatches = true;
|
contentMatches = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Element might not be ready yet, continue
|
// Element might not be ready yet, continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,20 +113,20 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
(expectedContent) => {
|
(expectedContent) => {
|
||||||
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
|
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
|
||||||
if (!contentElement) return false;
|
if (!contentElement) return false;
|
||||||
const text = (contentElement.textContent || "").trim();
|
const text = (contentElement.textContent || '').trim();
|
||||||
return text === expectedContent;
|
return text === expectedContent;
|
||||||
},
|
},
|
||||||
"hello world",
|
'hello world',
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 12: Verify the content was persisted
|
// Step 12: Verify the content was persisted
|
||||||
const persistedContent = await getEditorContent(page);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// This test covers the flow of:
|
// This test covers the flow of:
|
||||||
@@ -139,49 +141,47 @@ test.describe("Spec Editor Persistence", () => {
|
|||||||
state: {
|
state: {
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
currentView: "welcome",
|
currentView: 'welcome',
|
||||||
theme: "dark",
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
apiKeys: { anthropic: "", google: "" },
|
apiKeys: { anthropic: '', google: '' },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
localStorage.setItem('automaker-storage', JSON.stringify(mockState));
|
||||||
|
|
||||||
// Mark setup as complete
|
// Mark setup as complete
|
||||||
const setupState = {
|
const setupState = {
|
||||||
state: {
|
state: {
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
currentStep: "complete",
|
currentStep: 'complete',
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to the app
|
// Navigate to the app
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Wait for the sidebar to be visible
|
// Wait for the sidebar to be visible
|
||||||
const sidebar = await getByTestId(page, "sidebar");
|
const sidebar = await getByTestId(page, 'sidebar');
|
||||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
await sidebar.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
// Click the Open Project button
|
// 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)
|
// Check if the button is visible (it might not be in collapsed sidebar)
|
||||||
const isButtonVisible = await openProjectButton
|
const isButtonVisible = await openProjectButton.isVisible().catch(() => false);
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (isButtonVisible) {
|
if (isButtonVisible) {
|
||||||
await clickElement(page, "open-project-button");
|
await clickElement(page, 'open-project-button');
|
||||||
|
|
||||||
// The file browser dialog should open
|
// The file browser dialog should open
|
||||||
// Note: In web mode, this might use the FileBrowserDialog component
|
// 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
|
// For now, let's verify the dialog appeared and close it
|
||||||
// A full test would navigate through directories
|
// 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
|
// 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 () => {
|
test.beforeEach(async () => {
|
||||||
// Reset the fixture spec file to original content before each test
|
// Reset the fixture spec file to original content before each test
|
||||||
resetFixtureSpec();
|
resetFixtureSpec();
|
||||||
@@ -232,11 +232,9 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Skip in CI - file browser navigation is flaky in headless environments
|
// Skip in CI - file browser navigation is flaky in headless environments
|
||||||
test.skip("should open project via file browser, edit spec, and persist", async ({
|
test.skip('should open project via file browser, edit spec, and persist', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Navigate to app first
|
// Navigate to app first
|
||||||
await page.goto("/");
|
await page.goto('/');
|
||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Set up localStorage state (without a current project, but mark setup complete)
|
// 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: {
|
state: {
|
||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
currentView: "welcome",
|
currentView: 'welcome',
|
||||||
theme: "dark",
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
apiKeys: { anthropic: "", google: "" },
|
apiKeys: { anthropic: '', google: '' },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
},
|
},
|
||||||
version: 0,
|
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)
|
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
|
||||||
const setupState = {
|
const setupState = {
|
||||||
state: {
|
state: {
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
currentStep: "complete",
|
currentStep: 'complete',
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload to apply the localStorage state
|
// Reload to apply the localStorage state
|
||||||
@@ -277,69 +275,68 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
|||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Wait for sidebar
|
// Wait for sidebar
|
||||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
await waitForElement(page, 'sidebar', { timeout: 10000 });
|
||||||
|
|
||||||
// Click the Open Project button
|
// Click the Open Project button
|
||||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
const openProjectButton = await getByTestId(page, 'open-project-button');
|
||||||
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
await openProjectButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
await clickElement(page, "open-project-button");
|
await clickElement(page, 'open-project-button');
|
||||||
|
|
||||||
// Wait for the file browser dialog to open
|
// Wait for the file browser dialog to open
|
||||||
const dialogTitle = page.locator('text="Select Project Directory"');
|
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)
|
// Wait for the dialog to fully load (loading to complete)
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.body.textContent?.includes("Loading directories..."),
|
() => !document.body.textContent?.includes('Loading directories...'),
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use the path input to directly navigate to the fixture directory
|
// Use the path input to directly navigate to the fixture directory
|
||||||
const pathInput = await getByTestId(page, "path-input");
|
const pathInput = await getByTestId(page, 'path-input');
|
||||||
await pathInput.waitFor({ state: "visible", timeout: 5000 });
|
await pathInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
// Clear the input and type the full path to the fixture
|
// 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
|
// 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
|
// Wait for loading to complete
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => !document.body.textContent?.includes("Loading directories..."),
|
() => !document.body.textContent?.includes('Loading directories...'),
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify we're in the right directory by checking the path display
|
// Verify we're in the right directory by checking the path display
|
||||||
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
|
const pathDisplay = page.locator('.font-mono.text-sm.truncate');
|
||||||
await expect(pathDisplay).toContainText("projectA");
|
await expect(pathDisplay).toContainText('projectA');
|
||||||
|
|
||||||
// Click "Select Current Folder" button
|
// Click "Select Current Folder" button
|
||||||
const selectFolderButton = page.locator(
|
const selectFolderButton = page.locator('button:has-text("Select Current Folder")');
|
||||||
'button:has-text("Select Current Folder")'
|
|
||||||
);
|
|
||||||
await selectFolderButton.click();
|
await selectFolderButton.click();
|
||||||
|
|
||||||
// Wait for dialog to close and project to load
|
// Wait for dialog to close and project to load
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(() => !document.querySelector('[role="dialog"]'), {
|
||||||
() => !document.querySelector('[role="dialog"]'),
|
timeout: 10000,
|
||||||
{ timeout: 10000 }
|
});
|
||||||
);
|
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Navigate to spec editor
|
// Navigate to spec editor
|
||||||
const specNav = await getByTestId(page, "nav-spec");
|
const specNav = await getByTestId(page, 'nav-spec');
|
||||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
await specNav.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
await clickElement(page, "nav-spec");
|
await clickElement(page, 'nav-spec');
|
||||||
|
|
||||||
// Wait for spec view with the editor (not the empty state)
|
// Wait for spec view with the editor (not the empty state)
|
||||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
await waitForElement(page, 'spec-view', { timeout: 10000 });
|
||||||
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
|
const specEditorForOpenFlow = await getByTestId(page, 'spec-editor');
|
||||||
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
await specEditorForOpenFlow
|
||||||
|
.locator('.cm-content')
|
||||||
|
.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Edit the content
|
// Edit the content
|
||||||
await setEditorContent(page, "hello world");
|
await setEditorContent(page, 'hello world');
|
||||||
|
|
||||||
// Click save button
|
// Click save button
|
||||||
await clickSaveButton(page);
|
await clickSaveButton(page);
|
||||||
@@ -349,15 +346,17 @@ test.describe("Spec Editor - Full Open Project Flow", () => {
|
|||||||
await waitForNetworkIdle(page);
|
await waitForNetworkIdle(page);
|
||||||
|
|
||||||
// Navigate back to spec editor
|
// Navigate back to spec editor
|
||||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
await specNav.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
await clickElement(page, "nav-spec");
|
await clickElement(page, 'nav-spec');
|
||||||
|
|
||||||
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
|
const specEditorAfterRefresh = await getByTestId(page, 'spec-editor');
|
||||||
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
await specEditorAfterRefresh
|
||||||
|
.locator('.cm-content')
|
||||||
|
.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify the content persisted
|
// Verify the content persisted
|
||||||
const persistedContent = await getEditorContent(page);
|
const persistedContent = await getEditorContent(page);
|
||||||
expect(persistedContent.trim()).toBe("hello world");
|
expect(persistedContent.trim()).toBe('hello world');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Page, Locator } from "@playwright/test";
|
import { Page, Locator } from '@playwright/test';
|
||||||
import { waitForElement } from "../core/waiting";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for a toast notification with specific text to appear
|
* 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();
|
const toast = page.locator(`[data-sonner-toast]:has-text("${text}")`).first();
|
||||||
await toast.waitFor({
|
await toast.waitFor({
|
||||||
timeout: options?.timeout ?? 5000,
|
timeout: options?.timeout ?? 5000,
|
||||||
state: "visible",
|
state: 'visible',
|
||||||
});
|
});
|
||||||
return toast;
|
return toast;
|
||||||
}
|
}
|
||||||
@@ -32,19 +31,21 @@ export async function waitForErrorToast(
|
|||||||
|
|
||||||
if (titleText) {
|
if (titleText) {
|
||||||
// First try specific error type, then fallback to any toast with text
|
// First try specific error type, then fallback to any toast with text
|
||||||
const errorToast = page.locator(
|
const errorToast = page
|
||||||
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
|
.locator(
|
||||||
).first();
|
`[data-sonner-toast][data-type="error"]:has-text("${titleText}"), [data-sonner-toast]:has-text("${titleText}")`
|
||||||
|
)
|
||||||
|
.first();
|
||||||
await errorToast.waitFor({
|
await errorToast.waitFor({
|
||||||
timeout,
|
timeout,
|
||||||
state: "visible",
|
state: 'visible',
|
||||||
});
|
});
|
||||||
return errorToast;
|
return errorToast;
|
||||||
} else {
|
} else {
|
||||||
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
|
const errorToast = page.locator('[data-sonner-toast][data-type="error"]').first();
|
||||||
await errorToast.waitFor({
|
await errorToast.waitFor({
|
||||||
timeout,
|
timeout,
|
||||||
state: "visible",
|
state: 'visible',
|
||||||
});
|
});
|
||||||
return errorToast;
|
return errorToast;
|
||||||
}
|
}
|
||||||
@@ -53,10 +54,7 @@ export async function waitForErrorToast(
|
|||||||
/**
|
/**
|
||||||
* Check if an error toast is visible
|
* Check if an error toast is visible
|
||||||
*/
|
*/
|
||||||
export async function isErrorToastVisible(
|
export async function isErrorToastVisible(page: Page, titleText?: string): Promise<boolean> {
|
||||||
page: Page,
|
|
||||||
titleText?: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const toastSelector = titleText
|
const toastSelector = titleText
|
||||||
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
|
? `[data-sonner-toast][data-type="error"]:has-text("${titleText}")`
|
||||||
: '[data-sonner-toast][data-type="error"]';
|
: '[data-sonner-toast][data-type="error"]';
|
||||||
@@ -81,7 +79,7 @@ export async function waitForSuccessToast(
|
|||||||
const toast = page.locator(toastSelector).first();
|
const toast = page.locator(toastSelector).first();
|
||||||
await toast.waitFor({
|
await toast.waitFor({
|
||||||
timeout: options?.timeout ?? 5000,
|
timeout: options?.timeout ?? 5000,
|
||||||
state: "visible",
|
state: 'visible',
|
||||||
});
|
});
|
||||||
return toast;
|
return toast;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
* Provides helpers for creating test git repos and managing worktrees
|
* Provides helpers for creating test git repos and managing worktrees
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "fs";
|
import * as fs from 'fs';
|
||||||
import * as path from "path";
|
import * as path from 'path';
|
||||||
import { exec } from "child_process";
|
import { exec } from 'child_process';
|
||||||
import { promisify } from "util";
|
import { promisify } from 'util';
|
||||||
import { Page } from "@playwright/test";
|
import { Page } from '@playwright/test';
|
||||||
import { sanitizeBranchName, TIMEOUTS } from "../core/constants";
|
import { sanitizeBranchName, TIMEOUTS } from '../core/constants';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -40,8 +40,8 @@ export interface FeatureData {
|
|||||||
*/
|
*/
|
||||||
function getWorkspaceRoot(): string {
|
function getWorkspaceRoot(): string {
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
if (cwd.includes("apps/ui")) {
|
if (cwd.includes('apps/ui')) {
|
||||||
return path.resolve(cwd, "../..");
|
return path.resolve(cwd, '../..');
|
||||||
}
|
}
|
||||||
return cwd;
|
return cwd;
|
||||||
}
|
}
|
||||||
@@ -49,9 +49,9 @@ function getWorkspaceRoot(): string {
|
|||||||
/**
|
/**
|
||||||
* Create a unique temp directory path for tests
|
* 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)}`;
|
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 {
|
export function getWorktreePath(projectPath: string, branchName: string): string {
|
||||||
const sanitizedName = sanitizeBranchName(branchName);
|
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 });
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
|
||||||
// Initialize git repo
|
// 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.email "test@example.com"', { cwd: tmpDir });
|
||||||
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
||||||
|
|
||||||
// Create initial commit
|
// Create initial commit
|
||||||
fs.writeFileSync(path.join(tmpDir, "README.md"), "# Test Project\n");
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# Test Project\n');
|
||||||
await execAsync("git add .", { cwd: tmpDir });
|
await execAsync('git add .', { cwd: tmpDir });
|
||||||
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
||||||
|
|
||||||
// Create main branch explicitly
|
// Create main branch explicitly
|
||||||
await execAsync("git branch -M main", { cwd: tmpDir });
|
await execAsync('git branch -M main', { cwd: tmpDir });
|
||||||
|
|
||||||
// Create .automaker directories
|
// Create .automaker directories
|
||||||
const automakerDir = path.join(tmpDir, ".automaker");
|
const automakerDir = path.join(tmpDir, '.automaker');
|
||||||
const featuresDir = path.join(automakerDir, "features");
|
const featuresDir = path.join(automakerDir, 'features');
|
||||||
fs.mkdirSync(featuresDir, { recursive: true });
|
fs.mkdirSync(featuresDir, { recursive: true });
|
||||||
|
|
||||||
// Create empty categories.json to avoid ENOENT errors in tests
|
// 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 {
|
return {
|
||||||
path: tmpDir,
|
path: tmpDir,
|
||||||
@@ -113,16 +113,16 @@ export async function createTestGitRepo(tempDir: string): Promise<TestRepo> {
|
|||||||
export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Remove all worktrees first
|
// Remove all worktrees first
|
||||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||||
cwd: repoPath,
|
cwd: repoPath,
|
||||||
}).catch(() => ({ stdout: "" }));
|
}).catch(() => ({ stdout: '' }));
|
||||||
|
|
||||||
const worktrees = stdout
|
const worktrees = stdout
|
||||||
.split("\n\n")
|
.split('\n\n')
|
||||||
.slice(1) // Skip main worktree
|
.slice(1) // Skip main worktree
|
||||||
.map((block) => {
|
.map((block) => {
|
||||||
const pathLine = block.split("\n").find((line) => line.startsWith("worktree "));
|
const pathLine = block.split('\n').find((line) => line.startsWith('worktree '));
|
||||||
return pathLine ? pathLine.replace("worktree ", "") : null;
|
return pathLine ? pathLine.replace('worktree ', '') : null;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ export async function cleanupTestRepo(repoPath: string): Promise<void> {
|
|||||||
// Remove the repository
|
// Remove the repository
|
||||||
fs.rmSync(repoPath, { recursive: true, force: true });
|
fs.rmSync(repoPath, { recursive: true, force: true });
|
||||||
} catch (error) {
|
} 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[]> {
|
export async function listWorktrees(repoPath: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||||
cwd: repoPath,
|
cwd: repoPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
return stdout
|
return stdout
|
||||||
.split("\n\n")
|
.split('\n\n')
|
||||||
.slice(1) // Skip main worktree
|
.slice(1) // Skip main worktree
|
||||||
.map((block) => {
|
.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;
|
if (!pathLine) return null;
|
||||||
// Normalize path separators to OS native (git on Windows returns forward slashes)
|
// 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);
|
return path.normalize(worktreePath);
|
||||||
})
|
})
|
||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
@@ -195,10 +195,10 @@ export async function listWorktrees(repoPath: string): Promise<string[]> {
|
|||||||
* Get list of git branches
|
* Get list of git branches
|
||||||
*/
|
*/
|
||||||
export async function listBranches(repoPath: string): Promise<string[]> {
|
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
|
return stdout
|
||||||
.split("\n")
|
.split('\n')
|
||||||
.map((line) => line.trim().replace(/^[*+]\s*/, ""))
|
.map((line) => line.trim().replace(/^[*+]\s*/, ''))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ export async function listBranches(repoPath: string): Promise<string[]> {
|
|||||||
* Get the current branch name
|
* Get the current branch name
|
||||||
*/
|
*/
|
||||||
export async function getCurrentBranch(repoPath: string): Promise<string> {
|
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();
|
return stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ export async function createWorktreeDirectly(
|
|||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const sanitizedName = sanitizeBranchName(branchName);
|
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 });
|
await execAsync(`git worktree add "${targetPath}" -b ${branchName}`, { cwd: repoPath });
|
||||||
return targetPath;
|
return targetPath;
|
||||||
@@ -257,7 +257,7 @@ export async function commitFile(
|
|||||||
* Get the latest commit message
|
* Get the latest commit message
|
||||||
*/
|
*/
|
||||||
export async function getLatestCommitMessage(repoPath: string): Promise<string> {
|
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();
|
return stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,32 +268,36 @@ export async function getLatestCommitMessage(repoPath: string): Promise<string>
|
|||||||
/**
|
/**
|
||||||
* Create a feature file in the test repo
|
* Create a feature file in the test repo
|
||||||
*/
|
*/
|
||||||
export function createTestFeature(repoPath: string, featureId: string, featureData: FeatureData): void {
|
export function createTestFeature(
|
||||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
repoPath: string,
|
||||||
|
featureId: string,
|
||||||
|
featureData: FeatureData
|
||||||
|
): void {
|
||||||
|
const featuresDir = path.join(repoPath, '.automaker', 'features');
|
||||||
const featureDir = path.join(featuresDir, featureId);
|
const featureDir = path.join(featuresDir, featureId);
|
||||||
|
|
||||||
fs.mkdirSync(featureDir, { recursive: true });
|
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
|
* Read a feature file from the test repo
|
||||||
*/
|
*/
|
||||||
export function readTestFeature(repoPath: string, featureId: string): FeatureData | null {
|
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)) {
|
if (!fs.existsSync(featureFilePath)) {
|
||||||
return null;
|
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
|
* List all feature directories in the test repo
|
||||||
*/
|
*/
|
||||||
export function listTestFeatures(repoPath: string): string[] {
|
export function listTestFeatures(repoPath: string): string[] {
|
||||||
const featuresDir = path.join(repoPath, ".automaker", "features");
|
const featuresDir = path.join(repoPath, '.automaker', 'features');
|
||||||
|
|
||||||
if (!fs.existsSync(featuresDir)) {
|
if (!fs.existsSync(featuresDir)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -312,8 +316,8 @@ export function listTestFeatures(repoPath: string): string[] {
|
|||||||
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
|
export async function setupProjectWithPath(page: Page, projectPath: string): Promise<void> {
|
||||||
await page.addInitScript((pathArg: string) => {
|
await page.addInitScript((pathArg: string) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: "test-project-worktree",
|
id: 'test-project-worktree',
|
||||||
name: "Worktree Test Project",
|
name: 'Worktree Test Project',
|
||||||
path: pathArg,
|
path: pathArg,
|
||||||
lastOpened: new Date().toISOString(),
|
lastOpened: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -322,36 +326,36 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
|
|||||||
state: {
|
state: {
|
||||||
projects: [mockProject],
|
projects: [mockProject],
|
||||||
currentProject: mockProject,
|
currentProject: mockProject,
|
||||||
currentView: "board",
|
currentView: 'board',
|
||||||
theme: "dark",
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
apiKeys: { anthropic: "", google: "" },
|
apiKeys: { anthropic: '', google: '' },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
aiProfiles: [],
|
aiProfiles: [],
|
||||||
useWorktrees: true, // Enable worktree feature for tests
|
useWorktrees: true, // Enable worktree feature for tests
|
||||||
currentWorktreeByProject: {
|
currentWorktreeByProject: {
|
||||||
[pathArg]: { path: null, branch: "main" }, // Initialize to main branch
|
[pathArg]: { path: null, branch: 'main' }, // Initialize to main branch
|
||||||
},
|
},
|
||||||
worktreesByProject: {},
|
worktreesByProject: {},
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
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
|
// Mark setup as complete to skip the setup wizard
|
||||||
const setupState = {
|
const setupState = {
|
||||||
state: {
|
state: {
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
currentStep: "complete",
|
currentStep: 'complete',
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: 2, // Must match app-store.ts persist version
|
||||||
};
|
};
|
||||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
}, projectPath);
|
}, 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
|
* 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
|
* 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) => {
|
await page.addInitScript((pathArg: string) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: "test-project-no-worktree",
|
id: 'test-project-no-worktree',
|
||||||
name: "Test Project (No Worktrees)",
|
name: 'Test Project (No Worktrees)',
|
||||||
path: pathArg,
|
path: pathArg,
|
||||||
lastOpened: new Date().toISOString(),
|
lastOpened: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -372,10 +379,10 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
|
|||||||
state: {
|
state: {
|
||||||
projects: [mockProject],
|
projects: [mockProject],
|
||||||
currentProject: mockProject,
|
currentProject: mockProject,
|
||||||
currentView: "board",
|
currentView: 'board',
|
||||||
theme: "dark",
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
apiKeys: { anthropic: "", google: "" },
|
apiKeys: { anthropic: '', google: '' },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
@@ -387,19 +394,19 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
|
|||||||
version: 2, // Must match app-store.ts persist version
|
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
|
// Mark setup as complete to skip the setup wizard
|
||||||
const setupState = {
|
const setupState = {
|
||||||
state: {
|
state: {
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
currentStep: "complete",
|
currentStep: 'complete',
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: 2, // Must match app-store.ts persist version
|
||||||
};
|
};
|
||||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
}, projectPath);
|
}, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,11 +415,14 @@ export async function setupProjectWithPathNoWorktrees(page: Page, projectPath: s
|
|||||||
* The currentWorktreeByProject points to a worktree path that no longer exists
|
* 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
|
* 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) => {
|
await page.addInitScript((pathArg: string) => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: "test-project-stale-worktree",
|
id: 'test-project-stale-worktree',
|
||||||
name: "Stale Worktree Test Project",
|
name: 'Stale Worktree Test Project',
|
||||||
path: pathArg,
|
path: pathArg,
|
||||||
lastOpened: new Date().toISOString(),
|
lastOpened: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -421,10 +431,10 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
|
|||||||
state: {
|
state: {
|
||||||
projects: [mockProject],
|
projects: [mockProject],
|
||||||
currentProject: mockProject,
|
currentProject: mockProject,
|
||||||
currentView: "board",
|
currentView: 'board',
|
||||||
theme: "dark",
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
apiKeys: { anthropic: "", google: "" },
|
apiKeys: { anthropic: '', google: '' },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
@@ -432,26 +442,26 @@ export async function setupProjectWithStaleWorktree(page: Page, projectPath: str
|
|||||||
useWorktrees: true, // Enable worktree feature for tests
|
useWorktrees: true, // Enable worktree feature for tests
|
||||||
currentWorktreeByProject: {
|
currentWorktreeByProject: {
|
||||||
// This is STALE data - pointing to a worktree path that doesn't exist
|
// 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: {},
|
worktreesByProject: {},
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
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
|
// Mark setup as complete to skip the setup wizard
|
||||||
const setupState = {
|
const setupState = {
|
||||||
state: {
|
state: {
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
currentStep: "complete",
|
currentStep: 'complete',
|
||||||
skipClaudeSetup: false,
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 2, // Must match app-store.ts persist version
|
version: 2, // Must match app-store.ts persist version
|
||||||
};
|
};
|
||||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
}, projectPath);
|
}, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,8 +487,6 @@ export async function waitForBoardView(page: Page): Promise<void> {
|
|||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
const boardView = document.querySelector('[data-testid="board-view"]');
|
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 true only when board-view is visible (store hydrated with project)
|
||||||
return boardView !== null;
|
return boardView !== null;
|
||||||
},
|
},
|
||||||
@@ -490,8 +498,10 @@ export async function waitForBoardView(page: Page): Promise<void> {
|
|||||||
* Wait for the worktree selector to be visible
|
* Wait for the worktree selector to be visible
|
||||||
*/
|
*/
|
||||||
export async function waitForWorktreeSelector(page: Page): Promise<void> {
|
export async function waitForWorktreeSelector(page: Page): Promise<void> {
|
||||||
await page.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium }).catch(() => {
|
await page
|
||||||
// Fallback: wait for "Branch:" text
|
.waitForSelector('[data-testid="worktree-selector"]', { timeout: TIMEOUTS.medium })
|
||||||
return page.getByText("Branch:").waitFor({ timeout: TIMEOUTS.medium });
|
.catch(() => {
|
||||||
});
|
// Fallback: wait for "Branch:" text
|
||||||
|
return page.getByText('Branch:').waitFor({ timeout: TIMEOUTS.medium });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,23 @@
|
|||||||
import { Page, Locator } from "@playwright/test";
|
import { Page, Locator } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a kanban card by feature ID
|
* Get a kanban card by feature ID
|
||||||
*/
|
*/
|
||||||
export async function getKanbanCard(
|
export async function getKanbanCard(page: Page, featureId: string): Promise<Locator> {
|
||||||
page: Page,
|
|
||||||
featureId: string
|
|
||||||
): Promise<Locator> {
|
|
||||||
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a kanban column by its ID
|
* Get a kanban column by its ID
|
||||||
*/
|
*/
|
||||||
export async function getKanbanColumn(
|
export async function getKanbanColumn(page: Page, columnId: string): Promise<Locator> {
|
||||||
page: Page,
|
|
||||||
columnId: string
|
|
||||||
): Promise<Locator> {
|
|
||||||
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
return page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the width of a kanban column
|
* Get the width of a kanban column
|
||||||
*/
|
*/
|
||||||
export async function getKanbanColumnWidth(
|
export async function getKanbanColumnWidth(page: Page, columnId: string): Promise<number> {
|
||||||
page: Page,
|
|
||||||
columnId: string
|
|
||||||
): Promise<number> {
|
|
||||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
||||||
const box = await column.boundingBox();
|
const box = await column.boundingBox();
|
||||||
return box?.width ?? 0;
|
return box?.width ?? 0;
|
||||||
@@ -35,19 +26,16 @@ export async function getKanbanColumnWidth(
|
|||||||
/**
|
/**
|
||||||
* Check if a kanban column has CSS columns (masonry) layout
|
* Check if a kanban column has CSS columns (masonry) layout
|
||||||
*/
|
*/
|
||||||
export async function hasKanbanColumnMasonryLayout(
|
export async function hasKanbanColumnMasonryLayout(page: Page, columnId: string): Promise<boolean> {
|
||||||
page: Page,
|
|
||||||
columnId: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
const column = page.locator(`[data-testid="kanban-column-${columnId}"]`);
|
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 columnCount = await contentDiv.evaluate((el) => {
|
||||||
const style = window.getComputedStyle(el);
|
const style = window.getComputedStyle(el);
|
||||||
return style.columnCount;
|
return style.columnCount;
|
||||||
});
|
});
|
||||||
|
|
||||||
return columnCount === "2";
|
return columnCount === '2';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,11 +46,8 @@ export async function dragKanbanCard(
|
|||||||
featureId: string,
|
featureId: string,
|
||||||
targetColumnId: string
|
targetColumnId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
|
||||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||||
const targetColumn = page.locator(
|
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
|
||||||
`[data-testid="kanban-column-${targetColumnId}"]`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Perform drag and drop
|
// Perform drag and drop
|
||||||
await dragHandle.dragTo(targetColumn);
|
await dragHandle.dragTo(targetColumn);
|
||||||
@@ -71,15 +56,10 @@ export async function dragKanbanCard(
|
|||||||
/**
|
/**
|
||||||
* Click the view output button on a kanban card
|
* Click the view output button on a kanban card
|
||||||
*/
|
*/
|
||||||
export async function clickViewOutput(
|
export async function clickViewOutput(page: Page, featureId: string): Promise<void> {
|
||||||
page: Page,
|
|
||||||
featureId: string
|
|
||||||
): Promise<void> {
|
|
||||||
// Try the running version first, then the in-progress version
|
// Try the running version first, then the in-progress version
|
||||||
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
|
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
|
||||||
const inProgressBtn = page.locator(
|
const inProgressBtn = page.locator(`[data-testid="view-output-inprogress-${featureId}"]`);
|
||||||
`[data-testid="view-output-inprogress-${featureId}"]`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (await runningBtn.isVisible()) {
|
if (await runningBtn.isVisible()) {
|
||||||
await runningBtn.click();
|
await runningBtn.click();
|
||||||
@@ -104,10 +84,7 @@ export async function isDragHandleVisibleForFeature(
|
|||||||
/**
|
/**
|
||||||
* Get the drag handle element for a specific feature card
|
* Get the drag handle element for a specific feature card
|
||||||
*/
|
*/
|
||||||
export async function getDragHandleForFeature(
|
export async function getDragHandleForFeature(page: Page, featureId: string): Promise<Locator> {
|
||||||
page: Page,
|
|
||||||
featureId: string
|
|
||||||
): Promise<Locator> {
|
|
||||||
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
return page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,9 +111,7 @@ export async function fillAddFeatureDialog(
|
|||||||
options?: { branch?: string; category?: string }
|
options?: { branch?: string; category?: string }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Fill description (using the dropzone textarea)
|
// Fill description (using the dropzone textarea)
|
||||||
const descriptionInput = page
|
const descriptionInput = page.locator('[data-testid="add-feature-dialog"] textarea').first();
|
||||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
|
||||||
.first();
|
|
||||||
await descriptionInput.fill(description);
|
await descriptionInput.fill(description);
|
||||||
|
|
||||||
// Fill branch if provided (it's a combobox autocomplete)
|
// Fill branch if provided (it's a combobox autocomplete)
|
||||||
@@ -145,36 +120,34 @@ export async function fillAddFeatureDialog(
|
|||||||
const otherBranchRadio = page
|
const otherBranchRadio = page
|
||||||
.locator('[data-testid="feature-radio-group"]')
|
.locator('[data-testid="feature-radio-group"]')
|
||||||
.locator('[id="feature-other"]');
|
.locator('[id="feature-other"]');
|
||||||
await otherBranchRadio.waitFor({ state: "visible", timeout: 5000 });
|
await otherBranchRadio.waitFor({ state: 'visible', timeout: 5000 });
|
||||||
await otherBranchRadio.click();
|
await otherBranchRadio.click();
|
||||||
// Wait for the branch input to appear
|
// Wait for the branch input to appear
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
// Now click on the branch input (autocomplete)
|
// Now click on the branch input (autocomplete)
|
||||||
const branchInput = page.locator('[data-testid="feature-input"]');
|
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();
|
await branchInput.click();
|
||||||
// Wait for the popover to open
|
// Wait for the popover to open
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
// Type in the command input
|
// Type in the command input
|
||||||
const commandInput = page.locator("[cmdk-input]");
|
const commandInput = page.locator('[cmdk-input]');
|
||||||
await commandInput.fill(options.branch);
|
await commandInput.fill(options.branch);
|
||||||
// Press Enter to select/create the branch
|
// Press Enter to select/create the branch
|
||||||
await commandInput.press("Enter");
|
await commandInput.press('Enter');
|
||||||
// Wait for popover to close
|
// Wait for popover to close
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill category if provided (it's also a combobox autocomplete)
|
// Fill category if provided (it's also a combobox autocomplete)
|
||||||
if (options?.category) {
|
if (options?.category) {
|
||||||
const categoryButton = page.locator(
|
const categoryButton = page.locator('[data-testid="feature-category-input"]');
|
||||||
'[data-testid="feature-category-input"]'
|
|
||||||
);
|
|
||||||
await categoryButton.click();
|
await categoryButton.click();
|
||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
const commandInput = page.locator("[cmdk-input]");
|
const commandInput = page.locator('[cmdk-input]');
|
||||||
await commandInput.fill(options.category);
|
await commandInput.fill(options.category);
|
||||||
await commandInput.press("Enter");
|
await commandInput.press('Enter');
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,10 +158,9 @@ export async function fillAddFeatureDialog(
|
|||||||
export async function confirmAddFeature(page: Page): Promise<void> {
|
export async function confirmAddFeature(page: Page): Promise<void> {
|
||||||
await page.click('[data-testid="confirm-add-feature"]');
|
await page.click('[data-testid="confirm-add-feature"]');
|
||||||
// Wait for dialog to close
|
// Wait for dialog to close
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(() => !document.querySelector('[data-testid="add-feature-dialog"]'), {
|
||||||
() => !document.querySelector('[data-testid="add-feature-dialog"]'),
|
timeout: 5000,
|
||||||
{ timeout: 5000 }
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,12 +190,9 @@ export async function getWorktreeSelector(page: Page): Promise<Locator> {
|
|||||||
/**
|
/**
|
||||||
* Click on a branch button in the worktree selector
|
* Click on a branch button in the worktree selector
|
||||||
*/
|
*/
|
||||||
export async function selectWorktreeBranch(
|
export async function selectWorktreeBranch(page: Page, branchName: string): Promise<void> {
|
||||||
page: Page,
|
const branchButton = page.getByRole('button', {
|
||||||
branchName: string
|
name: new RegExp(branchName, 'i'),
|
||||||
): Promise<void> {
|
|
||||||
const branchButton = page.getByRole("button", {
|
|
||||||
name: new RegExp(branchName, "i"),
|
|
||||||
});
|
});
|
||||||
await branchButton.click();
|
await branchButton.click();
|
||||||
await page.waitForTimeout(500); // Wait for UI to update
|
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
|
* Get the currently selected branch in the worktree selector
|
||||||
*/
|
*/
|
||||||
export async function getSelectedWorktreeBranch(
|
export async function getSelectedWorktreeBranch(page: Page): Promise<string | null> {
|
||||||
page: Page
|
|
||||||
): Promise<string | null> {
|
|
||||||
// The main branch button has aria-pressed="true" when selected
|
// The main branch button has aria-pressed="true" when selected
|
||||||
const selectedButton = page.locator(
|
const selectedButton = page.locator(
|
||||||
'[data-testid="worktree-selector"] button[aria-pressed="true"]'
|
'[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
|
* Check if a branch button is visible in the worktree selector
|
||||||
*/
|
*/
|
||||||
export async function isWorktreeBranchVisible(
|
export async function isWorktreeBranchVisible(page: Page, branchName: string): Promise<boolean> {
|
||||||
page: Page,
|
const branchButton = page.getByRole('button', {
|
||||||
branchName: string
|
name: new RegExp(branchName, 'i'),
|
||||||
): Promise<boolean> {
|
|
||||||
const branchButton = page.getByRole("button", {
|
|
||||||
name: new RegExp(branchName, "i"),
|
|
||||||
});
|
});
|
||||||
return await branchButton.isVisible().catch(() => false);
|
return await branchButton.isVisible().catch(() => false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { Page, Locator } from "@playwright/test";
|
import { Page, Locator } from '@playwright/test';
|
||||||
import { getByTestId } from "../core/elements";
|
import { getByTestId } from '../core/elements';
|
||||||
import { waitForElement } from "../core/waiting";
|
import { waitForElement } from '../core/waiting';
|
||||||
import { setupFirstRun } from "../project/setup";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for setup view to be visible
|
* Wait for setup view to be visible
|
||||||
*/
|
*/
|
||||||
export async function waitForSetupView(page: Page): Promise<Locator> {
|
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
|
* Click "Get Started" button on setup welcome step
|
||||||
*/
|
*/
|
||||||
export async function clickSetupGetStarted(page: Page): Promise<void> {
|
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();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ export async function clickSetupGetStarted(page: Page): Promise<void> {
|
|||||||
* Click continue on Claude setup step
|
* Click continue on Claude setup step
|
||||||
*/
|
*/
|
||||||
export async function clickClaudeContinue(page: Page): Promise<void> {
|
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();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,46 +29,40 @@ export async function clickClaudeContinue(page: Page): Promise<void> {
|
|||||||
* Click finish on setup complete step
|
* Click finish on setup complete step
|
||||||
*/
|
*/
|
||||||
export async function clickSetupFinish(page: Page): Promise<void> {
|
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();
|
await button.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter Anthropic API key in setup
|
* Enter Anthropic API key in setup
|
||||||
*/
|
*/
|
||||||
export async function enterAnthropicApiKey(
|
export async function enterAnthropicApiKey(page: Page, apiKey: string): Promise<void> {
|
||||||
page: Page,
|
|
||||||
apiKey: string
|
|
||||||
): Promise<void> {
|
|
||||||
// Click "Use Anthropic API Key Instead" button
|
// 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();
|
await useApiKeyButton.click();
|
||||||
|
|
||||||
// Enter the API key
|
// 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);
|
await input.fill(apiKey);
|
||||||
|
|
||||||
// Click save button
|
// Click save button
|
||||||
const saveButton = await getByTestId(page, "save-anthropic-key-button");
|
const saveButton = await getByTestId(page, 'save-anthropic-key-button');
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter OpenAI API key in setup
|
* Enter OpenAI API key in setup
|
||||||
*/
|
*/
|
||||||
export async function enterOpenAIApiKey(
|
export async function enterOpenAIApiKey(page: Page, apiKey: string): Promise<void> {
|
||||||
page: Page,
|
|
||||||
apiKey: string
|
|
||||||
): Promise<void> {
|
|
||||||
// Click "Enter OpenAI API Key" button
|
// 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();
|
await useApiKeyButton.click();
|
||||||
|
|
||||||
// Enter the API key
|
// 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);
|
await input.fill(apiKey);
|
||||||
|
|
||||||
// Click save button
|
// Click save button
|
||||||
const saveButton = await getByTestId(page, "save-openai-key-button");
|
const saveButton = await getByTestId(page, 'save-openai-key-button');
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
}
|
}
|
||||||
|
|||||||
70
pnpm-lock.yaml
generated
70
pnpm-lock.yaml
generated
@@ -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
|
|
||||||
Reference in New Issue
Block a user