Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
3b8b14b714 Initial plan 2025-12-16 00:28:05 +00:00
951 changed files with 40079 additions and 110626 deletions

View File

@@ -1,74 +0,0 @@
# GitHub Issue Fix Command
Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid.
## Usage
This command accepts a GitHub issue number as input (e.g., `123`).
## Instructions
1. **Get the issue number from the user**
- The issue number should be provided as an argument to this command
- If no number is provided, ask the user for it
2. **Fetch the GitHub issue**
- Determine the current project path (check if there's a current project context)
- Verify the project has a GitHub remote:
```bash
git remote get-url origin
```
- Fetch the issue details using GitHub CLI:
```bash
gh issue view <ISSUE_NUMBER> --json number,title,state,author,createdAt,labels,url,body,assignees
```
- If the command fails, report the error and stop
3. **Verify the issue is real and valid**
- Check that the issue exists (not 404)
- Check the issue state:
- If **closed**: Inform the user and ask if they still want to proceed
- If **open**: Proceed with validation
- Review the issue content:
- Read the title and body to understand what needs to be fixed
- Check labels for context (bug, enhancement, etc.)
- Note any assignees or linked PRs
4. **Validate the issue**
- Determine if this is a legitimate issue that needs fixing:
- Is the description clear and actionable?
- Does it describe a real problem or feature request?
- Are there any obvious signs it's spam or invalid?
- If the issue seems invalid or unclear:
- Report findings to the user
- Ask if they want to proceed anyway
- Stop if user confirms it's not valid
5. **If the issue is valid, proceed to fix it**
- Analyze what needs to be done based on the issue description
- Check the current codebase state:
- Run relevant tests to see current behavior
- Check if the issue is already fixed
- Look for related code that might need changes
- Implement the fix:
- Make necessary code changes
- Update or add tests as needed
- Ensure the fix addresses the issue description
- Verify the fix:
- Run tests to ensure nothing broke
- If possible, manually verify the fix addresses the issue
6. **Report summary**
- Issue number and title
- Issue state (open/closed)
- Whether the issue was validated as real
- What was fixed (if anything)
- Any tests that were updated or added
- Next steps (if any)
## Error Handling
- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop
- If the project doesn't have a GitHub remote, report error and stop
- If the issue number doesn't exist, report error and stop
- If the issue is unclear or invalid, report findings and ask user before proceeding

View File

@@ -1,77 +0,0 @@
# Release Command
Bump the package.json version (major, minor, or patch) and build the Electron app with the new version.
## Usage
This command accepts a version bump type as input:
- `patch` - Bump patch version (0.1.0 -> 0.1.1)
- `minor` - Bump minor version (0.1.0 -> 0.2.0)
- `major` - Bump major version (0.1.0 -> 1.0.0)
## Instructions
1. **Get the bump type from the user**
- The bump type should be provided as an argument (patch, minor, or major)
- If no type is provided, ask the user which type they want
2. **Bump the version**
- Run the version bump script:
```bash
node apps/ui/scripts/bump-version.mjs <type>
```
- This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync)
- Verify the version was updated correctly by checking the output
3. **Build the Electron app**
- Run the electron build:
```bash
npm run build:electron --workspace=apps/ui
```
- The build process automatically:
- Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`)
- Injects the version into the app via Vite's `__APP_VERSION__` constant
- Displays the version below the logo in the sidebar
4. **Commit the version bump**
- Stage the updated package.json files:
```bash
git add apps/ui/package.json apps/server/package.json
```
- Commit with a release message:
```bash
git commit -m "chore: release v<version>"
```
5. **Create and push the git tag**
- Create an annotated tag for the release:
```bash
git tag -a v<version> -m "Release v<version>"
```
- Push the commit and tag to remote:
```bash
git push && git push --tags
```
6. **Verify the release**
- Check that the build completed successfully
- Confirm the version appears correctly in the built artifacts
- The version will be displayed in the app UI below the logo
- Verify the tag is visible on the remote repository
## Version Centralization
The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`:
- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName`
- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`)
- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints)
- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string`
This ensures consistency across:
- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`)
- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`)
- Server health endpoints (`/` and `/detailed`)
- Package metadata (both UI and server packages stay in sync)

View File

@@ -1,49 +0,0 @@
# Project Build and Fix Command
Run all builds and intelligently fix any failures based on what changed.
## Instructions
1. **Run the build**
```bash
npm run build
```
This builds all packages and the UI application.
2. **If the build succeeds**, report success and stop.
3. **If the build fails**, analyze the failures:
- Note which build step failed and the error messages
- Check for TypeScript compilation errors, missing dependencies, or configuration issues
- Run `git diff main` to see what code has changed
4. **Determine the nature of the failure**:
- **If the failure is due to intentional changes** (new features, refactoring, dependency updates):
- Fix any TypeScript type errors introduced by the changes
- Update build configuration if needed (e.g., tsconfig.json, vite.config.mts)
- Ensure all new dependencies are properly installed
- Fix import paths or module resolution issues
- **If the failure appears to be a regression** (broken imports, missing files, configuration errors):
- Fix the source code to restore the build
- Check for accidentally deleted files or broken references
- Verify build configuration files are correct
5. **Common build issues to check**:
- **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports
- **Missing dependencies**: Run `npm install` if packages are missing
- **Import/export errors**: Fix incorrect import paths or missing exports
- **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs
- **Package build order**: Ensure `build:packages` completes before building apps
6. **How to decide if it's intentional vs regression**:
- Look at the git diff and commit messages
- If the change was deliberate and introduced new code that needs fixing → fix the new code
- If the change broke existing functionality that should still build → fix the regression
- When in doubt, ask the user
7. **After making fixes**, re-run the build to verify everything compiles successfully.
8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.).

View File

@@ -1,36 +0,0 @@
# Project Test and Fix Command
Run all tests and intelligently fix any failures based on what changed.
## Instructions
1. **Run all tests**
```bash
npm run test:all
```
2. **If all tests pass**, report success and stop.
3. **If any tests fail**, analyze the failures:
- Note which tests failed and their error messages
- Run `git diff main` to see what code has changed
4. **Determine the nature of the change**:
- **If the logic change is intentional** (new feature, refactor, behavior change):
- Update the failing tests to match the new expected behavior
- The tests should reflect what the code NOW does correctly
- **If the logic change appears to be a bug** (regression, unintended side effect):
- Fix the source code to restore the expected behavior
- Do NOT modify the tests - they are catching a real bug
5. **How to decide if it's a bug vs intentional change**:
- Look at the git diff and commit messages
- If the change was deliberate and the test expectations are now outdated → update tests
- If the change broke existing functionality that should still work → fix the code
- When in doubt, ask the user
6. **After making fixes**, re-run the tests to verify everything passes.
7. **Report summary** of what was fixed (tests updated vs code fixed).

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir \"C:\\Users\\Ben\\Desktop\\appdev\\git\\automaker\\apps\\app\\public\")"
]
}
}

24
.claude_settings.json Normal file
View File

@@ -0,0 +1,24 @@
{
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": true
},
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read(./**)",
"Write(./**)",
"Edit(./**)",
"Glob(./**)",
"Grep(./**)",
"Bash(*)",
"mcp__puppeteer__puppeteer_navigate",
"mcp__puppeteer__puppeteer_screenshot",
"mcp__puppeteer__puppeteer_click",
"mcp__puppeteer__puppeteer_fill",
"mcp__puppeteer__puppeteer_select",
"mcp__puppeteer__puppeteer_hover",
"mcp__puppeteer__puppeteer_evaluate"
]
}
}

View File

@@ -1,117 +0,0 @@
name: Bug Report
description: File a bug report to help us improve Automaker
title: '[Bug]: '
labels: ['bug']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible.
- type: dropdown
id: operating-system
attributes:
label: Operating System
description: What operating system are you using?
options:
- macOS
- Windows
- Linux
- Other
default: 0
validations:
required: true
- type: dropdown
id: run-mode
attributes:
label: Run Mode
description: How are you running Automaker?
options:
- Electron (Desktop App)
- Web (Browser)
- Docker
default: 0
validations:
required: true
- type: input
id: app-version
attributes:
label: App Version
description: What version of Automaker are you using? (e.g., 0.1.0)
placeholder: '0.1.0'
validations:
required: true
- type: textarea
id: bug-description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
placeholder: What should have happened?
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: A clear and concise description of what actually happened.
placeholder: What actually happened?
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
placeholder: Drag and drop screenshots here or paste image URLs
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: If applicable, paste relevant logs or error messages.
placeholder: Paste logs here...
render: shell
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here.
placeholder: Any additional information that might be helpful...
- type: checkboxes
id: terms
attributes:
label: Checklist
options:
- label: I have searched existing issues to ensure this bug hasn't been reported already
required: true
- label: I have provided all required information above
required: true

View File

@@ -1,71 +0,0 @@
name: 'Setup Project'
description: 'Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '22'
check-lockfile:
description: 'Run lockfile lint check for SSH URLs'
required: false
default: 'false'
rebuild-node-pty-path:
description: 'Working directory for node-pty rebuild (empty = root)'
required: false
default: ''
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Check for SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
run: npm run lint:lockfile
- name: Configure Git for HTTPS
shell: bash
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
shell: bash
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
run: npm install --ignore-scripts
- name: Install Linux native bindings
shell: bash
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force --ignore-scripts \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Build shared packages
shell: bash
# Build shared packages (types, utils, platform, etc.) before apps can use them
run: npm run build:packages
- name: Rebuild native modules (root)
if: inputs.rebuild-node-pty-path == ''
shell: bash
# Rebuild node-pty and other native modules for Electron
run: npm rebuild node-pty
- name: Rebuild native modules (workspace)
if: inputs.rebuild-node-pty-path != ''
shell: bash
# Rebuild node-pty and other native modules needed for server
run: npm rebuild node-pty
working-directory: ${{ inputs.rebuild-node-pty-path }}

View File

@@ -1,11 +1,15 @@
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('fs');
const path = require('path');
const https = require('https');
const { pipeline } = require('stream/promises');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
} = require("@aws-sdk/client-s3");
const fs = require("fs");
const path = require("path");
const https = require("https");
const { pipeline } = require("stream/promises");
const s3Client = new S3Client({
region: 'auto',
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
@@ -24,14 +28,14 @@ async function fetchExistingReleases() {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
Key: "releases.json",
})
);
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
console.log('No existing releases.json found, creating new one');
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
console.log("No existing releases.json found, creating new one");
return { latestVersion: null, releases: [] };
}
throw error;
@@ -81,7 +85,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
resolve({
accessible: false,
statusCode,
error: 'Redirect without location header',
error: "Redirect without location header",
});
return;
}
@@ -89,16 +93,18 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
return https
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
const redirectStatus = redirectResponse.statusCode;
const contentType = redirectResponse.headers['content-type'] || '';
const contentType =
redirectResponse.headers["content-type"] || "";
// Check if it's actually a file (zip/tar.gz) and not HTML
const isFile =
contentType.includes('application/zip') ||
contentType.includes('application/gzip') ||
contentType.includes('application/x-gzip') ||
contentType.includes('application/x-tar') ||
redirectUrl.includes('.zip') ||
redirectUrl.includes('.tar.gz');
const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
contentType.includes("application/zip") ||
contentType.includes("application/gzip") ||
contentType.includes("application/x-gzip") ||
contentType.includes("application/x-tar") ||
redirectUrl.includes(".zip") ||
redirectUrl.includes(".tar.gz");
const isGood =
redirectStatus >= 200 && redirectStatus < 300 && isFile;
redirectResponse.destroy();
resolve({
accessible: isGood,
@@ -107,38 +113,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
contentType,
});
})
.on('error', (error) => {
.on("error", (error) => {
resolve({
accessible: false,
statusCode,
error: error.message,
});
})
.on('timeout', function () {
.on("timeout", function () {
this.destroy();
resolve({
accessible: false,
statusCode,
error: 'Timeout following redirect',
error: "Timeout following redirect",
});
});
}
// Check if status is good (200-299 range) and it's actually a file
const contentType = response.headers['content-type'] || '';
const contentType = response.headers["content-type"] || "";
const isFile =
contentType.includes('application/zip') ||
contentType.includes('application/gzip') ||
contentType.includes('application/x-gzip') ||
contentType.includes('application/x-tar') ||
url.includes('.zip') ||
url.includes('.tar.gz');
contentType.includes("application/zip") ||
contentType.includes("application/gzip") ||
contentType.includes("application/x-gzip") ||
contentType.includes("application/x-tar") ||
url.includes(".zip") ||
url.includes(".tar.gz");
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
response.destroy();
resolve({ accessible: isGood, statusCode, contentType });
});
request.on('error', (error) => {
request.on("error", (error) => {
resolve({
accessible: false,
statusCode: null,
@@ -146,12 +152,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
});
});
request.on('timeout', () => {
request.on("timeout", () => {
request.destroy();
resolve({
accessible: false,
statusCode: null,
error: 'Request timeout',
error: "Request timeout",
});
});
});
@@ -162,14 +168,22 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
);
} else {
console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
console.log(
`✓ URL ${url} is accessible (status: ${result.statusCode})`
);
}
return result.finalUrl || url; // Return the final URL (after redirects) if available
} else {
const errorMsg = result.error ? ` - ${result.error}` : '';
const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
const errorMsg = result.error ? ` - ${result.error}` : "";
const statusMsg = result.statusCode
? ` (status: ${result.statusCode})`
: "";
const contentTypeMsg = result.contentType
? ` [content-type: ${result.contentType}]`
: "";
console.log(
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
);
}
} catch (error) {
console.log(`✗ URL ${url} check failed: ${error.message}`);
@@ -177,7 +191,9 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
if (attempt < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, attempt);
console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
console.log(
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
@@ -191,7 +207,12 @@ async function downloadFromGitHub(url, outputPath) {
const statusCode = response.statusCode;
// Follow redirects (all redirect types)
if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
if (
statusCode === 301 ||
statusCode === 302 ||
statusCode === 307 ||
statusCode === 308
) {
const redirectUrl = response.headers.location;
response.destroy();
if (!redirectUrl) {
@@ -199,33 +220,39 @@ async function downloadFromGitHub(url, outputPath) {
return;
}
// Resolve relative redirects
const finalRedirectUrl = redirectUrl.startsWith('http')
const finalRedirectUrl = redirectUrl.startsWith("http")
? redirectUrl
: new URL(redirectUrl, url).href;
console.log(` Following redirect: ${finalRedirectUrl}`);
return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
return downloadFromGitHub(finalRedirectUrl, outputPath)
.then(resolve)
.catch(reject);
}
if (statusCode !== 200) {
response.destroy();
reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
reject(
new Error(
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
)
);
return;
}
const fileStream = fs.createWriteStream(outputPath);
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.on("finish", () => {
fileStream.close();
resolve();
});
fileStream.on('error', (error) => {
fileStream.on("error", (error) => {
response.destroy();
reject(error);
});
});
request.on('error', reject);
request.on('timeout', () => {
request.on("error", reject);
request.on("timeout", () => {
request.destroy();
reject(new Error(`Request timeout for ${url}`));
});
@@ -233,8 +260,8 @@ async function downloadFromGitHub(url, outputPath) {
}
async function main() {
const artifactsDir = 'artifacts';
const tempDir = path.join(artifactsDir, 'temp');
const artifactsDir = "artifacts";
const tempDir = path.join(artifactsDir, "temp");
// Create temp directory for downloaded GitHub archives
if (!fs.existsSync(tempDir)) {
@@ -265,30 +292,40 @@ async function main() {
// Find all artifacts
const artifacts = {
windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
macosArm: findArtifacts(
path.join(artifactsDir, "macos-builds"),
/-arm64\.dmg$/
),
linux: findArtifacts(
path.join(artifactsDir, "linux-builds"),
/\.AppImage$/
),
sourceZip: [sourceZipPath],
sourceTarGz: [sourceTarGzPath],
};
console.log('Found artifacts:');
console.log("Found artifacts:");
for (const [platform, files] of Object.entries(artifacts)) {
console.log(
` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
` ${platform}: ${
files.length > 0
? files.map((f) => path.basename(f)).join(", ")
: "none"
}`
);
}
// Upload each artifact to R2
const assets = {};
const contentTypes = {
windows: 'application/x-msdownload',
macos: 'application/x-apple-diskimage',
macosArm: 'application/x-apple-diskimage',
linux: 'application/x-executable',
sourceZip: 'application/zip',
sourceTarGz: 'application/gzip',
windows: "application/x-msdownload",
macos: "application/x-apple-diskimage",
macosArm: "application/x-apple-diskimage",
linux: "application/x-executable",
sourceZip: "application/zip",
sourceTarGz: "application/gzip",
};
for (const [platform, files] of Object.entries(artifacts)) {
@@ -308,11 +345,11 @@ async function main() {
filename,
size,
arch:
platform === 'macosArm'
? 'arm64'
: platform === 'sourceZip' || platform === 'sourceTarGz'
? 'source'
: 'x64',
platform === "macosArm"
? "arm64"
: platform === "sourceZip" || platform === "sourceTarGz"
? "source"
: "x64",
};
}
@@ -327,7 +364,9 @@ async function main() {
};
// Remove existing entry for this version if re-running
releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
releasesData.releases = releasesData.releases.filter(
(r) => r.version !== VERSION
);
// Prepend new release
releasesData.releases.unshift(newRelease);
@@ -337,19 +376,19 @@ async function main() {
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
Key: "releases.json",
Body: JSON.stringify(releasesData, null, 2),
ContentType: 'application/json',
CacheControl: 'public, max-age=60',
ContentType: "application/json",
CacheControl: "public, max-age=60",
})
);
console.log('Successfully updated releases.json');
console.log("Successfully updated releases.json");
console.log(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`);
}
main().catch((err) => {
console.error('Failed to upload to R2:', err);
console.error("Failed to upload to R2:", err);
process.exit(1);
});

View File

@@ -1,49 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -3,7 +3,7 @@ name: E2E Tests
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
@@ -18,15 +18,34 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
- name: Setup Node.js
uses: actions/setup-node@v4
with:
check-lockfile: 'true'
rebuild-node-pty-path: 'apps/server'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: apps/ui
working-directory: apps/app
- name: Build server
run: npm run build --workspace=apps/server
@@ -52,20 +71,20 @@ jobs:
exit 1
- name: Run E2E tests
# Playwright automatically starts the Vite frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui
# Playwright automatically starts the Next.js frontend via webServer config
# (see apps/app/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/app
env:
CI: true
VITE_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true'
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
NEXT_PUBLIC_SKIP_SETUP: "true"
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: apps/ui/playwright-report/
path: apps/app/playwright-report/
retention-days: 7
- name: Upload test results
@@ -73,5 +92,5 @@ jobs:
if: failure()
with:
name: test-results
path: apps/ui/test-results/
path: apps/app/test-results/
retention-days: 7

View File

@@ -1,31 +0,0 @@
name: Format Check
on:
pull_request:
branches:
- '*'
push:
branches:
- main
- master
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm install --ignore-scripts
- name: Check formatting
run: npm run format:check

View File

@@ -3,7 +3,7 @@ name: PR Build Check
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
@@ -17,10 +17,30 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
- name: Setup Node.js
uses: actions/setup-node@v4
with:
check-lockfile: 'true'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Run build:electron (dir only - faster CI)
run: npm run build:electron:dir
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run build:electron
run: npm run build:electron

View File

@@ -1,111 +1,180 @@
name: Release Build
name: Build and Release Electron App
on:
release:
types: [published]
push:
tags:
- "v*.*.*" # Triggers on version tags like v1.0.0
workflow_dispatch: # Allows manual triggering
inputs:
version:
description: "Version to release (e.g., v1.0.0)"
required: true
default: "v0.1.0"
jobs:
build:
build-and-release:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- os: macos-latest
name: macOS
artifact-name: macos-builds
- os: windows-latest
name: Windows
artifact-name: windows-builds
- os: ubuntu-latest
name: Linux
artifact-name: linux-builds
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from tag
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Only needed on Linux - macOS and Windows get their bindings automatically
if: matrix.os == 'ubuntu-latest'
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Extract and set version
id: version
shell: bash
run: |
# Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3")
VERSION="${{ github.event.release.tag_name }}"
VERSION="${VERSION#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Extracted version: ${VERSION}"
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION_TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
# Update the app's package.json version
cd apps/app
npm version $VERSION --no-git-tag-version
cd ../..
echo "Updated apps/app/package.json to version $VERSION"
- name: Update package.json version
shell: bash
run: |
node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
- name: Build Electron app (macOS)
- name: Build Electron App (macOS)
if: matrix.os == 'macos-latest'
shell: bash
run: npm run build:electron:mac --workspace=apps/ui
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --mac --x64 --arm64
- name: Build Electron app (Windows)
- name: Build Electron App (Windows)
if: matrix.os == 'windows-latest'
shell: bash
run: npm run build:electron:win --workspace=apps/ui
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --win --x64
- name: Build Electron app (Linux)
- name: Build Electron App (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: npm run build:electron:linux --workspace=apps/ui
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --linux --x64
- name: Upload macOS artifacts
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: macos-builds
path: apps/ui/release/*.{dmg,zip}
retention-days: 30
- name: Upload Windows artifacts
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: windows-builds
path: apps/ui/release/*.exe
retention-days: 30
- name: Upload Linux artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: linux-builds
path: apps/ui/release/*.{AppImage,deb}
retention-days: 30
upload:
needs: build
runs-on: ubuntu-latest
if: github.event.release.draft == false
steps:
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: macos-builds
path: artifacts/macos-builds
- name: Download Windows artifacts
uses: actions/download-artifact@v4
with:
name: windows-builds
path: artifacts/windows-builds
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
name: linux-builds
path: artifacts/linux-builds
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
- name: Upload Release Assets
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.event.inputs.version || github.ref_name }}
files: |
artifacts/macos-builds/*
artifacts/windows-builds/*
artifacts/linux-builds/*
apps/app/dist/*.exe
apps/app/dist/*.dmg
apps/app/dist/*.AppImage
apps/app/dist/*.zip
apps/app/dist/*.deb
apps/app/dist/*.rpm
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload macOS artifacts for R2
if: matrix.os == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: apps/app/dist/*.dmg
retention-days: 1
- name: Upload Windows artifacts for R2
if: matrix.os == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: apps/app/dist/*.exe
retention-days: 1
- name: Upload Linux artifacts for R2
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: apps/app/dist/*.AppImage
retention-days: 1
upload-to-r2:
needs: build-and-release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Install AWS SDK
run: npm install @aws-sdk/client-s3
- name: Extract version
id: version
shell: bash
run: |
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION_TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
- name: Upload to R2 and update releases.json
env:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: node .github/scripts/upload-to-r2.js

View File

@@ -1,30 +0,0 @@
name: Security Audit
on:
pull_request:
branches:
- '*'
push:
branches:
- main
- master
schedule:
# Run weekly on Mondays at 9 AM UTC
- cron: '0 9 * * 1'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
with:
check-lockfile: 'true'
- name: Run npm audit
run: npm audit --audit-level=moderate
continue-on-error: false

View File

@@ -3,7 +3,7 @@ name: Test Suite
on:
pull_request:
branches:
- '*'
- "*"
push:
branches:
- main
@@ -17,16 +17,30 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup project
uses: ./.github/actions/setup-project
- name: Setup Node.js
uses: actions/setup-node@v4
with:
check-lockfile: 'true'
rebuild-node-pty-path: 'apps/server'
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Run package tests
run: npm run test:packages
env:
NODE_ENV: test
- name: Configure Git for HTTPS
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Install dependencies
# Use npm install instead of npm ci to correctly resolve platform-specific
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Install Linux native bindings
# Workaround for npm optional dependencies bug (npm/cli#4828)
# Explicitly install Linux bindings needed for build tools
run: |
npm install --no-save --force \
@rollup/rollup-linux-x64-gnu@4.53.3 \
@tailwindcss/oxide-linux-x64-gnu@4.1.17
- name: Run server tests with coverage
run: npm run test:server:coverage

73
.gitignore vendored
View File

@@ -6,81 +6,10 @@ node_modules/
# Build outputs
dist/
build/
out/
.next/
.turbo/
# Automaker
.automaker/images/
.automaker/
/.automaker/*
/.automaker/
.worktrees/
/logs
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# OS-specific files
.DS_Store
.DS_Store?
._*
Thumbs.db
ehthumbs.db
Desktop.ini
# IDE/Editor configs
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# Editor backup/temp files
*~
*.bak
*.backup
*.orig
*.swp
*.swo
*.tmp
*.temp
# Local settings (user-specific)
*.local.json
# Application state/backup
backup.json
# Test artifacts
test-results/
coverage/
.nyc_output/
*.lcov
playwright-report/
blob-report/
# Environment files (keep .example)
.env
.env.local
.env.*.local
!.env.example
!.env.local.example
# TypeScript
*.tsbuildinfo
# Misc
*.pem
docker-compose.override.yml
.claude/docker-compose.override.yml
pnpm-lock.yaml
yarn.lock
/logs

View File

@@ -1 +0,0 @@
npx lint-staged

2
.nvmrc
View File

@@ -1,2 +0,0 @@
22

View File

@@ -1,39 +0,0 @@
# Dependencies
node_modules/
# Build outputs
dist/
build/
out/
.next/
.turbo/
release/
# Automaker
.automaker/
# Logs
logs/
*.log
# Lock files
package-lock.json
pnpm-lock.yaml
# Generated files
*.min.js
*.min.css
# Test artifacts
test-results/
coverage/
playwright-report/
blob-report/
# IDE/Editor
.vscode/
.idea/
# Electron
dist-electron/
server-bundle/

View File

@@ -1,10 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

172
CLAUDE.md
View File

@@ -1,172 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
## Common Commands
```bash
# Development
npm run dev # Interactive launcher (choose web or electron)
npm run dev:web # Web browser mode (localhost:3007)
npm run dev:electron # Desktop app mode
npm run dev:electron:debug # Desktop with DevTools open
# Building
npm run build # Build web application
npm run build:packages # Build all shared packages (required before other builds)
npm run build:electron # Build desktop app for current platform
npm run build:server # Build server only
# Testing
npm run test # E2E tests (Playwright, headless)
npm run test:headed # E2E tests with browser visible
npm run test:server # Server unit tests (Vitest)
npm run test:packages # All shared package tests
npm run test:all # All tests (packages + server)
# Single test file
npm run test:server -- tests/unit/specific.test.ts
# Linting and formatting
npm run lint # ESLint
npm run format # Prettier write
npm run format:check # Prettier check
```
## Architecture
### Monorepo Structure
```
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend (port 3007)
│ └── server/ # Express + WebSocket backend (port 3008)
└── libs/ # Shared packages (@automaker/*)
├── types/ # Core TypeScript definitions (no dependencies)
├── utils/ # Logging, errors, image processing, context loading
├── prompts/ # AI prompt templates
├── platform/ # Path management, security, process spawning
├── model-resolver/ # Claude model alias resolution
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### Package Dependency Chain
Packages can only depend on packages above them:
```
@automaker/types (no dependencies)
@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
@automaker/git-utils
@automaker/server, @automaker/ui
```
### Key Technologies
- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
- **Testing**: Playwright (E2E), Vitest (unit)
### Server Architecture
The server (`apps/server/src/`) follows a modular pattern:
- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
- `lib/` - Utilities (events, auth, worktree metadata)
### Frontend Architecture
The UI (`apps/ui/src/`) uses:
- `routes/` - TanStack Router file-based routing
- `components/views/` - Main view components (board, settings, terminal, etc.)
- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
- `hooks/` - Custom React hooks
- `lib/` - Utilities and API client
## Data Storage
### Per-Project Data (`.automaker/`)
```
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json
│ ├── agent-output.md
│ └── images/
├── context/ # Context files for AI agents (CLAUDE.md, etc.)
├── settings.json # Project-specific settings
├── spec.md # Project specification
└── analysis.json # Project structure analysis
```
### Global Data (`DATA_DIR`, default `./data`)
```
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
```
## Import Conventions
Always import from shared packages, never from old paths:
```typescript
// ✅ Correct
import type { Feature, ExecuteOptions } from '@automaker/types';
import { createLogger, classifyError } from '@automaker/utils';
import { getEnhancementPrompt } from '@automaker/prompts';
import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
import { resolveModelString } from '@automaker/model-resolver';
import { resolveDependencies } from '@automaker/dependency-resolver';
import { getGitRepositoryDiffs } from '@automaker/git-utils';
// ❌ Never import from old paths
import { Feature } from '../services/feature-loader'; // Wrong
import { createLogger } from '../lib/logger'; // Wrong
```
## Key Patterns
### Event-Driven Architecture
All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
### Git Worktree Isolation
Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
### Context Files
Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
### Model Resolution
Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
- `haiku``claude-haiku-4-5`
- `sonnet``claude-sonnet-4-20250514`
- `opus``claude-opus-4-5-20251101`
## Environment Variables
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing

View File

@@ -1,685 +0,0 @@
# Contributing to Automaker
Thank you for your interest in contributing to Automaker! We're excited to have you join our community of developers building the future of autonomous AI development.
Automaker is an autonomous AI development studio that provides a Kanban-based workflow where AI agents implement features in isolated git worktrees. Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your contributions help make this project better for everyone.
This guide will help you get started with contributing to Automaker. Please take a moment to read through these guidelines to ensure a smooth contribution process.
## Contribution License Agreement
**Important:** By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials to the Automaker project, you agree to assign all right, title, and interest in and to your contributions, including all copyrights, patents, and other intellectual property rights, to the Core Contributors of Automaker. This assignment is irrevocable and includes the right to use, modify, distribute, and monetize your contributions in any manner.
**You understand and agree that you will have no right to receive any royalties, compensation, or other financial benefits from any revenue, income, or commercial use generated from your contributed code or any derivative works thereof.** All contributions are made without expectation of payment or financial return.
For complete details on contribution terms and rights assignment, please review [Section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT) of the LICENSE](LICENSE#5-contributions-and-rights-assignment).
## Table of Contents
- [Contributing to Automaker](#contributing-to-automaker)
- [Table of Contents](#table-of-contents)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Fork and Clone](#fork-and-clone)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Pull Request Process](#pull-request-process)
- [Branch Naming Convention](#branch-naming-convention)
- [Commit Message Format](#commit-message-format)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [1. Prepare Your Changes](#1-prepare-your-changes)
- [2. Run Pre-submission Checks](#2-run-pre-submission-checks)
- [3. Push Your Changes](#3-push-your-changes)
- [4. Open a Pull Request](#4-open-a-pull-request)
- [PR Requirements Checklist](#pr-requirements-checklist)
- [Review Process](#review-process)
- [What to Expect](#what-to-expect)
- [Review Focus Areas](#review-focus-areas)
- [Responding to Feedback](#responding-to-feedback)
- [Approval Criteria](#approval-criteria)
- [Getting Help](#getting-help)
- [Code Style Guidelines](#code-style-guidelines)
- [Testing Requirements](#testing-requirements)
- [Running Tests](#running-tests)
- [Test Frameworks](#test-frameworks)
- [End-to-End Tests (Playwright)](#end-to-end-tests-playwright)
- [Unit Tests (Vitest)](#unit-tests-vitest)
- [Writing Tests](#writing-tests)
- [When to Write Tests](#when-to-write-tests)
- [CI/CD Pipeline](#cicd-pipeline)
- [CI Checks](#ci-checks)
- [CI Testing Environment](#ci-testing-environment)
- [Viewing CI Results](#viewing-ci-results)
- [Common CI Failures](#common-ci-failures)
- [Coverage Requirements](#coverage-requirements)
- [Issue Reporting](#issue-reporting)
- [Bug Reports](#bug-reports)
- [Before Reporting](#before-reporting)
- [Bug Report Template](#bug-report-template)
- [Feature Requests](#feature-requests)
- [Before Requesting](#before-requesting)
- [Feature Request Template](#feature-request-template)
- [Security Issues](#security-issues)
---
## Getting Started
### Prerequisites
Before contributing to Automaker, ensure you have the following installed on your system:
- **Node.js 18+** (tested with Node.js 22)
- Download from [nodejs.org](https://nodejs.org/)
- Verify installation: `node --version`
- **npm** (comes with Node.js)
- Verify installation: `npm --version`
- **Git** for version control
- Verify installation: `git --version`
- **Claude Code CLI** or **Anthropic API Key** (for AI agent functionality)
- Required to run the AI development features
**Optional but recommended:**
- A code editor with TypeScript support (VS Code recommended)
- GitHub CLI (`gh`) for easier PR management
### Fork and Clone
1. **Fork the repository** on GitHub
- Navigate to [https://github.com/AutoMaker-Org/automaker](https://github.com/AutoMaker-Org/automaker)
- Click the "Fork" button in the top-right corner
- This creates your own copy of the repository
2. **Clone your fork locally**
```bash
git clone https://github.com/YOUR_USERNAME/automaker.git
cd automaker
```
3. **Add the upstream remote** to keep your fork in sync
```bash
git remote add upstream https://github.com/AutoMaker-Org/automaker.git
```
4. **Verify remotes**
```bash
git remote -v
# Should show:
# origin https://github.com/YOUR_USERNAME/automaker.git (fetch)
# origin https://github.com/YOUR_USERNAME/automaker.git (push)
# upstream https://github.com/AutoMaker-Org/automaker.git (fetch)
# upstream https://github.com/AutoMaker-Org/automaker.git (push)
```
### Development Setup
1. **Install dependencies**
```bash
npm install
```
2. **Build shared packages** (required before running the app)
```bash
npm run build:packages
```
3. **Start the development server**
```bash
npm run dev # Interactive launcher - choose mode
npm run dev:web # Browser mode (web interface)
npm run dev:electron # Desktop app mode
```
**Common development commands:**
| Command | Description |
| ------------------------ | -------------------------------- |
| `npm run dev` | Interactive development launcher |
| `npm run dev:web` | Start in browser mode |
| `npm run dev:electron` | Start desktop app |
| `npm run build` | Build all packages and apps |
| `npm run build:packages` | Build shared packages only |
| `npm run lint` | Run ESLint checks |
| `npm run format` | Format code with Prettier |
| `npm run format:check` | Check formatting without changes |
| `npm run test` | Run E2E tests (Playwright) |
| `npm run test:server` | Run server unit tests |
| `npm run test:packages` | Run package tests |
| `npm run test:all` | Run all tests |
### Project Structure
Automaker is organized as an npm workspace monorepo:
```
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend
│ └── server/ # Express + WebSocket backend
├── libs/
│ ├── @automaker/types/ # Shared TypeScript types
│ ├── @automaker/utils/ # Utility functions
│ ├── @automaker/prompts/ # AI prompt templates
│ ├── @automaker/platform/ # Platform abstractions
│ ├── @automaker/model-resolver/ # AI model resolution
│ ├── @automaker/dependency-resolver/ # Dependency management
│ └── @automaker/git-utils/ # Git operations
├── docs/ # Documentation
└── package.json # Root package configuration
```
**Key conventions:**
- Always import from `@automaker/*` shared packages, never use relative paths to `libs/`
- Frontend code lives in `apps/ui/`
- Backend code lives in `apps/server/`
- Shared logic should be in the appropriate `libs/` package
---
## Pull Request Process
This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
### Branch Naming Convention
We use a consistent branch naming pattern to keep our repository organized:
```
<type>/<description>
```
**Branch types:**
| Type | Purpose | Example |
| ---------- | ------------------------ | --------------------------------- |
| `feature` | New functionality | `feature/add-user-authentication` |
| `fix` | Bug fixes | `fix/resolve-memory-leak` |
| `docs` | Documentation changes | `docs/update-contributing-guide` |
| `refactor` | Code restructuring | `refactor/simplify-api-handlers` |
| `test` | Adding or updating tests | `test/add-utils-unit-tests` |
| `chore` | Maintenance tasks | `chore/update-dependencies` |
**Guidelines:**
- Use lowercase letters and hyphens (no underscores or spaces)
- Keep descriptions short but descriptive
- Include issue number when applicable: `feature/123-add-login`
```bash
# Create and checkout a new feature branch
git checkout -b feature/add-dark-mode
# Create a fix branch with issue reference
git checkout -b fix/456-resolve-login-error
```
### Commit Message Format
We follow the **Conventional Commits** style for clear, readable commit history:
```
<type>: <description>
[optional body]
```
**Commit types:**
| Type | Purpose |
| ---------- | --------------------------- |
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `style` | Formatting (no code change) |
| `refactor` | Code restructuring |
| `test` | Adding or updating tests |
| `chore` | Maintenance tasks |
**Guidelines:**
- Use **imperative mood** ("Add feature" not "Added feature")
- Keep first line under **72 characters**
- Capitalize the first letter after the type prefix
- No period at the end of the subject line
- Add a blank line before the body for detailed explanations
**Examples:**
```bash
# Simple commit
git commit -m "feat: Add user authentication flow"
# Commit with body for more context
git commit -m "fix: Resolve memory leak in WebSocket handler
The connection cleanup was not being called when clients
disconnected unexpectedly. Added proper cleanup in the
error handler to prevent memory accumulation."
# Documentation update
git commit -m "docs: Update API documentation"
# Refactoring
git commit -m "refactor: Simplify state management logic"
```
### Submitting a Pull Request
Follow these steps to submit your contribution:
#### 1. Prepare Your Changes
Ensure you've synced with the latest upstream changes:
```bash
# Fetch latest changes from upstream
git fetch upstream
# Rebase your branch on main (if needed)
git rebase upstream/main
```
#### 2. Run Pre-submission Checks
Before opening your PR, verify everything passes locally:
```bash
# Run all tests
npm run test:all
# Check formatting
npm run format:check
# Run linter
npm run lint
# Build to verify no compile errors
npm run build
```
#### 3. Push Your Changes
```bash
# Push your branch to your fork
git push origin feature/your-feature-name
```
#### 4. Open a Pull Request
1. Go to your fork on GitHub
2. Click "Compare & pull request" for your branch
3. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main`
4. Fill out the PR template completely
#### PR Requirements Checklist
Your PR should include:
- [ ] **Clear title** describing the change (use conventional commit format)
- [ ] **Description** explaining what changed and why
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
- [ ] **All CI checks passing** (format, lint, build, tests)
- [ ] **No merge conflicts** with main branch
- [ ] **Tests included** for new functionality
- [ ] **Documentation updated** if adding/changing public APIs
**Example PR Description:**
```markdown
## Summary
This PR adds dark mode support to the Automaker UI.
- Implements theme toggle in settings panel
- Adds CSS custom properties for theme colors
- Persists theme preference to localStorage
## Related Issue
Closes #123
## Testing
- [x] Tested toggle functionality in Chrome and Firefox
- [x] Verified theme persists across page reloads
- [x] Checked accessibility contrast ratios
## Screenshots
[Include before/after screenshots for UI changes]
```
### Review Process
All contributions go through code review to maintain quality:
#### What to Expect
1. **CI Checks Run First** - Automated checks (format, lint, build, tests) must pass before review
2. **Maintainer Review** - The project maintainers will review your PR and decide whether to merge it
3. **Feedback & Discussion** - The reviewer may ask questions or request changes
4. **Iteration** - Make requested changes and push updates to the same branch
5. **Approval & Merge** - Once approved and checks pass, your PR will be merged
#### Review Focus Areas
The reviewer checks for:
- **Correctness** - Does the code work as intended?
- **Clean Code** - Does it follow our [code style guidelines](#code-style-guidelines)?
- **Test Coverage** - Are new features properly tested?
- **Documentation** - Are public APIs documented?
- **Breaking Changes** - Are any breaking changes discussed first?
#### Responding to Feedback
- Respond to **all** review comments, even if just to acknowledge
- Ask questions if feedback is unclear
- Push additional commits to address feedback (don't force-push during review)
- Mark conversations as resolved once addressed
#### Approval Criteria
Your PR is ready to merge when:
- ✅ All CI checks pass
- ✅ The maintainer has approved the changes
- ✅ All review comments are addressed
- ✅ No unresolved merge conflicts
#### Getting Help
If your PR seems stuck:
- Comment asking for status update (mention @webdevcody if needed)
- Reach out on [Discord](https://discord.gg/jjem7aEDKU)
- Make sure all checks are passing and you've responded to all feedback
---
## Code Style Guidelines
Automaker uses automated tooling to enforce code style. Run `npm run format` to format code and `npm run lint` to check for issues. Pre-commit hooks automatically format staged files before committing.
---
## Testing Requirements
Testing helps prevent regressions. Automaker uses **Playwright** for end-to-end testing and **Vitest** for unit tests.
### Running Tests
Use these commands to run tests locally:
| Command | Description |
| ------------------------------ | ------------------------------------- |
| `npm run test` | Run E2E tests (Playwright) |
| `npm run test:server` | Run server unit tests (Vitest) |
| `npm run test:packages` | Run shared package tests |
| `npm run test:all` | Run all tests |
| `npm run test:server:coverage` | Run server tests with coverage report |
**Before submitting a PR**, always run the full test suite:
```bash
npm run test:all
```
### Test Frameworks
#### End-to-End Tests (Playwright)
E2E tests verify the entire application works correctly from a user's perspective.
- **Framework:** [Playwright](https://playwright.dev/)
- **Location:** `e2e/` directory
- **Test ports:** UI on port 3007, Server on port 3008
**Running E2E tests:**
```bash
# Run all E2E tests
npm run test
# Run with headed browser (useful for debugging)
npx playwright test --headed
# Run a specific test file
npm test --workspace=@automaker/ui -- tests/example.spec.ts
```
**E2E Test Guidelines:**
- Write tests from a user's perspective
- Use descriptive test names that explain the scenario
- Clean up test data after each test
- Use appropriate timeouts for async operations
- Prefer `locator` over direct selectors for resilience
#### Unit Tests (Vitest)
Unit tests verify individual functions and modules work correctly in isolation.
- **Framework:** [Vitest](https://vitest.dev/)
- **Location:** In the `tests/` directory within each package (e.g., `apps/server/tests/`)
**Running unit tests:**
```bash
# Run all server unit tests
npm run test:server
# Run with coverage report
npm run test:server:coverage
# Run package tests
npm run test:packages
# Run in watch mode during development
npx vitest --watch
```
**Unit Test Guidelines:**
- Keep tests small and focused on one behavior
- Use descriptive test names: `it('should return null when user is not found')`
- Follow the AAA pattern: Arrange, Act, Assert
- Mock external dependencies to isolate the unit under test
- Aim for meaningful coverage, not just line coverage
### Writing Tests
#### When to Write Tests
- **New features:** All new features should include tests
- **Bug fixes:** Add a test that reproduces the bug before fixing
- **Refactoring:** Ensure existing tests pass after refactoring
- **Public APIs:** All public APIs must have test coverage
### CI/CD Pipeline
Automaker uses **GitHub Actions** for continuous integration. Every pull request triggers automated checks.
#### CI Checks
The following checks must pass before your PR can be merged:
| Check | Description |
| ----------------- | --------------------------------------------- |
| **Format** | Verifies code is formatted with Prettier |
| **Build** | Ensures the project compiles without errors |
| **Package Tests** | Runs tests for shared `@automaker/*` packages |
| **Server Tests** | Runs server unit tests with coverage |
#### CI Testing Environment
For CI environments, Automaker supports a mock agent mode:
```bash
# Enable mock agent mode for CI testing
AUTOMAKER_MOCK_AGENT=true npm run test
```
This allows tests to run without requiring a real Claude API connection.
#### Viewing CI Results
1. Go to your PR on GitHub
2. Scroll to the "Checks" section at the bottom
3. Click on any failed check to see detailed logs
4. Fix issues locally and push updates
#### Common CI Failures
| Issue | Solution |
| ------------------- | --------------------------------------------- |
| Format check failed | Run `npm run format` locally |
| Build failed | Run `npm run build` and fix TypeScript errors |
| Tests failed | Run `npm run test:all` locally to reproduce |
| Coverage decreased | Add tests for new code paths |
### Coverage Requirements
While we don't enforce strict coverage percentages, we expect:
- **New features:** Should include comprehensive tests
- **Bug fixes:** Should include a regression test
- **Critical paths:** Must have test coverage (authentication, data persistence, etc.)
To view coverage reports locally:
```bash
npm run test:server:coverage
```
This generates an HTML report you can open in your browser to see which lines are covered.
---
## Issue Reporting
Found a bug or have an idea for a new feature? We'd love to hear from you! This section explains how to report issues effectively.
### Bug Reports
When reporting a bug, please provide as much information as possible to help us understand and reproduce the issue.
#### Before Reporting
1. **Search existing issues** - Check if the bug has already been reported
2. **Try the latest version** - Make sure you're running the latest version of Automaker
3. **Reproduce the issue** - Verify you can consistently reproduce the bug
#### Bug Report Template
When creating a bug report, include:
- **Title:** A clear, descriptive title summarizing the issue
- **Environment:**
- Operating System and version
- Node.js version (`node --version`)
- Automaker version or commit hash
- **Steps to Reproduce:** Numbered list of steps to reproduce the bug
- **Expected Behavior:** What you expected to happen
- **Actual Behavior:** What actually happened
- **Logs/Screenshots:** Any relevant error messages, console output, or screenshots
**Example Bug Report:**
```markdown
## Bug: WebSocket connection drops after 5 minutes of inactivity
### Environment
- OS: Windows 11
- Node.js: 22.11.0
- Automaker: commit abc1234
### Steps to Reproduce
1. Start the application with `npm run dev:web`
2. Open the Kanban board
3. Leave the browser tab open for 5+ minutes without interaction
4. Try to move a card
### Expected Behavior
The card should move to the new column.
### Actual Behavior
The UI shows "Connection lost" and the card doesn't move.
### Logs
[WebSocket] Connection closed: 1006
```
### Feature Requests
We welcome ideas for improving Automaker! Here's how to submit a feature request:
#### Before Requesting
1. **Check existing issues** - Your idea may already be proposed or in development
2. **Consider scope** - Think about whether the feature fits Automaker's mission as an autonomous AI development studio
#### Feature Request Template
A good feature request includes:
- **Title:** A brief, descriptive title
- **Problem Statement:** What problem does this feature solve?
- **Proposed Solution:** How do you envision this working?
- **Alternatives Considered:** What other approaches did you consider?
- **Additional Context:** Mockups, examples, or references that help explain your idea
**Example Feature Request:**
```markdown
## Feature: Dark Mode Support
### Problem Statement
Working late at night, the bright UI causes eye strain and doesn't match
my system's dark theme preference.
### Proposed Solution
Add a theme toggle in the settings panel that allows switching between
light and dark modes. Ideally, it should also detect system preference.
### Alternatives Considered
- Browser extension to force dark mode (doesn't work well with custom styling)
- Custom CSS override (breaks with updates)
### Additional Context
Similar to how VS Code handles themes - a dropdown in settings with
immediate preview.
```
### Security Issues
**Important:** If you discover a security vulnerability, please do NOT open a public issue. Instead:
1. Join our [Discord server](https://discord.gg/jjem7aEDKU) and send a direct message to the user `@webdevcody`
2. Include detailed steps to reproduce
3. Allow time for us to address the issue before public disclosure
We take security seriously and appreciate responsible disclosure.
---
For license and contribution terms, see the [LICENSE](LICENSE) file in the repository root and the [README.md](README.md#license) for more details.
---
Thank you for contributing to Automaker!

View File

@@ -30,26 +30,6 @@ Before running Automaker, we strongly recommend reviewing the source code yourse
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
#### Running in Isolated Docker Container
For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
```bash
# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
# On Windows PowerShell, use instead:
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
# 2. Build and run isolated container
docker-compose up -d
# 3. Access the UI at http://localhost:3007
# API at http://localhost:3008/api/health
```
The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
### 3. Limit Access
If you must run locally:

View File

@@ -1,154 +0,0 @@
# Automaker Multi-Stage Dockerfile
# Single Dockerfile for both server and UI builds
# Usage:
# docker build --target server -t automaker-server .
# docker build --target ui -t automaker-ui .
# Or use docker-compose which selects targets automatically
# =============================================================================
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
# =============================================================================
FROM node:22-alpine AS base
# Install build dependencies for native modules (node-pty)
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy root package files
COPY package*.json ./
# Copy all libs package.json files (centralized - add new libs here)
COPY libs/types/package*.json ./libs/types/
COPY libs/utils/package*.json ./libs/utils/
COPY libs/prompts/package*.json ./libs/prompts/
COPY libs/platform/package*.json ./libs/platform/
COPY libs/model-resolver/package*.json ./libs/model-resolver/
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
COPY libs/git-utils/package*.json ./libs/git-utils/
# Copy scripts (needed by npm workspace)
COPY scripts ./scripts
# =============================================================================
# SERVER BUILD STAGE
# =============================================================================
FROM base AS server-builder
# Copy server-specific package.json
COPY apps/server/package*.json ./apps/server/
# Install dependencies (--ignore-scripts to skip husky/prepare, then rebuild native modules)
RUN npm ci --ignore-scripts && npm rebuild node-pty
# Copy all source files
COPY libs ./libs
COPY apps/server ./apps/server
# Build packages in dependency order, then build server
RUN npm run build:packages && npm run build --workspace=apps/server
# =============================================================================
# SERVER PRODUCTION STAGE
# =============================================================================
FROM node:22-alpine AS server
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
RUN apk add --no-cache git curl bash && \
GH_VERSION="2.63.2" && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) GH_ARCH="amd64" ;; \
aarch64|arm64) GH_ARCH="arm64" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
tar -xzf gh.tar.gz && \
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
# Install Claude CLI globally
RUN npm install -g @anthropic-ai/claude-code
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S automaker && \
adduser -S automaker -u 1001
# Copy root package.json (needed for workspace resolution)
COPY --from=server-builder /app/package*.json ./
# Copy built libs (workspace packages are symlinked in node_modules)
COPY --from=server-builder /app/libs ./libs
# Copy built server
COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
# Copy node_modules (includes symlinks to libs)
COPY --from=server-builder /app/node_modules ./node_modules
# Create data and projects directories
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
# Configure git for mounted volumes and authentication
# Use --system so it's not overwritten by mounted user .gitconfig
RUN git config --system --add safe.directory '*' && \
# Use gh as credential helper (works with GH_TOKEN env var)
git config --system credential.helper '!gh auth git-credential'
# Switch to non-root user
USER automaker
# Environment variables
ENV PORT=3008
ENV DATA_DIR=/data
# Expose port
EXPOSE 3008
# Health check (using curl since it's already installed, more reliable than busybox wget)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3008/api/health || exit 1
# Start server
CMD ["node", "apps/server/dist/index.js"]
# =============================================================================
# UI BUILD STAGE
# =============================================================================
FROM base AS ui-builder
# Copy UI-specific package.json
COPY apps/ui/package*.json ./apps/ui/
# Install dependencies (--ignore-scripts to skip husky and build:packages in prepare script)
RUN npm ci --ignore-scripts
# Copy all source files
COPY libs ./libs
COPY apps/ui ./apps/ui
# Build packages in dependency order, then build UI
# VITE_SERVER_URL tells the UI where to find the API server
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=http://localhost:3008
ENV VITE_SKIP_ELECTRON=true
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
RUN npm run build:packages && npm run build --workspace=apps/ui
# =============================================================================
# UI PRODUCTION STAGE
# =============================================================================
FROM nginx:alpine AS ui
# Copy built files
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
# Copy nginx config for SPA routing
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

559
README.md
View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="apps/ui/public/readme_logo.svg" alt="Automaker Logo" height="80" />
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
</p>
> **[!TIP]**
@@ -8,7 +8,7 @@
>
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
>
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
# Automaker
@@ -19,7 +19,7 @@
- [What Makes Automaker Different?](#what-makes-automaker-different)
- [The Workflow](#the-workflow)
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
- [Powered by Claude Code](#powered-by-claude-code)
- [Why This Matters](#why-this-matters)
- [Security Disclaimer](#security-disclaimer)
- [Community & Support](#community--support)
@@ -28,36 +28,22 @@
- [Quick Start](#quick-start)
- [How to Run](#how-to-run)
- [Development Mode](#development-mode)
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
- [Web Browser Mode](#web-browser-mode)
- [Building for Production](#building-for-production)
- [Running Production Build](#running-production-build)
- [Testing](#testing)
- [Linting](#linting)
- [Environment Configuration](#environment-configuration)
- [Authentication Setup](#authentication-setup)
- [Authentication Options](#authentication-options)
- [Persistent Setup (Optional)](#persistent-setup-optional)
- [Features](#features)
- [Core Workflow](#core-workflow)
- [AI & Planning](#ai--planning)
- [Project Management](#project-management)
- [Collaboration & Review](#collaboration--review)
- [Developer Tools](#developer-tools)
- [Advanced Features](#advanced-features)
- [Tech Stack](#tech-stack)
- [Frontend](#frontend)
- [Backend](#backend)
- [Testing & Quality](#testing--quality)
- [Shared Libraries](#shared-libraries)
- [Available Views](#available-views)
- [Architecture](#architecture)
- [Monorepo Structure](#monorepo-structure)
- [How It Works](#how-it-works)
- [Key Architectural Patterns](#key-architectural-patterns)
- [Security & Isolation](#security--isolation)
- [Data Storage](#data-storage)
- [Learn More](#learn-more)
- [License](#license)
</details>
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution.
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
![Automaker UI](https://i.imgur.com/jdwKydM.png)
@@ -73,27 +59,43 @@ Traditional development tools help you write code. Automaker helps you **orchest
4. **Review & Verify** - Review the changes, run tests, and approve when ready
5. **Ship Faster** - Build entire applications in days, not weeks
### Powered by Claude Agent SDK
### Powered by Claude Code
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention.
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
### Why This Matters
The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
---
> **[!CAUTION]**
>
> ## Security Disclaimer
>
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
>
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
>
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
>
> **[Read the full disclaimer](../DISCLAIMER.md)**
---
## Community & Support
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
In the Discord, you can:
- 💬 Discuss agentic coding patterns and best practices
- 🧠 Share ideas for AI-driven development workflows
- 🛠️ Get help setting up or extending Automaker
- 🚀 Show off projects built with AI agents
- 🤝 Collaborate with other developers and contributors
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
👉 **Join the Discord:**
https://discord.gg/jjem7aEDKU
---
@@ -101,49 +103,25 @@ In the Discord, you can:
### Prerequisites
- **Node.js 18+** (tested with Node.js 22)
- **npm** (comes with Node.js)
- **Authentication** (choose one):
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
- Node.js 18+
- npm
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
### Quick Start
```bash
# 1. Clone the repository
# 1. Clone the repo
git clone https://github.com/AutoMaker-Org/automaker.git
cd automaker
# 2. Install dependencies
npm install
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
npm run build:packages
# 4. Set up authentication (skip if using Claude Code CLI)
# If using Claude Code CLI: credentials are detected automatically
# If using API key directly, choose one method:
# Option A: Environment variable
export ANTHROPIC_API_KEY="sk-ant-..."
# Option B: Create .env file in project root
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
# 5. Start Automaker (interactive launcher)
# 3. Run Automaker (pick your mode)
npm run dev
# Choose between:
# 1. Web Application (browser at localhost:3007)
# 2. Desktop Application (Electron - recommended)
# Then choose your run mode when prompted, or use specific commands below
```
**Note:** The `npm run dev` command will:
- Check for dependencies and install if needed
- Install Playwright browsers for E2E tests
- Kill any processes on ports 3007/3008
- Present an interactive menu to choose your run mode
## How to Run
### Development Mode
@@ -181,162 +159,31 @@ npm run dev:web
### Building for Production
#### Web Application
```bash
# Build for web deployment (uses Vite)
# Build Next.js app
npm run build
# Run production build
# Build Electron app for distribution
npm run build:electron
```
### Running Production Build
```bash
# Start production Next.js server
npm run start
```
#### Desktop Application
```bash
# Build for current platform (macOS/Windows/Linux)
npm run build:electron
# Platform-specific builds
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
npm run build:electron:win # Windows (NSIS installer, x64)
npm run build:electron:linux # Linux (AppImage + DEB, x64)
# Output directory: apps/ui/release/
```
#### Docker Deployment
Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
```bash
# Build and run with Docker Compose
docker-compose up -d
# Access UI at http://localhost:3007
# API at http://localhost:3008
# View logs
docker-compose logs -f
# Stop containers
docker-compose down
```
##### Configuration
Create a `.env` file in the project root if using API key authentication:
```bash
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
ANTHROPIC_API_KEY=sk-ant-...
```
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) below.
##### Working with Projects (Host Directory Access)
By default, the container is isolated from your host filesystem. To work on projects from your host machine, create a `docker-compose.override.yml` file (gitignored):
```yaml
services:
server:
volumes:
# Mount your project directories
- /path/to/your/project:/projects/your-project
```
##### Claude CLI Authentication (Optional)
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory:
```yaml
services:
server:
volumes:
# Linux/macOS
- ~/.claude:/home/automaker/.claude
# Windows
- C:/Users/YourName/.claude:/home/automaker/.claude
```
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
##### GitHub CLI Authentication (For Git Push/PR Operations)
To enable git push and GitHub CLI operations inside the container:
```yaml
services:
server:
volumes:
# Mount GitHub CLI config
# Linux/macOS
- ~/.config/gh:/home/automaker/.config/gh
# Windows
- 'C:/Users/YourName/AppData/Roaming/GitHub CLI:/home/automaker/.config/gh'
# Mount git config for user identity (name, email)
- ~/.gitconfig:/home/automaker/.gitconfig:ro
environment:
# GitHub token (required on Windows where tokens are in Credential Manager)
# Get your token with: gh auth token
- GH_TOKEN=${GH_TOKEN}
```
Then add `GH_TOKEN` to your `.env` file:
```bash
GH_TOKEN=gho_your_github_token_here
```
##### Complete docker-compose.override.yml Example
```yaml
services:
server:
volumes:
# Your projects
- /path/to/project1:/projects/project1
- /path/to/project2:/projects/project2
# Authentication configs
- ~/.claude:/home/automaker/.claude
- ~/.config/gh:/home/automaker/.config/gh
- ~/.gitconfig:/home/automaker/.gitconfig:ro
environment:
- GH_TOKEN=${GH_TOKEN}
```
##### Architecture Support
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
### Testing
#### End-to-End Tests (Playwright)
```bash
npm run test # Headless E2E tests
npm run test:headed # Browser visible E2E tests
# Run tests headless
npm run test
# Run tests with browser visible
npm run test:headed
```
#### Unit Tests (Vitest)
```bash
npm run test:server # Server unit tests
npm run test:server:coverage # Server tests with coverage
npm run test:packages # All shared package tests
npm run test:all # Packages + server tests
```
#### Test Configuration
- E2E tests run on ports 3007 (UI) and 3008 (server)
- Automatically starts test servers before running
- Uses Chromium browser via Playwright
- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
### Linting
```bash
@@ -344,300 +191,59 @@ npm run test:all # Packages + server tests
npm run lint
```
### Environment Configuration
### Authentication Options
#### Authentication (if not using Claude Code CLI)
Automaker supports multiple authentication methods (in order of priority):
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
| Method | Environment Variable | Description |
| ---------------- | -------------------- | ------------------------------- |
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
| API Key (stored) | — | Anthropic API key stored in app |
#### Optional - Server
- `PORT` - Server port (default: 3008)
- `DATA_DIR` - Data storage directory (default: ./data)
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
#### Optional - Security
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
- `CORS_ORIGIN` - CORS policy (default: \*)
#### Optional - Development
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
### Authentication Setup
#### Option 1: Claude Code CLI (Recommended)
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
#### Option 2: Direct API Key
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
##### 2a. Shell Configuration
### Persistent Setup (Optional)
Add to your `~/.bashrc` or `~/.zshrc`:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
```
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
##### 2b. .env File
Create a `.env` file in the project root (gitignored):
```bash
ANTHROPIC_API_KEY=sk-ant-...
PORT=3008
DATA_DIR=./data
```
##### 2c. In-App Storage
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
Then restart your terminal or run `source ~/.bashrc`.
## Features
### Core Workflow
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
### AI & Planning
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
- **Plan Approval** - Review and approve AI-generated plans before implementation begins
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
### Project Management
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis
- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference
- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order
- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization
- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically
### Collaboration & Review
- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing
- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work
- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings
- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history
- 🔍 **Git Diff Viewer** - Review changes made by agents before approving
### Developer Tools
- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions
- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context
-**Concurrent Execution** - Configure how many features can run simultaneously (default: 3)
- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
### Advanced Features
- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access
- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees
- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics
- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings)
- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
- 📁 **Context Management** - Add context files to help AI agents understand your project better
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
- **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
## Tech Stack
### Frontend
- **React 19** - UI framework
- **Vite 7** - Build tool and development server
- **Electron 39** - Desktop application framework
- **TypeScript 5.9** - Type safety
- **TanStack Router** - File-based routing
- **Zustand 5** - State management with persistence
- **Tailwind CSS 4** - Utility-first styling with 25+ themes
- **Radix UI** - Accessible component primitives
- **dnd-kit** - Drag and drop for Kanban board
- **@xyflow/react** - Graph visualization for dependencies
- **xterm.js** - Integrated terminal emulator
- **CodeMirror 6** - Code editor for XML/syntax highlighting
- **Lucide Icons** - Icon library
### Backend
- **Node.js** - JavaScript runtime with ES modules
- **Express 5** - HTTP server framework
- **TypeScript 5.9** - Type safety
- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk)
- **WebSocket (ws)** - Real-time event streaming
- **node-pty** - PTY terminal sessions
### Testing & Quality
- **Playwright** - End-to-end testing
- **Vitest** - Unit testing framework
- **ESLint 9** - Code linting
- **Prettier 3** - Code formatting
- **Husky** - Git hooks for pre-commit formatting
### Shared Libraries
- **@automaker/types** - Shared TypeScript definitions
- **@automaker/utils** - Logging, error handling, image processing
- **@automaker/prompts** - AI prompt templates
- **@automaker/platform** - Path management and security
- **@automaker/model-resolver** - Claude model alias resolution
- **@automaker/dependency-resolver** - Feature dependency ordering
- **@automaker/git-utils** - Git operations and worktree management
## Available Views
Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts:
| View | Shortcut | Description |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------ |
| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) |
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks |
| **Running Agents** | - | View all active agents across projects with status and progress |
### Keyboard Navigation
All shortcuts are customizable in Settings. Default shortcuts:
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal)
- **UI:** `` ` `` (Toggle sidebar)
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
- **Projects:** `Q`/`E` (Cycle previous/next project)
## Architecture
### Monorepo Structure
Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
```text
automaker/
├── apps/
│ ├── ui/ # React + Vite + Electron frontend
│ └── server/ # Express + WebSocket backend
└── libs/ # Shared packages
├── types/ # Core TypeScript definitions
├── utils/ # Logging, errors, utilities
├── prompts/ # AI prompt templates
├── platform/ # Path management, security
├── model-resolver/ # Claude model aliasing
├── dependency-resolver/ # Feature dependency ordering
└── git-utils/ # Git operations & worktree management
```
### How It Works
1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
8. **Integration** - After approval, changes can be committed and PRs created from the worktree
### Key Architectural Patterns
- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
- **State Management** - Zustand with persistence for frontend state across restarts
- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
### Security & Isolation
- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
### Data Storage
Automaker uses a file-based storage system (no database required):
#### Per-Project Data
Stored in `{projectPath}/.automaker/`:
```text
.automaker/
├── features/ # Feature JSON files and images
│ └── {featureId}/
│ ├── feature.json # Feature metadata
│ ├── agent-output.md # AI agent output log
│ └── images/ # Attached images
├── context/ # Context files for AI agents
├── settings.json # Project-specific settings
├── spec.md # Project specification
├── analysis.json # Project structure analysis
└── feature-suggestions.json # AI-generated suggestions
```
#### Global Data
Stored in `DATA_DIR` (default `./data`):
```text
data/
├── settings.json # Global settings, profiles, shortcuts
├── credentials.json # API keys (encrypted)
├── sessions-metadata.json # Chat session metadata
└── agent-sessions/ # Conversation histories
└── {sessionId}.json
```
---
> **[!CAUTION]**
>
> ## Security Disclaimer
>
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
>
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
>
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
>
> **[Read the full disclaimer](./DISCLAIMER.md)**
---
- [Next.js](https://nextjs.org) - React framework
- [Electron](https://www.electronjs.org/) - Desktop application framework
- [Tailwind CSS](https://tailwindcss.com/) - Styling
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
## Learn More
### Documentation
To learn more about Next.js, take a look at the following resources:
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
### Community
Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## License
@@ -646,16 +252,19 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE
**Summary of Terms:**
- **Allowed:**
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
- **Restricted (The "No Monetization of the Tool" Rule):**
- **No Resale:** You cannot resell Automaker itself.
- **No SaaS:** You cannot host Automaker as a service for others.
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
- **Liability:**
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
- **Contributing:**

BIN
apps/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -13,9 +13,12 @@
# testing
/coverage
# Vite
/dist/
/dist-electron/
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
@@ -30,8 +33,12 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Playwright
/test-results/
@@ -40,8 +47,5 @@ yarn-error.log*
/playwright/.cache/
# Electron
/release/
/dist/
/server-bundle/
# TanStack Router generated
src/routeTree.gen.ts

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
module.exports = {
rules: {
"@typescript-eslint/no-require-imports": "off",
},
};

435
apps/app/electron/main.js Normal file
View File

@@ -0,0 +1,435 @@
/**
* Simplified Electron main process
*
* This version spawns the backend server and uses HTTP API for most operations.
* Only native features (dialogs, shell) use IPC.
*/
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
// Load environment variables from .env file (development only)
if (!app.isPackaged) {
try {
require("dotenv").config({ path: path.join(__dirname, "../.env") });
} catch (error) {
console.warn("[Electron] dotenv not available:", error.message);
}
}
let mainWindow = null;
let serverProcess = null;
let staticServer = null;
const SERVER_PORT = 3008;
const STATIC_PORT = 3007;
// Get icon path - works in both dev and production, cross-platform
function getIconPath() {
// Different icon formats for different platforms
let iconFile;
if (process.platform === "win32") {
iconFile = "icon.ico";
} else if (process.platform === "darwin") {
iconFile = "logo_larger.png";
} else {
// Linux
iconFile = "logo_larger.png";
}
const iconPath = path.join(__dirname, "../public", iconFile);
// Verify the icon exists
if (!fs.existsSync(iconPath)) {
console.warn(`[Electron] Icon not found at: ${iconPath}`);
return null;
}
return iconPath;
}
/**
* Start static file server for production builds
*/
async function startStaticServer() {
const staticPath = path.join(__dirname, "../out");
staticServer = http.createServer((request, response) => {
// Parse the URL and remove query string
let filePath = path.join(staticPath, request.url.split("?")[0]);
// Default to index.html for directory requests
if (filePath.endsWith("/")) {
filePath = path.join(filePath, "index.html");
} else if (!path.extname(filePath)) {
filePath += ".html";
}
// Check if file exists
fs.stat(filePath, (err, stats) => {
if (err || !stats.isFile()) {
// Try index.html for SPA fallback
filePath = path.join(staticPath, "index.html");
}
// Read and serve the file
fs.readFile(filePath, (error, content) => {
if (error) {
response.writeHead(500);
response.end("Server Error");
return;
}
// Set content type based on file extension
const ext = path.extname(filePath);
const contentTypes = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
};
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
response.end(content);
});
});
});
return new Promise((resolve, reject) => {
staticServer.listen(STATIC_PORT, (err) => {
if (err) {
reject(err);
} else {
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
resolve();
}
});
});
}
/**
* Start the backend server
*/
async function startServer() {
const isDev = !app.isPackaged;
// Server entry point - use tsx in dev, compiled version in production
let command, args, serverPath;
if (isDev) {
// In development, use tsx to run TypeScript directly
// Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
// spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
command = "node";
serverPath = path.join(__dirname, "../../server/src/index.ts");
// Find tsx CLI - check server node_modules first, then root
const serverNodeModules = path.join(
__dirname,
"../../server/node_modules/tsx"
);
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
} else {
// Last resort: try require.resolve
try {
tsxCliPath = require.resolve("tsx/cli.mjs", {
paths: [path.join(__dirname, "../../server")],
});
} catch {
throw new Error(
"Could not find tsx. Please run 'npm install' in the server directory."
);
}
}
args = [tsxCliPath, "watch", serverPath];
} else {
// In production, use compiled JavaScript
command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath];
// Verify server files exist
if (!fs.existsSync(serverPath)) {
throw new Error(`Server not found at: ${serverPath}`);
}
}
// Set environment variables for server
const serverNodeModules = app.isPackaged
? path.join(process.resourcesPath, "server", "node_modules")
: path.join(__dirname, "../../server/node_modules");
// Set default workspace directory to user's Documents/Automaker
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
// Ensure workspace directory exists
if (!fs.existsSync(defaultWorkspaceDir)) {
try {
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
} catch (error) {
console.error("[Electron] Failed to create workspace directory:", error);
}
}
const env = {
...process.env,
PORT: SERVER_PORT.toString(),
DATA_DIR: app.getPath("userData"),
NODE_PATH: serverNodeModules,
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
};
console.log("[Electron] Starting backend server...");
console.log("[Electron] Server path:", serverPath);
console.log("[Electron] NODE_PATH:", serverNodeModules);
serverProcess = spawn(command, args, {
cwd: path.dirname(serverPath),
env,
stdio: ["ignore", "pipe", "pipe"],
});
serverProcess.stdout.on("data", (data) => {
console.log(`[Server] ${data.toString().trim()}`);
});
serverProcess.stderr.on("data", (data) => {
console.error(`[Server Error] ${data.toString().trim()}`);
});
serverProcess.on("close", (code) => {
console.log(`[Server] Process exited with code ${code}`);
serverProcess = null;
});
serverProcess.on("error", (err) => {
console.error(`[Server] Failed to start server process:`, err);
serverProcess = null;
});
// Wait for server to be ready
await waitForServer();
}
/**
* Wait for server to be available
*/
async function waitForServer(maxAttempts = 30) {
const http = require("http");
for (let i = 0; i < maxAttempts; i++) {
try {
await new Promise((resolve, reject) => {
const req = http.get(
`http://localhost:${SERVER_PORT}/api/health`,
(res) => {
if (res.statusCode === 200) {
resolve();
} else {
reject(new Error(`Status: ${res.statusCode}`));
}
}
);
req.on("error", reject);
req.setTimeout(1000, () => {
req.destroy();
reject(new Error("Timeout"));
});
});
console.log("[Electron] Server is ready");
return;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
throw new Error("Server failed to start");
}
/**
* Create the main window
*/
function createWindow() {
const iconPath = getIconPath();
const windowOptions = {
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 700,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},
titleBarStyle: "hiddenInset",
backgroundColor: "#0a0a0a",
};
// Only set icon if it exists
if (iconPath) {
windowOptions.icon = iconPath;
}
mainWindow = new BrowserWindow(windowOptions);
// Load Next.js dev server in development or static server in production
const isDev = !app.isPackaged;
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
mainWindow.webContents.openDevTools();
}
mainWindow.on("closed", () => {
mainWindow = null;
});
// Handle external links - open in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
}
// App lifecycle
app.whenReady().then(async () => {
// Set app icon (dock icon on macOS)
if (process.platform === "darwin" && app.dock) {
const iconPath = getIconPath();
if (iconPath) {
try {
app.dock.setIcon(iconPath);
} catch (error) {
console.warn("[Electron] Failed to set dock icon:", error.message);
}
}
}
try {
// Start static file server in production
if (app.isPackaged) {
await startStaticServer();
}
// Start backend server
await startServer();
// Create window
createWindow();
} catch (error) {
console.error("[Electron] Failed to start:", error);
app.quit();
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
// Kill server process
if (serverProcess) {
console.log("[Electron] Stopping server...");
serverProcess.kill();
serverProcess = null;
}
// Close static server
if (staticServer) {
console.log("[Electron] Stopping static server...");
staticServer.close();
staticServer = null;
}
});
// ============================================
// IPC Handlers - Only native features
// ============================================
// Native file dialogs
ipcMain.handle("dialog:openDirectory", async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
});
return result;
});
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openFile"],
...options,
});
return result;
});
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
});
// Shell operations
ipcMain.handle("shell:openExternal", async (_, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle("shell:openPath", async (_, filePath) => {
try {
await shell.openPath(filePath);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// App info
ipcMain.handle("app:getPath", async (_, name) => {
return app.getPath(name);
});
ipcMain.handle("app:getVersion", async () => {
return app.getVersion();
});
ipcMain.handle("app:isPackaged", async () => {
return app.isPackaged;
});
// Ping - for connection check
ipcMain.handle("ping", async () => {
return "pong";
});
// Get server URL for HTTP client
ipcMain.handle("server:getUrl", async () => {
return `http://localhost:${SERVER_PORT}`;
});

View File

@@ -0,0 +1,37 @@
/**
* Simplified Electron preload script
*
* Only exposes native features (dialogs, shell) and server URL.
* All other operations go through HTTP API.
*/
const { contextBridge, ipcRenderer } = require("electron");
// Expose minimal API for native features
contextBridge.exposeInMainWorld("electronAPI", {
// Platform info
platform: process.platform,
isElectron: true,
// Connection check
ping: () => ipcRenderer.invoke("ping"),
// Get server URL for HTTP client
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
// Native dialogs - better UX than prompt()
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
// Shell operations
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
// App info
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
getVersion: () => ipcRenderer.invoke("app:getVersion"),
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
});
console.log("[Preload] Electron API exposed (simplified mode)");

View File

@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Electron files use CommonJS
"electron/**",
]),
]);
export default eslintConfig;

7
apps/app/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;

193
apps/app/package.json Normal file
View File

@@ -0,0 +1,193 @@
{
"name": "@automaker/app",
"version": "0.1.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker",
"repository": {
"type": "git",
"url": "https://github.com/AutoMaker-Org/automaker.git"
},
"author": {
"name": "Cody Seibert",
"email": "webdevcody@gmail.com"
},
"private": true,
"license": "Unlicense",
"main": "electron/main.js",
"scripts": {
"dev": "next dev -p 3007",
"dev:web": "next dev -p 3007",
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "next build",
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"postinstall": "electron-builder install-app-deps",
"start": "next start",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
},
"dependencies": {
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.556.0",
"next": "^16.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "^1.29.2",
"lightningcss-darwin-x64": "^1.29.2",
"lightningcss-linux-arm-gnueabihf": "^1.29.2",
"lightningcss-linux-arm64-gnu": "^1.29.2",
"lightningcss-linux-arm64-musl": "^1.29.2",
"lightningcss-linux-x64-gnu": "^1.29.2",
"lightningcss-linux-x64-musl": "^1.29.2",
"lightningcss-win32-arm64-msvc": "^1.29.2",
"lightningcss-win32-x64-msvc": "^1.29.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.2.1",
"electron": "39.2.7",
"electron-builder": "^26.0.12",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": {
"output": "dist"
},
"files": [
"electron/**/*",
"out/**/*",
"public/**/*",
"!node_modules/**/*"
],
"extraResources": [
{
"from": "server-bundle/dist",
"to": "server"
},
{
"from": "server-bundle/node_modules",
"to": "server/node_modules"
},
{
"from": "server-bundle/package.json",
"to": "server/package.json"
},
{
"from": "../../.env",
"to": ".env",
"filter": [
"**/*"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"icon": "public/logo_larger.png"
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "public/icon.ico"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": [
"x64"
]
}
],
"category": "Development",
"icon": "public/logo_larger.png",
"maintainer": "webdevcody@gmail.com",
"executableName": "automaker"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}

View File

@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 30000,
use: {
baseURL: `http://localhost:${port}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
...(reuseServer
? {}
: {
webServer: {
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
env: {
...process.env,
NEXT_PUBLIC_SKIP_SETUP: "true",
},
},
}),
});

View File

@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.TEST_PORT || 3007;
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 10000,
use: {
baseURL: `http://localhost:${port}`,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: `npx next dev -p ${port}`,
url: `http://localhost:${port}`,
reuseExistingServer: true,
timeout: 60000,
},
});

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

View File

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -16,20 +16,8 @@ const __dirname = dirname(__filename);
const APP_DIR = join(__dirname, '..');
const SERVER_DIR = join(APP_DIR, '..', 'server');
const LIBS_DIR = join(APP_DIR, '..', '..', 'libs');
const BUNDLE_DIR = join(APP_DIR, 'server-bundle');
// Local workspace packages that need to be bundled
const LOCAL_PACKAGES = [
'@automaker/types',
'@automaker/utils',
'@automaker/prompts',
'@automaker/platform',
'@automaker/model-resolver',
'@automaker/dependency-resolver',
'@automaker/git-utils',
];
console.log('🔧 Preparing server for Electron bundling...\n');
// Step 1: Clean up previous bundle
@@ -47,60 +35,24 @@ execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
console.log('📋 Copying server dist...');
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
// Step 4: Copy local workspace packages
console.log('📦 Copying local workspace packages...');
const bundleLibsDir = join(BUNDLE_DIR, 'libs');
mkdirSync(bundleLibsDir, { recursive: true });
for (const pkgName of LOCAL_PACKAGES) {
const pkgDir = pkgName.replace('@automaker/', '');
const srcDir = join(LIBS_DIR, pkgDir);
const destDir = join(bundleLibsDir, pkgDir);
if (!existsSync(srcDir)) {
console.warn(`⚠️ Warning: Package ${pkgName} not found at ${srcDir}`);
continue;
}
mkdirSync(destDir, { recursive: true });
// Copy dist folder
if (existsSync(join(srcDir, 'dist'))) {
cpSync(join(srcDir, 'dist'), join(destDir, 'dist'), { recursive: true });
}
// Copy package.json
if (existsSync(join(srcDir, 'package.json'))) {
cpSync(join(srcDir, 'package.json'), join(destDir, 'package.json'));
}
console.log(`${pkgName}`);
}
// Step 5: Create a minimal package.json for the server
// Step 4: Create a minimal package.json for the server
console.log('📝 Creating server package.json...');
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8'));
// Replace local package versions with file: references
const dependencies = { ...serverPkg.dependencies };
for (const pkgName of LOCAL_PACKAGES) {
if (dependencies[pkgName]) {
const pkgDir = pkgName.replace('@automaker/', '');
dependencies[pkgName] = `file:libs/${pkgDir}`;
}
}
const bundlePkg = {
name: '@automaker/server-bundle',
version: serverPkg.version,
type: 'module',
main: 'dist/index.js',
dependencies,
dependencies: serverPkg.dependencies
};
writeFileSync(join(BUNDLE_DIR, 'package.json'), JSON.stringify(bundlePkg, null, 2));
writeFileSync(
join(BUNDLE_DIR, 'package.json'),
JSON.stringify(bundlePkg, null, 2)
);
// Step 6: Install production dependencies
// Step 5: Install production dependencies
console.log('📥 Installing server production dependencies...');
execSync('npm install --omit=dev', {
cwd: BUNDLE_DIR,
@@ -108,23 +60,21 @@ execSync('npm install --omit=dev', {
env: {
...process.env,
// Prevent npm from using workspace resolution
npm_config_workspace: '',
},
npm_config_workspace: ''
}
});
// Step 7: Rebuild native modules for current architecture
// Step 6: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...');
try {
execSync('npm rebuild', {
cwd: BUNDLE_DIR,
stdio: 'inherit',
stdio: 'inherit'
});
console.log('✅ Native modules rebuilt successfully');
} catch (error) {
console.warn(
'⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.'
);
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
console.warn(' Error:', error.message);
}

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
interface AnthropicResponse {
content?: Array<{ type: string; text?: string }>;
model?: string;
error?: { message?: string };
}
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Send a simple test prompt to the Anthropic API
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": effectiveApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
},
],
}),
});
if (!response.ok) {
const errorData = (await response.json()) as AnthropicResponse;
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
if (response.status === 401) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (response.status === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: response.status }
);
}
const data = (await response.json()) as AnthropicResponse;
// Check if we got a valid response
if (data.content && data.content.length > 0) {
const textContent = data.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${textContent.text}"`,
model: data.model,
});
}
}
return NextResponse.json({
success: true,
message: "Connection successful! Claude responded.",
model: data.model,
});
} catch (error: unknown) {
console.error("Claude API test error:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Claude API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from "next/server";
interface GeminiContent {
parts: Array<{
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
}>;
role?: string;
}
interface GeminiRequest {
contents: GeminiContent[];
generationConfig?: {
maxOutputTokens?: number;
temperature?: number;
};
}
interface GeminiResponse {
candidates?: Array<{
content: {
parts: Array<{
text: string;
}>;
role: string;
};
finishReason: string;
safetyRatings?: Array<{
category: string;
probability: string;
}>;
}>;
promptFeedback?: {
safetyRatings?: Array<{
category: string;
probability: string;
}>;
};
error?: {
code: number;
message: string;
status: string;
};
}
export async function POST(request: NextRequest) {
try {
const { apiKey, imageData, mimeType, prompt } = await request.json();
// Use provided API key or fall back to environment variable
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
if (!effectiveApiKey) {
return NextResponse.json(
{ success: false, error: "No API key provided or configured in environment" },
{ status: 400 }
);
}
// Build the request body
const requestBody: GeminiRequest = {
contents: [
{
parts: [],
},
],
generationConfig: {
maxOutputTokens: 150,
temperature: 0.4,
},
};
// Add image if provided
if (imageData && mimeType) {
requestBody.contents[0].parts.push({
inlineData: {
mimeType: mimeType,
data: imageData,
},
});
}
// Add text prompt
const textPrompt = prompt || (imageData
? "Describe what you see in this image briefly."
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
requestBody.contents[0].parts.push({
text: textPrompt,
});
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
const response = await fetch(geminiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const data: GeminiResponse = await response.json();
// Check for API errors
if (data.error) {
const errorMessage = data.error.message || "Unknown Gemini API error";
const statusCode = data.error.code || 500;
if (statusCode === 400 && errorMessage.includes("API key")) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Google API key." },
{ status: 401 }
);
}
if (statusCode === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: statusCode }
);
}
// Check for valid response
if (!response.ok) {
return NextResponse.json(
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
{ status: response.status }
);
}
// Extract response text
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
const responseText = data.candidates[0].content.parts
.filter((part) => part.text)
.map((part) => part.text)
.join("");
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
model: model,
hasImage: !!imageData,
});
}
// Handle blocked responses
if (data.promptFeedback?.safetyRatings) {
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded (response may have been filtered).",
model: model,
hasImage: !!imageData,
});
}
return NextResponse.json({
success: true,
message: "Connection successful! Gemini responded.",
model: model,
hasImage: !!imageData,
});
} catch (error: unknown) {
console.error("Gemini API test error:", error);
if (error instanceof TypeError && error.message.includes("fetch")) {
return NextResponse.json(
{ success: false, error: "Network error. Unable to reach Gemini API." },
{ status: 503 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Gemini API";
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

2554
apps/app/src/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import { Toaster } from "sonner";
import "./globals.css";
export const metadata: Metadata = {
title: "Automaker - Autonomous AI Development Studio",
description: "Build software autonomously with intelligent orchestration",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
>
{children}
<Toaster richColors position="bottom-right" />
</body>
</html>
);
}

255
apps/app/src/app/page.tsx Normal file
View File

@@ -0,0 +1,255 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { WelcomeView } from "@/components/views/welcome-view";
import { BoardView } from "@/components/views/board-view";
import { SpecView } from "@/components/views/spec-view";
import { AgentView } from "@/components/views/agent-view";
import { SettingsView } from "@/components/views/settings-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { TerminalView } from "@/components/views/terminal-view";
import { WikiView } from "@/components/views/wiki-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
import {
FileBrowserProvider,
useFileBrowser,
setGlobalFileBrowser,
} from "@/contexts/file-browser-context";
function HomeContent() {
const {
currentView,
setCurrentView,
setIpcConnected,
theme,
currentProject,
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const { openFileBrowser } = useFileBrowser();
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
// Don't trigger when typing in inputs
const activeElement = document.activeElement;
if (activeElement) {
const tagName = activeElement.tagName.toLowerCase();
if (
tagName === "input" ||
tagName === "textarea" ||
tagName === "select"
) {
return;
}
if (activeElement.getAttribute("contenteditable") === "true") {
return;
}
const role = activeElement.getAttribute("role");
if (role === "textbox" || role === "searchbox" || role === "combobox") {
return;
}
}
// Don't trigger with modifier keys
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
// Check for "\" key (backslash)
if (event.key === "\\") {
event.preventDefault();
setStreamerPanelOpen((prev) => !prev);
}
}, []);
// Register the "\" shortcut for streamer panel
useEffect(() => {
window.addEventListener("keydown", handleStreamerPanelShortcut);
return () => {
window.removeEventListener("keydown", handleStreamerPanelShortcut);
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
const effectiveTheme = getEffectiveTheme();
// Prevent hydration issues
useEffect(() => {
setIsMounted(true);
}, []);
// Initialize global file browser for HttpApiClient
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
}, [openFileBrowser]);
// Check if this is first run and redirect to setup if needed
useEffect(() => {
console.log("[Setup Flow] Checking setup state:", {
isMounted,
isFirstRun,
setupComplete,
currentView,
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
});
if (isMounted && isFirstRun && !setupComplete) {
console.log(
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
);
setCurrentView("setup");
} else if (isMounted && setupComplete) {
console.log("[Setup Flow] Setup already complete, showing normal view");
}
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
// Test IPC connection on mount
useEffect(() => {
const testConnection = async () => {
try {
const api = getElectronAPI();
const result = await api.ping();
setIpcConnected(result === "pong");
} catch (error) {
console.error("IPC connection failed:", error);
setIpcConnected(false);
}
};
testConnection();
}, [setIpcConnected]);
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
useEffect(() => {
const root = document.documentElement;
root.classList.remove(
"dark",
"retro",
"light",
"dracula",
"nord",
"monokai",
"tokyonight",
"solarized",
"gruvbox",
"catppuccin",
"onedark",
"synthwave",
"red"
);
if (effectiveTheme === "dark") {
root.classList.add("dark");
} else if (effectiveTheme === "retro") {
root.classList.add("retro");
} else if (effectiveTheme === "dracula") {
root.classList.add("dracula");
} else if (effectiveTheme === "nord") {
root.classList.add("nord");
} else if (effectiveTheme === "monokai") {
root.classList.add("monokai");
} else if (effectiveTheme === "tokyonight") {
root.classList.add("tokyonight");
} else if (effectiveTheme === "solarized") {
root.classList.add("solarized");
} else if (effectiveTheme === "gruvbox") {
root.classList.add("gruvbox");
} else if (effectiveTheme === "catppuccin") {
root.classList.add("catppuccin");
} else if (effectiveTheme === "onedark") {
root.classList.add("onedark");
} else if (effectiveTheme === "synthwave") {
root.classList.add("synthwave");
} else if (effectiveTheme === "red") {
root.classList.add("red");
} else if (effectiveTheme === "light") {
root.classList.add("light");
} else if (effectiveTheme === "system") {
// System theme
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (isDark) {
root.classList.add("dark");
} else {
root.classList.add("light");
}
}
}, [effectiveTheme, previewTheme, currentProject, theme]);
const renderView = () => {
switch (currentView) {
case "welcome":
return <WelcomeView />;
case "setup":
return <SetupView />;
case "board":
return <BoardView />;
case "spec":
return <SpecView />;
case "agent":
return <AgentView />;
case "settings":
return <SettingsView />;
case "interview":
return <InterviewView />;
case "context":
return <ContextView />;
case "profiles":
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
case "terminal":
return <TerminalView />;
case "wiki":
return <WikiView />;
default:
return <WelcomeView />;
}
};
// Setup view is full-screen without sidebar
if (currentView === "setup") {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<SetupView />
</main>
);
}
return (
<main className="flex h-screen overflow-hidden" data-testid="app-container">
<Sidebar />
<div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
>
{renderView()}
</div>
{/* Hidden streamer panel - opens with "\" key, pushes content */}
<div
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
}`}
/>
</main>
);
}
export default function Home() {
return (
<FileBrowserProvider>
<HomeContent />
</FileBrowserProvider>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,316 @@
"use client";
import { useState, useEffect, useRef } from "react";
import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
CornerDownLeft,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DirectoryEntry {
name: string;
path: string;
}
interface BrowseResult {
success: boolean;
currentPath: string;
parentPath: string | null;
directories: DirectoryEntry[];
drives?: string[];
error?: string;
warning?: string;
}
interface FileBrowserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (path: string) => void;
title?: string;
description?: string;
initialPath?: string;
}
export function FileBrowserDialog({
open,
onOpenChange,
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder or paste a path directly",
initialPath,
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [pathInput, setPathInput] = useState<string>("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
try {
// Get server URL from environment or default
const serverUrl =
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dirPath }),
});
const result: BrowseResult = await response.json();
if (result.success) {
setCurrentPath(result.currentPath);
setPathInput(result.currentPath);
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
setWarning(result.warning || "");
} else {
setError(result.error || "Failed to browse directory");
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load directories"
);
} finally {
setLoading(false);
}
};
// Reset current path when dialog closes
useEffect(() => {
if (!open) {
setCurrentPath("");
setPathInput("");
setParentPath(null);
setDirectories([]);
setError("");
setWarning("");
}
}, [open]);
// Load initial path or home directory when dialog opens
useEffect(() => {
if (open && !currentPath) {
browseDirectory(initialPath);
}
}, [open, initialPath]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
};
const handleGoToParent = () => {
if (parentPath) {
browseDirectory(parentPath);
}
};
const handleGoHome = () => {
browseDirectory();
};
const handleSelectDrive = (drivePath: string) => {
browseDirectory(drivePath);
};
const handleGoToPath = () => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
browseDirectory(trimmedPath);
}
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleGoToPath();
}
};
const handleSelect = () => {
if (currentPath) {
onSelect(currentPath);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
{/* Direct path input */}
<div className="flex items-center gap-2">
<Input
ref={pathInputRef}
type="text"
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
className="flex-1 font-mono text-sm"
data-testid="path-input"
disabled={loading}
/>
<Button
variant="secondary"
size="sm"
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
>
<CornerDownLeft className="w-4 h-4 mr-1" />
Go
</Button>
</div>
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
<HardDrive className="w-3 h-3" />
<span>Drives:</span>
</div>
{drives.map((drive) => (
<Button
key={drive}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
disabled={loading}
>
{drive.replace("\\", "")}
</Button>
))}
</div>
)}
{/* Current path breadcrumb */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<Button
variant="ghost"
size="sm"
onClick={handleGoHome}
className="h-7 px-2"
disabled={loading}
>
<Home className="w-4 h-4" />
</Button>
{parentPath && (
<Button
variant="ghost"
size="sm"
onClick={handleGoToParent}
className="h-7 px-2"
disabled={loading}
>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
{currentPath || "Loading..."}
</div>
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
Loading directories...
</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-destructive">{error}</div>
</div>
)}
{warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
<div className="text-sm text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
No subdirectories found
</div>
</div>
)}
{!loading && !error && directories.length > 0 && (
<div className="divide-y divide-sidebar-border">
{directories.map((dir) => (
<button
key={dir.path}
onClick={() => handleSelectDirectory(dir)}
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
>
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
<span className="flex-1 truncate text-sm">{dir.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path.
</div>
</div>
<DialogFooter className="border-t border-border pt-4 gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSelect} disabled={!currentPath || loading}>
<FolderOpen className="w-4 h-4 mr-2" />
Select Current Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { Sparkles, Clock } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
// Feature count options
export type FeatureCount = 20 | 50 | 100;
const FEATURE_COUNT_OPTIONS: {
value: FeatureCount;
label: string;
warning?: string;
}[] = [
{ value: 20, label: "20" },
{ value: 50, label: "50", warning: "May take up to 5 minutes" },
{ value: 100, label: "100", warning: "May take up to 5 minutes" },
];
interface ProjectSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectOverview: string;
onProjectOverviewChange: (value: string) => void;
generateFeatures: boolean;
onGenerateFeaturesChange: (value: boolean) => void;
featureCount: FeatureCount;
onFeatureCountChange: (value: FeatureCount) => void;
onCreateSpec: () => void;
onSkip: () => void;
isCreatingSpec: boolean;
}
export function ProjectSetupDialog({
open,
onOpenChange,
projectOverview,
onProjectOverviewChange,
generateFeatures,
onGenerateFeaturesChange,
featureCount,
onFeatureCountChange,
onCreateSpec,
onSkip,
isCreatingSpec,
}: ProjectSetupDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(open) => {
onOpenChange(open);
if (!open && !isCreatingSpec) {
onSkip();
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Set Up Your Project</DialogTitle>
<DialogDescription className="text-muted-foreground">
We didn&apos;t find an app_spec.txt file. Let us help you generate
your app_spec.txt to help describe your project for our system.
We&apos;ll analyze your project&apos;s tech stack and create a
comprehensive specification.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">
Describe what your project does and what features you want to
build. Be as detailed as you want - this will help us create a
better specification.
</p>
<textarea
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
value={projectOverview}
onChange={(e) => onProjectOverviewChange(e.target.value)}
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
autoFocus
/>
</div>
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="sidebar-generate-features"
checked={generateFeatures}
onCheckedChange={(checked) =>
onGenerateFeaturesChange(checked === true)
}
/>
<div className="space-y-1">
<label
htmlFor="sidebar-generate-features"
className="text-sm font-medium cursor-pointer"
>
Generate feature list
</label>
<p className="text-xs text-muted-foreground">
Automatically create features in the features folder from the
implementation roadmap after the spec is generated.
</p>
</div>
</div>
{/* Feature Count Selection - only shown when generateFeatures is enabled */}
{generateFeatures && (
<div className="space-y-2 pt-2 pl-7">
<label className="text-sm font-medium">Number of Features</label>
<div className="flex gap-2">
{FEATURE_COUNT_OPTIONS.map((option) => (
<Button
key={option.value}
type="button"
variant={
featureCount === option.value ? "default" : "outline"
}
size="sm"
onClick={() => onFeatureCountChange(option.value)}
className={cn(
"flex-1 transition-all",
featureCount === option.value
? "bg-primary hover:bg-primary/90 text-primary-foreground"
: "bg-muted/30 hover:bg-muted/50 border-border"
)}
data-testid={`feature-count-${option.value}`}
>
{option.label}
</Button>
))}
</div>
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning && (
<p className="text-xs text-amber-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
?.warning
}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onSkip}>
Skip for now
</Button>
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
<Sparkles className="w-4 h-4 mr-2" />
Generate Spec
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react';
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -6,13 +8,13 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
FolderPlus,
FolderOpen,
@@ -22,12 +24,14 @@ import {
Loader2,
Link,
Folder,
} from 'lucide-react';
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
import { useFileBrowser } from '@/contexts/file-browser-context';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
} from "lucide-react";
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
import { getElectronAPI } from "@/lib/electron";
import { getHttpApiClient } from "@/lib/http-api-client";
import { cn } from "@/lib/utils";
import { useFileBrowser } from "@/contexts/file-browser-context";
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
interface ValidationErrors {
projectName?: boolean;
@@ -39,13 +43,20 @@ interface ValidationErrors {
interface NewProjectModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
onCreateBlankProject: (
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromTemplate: (
template: StarterTemplate,
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise<void>;
onCreateFromCustomUrl: (
repoUrl: string,
projectName: string,
parentDir: string
) => Promise<void>;
isCreating: boolean;
}
@@ -57,28 +68,39 @@ export function NewProjectModal({
onCreateFromCustomUrl,
isCreating,
}: NewProjectModalProps) {
const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank');
const [projectName, setProjectName] = useState('');
const [workspaceDir, setWorkspaceDir] = useState<string>('');
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] =
useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState('');
const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({});
const { openFileBrowser } = useFileBrowser();
// Fetch workspace directory when modal opens
useEffect(() => {
if (open) {
// First, check localStorage for last used directory
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
if (lastUsedDir) {
setWorkspaceDir(lastUsedDir);
return;
}
// Fall back to server config if no saved directory
setIsLoadingWorkspace(true);
getDefaultWorkspaceDirectory()
.then((defaultDir) => {
if (defaultDir) {
setWorkspaceDir(defaultDir);
const httpClient = getHttpApiClient();
httpClient.workspace
.getConfig()
.then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
}
})
.catch((error) => {
console.error('Failed to get default workspace directory:', error);
console.error("Failed to get workspace config:", error);
})
.finally(() => {
setIsLoadingWorkspace(false);
@@ -89,11 +111,11 @@ export function NewProjectModal({
// Reset form when modal closes
useEffect(() => {
if (!open) {
setProjectName('');
setProjectName("");
setSelectedTemplate(null);
setUseCustomUrl(false);
setCustomUrl('');
setActiveTab('blank');
setCustomUrl("");
setActiveTab("blank");
setErrors({});
}
}, [open]);
@@ -106,7 +128,10 @@ export function NewProjectModal({
}, [projectName, errors.projectName]);
useEffect(() => {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
if (
(selectedTemplate || (useCustomUrl && customUrl)) &&
errors.templateSelection
) {
setErrors((prev) => ({ ...prev, templateSelection: false }));
}
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
@@ -131,7 +156,7 @@ export function NewProjectModal({
}
// Check template selection (only for template tab)
if (activeTab === 'template') {
if (activeTab === "template") {
if (useCustomUrl) {
if (!customUrl.trim()) {
newErrors.customUrl = true;
@@ -150,7 +175,7 @@ export function NewProjectModal({
// Clear errors and proceed
setErrors({});
if (activeTab === 'blank') {
if (activeTab === "blank") {
await onCreateBlankProject(projectName, workspaceDir);
} else if (useCustomUrl && customUrl) {
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
@@ -167,7 +192,7 @@ export function NewProjectModal({
const handleSelectTemplate = (template: StarterTemplate) => {
setSelectedTemplate(template);
setUseCustomUrl(false);
setCustomUrl('');
setCustomUrl("");
};
const handleToggleCustomUrl = () => {
@@ -179,14 +204,15 @@ export function NewProjectModal({
const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({
title: 'Select Base Project Directory',
description: 'Choose the parent directory where your project will be created',
title: "Select Base Project Directory",
description:
"Choose the parent directory where your project will be created",
initialPath: workspaceDir || undefined,
});
if (selectedPath) {
setWorkspaceDir(selectedPath);
// Save to localStorage for next time
saveLastProjectDirectory(selectedPath);
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
// Clear any workspace error when a valid directory is selected
if (errors.workspaceDir) {
setErrors((prev) => ({ ...prev, workspaceDir: false }));
@@ -196,12 +222,15 @@ export function NewProjectModal({
// Use platform-specific path separator
const pathSep =
typeof window !== 'undefined' && (window as any).electronAPI
? navigator.platform.indexOf('Win') !== -1
? '\\'
: '/'
: '/';
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : '';
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const projectPath =
workspaceDir && projectName
? `${workspaceDir}${pathSep}${projectName}`
: "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -210,7 +239,9 @@ export function NewProjectModal({
data-testid="new-project-modal"
>
<DialogHeader className="pb-2">
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
<DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template.
</DialogDescription>
@@ -221,9 +252,13 @@ export function NewProjectModal({
<div className="space-y-2">
<Label
htmlFor="project-name"
className={cn('text-foreground', errors.projectName && 'text-red-500')}
className={cn(
"text-foreground",
errors.projectName && "text-red-500"
)}
>
Project Name {errors.projectName && <span className="text-red-500">*</span>}
Project Name{" "}
{errors.projectName && <span className="text-red-500">*</span>}
</Label>
<Input
id="project-name"
@@ -231,39 +266,40 @@ export function NewProjectModal({
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className={cn(
'bg-input text-foreground placeholder:text-muted-foreground',
"bg-input text-foreground placeholder:text-muted-foreground",
errors.projectName
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'border-border'
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-border"
)}
data-testid="project-name-input"
autoFocus
/>
{errors.projectName && <p className="text-xs text-red-500">Project name is required</p>}
{errors.projectName && (
<p className="text-xs text-red-500">Project name is required</p>
)}
</div>
{/* Workspace Directory Display */}
<div
className={cn(
'flex items-start gap-2 text-sm',
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}
>
<Folder className="w-4 h-4 shrink-0 mt-0.5" />
<span className="flex-1 min-w-0 flex flex-col gap-1">
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
'Loading workspace...'
"Loading workspace..."
) : workspaceDir ? (
<>
<span>Will be created at:</span>
<code
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
title={projectPath || workspaceDir}
>
Will be created at:{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || workspaceDir}
</code>
</>
) : null}
) : (
<span className="text-red-500">No workspace configured</span>
)}
</span>
<Button
type="button"
@@ -282,7 +318,7 @@ export function NewProjectModal({
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'blank' | 'template')}
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
className="flex-1 flex flex-col overflow-hidden"
>
<TabsList className="w-full justify-start">
@@ -300,8 +336,9 @@ export function NewProjectModal({
<TabsContent value="blank" className="mt-0">
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
Create an empty project with the standard .automaker directory structure. Perfect
for starting from scratch or importing an existing codebase.
Create an empty project with the standard .automaker directory
structure. Perfect for starting from scratch or importing an
existing codebase.
</p>
</div>
</TabsContent>
@@ -318,18 +355,18 @@ export function NewProjectModal({
{/* Preset Templates */}
<div
className={cn(
'space-y-3 rounded-lg p-1 -m-1',
errors.templateSelection && 'ring-2 ring-red-500/50'
"space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && "ring-2 ring-red-500/50"
)}
>
{starterTemplates.map((template) => (
<div
key={template.id}
className={cn(
'p-4 rounded-lg border cursor-pointer transition-all',
"p-4 rounded-lg border cursor-pointer transition-all",
selectedTemplate?.id === template.id && !useCustomUrl
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
? "border-brand-500 bg-brand-500/10"
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
)}
onClick={() => handleSelectTemplate(template)}
data-testid={`template-${template.id}`}
@@ -337,10 +374,13 @@ export function NewProjectModal({
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-foreground">{template.name}</h4>
{selectedTemplate?.id === template.id && !useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
<h4 className="font-medium text-foreground">
{template.name}
</h4>
{selectedTemplate?.id === template.id &&
!useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
{template.description}
@@ -349,7 +389,11 @@ export function NewProjectModal({
{/* Tech Stack */}
<div className="flex flex-wrap gap-1.5 mb-3">
{template.techStack.slice(0, 6).map((tech) => (
<Badge key={tech} variant="secondary" className="text-xs">
<Badge
key={tech}
variant="secondary"
className="text-xs"
>
{tech}
</Badge>
))}
@@ -363,7 +407,7 @@ export function NewProjectModal({
{/* Key Features */}
<div className="text-xs text-muted-foreground">
<span className="font-medium">Features: </span>
{template.features.slice(0, 3).join(' · ')}
{template.features.slice(0, 3).join(" · ")}
{template.features.length > 3 &&
` · +${template.features.length - 3} more`}
</div>
@@ -388,38 +432,47 @@ export function NewProjectModal({
{/* Custom URL Option */}
<div
className={cn(
'p-4 rounded-lg border cursor-pointer transition-all',
"p-4 rounded-lg border cursor-pointer transition-all",
useCustomUrl
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
? "border-brand-500 bg-brand-500/10"
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
)}
onClick={handleToggleCustomUrl}
>
<div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
<h4 className="font-medium text-foreground">
Custom GitHub URL
</h4>
{useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div>
<p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point.
</p>
{useCustomUrl && (
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
<div
onClick={(e) => e.stopPropagation()}
className="space-y-1"
>
<Input
placeholder="https://github.com/username/repository"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className={cn(
'bg-input text-foreground placeholder:text-muted-foreground',
"bg-input text-foreground placeholder:text-muted-foreground",
errors.customUrl
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
: 'border-border'
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: "border-border"
)}
data-testid="custom-url-input"
/>
{errors.customUrl && (
<p className="text-xs text-red-500">GitHub URL is required</p>
<p className="text-xs text-red-500">
GitHub URL is required
</p>
)}
</div>
)}
@@ -442,14 +495,14 @@ export function NewProjectModal({
onClick={validateAndCreate}
disabled={isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open}
data-testid="confirm-create-project"
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
{activeTab === "template" ? "Cloning..." : "Creating..."}
</>
) : (
<>Create Project</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface CategoryAutocompleteProps {
value: string;
onChange: (value: string) => void;
suggestions: string[];
placeholder?: string;
className?: string;
disabled?: boolean;
"data-testid"?: string;
}
export function CategoryAutocomplete({
value,
onChange,
suggestions,
placeholder = "Select or type a category...",
className,
disabled = false,
"data-testid": testId,
}: CategoryAutocompleteProps) {
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between", className)}
data-testid={testId}
>
{value
? suggestions.find((s) => s === value) ?? value
: placeholder}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search category..." className="h-9" />
<CommandList>
<CommandEmpty>No category found.</CommandEmpty>
<CommandGroup>
{suggestions.map((suggestion) => (
<CommandItem
key={suggestion}
value={suggestion}
onSelect={(currentValue) => {
onChange(currentValue === value ? "" : currentValue);
setOpen(false);
}}
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
>
{suggestion}
<Check
className={cn(
"ml-auto",
value === suggestion ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
checked?: boolean | "indeterminate";
defaultChecked?: boolean | "indeterminate";
onCheckedChange?: (checked: boolean) => void;
required?: boolean;
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
className
)}
onCheckedChange={(checked) => {
// Handle indeterminate state by treating it as false for consumers expecting boolean
if (onCheckedChange) {
onCheckedChange(checked === true);
}
}}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { Sparkles, X } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface CoursePromoBadgeProps {
sidebarOpen?: boolean;
}
export function CoursePromoBadge({ sidebarOpen = true }: CoursePromoBadgeProps) {
const [dismissed, setDismissed] = React.useState(false);
if (dismissed) {
return null;
}
// Collapsed state - show only icon with tooltip
if (!sidebarOpen) {
return (
<div className="p-2 pb-0 flex justify-center">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://agenticjumpstart.com"
target="_blank"
rel="noopener noreferrer"
className="group cursor-pointer flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-all border border-primary/30"
data-testid="course-promo-badge-collapsed"
>
<Sparkles className="size-4 shrink-0" />
</a>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2">
<span>Become a 10x Dev</span>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDismissed(true);
}}
className="p-0.5 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
aria-label="Dismiss"
>
<X className="size-3" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Expanded state - show full badge
return (
<div className="p-2 pb-0">
<a
href="https://agenticjumpstart.com"
target="_blank"
rel="noopener noreferrer"
className="group cursor-pointer flex items-center justify-between w-full px-2 lg:px-3 py-2.5 bg-primary/10 text-primary rounded-lg font-medium text-sm hover:bg-primary/20 transition-all border border-primary/30"
data-testid="course-promo-badge"
>
<div className="flex items-center gap-2">
<Sparkles className="size-4 shrink-0" />
<span className="hidden lg:block">Become a 10x Dev</span>
</div>
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDismissed(true);
}}
className="hidden lg:block p-1 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
aria-label="Dismiss"
>
<X className="size-3.5" />
</span>
</a>
</div>
);
}

View File

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

View File

@@ -0,0 +1,422 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import { getElectronAPI } from "@/lib/electron";
import { useAppStore } from "@/store/app-store";
export interface FeatureImagePath {
id: string;
path: string; // Path to the temp file
filename: string;
mimeType: string;
}
// Map to store preview data by image ID (persisted across component re-mounts)
export type ImagePreviewMap = Map<string, string>;
interface DescriptionImageDropZoneProps {
value: string;
onChange: (value: string) => void;
images: FeatureImagePath[];
onImagesChange: (images: FeatureImagePath[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
// Optional: pass preview map from parent to persist across tab switches
previewMap?: ImagePreviewMap;
onPreviewMapChange?: (map: ImagePreviewMap) => void;
autoFocus?: boolean;
error?: boolean; // Show error state with red border
}
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function DescriptionImageDropZone({
value,
onChange,
images,
onImagesChange,
placeholder = "Describe the feature...",
className,
disabled = false,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
previewMap,
onPreviewMapChange,
autoFocus = false,
error = false,
}: DescriptionImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Use parent-provided preview map if available, otherwise use local state
const [localPreviewImages, setLocalPreviewImages] = useState<Map<string, string>>(
() => new Map()
);
// Determine which preview map to use - prefer parent-controlled state
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
if (onPreviewMapChange) {
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
onPreviewMapChange(newMap);
} else {
setLocalPreviewImages((prev) => {
const newMap = typeof updater === 'function' ? updater(prev) : updater;
return newMap;
});
}
}, [onPreviewMapChange, previewMap, localPreviewImages]);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentProject = useAppStore((state) => state.currentProject);
// Construct server URL for loading saved images
const getImageServerUrl = useCallback((imagePath: string): string => {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const projectPath = currentProject?.path || "";
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
}, [currentProject?.path]);
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
};
const saveImageToTemp = useCallback(async (
base64Data: string,
filename: string,
mimeType: string
): Promise<string | null> => {
try {
const api = getElectronAPI();
// Check if saveImageToTemp method exists
if (!api.saveImageToTemp) {
// Fallback path when saveImageToTemp is not available
console.log("[DescriptionImageDropZone] Using fallback path for image");
return `.automaker/images/${Date.now()}_${filename}`;
}
// Get projectPath from the store if available
const projectPath = currentProject?.path;
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
if (result.success && result.path) {
return result.path;
}
console.error("[DescriptionImageDropZone] Failed to save image:", result.error);
return null;
} catch (error) {
console.error("[DescriptionImageDropZone] Error saving image:", error);
return null;
}
}, [currentProject?.path]);
const processFiles = useCallback(
async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: FeatureImagePath[] = [];
const newPreviews = new Map(previewImages);
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Validate file size
if (file.size > maxFileSize) {
const maxSizeMB = maxFileSize / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
// Check if we've reached max files
if (newImages.length + images.length >= maxFiles) {
errors.push(`Maximum ${maxFiles} images allowed.`);
break;
}
try {
const base64 = await fileToBase64(file);
const tempPath = await saveImageToTemp(base64, file.name, file.type);
if (tempPath) {
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
const imagePathRef: FeatureImagePath = {
id: imageId,
path: tempPath,
filename: file.name,
mimeType: file.type,
};
newImages.push(imagePathRef);
// Store preview for display
newPreviews.set(imageId, base64);
} else {
errors.push(`${file.name}: Failed to save image.`);
}
} catch (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
onImagesChange([...images, ...newImages]);
setPreviewImages(newPreviews);
}
setIsProcessing(false);
},
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
);
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) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
},
[processFiles]
);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback(
(imageId: string) => {
onImagesChange(images.filter((img) => img.id !== imageId));
setPreviewImages((prev) => {
const newMap = new Map(prev);
newMap.delete(imageId);
return newMap;
});
},
[images, onImagesChange]
);
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
data-testid="description-image-input"
/>
{/* Drop zone wrapper */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-md transition-all duration-200",
{
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
isDragOver && !disabled,
}
)}
>
{/* Drag overlay */}
{isDragOver && !disabled && (
<div
className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-blue-500/20 border-2 border-dashed border-blue-400 pointer-events-none"
data-testid="drop-overlay"
>
<div className="flex flex-col items-center gap-2 text-blue-400">
<ImageIcon className="w-8 h-8" />
<span className="text-sm font-medium">Drop images here</span>
</div>
</div>
)}
{/* Textarea */}
<Textarea
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
autoFocus={autoFocus}
aria-invalid={error}
className={cn(
"min-h-[120px]",
isProcessing && "opacity-50 pointer-events-none"
)}
data-testid="feature-description-input"
/>
</div>
{/* Hint text */}
<p className="text-xs text-muted-foreground mt-1">
Drag and drop images here or{" "}
<button
type="button"
onClick={handleBrowseClick}
className="text-primary hover:text-primary/80 underline"
disabled={disabled || isProcessing}
>
browse
</button>{" "}
to attach context images
</p>
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>Saving images...</span>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="mt-3 space-y-2" data-testid="description-image-previews">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? "s" : ""} attached
</p>
<button
type="button"
onClick={() => {
onImagesChange([]);
setPreviewImages(new Map());
}}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
data-testid={`description-image-preview-${image.id}`}
>
{/* Image thumbnail or placeholder */}
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
{previewImages.has(image.id) ? (
<img
src={previewImages.get(image.id)}
alt={image.filename}
className="max-w-full max-h-full object-contain"
/>
) : (
<img
src={getImageServerUrl(image.path)}
alt={image.filename}
className="max-w-full max-h-full object-contain"
onError={(e) => {
// If image fails to load, hide it
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
</div>
{/* Remove button */}
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeImage(image.id);
}}
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
data-testid={`remove-description-image-${image.id}`}
>
<X className="h-3 w-3" />
</button>
)}
{/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">
{image.filename}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"duration-200",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
compact = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
compact?: boolean;
}) {
// Check if className contains a custom max-width
const hasCustomMaxWidth =
typeof className === "string" && className.includes("max-w-");
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
"flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]",
"bg-card border border-border rounded-xl shadow-2xl",
// Premium shadow
"shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]",
// Animations - smoother with scale
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
"duration-200",
compact
? "max-w-4xl p-4"
: !hasCustomMaxWidth
? "sm:max-w-2xl p-6"
: "p-6",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className={cn(
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
"hover:opacity-100 hover:bg-muted",
"focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none",
"disabled:pointer-events-none disabled:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
"p-1.5",
compact ? "top-2 right-3" : "top-4 right-4"
)}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,290 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Upload } from "lucide-react";
import type { ImageAttachment } from "@/store/app-store";
interface ImageDropZoneProps {
onImagesSelected: (images: ImageAttachment[]) => void;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
className?: string;
children?: React.ReactNode;
disabled?: boolean;
images?: ImageAttachment[]; // Optional controlled images prop
}
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function ImageDropZone({
onImagesSelected,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
className,
children,
disabled = false,
images,
}: ImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
// Use controlled images if provided, otherwise use internal state
const selectedImages = images ?? internalImages;
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
const updateImages = useCallback((newImages: ImageAttachment[]) => {
if (images === undefined) {
setInternalImages(newImages);
}
onImagesSelected(newImages);
}, [images, onImagesSelected]);
const processFiles = useCallback(async (files: FileList) => {
if (disabled || isProcessing) return;
setIsProcessing(true);
const newImages: ImageAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
continue;
}
// 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 (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn('Image upload errors:', errors);
// You could show these errors to the user via a toast or notification
}
if (newImages.length > 0) {
const allImages = [...selectedImages, ...newImages];
updateImages(allImages);
}
setIsProcessing(false);
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length > 0) {
processFiles(files);
}
}, [disabled, processFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
processFiles(files);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [processFiles]);
const handleBrowseClick = useCallback(() => {
if (!disabled && fileInputRef.current) {
fileInputRef.current.click();
}
}, [disabled]);
const removeImage = useCallback((imageId: string) => {
const updated = selectedImages.filter(img => img.id !== imageId);
updateImages(updated);
}, [selectedImages, updateImages]);
const clearAllImages = useCallback(() => {
updateImages([]);
}, [updateImages]);
return (
<div className={cn("relative", className)}>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={disabled}
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"relative rounded-lg border-2 border-dashed transition-all duration-200",
{
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
"border-muted-foreground/25": !isDragOver && !disabled,
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
}
)}
>
{children || (
<div className="flex flex-col items-center justify-center p-6 text-center">
<div className={cn(
"rounded-full p-3 mb-4",
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
)}>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<p className="text-sm font-medium text-foreground mb-1">
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
</p>
<p className="text-xs text-muted-foreground">
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
</p>
{!disabled && (
<button
onClick={handleBrowseClick}
className="mt-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
disabled={isProcessing}
>
Browse files
</button>
)}
</div>
)}
</div>
{/* Image previews */}
{selectedImages.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length} image{selectedImages.length > 1 ? 's' : ''} selected
</p>
<button
onClick={clearAllImages}
className="text-xs text-muted-foreground hover:text-foreground"
disabled={disabled}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{selectedImages.map((image) => (
<div
key={image.id}
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
>
{/* Image thumbnail */}
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
<img
src={image.data}
alt={image.filename}
className="w-full h-full object-cover"
/>
</div>
{/* Image info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">
{image.filename}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size)}
</p>
</div>
{/* Remove button */}
{!disabled && (
<button
onClick={() => removeImage(image.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Failed to read file as base64'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,284 @@
"use client";
import { useState, useMemo } from "react";
import {
ChevronDown,
ChevronRight,
MessageSquare,
Wrench,
Zap,
AlertCircle,
CheckCircle2,
AlertTriangle,
Bug,
Info,
FileOutput,
Brain,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
parseLogOutput,
getLogTypeColors,
type LogEntry,
type LogEntryType,
} from "@/lib/log-parser";
interface LogViewerProps {
output: string;
className?: string;
}
const getLogIcon = (type: LogEntryType) => {
switch (type) {
case "prompt":
return <MessageSquare className="w-4 h-4" />;
case "tool_call":
return <Wrench className="w-4 h-4" />;
case "tool_result":
return <FileOutput className="w-4 h-4" />;
case "phase":
return <Zap className="w-4 h-4" />;
case "error":
return <AlertCircle className="w-4 h-4" />;
case "success":
return <CheckCircle2 className="w-4 h-4" />;
case "warning":
return <AlertTriangle className="w-4 h-4" />;
case "thinking":
return <Brain className="w-4 h-4" />;
case "debug":
return <Bug className="w-4 h-4" />;
default:
return <Info className="w-4 h-4" />;
}
};
interface LogEntryItemProps {
entry: LogEntry;
isExpanded: boolean;
onToggle: () => void;
}
function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
const colors = getLogTypeColors(entry.type);
const hasContent = entry.content.length > 100;
// Format content - detect and highlight JSON
const formattedContent = useMemo(() => {
const content = entry.content;
// Try to find and format JSON blocks
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
let lastIndex = 0;
const parts: { type: "text" | "json"; content: string }[] = [];
let match;
while ((match = jsonRegex.exec(content)) !== null) {
// Add text before JSON
if (match.index > lastIndex) {
parts.push({
type: "text",
content: content.slice(lastIndex, match.index),
});
}
// Try to parse and format JSON
try {
const parsed = JSON.parse(match[1]);
parts.push({
type: "json",
content: JSON.stringify(parsed, null, 2),
});
} catch {
// Not valid JSON, treat as text
parts.push({ type: "text", content: match[1] });
}
lastIndex = match.index + match[1].length;
}
// Add remaining text
if (lastIndex < content.length) {
parts.push({ type: "text", content: content.slice(lastIndex) });
}
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
}, [entry.content]);
return (
<div
className={cn(
"rounded-lg border-l-4 transition-all duration-200",
colors.bg,
colors.border,
"hover:brightness-110"
)}
data-testid={`log-entry-${entry.type}`}
>
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left"
data-testid={`log-entry-toggle-${entry.id}`}
>
{hasContent ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
)
) : (
<span className="w-4 flex-shrink-0" />
)}
<span className={cn("flex-shrink-0", colors.icon)}>
{getLogIcon(entry.type)}
</span>
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
colors.badge
)}
data-testid="log-entry-badge"
>
{entry.title}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
{!isExpanded &&
entry.content.slice(0, 80) +
(entry.content.length > 80 ? "..." : "")}
</span>
</button>
{(isExpanded || !hasContent) && (
<div
className="px-4 pb-3 pt-1"
data-testid={`log-entry-content-${entry.id}`}
>
<div className="font-mono text-xs space-y-1">
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === "json" ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
{part.content}
</pre>
) : (
<pre
className={cn(
"whitespace-pre-wrap break-words",
colors.text
)}
>
{part.content}
</pre>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
export function LogViewer({ output, className }: LogViewerProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const entries = useMemo(() => parseLogOutput(output), [output]);
const toggleEntry = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const expandAll = () => {
setExpandedIds(new Set(entries.map((e) => e.id)));
};
const collapseAll = () => {
setExpandedIds(new Set());
};
if (entries.length === 0) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
<div className="text-center">
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
{output && output.trim() && (
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
<pre className="whitespace-pre-wrap">{output}</pre>
</div>
)}
</div>
</div>
);
}
// Count entries by type
const typeCounts = entries.reduce((acc, entry) => {
acc[entry.type] = (acc[entry.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Header with controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
return (
<span
key={type}
className={cn(
"text-xs px-2 py-0.5 rounded-full",
colors.badge
)}
data-testid={`log-type-count-${type}`}
>
{type}: {count}
</span>
);
})}
</div>
<div className="flex items-center gap-1">
<button
onClick={expandAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-expand-all"
>
Expand All
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
data-testid="log-collapse-all"
>
Collapse All
</button>
</div>
</div>
{/* Log entries */}
<div className="space-y-2" data-testid="log-entries-container">
{entries.map((entry) => (
<LogEntryItem
key={entry.id}
entry={entry}
isExpanded={expandedIds.has(entry.id)}
onToggle={() => toggleEntry(entry.id)}
/>
))}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

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

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
interface SliderProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "defaultValue" | "dir"> {
value?: number[];
defaultValue?: number[];
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
orientation?: "horizontal" | "vertical";
dir?: "ltr" | "rtl";
inverted?: boolean;
minStepsBetweenThumbs?: number;
}
const Slider = React.forwardRef<HTMLSpanElement, SliderProps>(
({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
</SliderPrimitive.Root>
)
);
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
"text-foreground/70 hover:text-foreground hover:bg-accent",
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

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

View File

@@ -0,0 +1,42 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-lg border border-border bg-popover px-3 py-1.5 text-xs font-medium text-popover-foreground",
// Premium shadow
"shadow-lg shadow-black/10",
// Faster, snappier animations
"animate-in fade-in-0 zoom-in-95 duration-150",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-100",
// Slide from edge
"data-[side=bottom]:slide-in-from-top-1",
"data-[side=left]:slide-in-from-right-1",
"data-[side=right]:slide-in-from-left-1",
"data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

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

View File

@@ -1,18 +1,19 @@
import { useEffect, useRef, useState } from 'react';
"use client";
import { useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
import { TaskProgressPanel } from '@/components/ui/task-progress-panel';
import { useAppStore } from '@/store/app-store';
import type { AutoModeEvent } from '@/types/electron';
} from "@/components/ui/dialog";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
interface AgentOutputModalProps {
open: boolean;
@@ -23,11 +24,9 @@ interface AgentOutputModalProps {
featureStatus?: string;
/** Called when a number key (0-9) is pressed while the modal is open */
onNumberKeyPress?: (key: string) => void;
/** Project path - if not provided, falls back to window.__currentProject for backward compatibility */
projectPath?: string;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({
open,
@@ -36,15 +35,14 @@ export function AgentOutputModal({
featureId,
featureStatus,
onNumberKeyPress,
projectPath: projectPathProp,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [output, setOutput] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [projectPath, setProjectPath] = useState<string>('');
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -65,38 +63,59 @@ export function AgentOutputModal({
setIsLoading(true);
try {
// Use projectPath prop if provided, otherwise fall back to window.__currentProject for backward compatibility
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path;
if (!resolvedProjectPath) {
// Get current project path from store (we'll need to pass this)
const currentProject = (window as any).__currentProject;
if (!currentProject?.path) {
setIsLoading(false);
return;
}
projectPathRef.current = resolvedProjectPath;
setProjectPath(resolvedProjectPath);
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(resolvedProjectPath, featureId);
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
if (result.success) {
setOutput(result.content || '');
setOutput(result.content || "");
} else {
setOutput('');
setOutput("");
}
} else {
setOutput('');
setOutput("");
}
} catch (error) {
console.error('Failed to load output:', error);
setOutput('');
console.error("Failed to load output:", error);
setOutput("");
} finally {
setIsLoading(false);
}
};
loadOutput();
}, [open, featureId, projectPathProp]);
}, [open, featureId]);
// Save output to file
const saveOutput = async (newContent: string) => {
if (!projectPathRef.current) return;
const api = getElectronAPI();
if (!api) return;
try {
// Use features API - agent output is stored in features/{id}/agent-output.md
// We need to write it directly since there's no updateAgentOutput method
// The context-manager handles this on the backend, but for frontend edits we write directly
const outputPath = `${projectPathRef.current}/.automaker/features/${featureId}/agent-output.md`;
await api.writeFile(outputPath, newContent);
} catch (error) {
console.error("Failed to save output:", error);
}
};
// Listen to auto mode events and update output
useEffect(() => {
@@ -105,46 +124,40 @@ export function AgentOutputModal({
const api = getElectronAPI();
if (!api?.autoMode) return;
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
const unsubscribe = api.autoMode.onEvent((event) => {
console.log(
'[AgentOutputModal] Received event:',
event.type,
'featureId:',
'featureId' in event ? event.featureId : 'none',
'modalFeatureId:',
featureId
);
// Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) {
console.log('[AgentOutputModal] Skipping event - featureId mismatch');
if ("featureId" in event && event.featureId !== featureId) {
return;
}
let newContent = '';
let newContent = "";
switch (event.type) {
case 'auto_mode_progress':
newContent = event.content || '';
case "auto_mode_progress":
newContent = event.content || "";
break;
case 'auto_mode_tool': {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : ""
}`;
break;
}
case 'auto_mode_phase': {
case "auto_mode_phase":
const phaseEmoji =
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅';
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
}
case 'auto_mode_error':
case "auto_mode_error":
newContent = `\n❌ Error: ${event.error}\n`;
break;
case 'auto_mode_ultrathink_preparation': {
case "auto_mode_ultrathink_preparation":
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
@@ -174,74 +187,8 @@ export function AgentOutputModal({
newContent = prepContent;
break;
}
case 'planning_started': {
// Show when planning mode begins
if ('mode' in event && 'message' in event) {
const modeLabel =
event.mode === 'lite' ? 'Lite' : event.mode === 'spec' ? 'Spec' : 'Full';
newContent = `\n📋 Planning Mode: ${modeLabel}\n${event.message}\n`;
}
break;
}
case 'plan_approval_required':
// Show when plan requires approval
if ('planningMode' in event) {
newContent = `\n⏸ Plan generated - waiting for your approval...\n`;
}
break;
case 'plan_approved':
// Show when plan is manually approved
if ('hasEdits' in event) {
newContent = event.hasEdits
? `\n✅ Plan approved (with edits) - continuing to implementation...\n`
: `\n✅ Plan approved - continuing to implementation...\n`;
}
break;
case 'plan_auto_approved':
// Show when plan is auto-approved
newContent = `\n✅ Plan auto-approved - continuing to implementation...\n`;
break;
case 'plan_revision_requested': {
// Show when user requests plan revision
if ('planVersion' in event) {
const revisionEvent = event as Extract<
AutoModeEvent,
{ type: 'plan_revision_requested' }
>;
newContent = `\n🔄 Revising plan based on your feedback (v${revisionEvent.planVersion})...\n`;
}
break;
}
case 'auto_mode_task_started': {
// Show when a task starts
if ('taskId' in event && 'taskDescription' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_started' }>;
newContent = `\n▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}\n`;
}
break;
}
case 'auto_mode_task_complete': {
// Show task completion progress
if ('taskId' in event && 'tasksCompleted' in event && 'tasksTotal' in event) {
const taskEvent = event as Extract<AutoModeEvent, { type: 'auto_mode_task_complete' }>;
newContent = `\n✓ ${taskEvent.taskId} completed (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})\n`;
}
break;
}
case 'auto_mode_phase_complete': {
// Show phase completion for full mode
if ('phaseNumber' in event) {
const phaseEvent = event as Extract<
AutoModeEvent,
{ type: 'auto_mode_phase_complete' }
>;
newContent = `\n🏁 Phase ${phaseEvent.phaseNumber} complete\n`;
}
break;
}
case 'auto_mode_feature_complete': {
const emoji = event.passes ? '✅' : '⚠️';
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
@@ -252,12 +199,14 @@ export function AgentOutputModal({
}, 1500);
}
break;
}
}
if (newContent) {
// Only update local state - server is the single source of truth for file writes
setOutput((prev) => prev + newContent);
setOutput((prev) => {
const updated = prev + newContent;
saveOutput(updated);
return updated;
});
}
});
@@ -281,15 +230,20 @@ export function AgentOutputModal({
const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers
if (!event.ctrlKey && !event.altKey && !event.metaKey && /^[0-9]$/.test(event.key)) {
if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
/^[0-9]$/.test(event.key)
) {
event.preventDefault();
onNumberKeyPress(event.key);
}
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [open, onNumberKeyPress]);
@@ -302,18 +256,19 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
{featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
onClick={() => setViewMode("parsed")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
viewMode === "parsed"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-parsed"
>
@@ -321,11 +276,11 @@ export function AgentOutputModal({
Logs
</button>
<button
onClick={() => setViewMode('changes')}
onClick={() => setViewMode("changes")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-changes"
>
@@ -333,11 +288,11 @@ export function AgentOutputModal({
Changes
</button>
<button
onClick={() => setViewMode('raw')}
onClick={() => setViewMode("raw")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
viewMode === "raw"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-raw"
>
@@ -354,14 +309,7 @@ export function AgentOutputModal({
</DialogDescription>
</DialogHeader>
{/* Task Progress Panel - shows when tasks are being executed */}
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="flex-shrink-0 mx-1"
/>
{viewMode === 'changes' ? (
{viewMode === "changes" ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
@@ -394,17 +342,19 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === 'parsed' ? (
) : viewMode === "parsed" ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
</div>
</>
)}

View File

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

View File

@@ -1,12 +1,15 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useAppStore, type AgentModel } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ImageDropZone } from '@/components/ui/image-drop-zone';
"use client";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
Wrench,
Trash2,
@@ -14,57 +17,28 @@ import {
PanelLeft,
Paperclip,
X,
ImageIcon,
ChevronDown,
FileText,
Square,
ListOrdered,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useElectronAgent } from '@/hooks/use-electron-agent';
import { SessionManager } from '@/components/session-manager';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
formatFileSize,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
import { SessionManager } from "@/components/session-manager";
import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
import { Textarea } from '@/components/ui/textarea';
} from "@/hooks/use-keyboard-shortcuts";
export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
@@ -74,7 +48,7 @@ export function AgentView() {
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
// Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Ref for quick create session function from SessionManager
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
@@ -86,16 +60,10 @@ export function AgentView() {
isConnected,
sendMessage,
clearHistory,
stopExecution,
error: agentError,
serverQueue,
addToServerQueue,
removeFromServerQueue,
clearServerQueue,
} = useElectronAgent({
sessionId: currentSessionId || '',
sessionId: currentSessionId || "",
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000);
@@ -103,16 +71,13 @@ export function AgentView() {
});
// Handle session selection with persistence
const handleSelectSession = useCallback(
(sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (currentProject?.path) {
setLastSelectedSession(currentProject.path, sessionId);
}
},
[currentProject?.path, setLastSelectedSession]
);
const handleSelectSession = useCallback((sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (currentProject?.path) {
setLastSelectedSession(currentProject.path, sessionId);
}
}, [currentProject?.path, setLastSelectedSession]);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
@@ -129,7 +94,7 @@ export function AgentView() {
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log('[AgentView] Restoring last selected session:', lastSessionId);
console.log("[AgentView] Restoring last selected session:", lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
@@ -140,24 +105,17 @@ export function AgentView() {
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) return;
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
const messageContent = input;
const messageImages = selectedImages;
const messageTextFiles = selectedTextFiles;
setInput('');
setInput("");
setSelectedImages([]);
setSelectedTextFiles([]);
setShowImageDropZone(false);
// If already processing, add to server queue instead
if (isProcessing) {
await addToServerQueue(messageContent, messageImages, messageTextFiles);
} else {
await sendMessage(messageContent, messageImages, messageTextFiles);
}
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage, addToServerQueue]);
await sendMessage(messageContent, messageImages);
}, [input, selectedImages, isProcessing, sendMessage]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
@@ -167,99 +125,88 @@ export function AgentView() {
setShowImageDropZone(!showImageDropZone);
}, [showImageDropZone]);
// Process dropped files (images and text files)
// Helper function to convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
} else {
reject(new Error("Failed to read file as base64"));
}
};
reader.onerror = () => reject(new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}, []);
// Process dropped files
const processDroppedFiles = useCallback(
async (files: FileList) => {
if (isProcessing) return;
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_FILES = 5;
const newImages: ImageAttachment[] = [];
const newTextFiles: TextFileAttachment[] = [];
const errors: string[] = [];
for (const file of Array.from(files)) {
// Check if it's a text file
if (isTextFile(file)) {
const validation = validateTextFile(file);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
try {
const content = await fileToText(file);
const textFileAttachment: TextFileAttachment = {
id: generateFileId(),
content,
mimeType: getTextFileMimeType(file.name),
filename: file.name,
size: file.size,
};
newTextFiles.push(textFileAttachment);
} catch {
errors.push(`${file.name}: Failed to read text file.`);
}
// Validate file type
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
errors.push(
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
);
continue;
}
// Check if it's an image file
else if (isImageFile(file)) {
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
errors.push(validation.error!);
continue;
}
// Check if we've reached max files
const totalFiles =
newImages.length +
selectedImages.length +
newTextFiles.length +
selectedTextFiles.length;
if (totalFiles >= DEFAULT_MAX_FILES) {
errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`);
break;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue;
}
try {
const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = {
id: generateImageId(),
data: base64,
mimeType: file.type,
filename: file.name,
size: file.size,
};
newImages.push(imageAttachment);
} catch {
errors.push(`${file.name}: Failed to process image.`);
}
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
// Check if we've reached max files
if (newImages.length + selectedImages.length >= MAX_FILES) {
errors.push(`Maximum ${MAX_FILES} 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 (error) {
errors.push(`${file.name}: Failed to process image.`);
}
}
if (errors.length > 0) {
console.warn('File upload errors:', errors);
console.warn("Image upload errors:", errors);
}
if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]);
}
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
},
[isProcessing, selectedImages, selectedTextFiles]
[isProcessing, selectedImages, fileToBase64]
);
// Remove individual image
@@ -267,11 +214,6 @@ export function AgentView() {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
}, []);
// Remove individual text file
const removeTextFile = useCallback((fileId: string) => {
setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId));
}, []);
// Drag and drop handlers for the input area
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
@@ -280,7 +222,7 @@ export function AgentView() {
if (isProcessing || !isConnected) return;
// Check if dragged items contain files
if (e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
},
@@ -326,7 +268,7 @@ export function AgentView() {
if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
const dataTransfer = new DataTransfer();
@@ -350,9 +292,9 @@ export function AgentView() {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
if (item.kind === "file") {
const file = item.getAsFile();
if (file && file.type.startsWith('image/')) {
if (file && file.type.startsWith("image/")) {
e.preventDefault(); // Prevent default paste of file path
files.push(file);
}
@@ -369,26 +311,15 @@ export function AgentView() {
[processDroppedFiles]
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const adjustTextareaHeight = useCallback(() => {
const textarea = inputRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [input, adjustTextareaHeight]);
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();
};
@@ -399,13 +330,14 @@ export function AgentView() {
const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold;
container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
setIsUserAtBottom(isAtBottom);
}, []);
// Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = messagesContainerRef.current;
if (!container) return;
@@ -426,7 +358,7 @@ export function AgentView() {
if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated
setTimeout(() => {
scrollToBottom('smooth');
scrollToBottom("smooth");
}, 100);
}
}, [messages, isUserAtBottom, scrollToBottom]);
@@ -436,7 +368,7 @@ export function AgentView() {
if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions
setTimeout(() => {
scrollToBottom('auto');
scrollToBottom("auto");
setIsUserAtBottom(true);
}, 100);
}
@@ -465,7 +397,7 @@ export function AgentView() {
quickCreateSessionRef.current();
}
},
description: 'Create new session',
description: "Create new session",
});
}
@@ -499,8 +431,8 @@ export function AgentView() {
messages.length === 0
? [
{
id: 'welcome',
role: 'assistant' as const,
id: "welcome",
role: "assistant" as const,
content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
timestamp: new Date().toISOString(),
@@ -509,7 +441,10 @@ export function AgentView() {
: messages;
return (
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
<div
className="flex-1 flex overflow-hidden bg-background"
data-testid="agent-view"
>
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
@@ -547,7 +482,7 @@ export function AgentView() {
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground">
{currentProject.name}
{currentSessionId && !isConnected && ' - Connecting...'}
{currentSessionId && !isConnected && " - Connecting..."}
</p>
</div>
</div>
@@ -588,7 +523,9 @@ export function AgentView() {
<div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-3 text-foreground">No Session Selected</h2>
<h2 className="text-lg font-semibold mb-3 text-foreground">
No Session Selected
</h2>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent
</p>
@@ -598,7 +535,7 @@ export function AgentView() {
className="gap-2"
>
<PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions
{showSessionManager ? "View" : "Show"} Sessions
</Button>
</div>
</div>
@@ -613,20 +550,20 @@ export function AgentView() {
<div
key={message.id}
className={cn(
'flex gap-4 max-w-4xl',
message.role === 'user' ? 'flex-row-reverse ml-auto' : ''
"flex gap-4 max-w-4xl",
message.role === "user" ? "flex-row-reverse ml-auto" : ""
)}
>
{/* Avatar */}
<div
className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm',
message.role === 'assistant'
? 'bg-primary/10 ring-1 ring-primary/20'
: 'bg-muted ring-1 ring-border'
"w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
message.role === "assistant"
? "bg-primary/10 ring-1 ring-primary/20"
: "bg-muted ring-1 ring-border"
)}
>
{message.role === 'assistant' ? (
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4 text-muted-foreground" />
@@ -636,67 +573,32 @@ export function AgentView() {
{/* Message Bubble */}
<div
className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm',
message.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-card border border-border'
"flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-card border border-border"
)}
>
{message.role === 'assistant' ? (
{message.role === "assistant" ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{message.content}</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
)}
{/* Display attached images for user messages */}
{message.role === 'user' && message.images && message.images.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs text-primary-foreground/80">
<ImageIcon className="w-3 h-3" />
<span>
{message.images.length} image
{message.images.length > 1 ? 's' : ''} attached
</span>
</div>
<div className="flex flex-wrap gap-2">
{message.images.map((image, index) => {
// Construct proper data URL from base64 data and mime type
const dataUrl = image.data.startsWith('data:')
? image.data
: `data:${image.mimeType || 'image/png'};base64,${image.data}`;
return (
<div
key={image.id || `img-${index}`}
className="relative group rounded-lg overflow-hidden border border-primary-foreground/20 bg-primary-foreground/10"
>
<img
src={dataUrl}
alt={image.filename || `Attached image ${index + 1}`}
className="w-20 h-20 object-cover hover:opacity-90 transition-opacity"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 px-1.5 py-0.5 text-[9px] text-white truncate">
{image.filename || `Image ${index + 1}`}
</div>
</div>
);
})}
</div>
</div>
)}
<p
className={cn(
'text-[11px] mt-2 font-medium',
message.role === 'user'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
"text-[11px] mt-2 font-medium",
message.role === "user"
? "text-primary-foreground/70"
: "text-muted-foreground"
)}
>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
@@ -712,20 +614,13 @@ export function AgentView() {
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-sm text-muted-foreground">Thinking...</span>
<span className="text-sm text-muted-foreground">
Thinking...
</span>
</div>
</div>
</div>
@@ -743,72 +638,27 @@ export function AgentView() {
images={selectedImages}
maxFiles={5}
className="mb-4"
disabled={!isConnected}
disabled={isProcessing || !isConnected}
/>
)}
{/* Queued Prompts List */}
{serverQueue.length > 0 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
</p>
<button
onClick={clearServerQueue}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="space-y-1.5">
{serverQueue.map((item, index) => (
<div
key={item.id}
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
>
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
{index + 1}.
</span>
<span className="flex-1 truncate text-foreground">{item.message}</span>
{item.imagePaths && item.imagePaths.length > 0 && (
<span className="text-xs text-muted-foreground">
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
</span>
)}
<button
onClick={() => removeFromServerQueue(item.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
title="Remove from queue"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
{/* Selected Images Preview */}
{selectedImages.length > 0 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground">
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached
{selectedImages.length} image
{selectedImages.length > 1 ? "s" : ""} attached
</p>
<button
onClick={() => {
setSelectedImages([]);
setSelectedTextFiles([]);
}}
onClick={() => setSelectedImages([])}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
>
Clear all
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
@@ -827,46 +677,13 @@ export function AgentView() {
<p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename}
</p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground">
{formatFileSize(image.size)}
</p>
)}
</div>
{/* Remove button */}
{image.id && (
<button
onClick={() => removeImage(image.id!)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{/* Text file attachments */}
{selectedTextFiles.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border border-border bg-muted/30 p-2 flex items-center gap-2 hover:border-primary/30 transition-colors"
>
{/* File icon */}
<div className="w-8 h-8 rounded-md bg-muted flex-shrink-0 flex items-center justify-center">
<FileText className="w-4 h-4 text-muted-foreground" />
</div>
{/* File info */}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate max-w-24">
{file.filename}
</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(file.size)}
{formatFileSize(image.size)}
</p>
</div>
{/* Remove button */}
<button
onClick={() => removeTextFile(file.id)}
onClick={() => removeImage(image.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
disabled={isProcessing}
>
@@ -881,8 +698,8 @@ export function AgentView() {
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
"flex gap-2 transition-all duration-200 rounded-xl p-1",
isDragOver && "bg-primary/5 ring-2 ring-primary/30"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -890,34 +707,29 @@ export function AgentView() {
onDrop={handleDrop}
>
<div className="flex-1 relative">
<Textarea
<Input
ref={inputRef}
placeholder={
isDragOver
? 'Drop your files here...'
: isProcessing
? 'Type to queue another prompt...'
: 'Describe what you want to build...'
? "Drop your images here..."
: "Describe what you want to build..."
}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
onPaste={handlePaste}
disabled={!isConnected}
disabled={isProcessing || !isConnected}
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
"h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
"focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
selectedImages.length > 0 && "border-primary/30",
isDragOver && "border-primary bg-primary/5"
)}
/>
{(selectedImages.length > 0 || selectedTextFiles.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">
{selectedImages.length + selectedTextFiles.length} file
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
{selectedImages.length} image{selectedImages.length > 1 ? "s" : ""}
</div>
)}
{isDragOver && (
@@ -928,96 +740,40 @@ export function AgentView() {
)}
</div>
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-11 gap-1 text-xs font-medium rounded-xl border-border px-2.5"
data-testid="model-selector"
>
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* File Attachment Button */}
{/* Image Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={toggleImageDropZone}
disabled={!isConnected}
disabled={isProcessing || !isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
(selectedImages.length > 0 || selectedTextFiles.length > 0) &&
'border-primary/30 text-primary'
"h-11 w-11 rounded-xl border-border",
showImageDropZone && "bg-primary/10 text-primary border-primary/30",
selectedImages.length > 0 && "border-primary/30 text-primary"
)}
title="Attach files (images, .txt, .md)"
title="Attach images"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={stopExecution}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
{/* Send Button */}
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
(!input.trim() && selectedImages.length === 0) ||
isProcessing ||
!isConnected
}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
<Send className="w-4 h-4" />
</Button>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">
Shift+Enter
</kbd>{' '}
for new line
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to send
</p>
</div>
)}
@@ -1025,3 +781,12 @@ export function AgentView() {
</div>
);
}
// Helper function to format file size
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}

View File

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

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