Compare commits

...

23 Commits

Author SHA1 Message Date
Kacper
3ccea7a67b fix(docker): Address remaining PR #745 review comments
- Move Playwright install after node_modules copy to use pinned version
- Use local playwright binary instead of npx to avoid registry fetch
- Add --user automaker -w /app flags to docker exec commands
- Change bold text to proper heading in README (MD036 lint fix)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:07:53 +01:00
Kacper
b37a287c9c fix(docker): Address PR #745 review feedback
- Clean up npx cache after Playwright installation to reduce image size
- Clarify README: volume mounts persist cache across container lifecycles,
  not image rebuilds
- Add first-use warning: empty volume overrides pre-installed browsers,
  users must re-install with docker exec command

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:55:11 +01:00
Kacper
45f6f17eb0 fix(docker): Pre-install Playwright Chromium browsers for automated testing
Fixes #725

AI agents in automated testing mode require Playwright to verify implementations,
but Docker containers had only system dependencies installed, not browser binaries.
This caused verification failures with permissions errors.

Changes:
- Install Playwright Chromium in Dockerfile (~300MB increase)
- Update docker-compose.override.yml.example with clearer Playwright documentation
- Add "Playwright for Automated Testing" section to README
- Document optional volume mount for persisting browsers across rebuilds

Browsers are now pre-installed and work out of the box for Docker users.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 15:47:18 +01:00
Shirone
29b3eef500 Merge pull request #744 from AutoMaker-Org/fix/git-project-initial-branch
fix(server): Use 'main' as default branch for new git projects
2026-02-02 14:20:03 +00:00
Kacper
010e516b0e fix(server): Use 'main' as default branch for new git projects
Git initialization now explicitly specifies --initial-branch=main to match
GitHub's default branch standard (since October 2020). This prevents the
branch name mismatch that caused features to disappear from the UI when
pushing to GitHub.

Fixes #734

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:07:43 +01:00
Shirone
00e4712ae7 Merge pull request #743 from AutoMaker-Org/fix/broken-syslinks-on-server
fix(electron): Fix broken symlinks in server bundle preventing app startup
2026-02-02 13:50:39 +00:00
Kacper
4b4ae04fbe refactor: Address PR review feedback for symlink and directory handling
- Use lstatSync with try/catch for robust broken symlink detection
- Remove redundant existsSync check before mkdirSync with recursive: true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:36:24 +01:00
Kacper
04775af561 fix(electron): Fix broken symlinks in server bundle preventing app startup
Fixes #742

This commit resolves two critical issues that prevented the Electron app from starting:

1. **Broken symlinks in server bundle**
   - After npm install, local @automaker/* packages were symlinked in node_modules
   - These symlinks broke after electron-builder packaging since relative paths no longer existed
   - Solution: Added Step 6b in prepare-server.mjs to replace symlinks with real directory copies
   - Added lstatSync and resolve imports to support symlink detection and replacement

2. **electronUserDataWriteFileSync fails on first launch**
   - The userData directory doesn't exist on first app launch
   - Writing .api-key file would fail with ENOENT error
   - Solution: Added directory existence check and creation with { recursive: true } before writing

Files modified:
- apps/ui/scripts/prepare-server.mjs: Added symlink replacement logic after npm install
- libs/platform/src/system-paths.ts: Added parent directory creation in electronUserDataWriteFileSync

Verification: After these fixes, npm run build:electron produces a working app that starts without ERR_MODULE_NOT_FOUND errors.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:26:59 +01:00
Shirone
b8fa7fc579 Merge pull request #732 from AutoMaker-Org/fix/icon-posiition-on-mac
fix(ui): adjust padding for logo for mac
2026-01-31 12:01:49 +00:00
Shirone
7fb0d0f2ca refactor(ui): Integrate macOS Electron padding logic into ProjectSwitcher
Updated the ProjectSwitcher component to conditionally apply top padding based on the operating system and Electron environment. This change utilizes the newly created MACOS_ELECTRON_TOP_PADDING_CLASS for improved maintainability and consistency across the UI.
2026-01-31 12:54:36 +01:00
Kacper
f15725f28a refactor(ui): Extract macOS Electron padding into shared constant
Extract the hardcoded 'pt-[38px]' magic number into a shared constant
MACOS_ELECTRON_TOP_PADDING_CLASS for better maintainability. This
addresses the PR #732 review feedback from Gemini Code Assist.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:43:28 +01:00
Kacper
7d7d152d4e fix(ui): Adjust sidebar padding for macOS Electron compatibility
Updated the sidebar header and navigation components to increase top padding for macOS Electron users from 10px to 38px, ensuring better layout and avoiding overlap with the traffic light controls. This change enhances the user experience on macOS platforms.
2026-01-30 20:36:33 +01:00
DhanushSantosh
1a460c301a fix(test): Set HOSTNAME in dev server tests for consistent behavior
Dev server test was failing on non-localhost hostnames (e.g., 'fedora')
because it expected 'localhost' in the URL. Now sets HOSTNAME env var
in test setup and restores it in teardown for consistent test behavior
across all environments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:23 +05:30
DhanushSantosh
c1f480fe49 fix(ui): Make GitHub Copilot icon theme-aware for light mode visibility
The Copilot icon had a hardcoded white fill that made it invisible on
light theme backgrounds. Changed to use currentColor so it adapts to
theme and respects CSS text color classes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:08 +05:30
Shirone
ef3f8de33b Merge pull request #715 from OG-Ken/fix/opencode-dynamic-models-404-endpoint
fix: Correct OpenCode dynamic models API endpoint URL
2026-01-27 12:02:33 +00:00
Ken Lopez
d379bf412a fix: Correct OpenCode dynamic models API endpoint URL
The fetchOpencodeModels function was calling '/api/opencode/models' which
returns 404. Changed to '/api/setup/opencode/models' which correctly
returns the dynamic models.

This fixes an issue where enabled OpenCode dynamic models (e.g., local
Ollama models) were not appearing in the Model Defaults dropdown selectors
despite being visible and enabled in the OpenCode Settings page.
2026-01-27 03:06:28 -05:00
Shirone
cf35ca8650 Merge pull request #714 from AutoMaker-Org/feature/bug-request-changes-on-plan-mode-is-not-proceedin-8xpd
refactor(auto-mode): Enhance revision prompt customization
2026-01-26 23:36:30 +00:00
Shirone
4f1555f196 feat(event-history): Replace alert with toast notifications for event replay results
Update the EventHistoryView component to use toast notifications instead of alert dialogs for displaying event replay results, enhancing user experience and providing clearer feedback on success and failure states.
2026-01-27 00:29:34 +01:00
Shirone
5aace0ce0f fix(event-hook): Update featureName assignment to prioritize loaded feature title over payload 2026-01-27 00:25:36 +01:00
Shirone
e439d8a632 fix(routes): Update feature creation event to use title instead of name
Change the feature creation event to emit 'Untitled Feature' when the title is not provided, improving clarity in event handling.
2026-01-27 00:25:16 +01:00
Shirone
b7c6b8bfc6 feat(ui): Show project name in classic sidebar layout
Add project name display at the top of the navigation for the classic
(discord) sidebar style, which previously didn't show the project name
anywhere. Shows the project icon (custom or Lucide) and name with a
separator below.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:15:38 +01:00
Shirone
a60904bd51 fix(ui,server): Fix project icon updates and image upload issues
- Fix setProjectCustomIcon using wrong property name (customIcon -> customIconPath)
- Add currentProject state update to setProjectIcon and setProjectCustomIcon
- Fix data URL regex to handle all formats (e.g., charset=utf-8 in GIFs)
- Increase project icon size limit from 2MB to 5MB for animated GIFs
- Add toast notifications for upload validation errors
- Add image error fallback to folder icon in project switcher
- Make HttpApiClient get/put methods public for store access
- Fix TypeScript errors in app-store.ts (trashedAt type, font properties)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 00:09:55 +01:00
Kacper
d7c3337330 refactor(auto-mode): Enhance revision prompt customization and task format validation
- Updated the revision prompt generation to utilize a customizable template, allowing for dynamic insertion of plan version, previous plan content, user feedback, and task format examples.
- Added validation to ensure the presence of a tasks block in the revised specification, with clear instructions on the required format to prevent execution issues.
- Introduced logging for scenarios where no tasks are found in the revised plan, warning about potential fallback to single-agent execution.
2026-01-26 19:53:07 +01:00
29 changed files with 391 additions and 186 deletions

View File

@@ -118,6 +118,7 @@ RUN curl -fsSL https://opencode.ai/install | bash && \
echo "=== Checking OpenCode CLI installation ===" && \ echo "=== Checking OpenCode CLI installation ===" && \
ls -la /home/automaker/.local/bin/ && \ ls -la /home/automaker/.local/bin/ && \
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)" (which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
USER root USER root
# Add PATH to profile so it's available in all interactive shells (for login shells) # Add PATH to profile so it's available in all interactive shells (for login shells)
@@ -147,6 +148,15 @@ COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
# Copy node_modules (includes symlinks to libs) # Copy node_modules (includes symlinks to libs)
COPY --from=server-builder /app/node_modules ./node_modules COPY --from=server-builder /app/node_modules ./node_modules
# Install Playwright Chromium browser for AI agent verification tests
# This adds ~300MB to the image but enables automated testing mode out of the box
# Using the locally installed playwright ensures we use the pinned version from package-lock.json
USER automaker
RUN ./node_modules/.bin/playwright install chromium && \
echo "=== Playwright Chromium installed ===" && \
ls -la /home/automaker/.cache/ms-playwright/ || echo "Playwright browsers installed"
USER root
# Create data and projects directories # Create data and projects directories
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects RUN mkdir -p /data /projects && chown automaker:automaker /data /projects

View File

@@ -338,6 +338,42 @@ services:
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build. The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
##### Playwright for Automated Testing
The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly.
**No additional setup required** - Playwright verification works out of the box.
#### Optional: Persist browsers for manual updates
By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume.
**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them:
```bash
# After adding the volume mount for the first time
docker exec --user automaker -w /app automaker-server npx playwright install chromium
```
Add this to your `docker-compose.override.yml`:
```yaml
services:
server:
volumes:
- playwright-cache:/home/automaker/.cache/ms-playwright
volumes:
playwright-cache:
name: automaker-playwright-cache
```
**Updating browsers manually:**
```bash
docker exec --user automaker -w /app automaker-server npx playwright install chromium
```
### Testing ### Testing
#### End-to-End Tests (Playwright) #### End-to-End Tests (Playwright)

View File

@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
if (events) { if (events) {
events.emit('feature:created', { events.emit('feature:created', {
featureId: created.id, featureId: created.id,
featureName: created.name, featureName: created.title || 'Untitled Feature',
projectPath, projectPath,
}); });
} }

View File

@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
await secureFs.mkdir(boardDir, { recursive: true }); await secureFs.mkdir(boardDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present) // Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, ''); // Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, 'base64');
// Use a fixed filename for the board background (overwrite previous) // Use a fixed filename for the board background (overwrite previous)

View File

@@ -31,7 +31,9 @@ export function createSaveImageHandler() {
await secureFs.mkdir(imagesDir, { recursive: true }); await secureFs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present) // Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, ''); // Use a regex that handles all data URL formats including those with extra params
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
const base64Data = data.replace(/^data:[^,]+,/, '');
const buffer = Buffer.from(base64Data, 'base64'); const buffer = Buffer.from(base64Data, 'base64');
// Generate unique filename with timestamp // Generate unique filename with timestamp

View File

@@ -43,10 +43,14 @@ export function createInitGitHandler() {
// .git doesn't exist, continue with initialization // .git doesn't exist, continue with initialization
} }
// Initialize git and create an initial empty commit // Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, { // and create an initial empty commit
cwd: projectPath, await execAsync(
}); `git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath,
}
);
res.json({ res.json({
success: true, success: true,

View File

@@ -4597,21 +4597,54 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
planVersion, planVersion,
}); });
// Build revision prompt // Build revision prompt using customizable template
let revisionPrompt = `The user has requested revisions to the plan/specification. const revisionPrompts = await getPromptCustomization(
this.settingsService,
'[AutoMode]'
);
## Previous Plan (v${planVersion - 1}) // Get task format example based on planning mode
${hasEdits ? approvalResult.editedPlan : currentPlanContent} const taskFormatExample =
planningMode === 'full'
? `\`\`\`tasks
## Phase 1: Foundation
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
## User Feedback ## Phase 2: Core Implementation
${approvalResult.feedback || 'Please revise the plan based on the edits above.'} - [ ] T003: [Description] | File: [path/to/file]
- [ ] T004: [Description] | File: [path/to/file]
\`\`\``
: `\`\`\`tasks
- [ ] T001: [Description] | File: [path/to/file]
- [ ] T002: [Description] | File: [path/to/file]
- [ ] T003: [Description] | File: [path/to/file]
\`\`\``;
## Instructions let revisionPrompt = revisionPrompts.taskExecution.planRevisionTemplate;
Please regenerate the specification incorporating the user's feedback. revisionPrompt = revisionPrompt.replace(
Keep the same format with the \`\`\`tasks block for task definitions. /\{\{planVersion\}\}/g,
After generating the revised spec, output: String(planVersion - 1)
"[SPEC_GENERATED] Please review the revised specification above." );
`; revisionPrompt = revisionPrompt.replace(
/\{\{previousPlan\}\}/g,
hasEdits
? approvalResult.editedPlan || currentPlanContent
: currentPlanContent
);
revisionPrompt = revisionPrompt.replace(
/\{\{userFeedback\}\}/g,
approvalResult.feedback ||
'Please revise the plan based on the edits above.'
);
revisionPrompt = revisionPrompt.replace(
/\{\{planningMode\}\}/g,
planningMode
);
revisionPrompt = revisionPrompt.replace(
/\{\{taskFormatExample\}\}/g,
taskFormatExample
);
// Update status to regenerating // Update status to regenerating
await this.updateFeaturePlanSpec(projectPath, featureId, { await this.updateFeaturePlanSpec(projectPath, featureId, {
@@ -4663,6 +4696,26 @@ After generating the revised spec, output:
const revisedTasks = parseTasksFromSpec(currentPlanContent); const revisedTasks = parseTasksFromSpec(currentPlanContent);
logger.info(`Revised plan has ${revisedTasks.length} tasks`); logger.info(`Revised plan has ${revisedTasks.length} tasks`);
// Warn if no tasks found in spec/full mode - this may cause fallback to single-agent
if (
revisedTasks.length === 0 &&
(planningMode === 'spec' || planningMode === 'full')
) {
logger.warn(
`WARNING: Revised plan in ${planningMode} mode has no tasks! ` +
`This will cause fallback to single-agent execution. ` +
`The AI may have omitted the required \`\`\`tasks block.`
);
this.emitAutoModeEvent('plan_revision_warning', {
featureId,
projectPath,
branchName,
planningMode,
warning:
'Revised plan missing tasks block - will use single-agent execution',
});
}
// Update planSpec with revised content // Update planSpec with revised content
await this.updateFeaturePlanSpec(projectPath, featureId, { await this.updateFeaturePlanSpec(projectPath, featureId, {
status: 'generated', status: 'generated',

View File

@@ -169,9 +169,10 @@ export class EventHookService {
} }
// Build context for variable substitution // Build context for variable substitution
// Use loaded featureName (from feature.title) or fall back to payload.featureName
const context: HookContext = { const context: HookContext = {
featureId: payload.featureId, featureId: payload.featureId,
featureName: payload.featureName, featureName: featureName || payload.featureName,
projectPath: payload.projectPath, projectPath: payload.projectPath,
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
error: payload.error || payload.message, error: payload.error || payload.message,

View File

@@ -20,8 +20,8 @@ export interface TestRepo {
export async function createTestGitRepo(): Promise<TestRepo> { export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-')); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
// Initialize git repo // Initialize git repo with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init', { cwd: tmpDir }); await execAsync('git init --initial-branch=main', { cwd: tmpDir });
// Use environment variables instead of git config to avoid affecting user's git config // Use environment variables instead of git config to avoid affecting user's git config
// These env vars override git config without modifying it // These env vars override git config without modifying it
@@ -38,9 +38,6 @@ export async function createTestGitRepo(): Promise<TestRepo> {
await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir });
return { return {
path: tmpDir, path: tmpDir,
cleanup: async () => { cleanup: async () => {

View File

@@ -14,7 +14,8 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() { async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
await execAsync('git init', { cwd: repoPath }); // Initialize with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init --initial-branch=main', { cwd: repoPath });
// Don't set git config - use environment variables in commit operations instead // Don't set git config - use environment variables in commit operations instead
// to avoid affecting user's git config // to avoid affecting user's git config
// Intentionally skip creating an initial commit // Intentionally skip creating an initial commit

View File

@@ -30,11 +30,16 @@ import net from 'net';
describe('dev-server-service.ts', () => { describe('dev-server-service.ts', () => {
let testDir: string; let testDir: string;
let originalHostname: string | undefined;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.resetModules(); vi.resetModules();
// Store and set HOSTNAME for consistent test behavior
originalHostname = process.env.HOSTNAME;
process.env.HOSTNAME = 'localhost';
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`); testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true }); await fs.mkdir(testDir, { recursive: true });
@@ -56,6 +61,13 @@ describe('dev-server-service.ts', () => {
}); });
afterEach(async () => { afterEach(async () => {
// Restore original HOSTNAME
if (originalHostname === undefined) {
delete process.env.HOSTNAME;
} else {
process.env.HOSTNAME = originalHostname;
}
try { try {
await fs.rm(testDir, { recursive: true, force: true }); await fs.rm(testDir, { recursive: true, force: true });
} catch { } catch {

View File

@@ -7,8 +7,8 @@
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs'; import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -112,6 +112,29 @@ execSync('npm install --omit=dev', {
}, },
}); });
// Step 6b: Replace symlinks for local packages with real copies
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
console.log('🔗 Replacing symlinks with real directory copies...');
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
for (const pkgName of LOCAL_PACKAGES) {
const pkgDir = pkgName.replace('@automaker/', '');
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
try {
// lstatSync does not follow symlinks, allowing us to check for broken ones
if (lstatSync(nmPkgPath).isSymbolicLink()) {
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
rmSync(nmPkgPath);
cpSync(realPath, nmPkgPath, { recursive: true });
console.log(` ✓ Replaced symlink: ${pkgName}`);
}
} catch (error) {
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
if (error.code !== 'ENOENT') {
throw error;
}
}
}
// Step 7: Rebuild native modules for current architecture // Step 7: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings // This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...'); console.log('🔨 Rebuilding native modules for current architecture...');

View File

@@ -15,6 +15,7 @@ import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker'; import { IconPicker } from './icon-picker';
import { toast } from 'sonner';
interface EditProjectDialogProps { interface EditProjectDialogProps {
project: Project; project: Project;
@@ -52,11 +53,18 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
// Validate file type // Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) { if (!validTypes.includes(file.type)) {
toast.error(
`Invalid file type: ${file.type || 'unknown'}. Please use JPG, PNG, GIF or WebP.`
);
return; return;
} }
// Validate file size (max 2MB for icons) // Validate file size (max 5MB for icons - allows animated GIFs)
if (file.size > 2 * 1024 * 1024) { const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
toast.error(
`File too large (${(file.size / 1024 / 1024).toFixed(2)} MB). Maximum size is 5 MB.`
);
return; return;
} }
@@ -72,15 +80,24 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
file.type, file.type,
project.path project.path
); );
if (result.success && result.path) { if (result.success && result.path) {
setCustomIconPath(result.path); setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set // Clear the Lucide icon when custom icon is set
setIcon(null); setIcon(null);
toast.success('Icon uploaded successfully');
} else {
toast.error('Failed to upload icon');
} }
setIsUploadingIcon(false); setIsUploadingIcon(false);
}; };
reader.onerror = () => {
toast.error('Failed to read file');
setIsUploadingIcon(false);
};
reader.readAsDataURL(file); reader.readAsDataURL(file);
} catch { } catch {
toast.error('Failed to upload icon');
setIsUploadingIcon(false); setIsUploadingIcon(false);
} }
}; };
@@ -162,7 +179,7 @@ export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDi
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'} {isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button> </Button>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB. PNG, JPG, GIF or WebP. Max 5MB.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -59,7 +59,7 @@ interface ThemeButtonProps {
/** Handler for pointer leave events (used to clear preview) */ /** Handler for pointer leave events (used to clear preview) */
onPointerLeave: (e: React.PointerEvent) => void; onPointerLeave: (e: React.PointerEvent) => void;
/** Handler for click events (used to select theme) */ /** Handler for click events (used to select theme) */
onClick: () => void; onClick: (e: React.MouseEvent) => void;
} }
/** /**
@@ -77,6 +77,7 @@ const ThemeButton = memo(function ThemeButton({
const Icon = option.icon; const Icon = option.icon;
return ( return (
<button <button
type="button"
onPointerEnter={onPointerEnter} onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave} onPointerLeave={onPointerLeave}
onClick={onClick} onClick={onClick}
@@ -145,7 +146,10 @@ const ThemeColumn = memo(function ThemeColumn({
isSelected={selectedTheme === option.value} isSelected={selectedTheme === option.value}
onPointerEnter={() => onPreviewEnter(option.value)} onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave} onPointerLeave={onPreviewLeave}
onClick={() => onSelect(option.value)} onClick={(e) => {
e.stopPropagation();
onSelect(option.value);
}}
/> />
))} ))}
</div> </div>
@@ -193,7 +197,6 @@ export function ProjectContextMenu({
const { const {
moveProjectToTrash, moveProjectToTrash,
theme: globalTheme, theme: globalTheme,
setTheme,
setProjectTheme, setProjectTheme,
setPreviewTheme, setPreviewTheme,
} = useAppStore(); } = useAppStore();
@@ -316,13 +319,24 @@ export function ProjectContextMenu({
const handleThemeSelect = useCallback( const handleThemeSelect = useCallback(
(value: ThemeMode | typeof USE_GLOBAL_THEME) => { (value: ThemeMode | typeof USE_GLOBAL_THEME) => {
// Clear any pending close timeout to prevent race conditions
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
// Close menu first
setShowThemeSubmenu(false);
onClose();
// Then apply theme changes
setPreviewTheme(null); setPreviewTheme(null);
const isUsingGlobal = value === USE_GLOBAL_THEME; const isUsingGlobal = value === USE_GLOBAL_THEME;
setTheme(isUsingGlobal ? globalTheme : value); // Only set project theme - don't change global theme
// The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
setProjectTheme(project.id, isUsingGlobal ? null : value); setProjectTheme(project.id, isUsingGlobal ? null : value);
setShowThemeSubmenu(false);
}, },
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme] [onClose, project.id, setPreviewTheme, setProjectTheme]
); );
const handleConfirmRemove = useCallback(() => { const handleConfirmRemove = useCallback(() => {
@@ -426,9 +440,13 @@ export function ProjectContextMenu({
<div className="p-2"> <div className="p-2">
{/* Use Global Option */} {/* Use Global Option */}
<button <button
type="button"
onPointerEnter={() => handlePreviewEnter(globalTheme)} onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave} onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)} onClick={(e) => {
e.stopPropagation();
handleThemeSelect(USE_GLOBAL_THEME);
}}
className={cn( className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md', 'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left', 'text-sm font-medium text-left',

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Folder, LucideIcon } from 'lucide-react'; import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import { cn, sanitizeForTestId } from '@/lib/utils'; import { cn, sanitizeForTestId } from '@/lib/utils';
@@ -19,6 +20,8 @@ export function ProjectSwitcherItem({
onClick, onClick,
onContextMenu, onContextMenu,
}: ProjectSwitcherItemProps) { }: ProjectSwitcherItemProps) {
const [imageError, setImageError] = useState(false);
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0" // Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel = const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9 hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
@@ -35,7 +38,7 @@ export function ProjectSwitcherItem({
}; };
const IconComponent = getIconComponent(); const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath; const hasCustomIcon = !!project.customIconPath && !imageError;
// Combine project.id with sanitized name for uniqueness and readability // Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName} // Format: project-switcher-{id}-{sanitizedName}
@@ -74,6 +77,7 @@ export function ProjectSwitcherItem({
'w-8 h-8 rounded-lg object-cover transition-all duration-200', 'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110' isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)} )}
onError={() => setImageError(true)}
/> />
) : ( ) : (
<IconComponent <IconComponent

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react'; import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router'; import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection'; import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectSwitcherItem } from './components/project-switcher-item';
@@ -11,9 +11,12 @@ import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation } from '@/components/layout/sidebar/hooks'; import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants'; import {
MACOS_ELECTRON_TOP_PADDING_CLASS,
SIDEBAR_FEATURE_FLAGS,
} from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI, isElectron } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
@@ -279,7 +282,12 @@ export function ProjectSwitcher() {
data-testid="project-switcher" data-testid="project-switcher"
> >
{/* Automaker Logo and Version */} {/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2"> <div
className={cn(
'flex flex-col items-center pb-2 px-2',
isMac && isElectron() ? MACOS_ELECTRON_TOP_PADDING_CLASS : 'pt-3'
)}
>
<button <button
onClick={() => navigate({ to: '/dashboard' })} onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5" className="group flex flex-col items-center gap-0.5"

View File

@@ -100,14 +100,8 @@ export function ProjectSelectorWithOptions({
const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects }); const { sensors, handleDragEnd } = useDragAndDrop({ projects, reorderProjects });
const { const { globalTheme, setProjectTheme, setPreviewTheme, handlePreviewEnter, handlePreviewLeave } =
globalTheme, useProjectTheme();
setTheme,
setProjectTheme,
setPreviewTheme,
handlePreviewEnter,
handlePreviewLeave,
} = useProjectTheme();
if (!sidebarOpen || projects.length === 0) { if (!sidebarOpen || projects.length === 0) {
return null; return null;
@@ -281,11 +275,8 @@ export function ProjectSelectorWithOptions({
onValueChange={(value) => { onValueChange={(value) => {
if (currentProject) { if (currentProject) {
setPreviewTheme(null); setPreviewTheme(null);
if (value !== '') { // Only set project theme - don't change global theme
setTheme(value as ThemeMode); // The UI uses getEffectiveTheme() which handles: previewTheme ?? projectTheme ?? globalTheme
} else {
setTheme(globalTheme);
}
setProjectTheme( setProjectTheme(
currentProject.id, currentProject.id,
value === '' ? null : (value as ThemeMode) value === '' ? null : (value as ThemeMode)

View File

@@ -6,6 +6,7 @@ import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { isElectron, type Project } from '@/lib/electron'; import { isElectron, type Project } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { import {
@@ -89,7 +90,7 @@ export function SidebarHeader({
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2', 'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]' isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
)} )}
> >
<Tooltip> <Tooltip>
@@ -240,7 +241,7 @@ export function SidebarHeader({
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col relative px-3 pt-3 pb-2', 'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
isMac && isElectron() && 'pt-[10px]' isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
)} )}
> >
{/* Header with logo and project dropdown */} {/* Header with logo and project dropdown */}

View File

@@ -1,8 +1,13 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router'; import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github } from 'lucide-react'; import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
import { cn } from '@/lib/utils'; import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { isElectron } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { formatShortcut, useAppStore } from '@/store/app-store'; import { formatShortcut, useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { NavSection } from '../types'; import type { NavSection } from '../types';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import type { SidebarStyle } from '@automaker/types'; import type { SidebarStyle } from '@automaker/types';
@@ -97,15 +102,52 @@ export function SidebarNavigation({
return !!currentProject; return !!currentProject;
}); });
// Get the icon component for the current project
const getProjectIcon = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
const ProjectIcon = getProjectIcon();
const hasCustomIcon = !!currentProject?.customIconPath;
return ( return (
<nav <nav
ref={navRef} ref={navRef}
className={cn( className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2', 'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header // Add top padding in discord mode since there's no header
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1' // Extra padding for macOS Electron to avoid traffic light overlap
sidebarStyle === 'discord'
? isMac && isElectron()
? MACOS_ELECTRON_TOP_PADDING_CLASS
: 'pt-3'
: 'mt-1'
)} )}
> >
{/* Project name display for classic/discord mode */}
{sidebarStyle === 'discord' && currentProject && sidebarOpen && (
<div className="mb-3">
<div className="flex items-center gap-2.5 px-3 py-2">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-5 h-5 rounded object-cover"
/>
) : (
<ProjectIcon className="w-5 h-5 text-brand-500 shrink-0" />
)}
<span className="text-sm font-medium text-foreground truncate">
{currentProject.name}
</span>
</div>
<div className="h-px bg-border/40 mx-1 mt-1" />
</div>
)}
{/* Navigation sections */} {/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => { {visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedNavSections[section.label] : false; const isCollapsed = section.label ? collapsedNavSections[section.label] : false;

View File

@@ -9,19 +9,15 @@ export const ThemeMenuItem = memo(function ThemeMenuItem({
}: ThemeMenuItemProps) { }: ThemeMenuItemProps) {
const Icon = option.icon; const Icon = option.icon;
return ( return (
<div <DropdownMenuRadioItem
key={option.value} value={option.value}
data-testid={`project-theme-${option.value}`}
className="text-xs py-1.5"
onPointerEnter={() => onPreviewEnter(option.value)} onPointerEnter={() => onPreviewEnter(option.value)}
onPointerLeave={onPreviewLeave} onPointerLeave={onPreviewLeave}
> >
<DropdownMenuRadioItem <Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
value={option.value} <span>{option.label}</span>
data-testid={`project-theme-${option.value}`} </DropdownMenuRadioItem>
className="text-xs py-1.5"
>
<Icon className="w-3.5 h-3.5 mr-1.5" style={{ color: option.color }} />
<span>{option.label}</span>
</DropdownMenuRadioItem>
</div>
); );
}); });

View File

@@ -1,5 +1,11 @@
import { darkThemes, lightThemes } from '@/config/theme-options'; import { darkThemes, lightThemes } from '@/config/theme-options';
/**
* Tailwind class for top padding on macOS Electron to avoid overlapping with traffic light window controls.
* This padding is applied conditionally when running on macOS in Electron.
*/
export const MACOS_ELECTRON_TOP_PADDING_CLASS = 'pt-[38px]';
/** /**
* Shared constants for theme submenu positioning and layout. * Shared constants for theme submenu positioning and layout.
* Used across project-context-menu and project-selector-with-options components * Used across project-context-menu and project-selector-with-options components

View File

@@ -116,9 +116,8 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
}, },
copilot: { copilot: {
viewBox: '0 0 98 96', viewBox: '0 0 98 96',
// Official GitHub Octocat logo mark // Official GitHub Octocat logo mark (theme-aware via currentColor)
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z', path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
fill: '#ffffff',
}, },
}; };

View File

@@ -68,10 +68,10 @@ export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps)
return; return;
} }
// Validate file size (max 2MB for icons) // Validate file size (max 5MB for icons - allows animated GIFs)
if (file.size > 2 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
toast.error('File too large', { toast.error('File too large', {
description: 'Please upload an image smaller than 2MB.', description: 'Please upload an image smaller than 5MB.',
}); });
return; return;
} }
@@ -208,7 +208,7 @@ export function ProjectIdentitySection({ project }: ProjectIdentitySectionProps)
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'} {isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button> </Button>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB. PNG, JPG, GIF or WebP. Max 5MB.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -19,6 +19,7 @@ import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automak
import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types'; import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
export function EventHistoryView() { export function EventHistoryView() {
const currentProject = useAppStore((state) => state.currentProject); const currentProject = useAppStore((state) => state.currentProject);
@@ -85,16 +86,18 @@ export function EventHistoryView() {
const failCount = hookResults.filter((r) => !r.success).length; const failCount = hookResults.filter((r) => !r.success).length;
if (hooksTriggered === 0) { if (hooksTriggered === 0) {
alert('No matching hooks found for this event trigger.'); toast.info('No matching hooks found for this event trigger.');
} else if (failCount === 0) { } else if (failCount === 0) {
alert(`Successfully ran ${successCount} hook(s).`); toast.success(`Successfully ran ${successCount} hook(s).`);
} else { } else {
alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`); toast.warning(
`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`
);
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to replay event:', error); console.error('Failed to replay event:', error);
alert('Failed to replay event. Check console for details.'); toast.error('Failed to replay event. Check console for details.');
} finally { } finally {
setReplayingEvent(null); setReplayingEvent(null);
} }

View File

@@ -946,7 +946,7 @@ export class HttpApiClient implements ElectronAPI {
return response.json(); return response.json();
} }
private async get<T>(endpoint: string): Promise<T> { async get<T>(endpoint: string): Promise<T> {
// Ensure API key is initialized before making request // Ensure API key is initialized before making request
await waitForApiKeyInit(); await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, { const response = await fetch(`${this.serverUrl}${endpoint}`, {
@@ -976,7 +976,7 @@ export class HttpApiClient implements ElectronAPI {
return response.json(); return response.json();
} }
private async put<T>(endpoint: string, body?: unknown): Promise<T> { async put<T>(endpoint: string, body?: unknown): Promise<T> {
// Ensure API key is initialized before making request // Ensure API key is initialized before making request
await waitForApiKeyInit(); await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, { const response = await fetch(`${this.serverUrl}${endpoint}`, {

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts) // Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron'; import type { Project, TrashedProject } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron'; import { saveProjects, saveTrashedProjects } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
// Note: setItem/getItem moved to ./utils/theme-utils.ts // Note: setItem/getItem moved to ./utils/theme-utils.ts
@@ -360,7 +360,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const trashedProject: TrashedProject = { const trashedProject: TrashedProject = {
...project, ...project,
trashedAt: Date.now(), trashedAt: new Date().toISOString(),
}; };
set((state) => ({ set((state) => ({
@@ -369,12 +369,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentProject: state.currentProject?.id === projectId ? null : state.currentProject, currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) { saveTrashedProjects(get().trashedProjects);
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
}, },
restoreTrashedProject: (projectId: string) => { restoreTrashedProject: (projectId: string) => {
@@ -390,12 +387,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId), trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) { saveTrashedProjects(get().trashedProjects);
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
}, },
deleteTrashedProject: (projectId: string) => { deleteTrashedProject: (projectId: string) => {
@@ -403,21 +397,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId), trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveTrashedProjects(get().trashedProjects);
if (electronAPI) {
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
}, },
emptyTrash: () => { emptyTrash: () => {
set({ trashedProjects: [] }); set({ trashedProjects: [] });
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveTrashedProjects([]);
if (electronAPI) {
electronAPI.projects.setTrashedProjects([]);
}
}, },
setCurrentProject: (project) => { setCurrentProject: (project) => {
@@ -474,14 +462,10 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
get().addProject(newProject); get().addProject(newProject);
get().setCurrentProject(newProject); get().setCurrentProject(newProject);
// Persist to Electron store if available // Persist to storage (small delay to ensure state is updated)
const electronAPI = getElectronAPI(); setTimeout(() => {
if (electronAPI) { saveProjects(get().projects);
// Small delay to ensure state is updated before persisting }, 0);
setTimeout(() => {
electronAPI.projects.setProjects(get().projects);
}, 0);
}
return newProject; return newProject;
}, },
@@ -564,11 +548,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
), ),
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
setProjectIcon: (projectId: string, icon: string | null) => { setProjectIcon: (projectId: string, icon: string | null) => {
@@ -576,27 +557,31 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
projects: state.projects.map((p) => projects: state.projects.map((p) =>
p.id === projectId ? { ...p, icon: icon ?? undefined } : p p.id === projectId ? { ...p, icon: icon ?? undefined } : p
), ),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, icon: icon ?? undefined }
: state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => { setProjectCustomIcon: (projectId: string, customIconPath: string | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => projects: state.projects.map((p) =>
p.id === projectId ? { ...p, customIcon: customIconPath ?? undefined } : p p.id === projectId ? { ...p, customIconPath: customIconPath ?? undefined } : p
), ),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, customIconPath: customIconPath ?? undefined }
: state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
setProjectName: (projectId: string, name: string) => { setProjectName: (projectId: string, name: string) => {
@@ -609,11 +594,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject, : state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
// View actions // View actions
@@ -659,11 +641,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
); );
} }
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
getEffectiveTheme: () => { getEffectiveTheme: () => {
const state = get(); const state = get();
@@ -696,11 +675,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject, : state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
setProjectFontMono: (projectId: string, fontFamily: string | null) => { setProjectFontMono: (projectId: string, fontFamily: string | null) => {
set((state) => ({ set((state) => ({
@@ -714,20 +690,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject, : state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
getEffectiveFontSans: () => { getEffectiveFontSans: () => {
const state = get(); const state = get();
const projectFont = state.currentProject?.fontSans; const projectFont = state.currentProject?.fontFamilySans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS); return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
}, },
getEffectiveFontMono: () => { getEffectiveFontMono: () => {
const state = get(); const state = get();
const projectFont = state.currentProject?.fontMono; const projectFont = state.currentProject?.fontFamilyMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS); return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
}, },
@@ -744,11 +717,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
: state.currentProject, : state.currentProject,
})); }));
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
// Project Phase Model Overrides // Project Phase Model Overrides
@@ -781,11 +751,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}; };
}); });
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
clearAllProjectPhaseModelOverrides: (projectId: string) => { clearAllProjectPhaseModelOverrides: (projectId: string) => {
@@ -804,11 +771,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}; };
}); });
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
// Project Default Feature Model Override // Project Default Feature Model Override
@@ -830,11 +794,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}; };
}); });
// Persist to Electron store if available // Persist to storage
const electronAPI = getElectronAPI(); saveProjects(get().projects);
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
}, },
// Feature actions // Feature actions
@@ -845,7 +806,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})), })),
addFeature: (feature) => { addFeature: (feature) => {
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`; const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newFeature: Feature = { ...feature, id }; const newFeature = { ...feature, id } as Feature;
set((state) => ({ features: [...state.features, newFeature] })); set((state) => ({ features: [...state.features, newFeature] }));
return newFeature; return newFeature;
}, },
@@ -2471,8 +2432,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/codex/models'); const data = await httpApi.get<{
const data = response.data as {
success: boolean; success: boolean;
models?: Array<{ models?: Array<{
id: string; id: string;
@@ -2484,7 +2444,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
isDefault: boolean; isDefault: boolean;
}>; }>;
error?: string; error?: string;
}; }>('/api/codex/models');
if (data.success && data.models) { if (data.success && data.models) {
set({ set({
@@ -2542,8 +2502,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
try { try {
const httpApi = getHttpApiClient(); const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/opencode/models'); const data = await httpApi.get<{
const data = response.data as {
success: boolean; success: boolean;
models?: ModelDefinition[]; models?: ModelDefinition[];
providers?: Array<{ providers?: Array<{
@@ -2553,7 +2512,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
authMethod?: string; authMethod?: string;
}>; }>;
error?: string; error?: string;
}; }>('/api/setup/opencode/models');
if (data.success && data.models) { if (data.success && data.models) {
// Filter out Bedrock models // Filter out Bedrock models

View File

@@ -21,9 +21,13 @@ services:
# - ~/.local/share/opencode:/home/automaker/.local/share/opencode # - ~/.local/share/opencode:/home/automaker/.local/share/opencode
# - ~/.config/opencode:/home/automaker/.config/opencode # - ~/.config/opencode:/home/automaker/.config/opencode
# Playwright browser cache - persists installed browsers across container restarts # ===== Playwright Browser Cache (Optional) =====
# Run 'npx playwright install --with-deps chromium' once, and it will persist # Playwright Chromium is PRE-INSTALLED in the Docker image for automated testing.
# Uncomment below to persist browser cache across container rebuilds (saves ~300MB download):
# - playwright-cache:/home/automaker/.cache/ms-playwright # - playwright-cache:/home/automaker/.cache/ms-playwright
#
# To update Playwright browsers manually:
# docker exec --user automaker -w /app automaker-server npx playwright install chromium
environment: environment:
# Set root directory for all projects and file operations # Set root directory for all projects and file operations
# Users can only create/open projects within this directory # Users can only create/open projects within this directory
@@ -37,6 +41,7 @@ services:
# - CURSOR_API_KEY=${CURSOR_API_KEY:-} # - CURSOR_API_KEY=${CURSOR_API_KEY:-}
volumes: volumes:
# Playwright cache volume (persists Chromium installs) # Playwright cache volume - optional, persists browser updates across container rebuilds
# Uncomment if you mounted the playwright-cache volume above
# playwright-cache: # playwright-cache:
# name: automaker-playwright-cache # name: automaker-playwright-cache

View File

@@ -750,6 +750,9 @@ export function electronUserDataWriteFileSync(
throw new Error('[SystemPaths] Electron userData path not initialized'); throw new Error('[SystemPaths] Electron userData path not initialized');
} }
const fullPath = path.join(electronUserDataPath, relativePath); const fullPath = path.join(electronUserDataPath, relativePath);
// Ensure parent directory exists (may not exist on first launch)
const dir = path.dirname(fullPath);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(fullPath, data, options); fsSync.writeFileSync(fullPath, data, options);
} }

View File

@@ -965,8 +965,20 @@ export const DEFAULT_PLAN_REVISION_TEMPLATE = `The user has requested revisions
## Instructions ## Instructions
Please regenerate the specification incorporating the user's feedback. Please regenerate the specification incorporating the user's feedback.
Keep the same format with the \`\`\`tasks block for task definitions. **Current planning mode: {{planningMode}}**
After generating the revised spec, output:
**CRITICAL REQUIREMENT**: Your revised specification MUST include a \`\`\`tasks code block containing task definitions in the EXACT format shown below. This is MANDATORY - without the tasks block, the system cannot track or execute tasks properly.
### Required Task Format
{{taskFormatExample}}
**IMPORTANT**:
1. The \`\`\`tasks block must appear in your response
2. Each task MUST start with "- [ ] T###:" where ### is a sequential number (T001, T002, T003, etc.)
3. Each task MUST include "| File:" followed by the primary file path
4. Tasks should be ordered by dependencies (foundational tasks first)
After generating the revised spec with the tasks block, output:
"[SPEC_GENERATED] Please review the revised specification above."`; "[SPEC_GENERATED] Please review the revised specification above."`;
export const DEFAULT_CONTINUATION_AFTER_APPROVAL_TEMPLATE = `The plan/specification has been approved. Now implement it. export const DEFAULT_CONTINUATION_AFTER_APPROVAL_TEMPLATE = `The plan/specification has been approved. Now implement it.