Compare commits

..

1 Commits

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

View File

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

View File

@@ -21,4 +21,4 @@
"mcp__puppeteer__puppeteer_evaluate"
]
}
}
}

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

69
.gitignore vendored
View File

@@ -6,77 +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
/logs

View File

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

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"
}

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,5 +1,5 @@
<p align="center">
<img src="apps/ui/public/readme_logo.png" 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
@@ -79,7 +79,7 @@ The future of software development is **agentic coding**—where developers beco
>
> **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)**
> **[Read the full disclaimer](../DISCLAIMER.md)**
---
@@ -88,7 +88,6 @@ The future of software development is **agentic coding**—where developers beco
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
@@ -118,10 +117,7 @@ cd automaker
# 2. Install dependencies
npm install
# 3. Build local shared packages
npm run build:packages
# 4. Run Automaker (pick your mode)
# 3. Run Automaker (pick your mode)
npm run dev
# Then choose your run mode when prompted, or use specific commands below
```
@@ -256,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;

View File

@@ -1,5 +1,5 @@
{
"name": "@automaker/ui",
"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",
@@ -7,36 +7,33 @@
"type": "git",
"url": "https://github.com/AutoMaker-Org/automaker.git"
},
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Cody Seibert",
"email": "webdevcody@gmail.com"
},
"private": true,
"main": "dist-electron/main.js",
"license": "Unlicense",
"main": "electron/main.js",
"scripts": {
"dev": "vite",
"dev:web": "cross-env VITE_SKIP_ELECTRON=true vite",
"dev:electron": "vite",
"dev:electron:debug": "cross-env OPEN_DEVTOOLS=true vite",
"build": "vite build",
"build:electron": "node scripts/prepare-server.mjs && vite build && electron-builder",
"build:electron:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --dir",
"build:electron:win": "node scripts/prepare-server.mjs && vite build && electron-builder --win",
"build:electron:win:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --win --dir",
"build:electron:mac": "node scripts/prepare-server.mjs && vite build && electron-builder --mac",
"build:electron:mac:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --mac --dir",
"build:electron:linux": "node scripts/prepare-server.mjs && vite build && electron-builder --linux",
"build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir",
"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",
"preview": "vite preview",
"start": "next start",
"lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.mjs",
"pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test",
"test:headed": "playwright test --headed",
"dev:electron:wsl": "cross-env vite",
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite"
"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": {
"@automaker/dependency-resolver": "^1.0.0",
"@automaker/types": "^1.0.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -48,33 +45,25 @@
"@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-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.2.3",
"geist": "^1.5.1",
"lucide-react": "^0.562.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"lucide-react": "^0.556.0",
"next": "^16.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -93,40 +82,32 @@
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.7",
"@types/dagre": "^0.7.53",
"@types/node": "^22",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitejs/plugin-react": "^5.1.2",
"cross-env": "^10.1.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.39.2",
"tailwindcss": "^4.1.18",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"vite": "^7.3.0",
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6"
"wait-on": "^9.0.3"
},
"build": {
"appId": "com.automaker.app",
"productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false,
"afterPack": "./scripts/rebuild-server-natives.cjs",
"afterPack": "./scripts/rebuild-server-natives.js",
"directories": {
"output": "release"
"output": "dist"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"electron/**/*",
"out/**/*",
"public/**/*",
"!node_modules/**/*"
],

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();

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +0,0 @@
{
"name": "@automaker/server-bundle",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"morgan": "^1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
}
}

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 } 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 || 'http://localhost:3008';
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,36 +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-center gap-2 text-sm',
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
"flex items-center gap-2 text-sm",
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)}
>
<Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0">
{isLoadingWorkspace ? (
'Loading workspace...'
"Loading workspace..."
) : workspaceDir ? (
<>
Will be created at:{' '}
Will be created at:{" "}
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
{projectPath || workspaceDir}
</code>
</>
) : null}
) : (
<span className="text-red-500">No workspace configured</span>
)}
</span>
<Button
type="button"
@@ -279,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">
@@ -297,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>
@@ -315,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}`}
@@ -334,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}
@@ -346,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>
))}
@@ -360,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>
@@ -385,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>
)}
@@ -439,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;
@@ -25,7 +26,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = 'parsed' | 'raw' | 'changes';
type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({
open,
@@ -35,13 +36,13 @@ export function AgentOutputModal({
featureStatus,
onNumberKeyPress,
}: AgentOutputModalProps) {
const [output, setOutput] = useState<string>('');
const [output, setOutput] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('parsed');
const [projectPath, setProjectPath] = useState<string>('');
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>('');
const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
@@ -74,19 +75,22 @@ export function AgentOutputModal({
// Use features API to get agent output
if (api.features) {
const result = await api.features.getAgentOutput(currentProject.path, featureId);
const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
if (result.success) {
setOutput(result.content || '');
setOutput(result.content || "");
} else {
setOutput('');
setOutput("");
}
} else {
setOutput('');
setOutput("");
}
} catch (error) {
console.error('Failed to load output:', error);
setOutput('');
console.error("Failed to load output:", error);
setOutput("");
} finally {
setIsLoading(false);
}
@@ -95,6 +99,24 @@ export function AgentOutputModal({
loadOutput();
}, [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(() => {
if (!open) return;
@@ -104,32 +126,38 @@ export function AgentOutputModal({
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) {
if ("featureId" in event && event.featureId !== featureId) {
return;
}
let newContent = '';
let newContent = "";
switch (event.type) {
case 'auto_mode_progress':
newContent = event.content || '';
case "auto_mode_progress":
newContent = event.content || "";
break;
case 'auto_mode_tool': {
const toolName = event.tool || 'Unknown Tool';
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : '';
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : ""
}`;
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`;
@@ -159,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)
@@ -237,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;
});
}
});
@@ -266,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]);
@@ -287,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"
>
@@ -306,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"
>
@@ -318,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"
>
@@ -339,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
@@ -379,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,54 +17,28 @@ import {
PanelLeft,
Paperclip,
X,
ImageIcon,
ChevronDown,
FileText,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useElectronAgent } from '@/hooks/use-electron-agent';
import { SessionManager } from '@/components/session-manager';
import { Markdown } from '@/components/ui/markdown';
import type { ImageAttachment, TextFileAttachment } from '@/store/app-store';
import {
fileToBase64,
generateImageId,
generateFileId,
validateImageFile,
validateTextFile,
isTextFile,
isImageFile,
fileToText,
getTextFileMimeType,
formatFileSize,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
} from '@/lib/image-utils';
} 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';
} 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);
@@ -85,9 +62,8 @@ export function AgentView() {
clearHistory,
error: agentError,
} = useElectronAgent({
sessionId: currentSessionId || '',
sessionId: currentSessionId || "",
workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => {
setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000);
@@ -95,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(() => {
@@ -121,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]);
@@ -132,23 +105,17 @@ export function AgentView() {
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if (
(!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) ||
isProcessing
)
return;
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
const messageContent = input;
const messageImages = selectedImages;
const messageTextFiles = selectedTextFiles;
setInput('');
setInput("");
setSelectedImages([]);
setSelectedTextFiles([]);
setShowImageDropZone(false);
await sendMessage(messageContent, messageImages, messageTextFiles);
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]);
await sendMessage(messageContent, messageImages);
}, [input, selectedImages, isProcessing, sendMessage]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images);
@@ -158,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
@@ -258,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) => {
@@ -271,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);
}
},
@@ -317,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();
@@ -341,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);
}
@@ -361,14 +312,14 @@ export function AgentView() {
);
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleClearChat = async () => {
if (!confirm('Are you sure you want to clear this conversation?')) return;
if (!confirm("Are you sure you want to clear this conversation?")) return;
await clearHistory();
};
@@ -379,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;
@@ -406,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]);
@@ -416,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);
}
@@ -445,7 +397,7 @@ export function AgentView() {
quickCreateSessionRef.current();
}
},
description: 'Create new session',
description: "Create new session",
});
}
@@ -479,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(),
@@ -489,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">
@@ -527,48 +482,13 @@ 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>
{/* Status indicators & actions */}
<div className="flex items-center gap-3">
{/* Model Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs font-medium"
disabled={isProcessing}
data-testid="model-selector"
>
<Bot className="w-3.5 h-3.5" />
{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>
{currentTool && (
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 rounded-full border border-border">
<Wrench className="w-3 h-3 text-primary" />
@@ -603,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>
@@ -613,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>
@@ -628,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" />
@@ -651,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>
@@ -727,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>
@@ -762,19 +642,16 @@ export function AgentView() {
/>
)}
{/* 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}
>
@@ -782,7 +659,6 @@ export function AgentView() {
</button>
</div>
<div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => (
<div
key={image.id}
@@ -801,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}
>
@@ -855,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}
@@ -867,7 +710,9 @@ export function AgentView() {
<Input
ref={inputRef}
placeholder={
isDragOver ? 'Drop your files here...' : 'Describe what you want to build...'
isDragOver
? "Drop your images here..."
: "Describe what you want to build..."
}
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -876,17 +721,15 @@ export function AgentView() {
disabled={isProcessing || !isConnected}
data-testid="agent-input"
className={cn(
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
(selectedImages.length > 0 || 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 && (
@@ -897,19 +740,18 @@ export function AgentView() {
)}
</div>
{/* File Attachment Button */}
{/* Image Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={toggleImageDropZone}
disabled={isProcessing || !isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
(selectedImages.length > 0 || 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>
@@ -918,9 +760,7 @@ export function AgentView() {
<Button
onClick={handleSend}
disabled={
(!input.trim() &&
selectedImages.length === 0 &&
selectedTextFiles.length === 0) ||
(!input.trim() && selectedImages.length === 0) ||
isProcessing ||
!isConnected
}
@@ -933,9 +773,7 @@ export function AgentView() {
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to send
</p>
</div>
)}
@@ -943,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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
"use client";
import { useState } from "react";
import { useAppStore } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
MessageSquare,
@@ -11,16 +13,16 @@ import {
Search,
ChevronLeft,
ArchiveRestore,
} from 'lucide-react';
import { cn } from '@/lib/utils';
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
export function ChatHistory() {
const {
@@ -36,7 +38,7 @@ export function ChatHistory() {
setChatHistoryOpen,
} = useAppStore();
const [searchQuery, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState("");
const [showArchived, setShowArchived] = useState(false);
if (!currentProject) {
@@ -44,12 +46,18 @@ export function ChatHistory() {
}
// Filter sessions for current project
const projectSessions = chatSessions.filter((session) => session.projectId === currentProject.id);
const projectSessions = chatSessions.filter(
(session) => session.projectId === currentProject.id
);
// Filter by search query and archived status
const filteredSessions = projectSessions.filter((session) => {
const matchesSearch = session.title.toLowerCase().includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived ? session.archived : !session.archived;
const matchesSearch = session.title
.toLowerCase()
.includes(searchQuery.toLowerCase());
const matchesArchivedStatus = showArchived
? session.archived
: !session.archived;
return matchesSearch && matchesArchivedStatus;
});
@@ -78,7 +86,7 @@ export function ChatHistory() {
const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this chat session?')) {
if (confirm("Are you sure you want to delete this chat session?")) {
deleteChatSession(sessionId);
}
};
@@ -86,8 +94,8 @@ export function ChatHistory() {
return (
<div
className={cn(
'flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200',
chatHistoryOpen ? 'w-80' : 'w-0 overflow-hidden'
"flex flex-col h-full bg-zinc-950/50 backdrop-blur-md border-r border-white/10 transition-all duration-200",
chatHistoryOpen ? "w-80" : "w-0 overflow-hidden"
)}
>
{chatHistoryOpen && (
@@ -98,7 +106,11 @@ export function ChatHistory() {
<MessageSquare className="w-5 h-5" />
<h2 className="font-semibold">Chat History</h2>
</div>
<Button variant="ghost" size="sm" onClick={() => setChatHistoryOpen(false)}>
<Button
variant="ghost"
size="sm"
onClick={() => setChatHistoryOpen(false)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
</div>
@@ -141,7 +153,7 @@ export function ChatHistory() {
) : (
<Archive className="w-4 h-4" />
)}
{showArchived ? 'Show Active' : 'Show Archived'}
{showArchived ? "Show Active" : "Show Archived"}
{showArchived && (
<Badge variant="outline" className="ml-auto">
{projectSessions.filter((s) => s.archived).length}
@@ -168,13 +180,15 @@ export function ChatHistory() {
<div
key={session.id}
className={cn(
'flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group',
currentChatSession?.id === session.id && 'bg-accent'
"flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-accent transition-colors group",
currentChatSession?.id === session.id && "bg-accent"
)}
onClick={() => handleSelectSession(session)}
>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{session.title}</h3>
<h3 className="font-medium text-sm truncate">
{session.title}
</h3>
<p className="text-xs text-muted-foreground truncate">
{session.messages.length} messages
</p>
@@ -186,20 +200,30 @@ export function ChatHistory() {
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<MoreVertical className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{session.archived ? (
<DropdownMenuItem
onClick={(e) => handleUnarchiveSession(session.id, e)}
onClick={(e) =>
handleUnarchiveSession(session.id, e)
}
>
<ArchiveRestore className="w-4 h-4 mr-2" />
Unarchive
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={(e) => handleArchiveSession(session.id, e)}>
<DropdownMenuItem
onClick={(e) =>
handleArchiveSession(session.id, e)
}
>
<Archive className="w-4 h-4 mr-2" />
Archive
</DropdownMenuItem>

View File

@@ -1,10 +1,20 @@
import { useEffect, useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
import { cn } from '@/lib/utils';
"use client";
import { useEffect, useState, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
File,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
RefreshCw,
Code,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface FileTreeNode {
name: string;
@@ -14,11 +24,19 @@ interface FileTreeNode {
isExpanded?: boolean;
}
const IGNORE_PATTERNS = ['node_modules', '.git', '.next', 'dist', 'build', '.DS_Store', '*.log'];
const IGNORE_PATTERNS = [
"node_modules",
".git",
".next",
"dist",
"build",
".DS_Store",
"*.log",
];
const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith('*')) {
if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1));
}
return name === pattern;
@@ -29,9 +47,11 @@ export function CodeView() {
const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>('');
const [fileContent, setFileContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
// Load directory tree
const loadTree = useCallback(async () => {
@@ -60,7 +80,7 @@ export function CodeView() {
setFileTree(entries);
}
} catch (error) {
console.error('Failed to load file tree:', error);
console.error("Failed to load file tree:", error);
} finally {
setIsLoading(false);
}
@@ -91,7 +111,7 @@ export function CodeView() {
}));
}
} catch (error) {
console.error('Failed to load subdirectory:', error);
console.error("Failed to load subdirectory:", error);
}
return [];
};
@@ -107,7 +127,7 @@ export function CodeView() {
setSelectedFile(path);
}
} catch (error) {
console.error('Failed to load file:', error);
console.error("Failed to load file:", error);
}
};
@@ -151,8 +171,8 @@ export function CodeView() {
<div key={node.path}>
<div
className={cn(
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50',
isSelected && 'bg-muted'
"flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-muted"
)}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => {
@@ -186,7 +206,9 @@ export function CodeView() {
<span className="text-sm truncate">{node.name}</span>
</div>
{node.isDirectory && isExpanded && node.children && (
<div>{node.children.map((child) => renderNode(child, depth + 1))}</div>
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
@@ -194,7 +216,10 @@ export function CodeView() {
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="code-view-no-project">
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-no-project"
>
<p className="text-muted-foreground">No project selected</p>
</div>
);
@@ -202,24 +227,37 @@ export function CodeView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
<div
className="flex-1 flex items-center justify-center"
data-testid="code-view-loading"
>
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="code-view">
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="code-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">
<Code className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Code Explorer</h1>
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
<p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={loadTree} data-testid="refresh-tree">
<Button
variant="outline"
size="sm"
onClick={loadTree}
data-testid="refresh-tree"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
@@ -238,7 +276,7 @@ export function CodeView() {
<div className="h-full flex flex-col">
<div className="px-4 py-2 border-b bg-muted/30">
<p className="text-sm font-mono text-muted-foreground truncate">
{selectedFile.replace(currentProject.path, '')}
{selectedFile.replace(currentProject.path, "")}
</p>
</div>
<Card className="flex-1 m-4 overflow-hidden">
@@ -251,7 +289,9 @@ export function CodeView() {
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">Select a file to view its contents</p>
<p className="text-muted-foreground">
Select a file to view its contents
</p>
</div>
)}
</div>

View File

@@ -0,0 +1,735 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Card } from "@/components/ui/card";
import {
Plus,
RefreshCw,
FileText,
Image as ImageIcon,
Trash2,
Save,
Upload,
File,
X,
BookOpen,
EditIcon,
Eye,
} from "lucide-react";
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { Markdown } from "../ui/markdown";
interface ContextFile {
name: string;
type: "text" | "image";
content?: string;
path: string;
}
export function ContextView() {
const { currentProject } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [editedContent, setEditedContent] = useState("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
null
);
const [newFileContent, setNewFileContent] = useState("");
const [isDropHovering, setIsDropHovering] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false);
// Keyboard shortcuts for this view
const contextShortcuts: KeyboardShortcut[] = useMemo(
() => [
{
key: shortcuts.addContextFile,
action: () => setIsAddDialogOpen(true),
description: "Add new context file",
},
],
[shortcuts]
);
useKeyboardShortcuts(contextShortcuts);
// Get context directory path for user-added context files
const getContextPath = useCallback(() => {
if (!currentProject) return null;
return `${currentProject.path}/.automaker/context`;
}, [currentProject]);
const isMarkdownFile = (filename: string): boolean => {
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return ext === ".md" || ext === ".markdown";
};
// Determine if a file is an image based on extension
const isImageFile = (filename: string): boolean => {
const imageExtensions = [
".png",
".jpg",
".jpeg",
".gif",
".webp",
".svg",
".bmp",
];
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
return imageExtensions.includes(ext);
};
// Load context files
const loadContextFiles = useCallback(async () => {
const contextPath = getContextPath();
if (!contextPath) return;
setIsLoading(true);
try {
const api = getElectronAPI();
// Ensure context directory exists
await api.mkdir(contextPath);
// Read directory contents
const result = await api.readdir(contextPath);
if (result.success && result.entries) {
const files: ContextFile[] = result.entries
.filter((entry) => entry.isFile)
.map((entry) => ({
name: entry.name,
type: isImageFile(entry.name) ? "image" : "text",
path: `${contextPath}/${entry.name}`,
}));
setContextFiles(files);
}
} catch (error) {
console.error("Failed to load context files:", error);
} finally {
setIsLoading(false);
}
}, [getContextPath]);
useEffect(() => {
loadContextFiles();
}, [loadContextFiles]);
// Load selected file content
const loadFileContent = useCallback(async (file: ContextFile) => {
try {
const api = getElectronAPI();
const result = await api.readFile(file.path);
if (result.success && result.content !== undefined) {
setEditedContent(result.content);
setSelectedFile({ ...file, content: result.content });
setHasChanges(false);
}
} catch (error) {
console.error("Failed to load file content:", error);
}
}, []);
// Select a file
const handleSelectFile = (file: ContextFile) => {
if (hasChanges) {
// Could add a confirmation dialog here
}
loadFileContent(file);
setIsPreviewMode(isMarkdownFile(file.name));
};
// Save current file
const saveFile = async () => {
if (!selectedFile) return;
setIsSaving(true);
try {
const api = getElectronAPI();
await api.writeFile(selectedFile.path, editedContent);
setSelectedFile({ ...selectedFile, content: editedContent });
setHasChanges(false);
} catch (error) {
console.error("Failed to save file:", error);
} finally {
setIsSaving(false);
}
};
// Handle content change
const handleContentChange = (value: string) => {
setEditedContent(value);
setHasChanges(true);
};
// Add new context file
const handleAddFile = async () => {
const contextPath = getContextPath();
if (!contextPath || !newFileName.trim()) return;
try {
const api = getElectronAPI();
let filename = newFileName.trim();
// Add default extension if not provided
if (newFileType === "text" && !filename.includes(".")) {
filename += ".md";
}
const filePath = `${contextPath}/${filename}`;
if (newFileType === "image" && uploadedImageData) {
// Write image data
await api.writeFile(filePath, uploadedImageData);
} else {
// Write text file with content (or empty if no content)
await api.writeFile(filePath, newFileContent);
}
setIsAddDialogOpen(false);
setNewFileName("");
setNewFileType("text");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to add file:", error);
}
};
// Delete selected file
const handleDeleteFile = async () => {
if (!selectedFile) return;
try {
const api = getElectronAPI();
await api.deleteFile(selectedFile.path);
setIsDeleteDialogOpen(false);
setSelectedFile(null);
setEditedContent("");
setHasChanges(false);
await loadContextFiles();
} catch (error) {
console.error("Failed to delete file:", error);
}
};
// Handle image upload
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const base64 = event.target?.result as string;
setUploadedImageData(base64);
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsDataURL(file);
};
// Handle drag and drop for file upload
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const contextPath = getContextPath();
if (!contextPath) return;
const api = getElectronAPI();
for (const file of files) {
const reader = new FileReader();
reader.onload = async (event) => {
const content = event.target?.result as string;
const filePath = `${contextPath}/${file.name}`;
await api.writeFile(filePath, content);
await loadContextFiles();
};
if (isImageFile(file.name)) {
reader.readAsDataURL(file);
} else {
reader.readAsText(file);
}
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
// Handle drag and drop for .txt and .md files in the add context dialog textarea
const handleTextAreaDrop = async (
e: React.DragEvent<HTMLTextAreaElement>
) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
const file = files[0]; // Only handle the first file
const fileName = file.name.toLowerCase();
// Only accept .txt and .md files
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
console.warn("Only .txt and .md files are supported for drag and drop");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setNewFileContent(content);
// Auto-fill filename if empty
if (!newFileName) {
setNewFileName(file.name);
}
};
reader.readAsText(file);
};
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(true);
};
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDropHovering(false);
};
if (!currentProject) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-no-project"
>
<p className="text-muted-foreground">No project selected</p>
</div>
);
}
if (isLoading) {
return (
<div
className="flex-1 flex items-center justify-center"
data-testid="context-view-loading"
>
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="context-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
<BookOpen className="w-5 h-5 text-muted-foreground" />
<div>
<h1 className="text-xl font-bold">Context Files</h1>
<p className="text-sm text-muted-foreground">
Add context files to include in AI prompts
</p>
</div>
</div>
<div className="flex gap-2">
<HotkeyButton
size="sm"
onClick={() => setIsAddDialogOpen(true)}
hotkey={shortcuts.addContextFile}
hotkeyActive={false}
data-testid="add-context-file"
>
<Plus className="w-4 h-4 mr-2" />
Add File
</HotkeyButton>
</div>
</div>
{/* Main content area with file list and editor */}
<div
className="flex-1 flex overflow-hidden"
onDrop={handleDrop}
onDragOver={handleDragOver}
data-testid="context-drop-zone"
>
{/* Left Panel - File List */}
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
<div className="p-3 border-b border-border">
<h2 className="text-sm font-semibold text-muted-foreground">
Context Files ({contextFiles.length})
</h2>
</div>
<div
className="flex-1 overflow-y-auto p-2"
data-testid="context-file-list"
>
{contextFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
No context files yet.
<br />
Drop files here or click Add File.
</p>
</div>
) : (
<div className="space-y-1">
{contextFiles.map((file) => (
<button
key={file.path}
onClick={() => handleSelectFile(file)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
selectedFile?.path === file.path
? "bg-primary/20 text-foreground border border-primary/30"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
data-testid={`context-file-${file.name}`}
>
{file.type === "image" ? (
<ImageIcon className="w-4 h-4 flex-shrink-0" />
) : (
<FileText className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate text-sm">{file.name}</span>
</button>
))}
</div>
)}
</div>
</div>
{/* Right Panel - Editor/Preview */}
<div className="flex-1 flex flex-col overflow-hidden">
{selectedFile ? (
<>
{/* File toolbar */}
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
<div className="flex items-center gap-2">
{selectedFile.type === "image" ? (
<ImageIcon className="w-4 h-4 text-muted-foreground" />
) : (
<FileText className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">
{selectedFile.name}
</span>
</div>
<div className="flex gap-2">
{selectedFile.type === "text" &&
isMarkdownFile(selectedFile.name) && (
<Button
variant={"outline"}
size="sm"
onClick={() => setIsPreviewMode(!isPreviewMode)}
data-testid="toggle-preview-mode"
>
{isPreviewMode ? (
<>
<EditIcon className="w-4 h-4 mr-2" />
Edit
</>
) : (
<>
<Eye className="w-4 h-4 mr-2" />
Preview
</>
)}
</Button>
)}
{selectedFile.type === "text" && (
<Button
size="sm"
onClick={saveFile}
disabled={!hasChanges || isSaving}
data-testid="save-context-file"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsDeleteDialogOpen(true)}
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
data-testid="delete-context-file"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden p-4">
{selectedFile.type === "image" ? (
<div
className="h-full flex items-center justify-center bg-card rounded-lg"
data-testid="image-preview"
>
<img
src={editedContent}
alt={selectedFile.name}
className="max-w-full max-h-full object-contain"
/>
</div>
) : isPreviewMode ? (
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
<Markdown>{editedContent}</Markdown>
</Card>
) : (
<Card className="h-full overflow-hidden">
<textarea
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
value={editedContent}
onChange={(e) => handleContentChange(e.target.value)}
placeholder="Enter context content here..."
spellCheck={false}
data-testid="context-editor"
/>
</Card>
)}
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground-secondary">
Select a file to view or edit
</p>
<p className="text-muted-foreground text-sm mt-1">
Or drop files here to add them
</p>
</div>
</div>
)}
</div>
</div>
{/* Add File Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent
data-testid="add-context-dialog"
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
>
<DialogHeader>
<DialogTitle>Add Context File</DialogTitle>
<DialogDescription>
Add a new text or image file to the context.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Button
variant={newFileType === "text" ? "default" : "outline"}
size="sm"
onClick={() => setNewFileType("text")}
data-testid="add-text-type"
>
<FileText className="w-4 h-4 mr-2" />
Text
</Button>
<Button
variant={newFileType === "image" ? "default" : "outline"}
size="sm"
onClick={() => setNewFileType("image")}
data-testid="add-image-type"
>
<ImageIcon className="w-4 h-4 mr-2" />
Image
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="filename">File Name</Label>
<Input
id="filename"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={
newFileType === "text" ? "context.md" : "image.png"
}
data-testid="new-file-name"
/>
</div>
{newFileType === "text" && (
<div className="space-y-2">
<Label htmlFor="context-content">Context Content</Label>
<div
className={cn(
"relative rounded-lg transition-colors",
isDropHovering && "ring-2 ring-primary"
)}
>
<textarea
id="context-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
onDrop={handleTextAreaDrop}
onDragOver={handleTextAreaDragOver}
onDragLeave={handleTextAreaDragLeave}
placeholder="Enter context content here or drag & drop a .txt or .md file..."
className={cn(
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
isDropHovering && "border-primary bg-primary/10"
)}
spellCheck={false}
data-testid="new-file-content"
/>
{isDropHovering && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
<div className="flex flex-col items-center text-primary">
<Upload className="w-8 h-8 mb-2" />
<span className="text-sm font-medium">
Drop .txt or .md file here
</span>
</div>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Drag & drop .txt or .md files to import their content
</p>
</div>
)}
{newFileType === "image" && (
<div className="space-y-2">
<Label>Upload Image</Label>
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="image-upload"
data-testid="image-upload-input"
/>
<label
htmlFor="image-upload"
className="cursor-pointer flex flex-col items-center"
>
{uploadedImageData ? (
<img
src={uploadedImageData}
alt="Preview"
className="max-w-32 max-h-32 object-contain mb-2"
/>
) : (
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
)}
<span className="text-sm text-muted-foreground">
{uploadedImageData
? "Click to change"
: "Click to upload"}
</span>
</label>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setNewFileName("");
setUploadedImageData(null);
setNewFileContent("");
setIsDropHovering(false);
}}
>
Cancel
</Button>
<HotkeyButton
onClick={handleAddFile}
disabled={
!newFileName.trim() ||
(newFileType === "image" && !uploadedImageData)
}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={isAddDialogOpen}
data-testid="confirm-add-file"
>
Add File
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent data-testid="delete-context-dialog">
<DialogHeader>
<DialogTitle>Delete Context File</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedFile?.name}"? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteFile}
className="bg-red-600 hover:bg-red-700"
data-testid="confirm-delete-file"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,4 +1,6 @@
import { useEffect, useRef, useState, useCallback } from 'react';
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
@@ -6,11 +8,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Loader2,
Lightbulb,
@@ -21,15 +23,10 @@ import {
RefreshCw,
Shield,
Zap,
} from 'lucide-react';
import {
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
} from "lucide-react";
import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
import { useAppStore, Feature } from "@/store/app-store";
import { toast } from "sonner";
interface FeatureSuggestionsDialogProps {
open: boolean;
@@ -43,38 +40,35 @@ interface FeatureSuggestionsDialogProps {
}
// Configuration for each suggestion type
const suggestionTypeConfig: Record<
SuggestionType,
{
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}
> = {
const suggestionTypeConfig: Record<SuggestionType, {
label: string;
icon: React.ComponentType<{ className?: string }>;
description: string;
color: string;
}> = {
features: {
label: 'Feature Suggestions',
label: "Feature Suggestions",
icon: Lightbulb,
description: 'Discover missing features and improvements',
color: 'text-yellow-500',
description: "Discover missing features and improvements",
color: "text-yellow-500",
},
refactoring: {
label: 'Refactoring Suggestions',
label: "Refactoring Suggestions",
icon: RefreshCw,
description: 'Find code smells and refactoring opportunities',
color: 'text-blue-500',
description: "Find code smells and refactoring opportunities",
color: "text-blue-500",
},
security: {
label: 'Security Suggestions',
label: "Security Suggestions",
icon: Shield,
description: 'Identify security vulnerabilities and issues',
color: 'text-red-500',
description: "Identify security vulnerabilities and issues",
color: "text-red-500",
},
performance: {
label: 'Performance Suggestions',
label: "Performance Suggestions",
icon: Zap,
description: 'Discover performance bottlenecks and optimizations',
color: 'text-green-500',
description: "Discover performance bottlenecks and optimizations",
color: "text-green-500",
},
};
@@ -119,25 +113,23 @@ export function FeatureSuggestionsDialog({
if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === 'suggestions_progress') {
setProgress((prev) => [...prev, event.content || '']);
} else if (event.type === 'suggestions_tool') {
const toolName = event.tool || 'Unknown Tool';
if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === "suggestions_tool") {
const toolName = event.tool || "Unknown Tool";
setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
} else if (event.type === 'suggestions_complete') {
} else if (event.type === "suggestions_complete") {
setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions);
// Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType
? suggestionTypeConfig[currentSuggestionType].label.toLowerCase()
: 'suggestions';
const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else {
toast.info('No suggestions generated. Try again.');
toast.info("No suggestions generated. Try again.");
}
} else if (event.type === 'suggestions_error') {
} else if (event.type === "suggestions_error") {
setIsGenerating(false);
toast.error(`Error: ${event.error}`);
}
@@ -149,34 +141,31 @@ export function FeatureSuggestionsDialog({
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type
const handleGenerate = useCallback(
async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error('Suggestions API not available');
return;
}
const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
const api = getElectronAPI();
if (!api?.suggestions) {
toast.error("Suggestions API not available");
return;
}
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
setIsGenerating(true);
setProgress([]);
setSuggestions([]);
setSelectedIds(new Set());
setCurrentSuggestionType(suggestionType);
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || 'Failed to start generation');
setIsGenerating(false);
}
} catch (error) {
console.error('Failed to generate suggestions:', error);
toast.error('Failed to start generation');
try {
const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) {
toast.error(result.error || "Failed to start generation");
setIsGenerating(false);
}
},
[projectPath, setIsGenerating, setSuggestions]
);
} catch (error) {
console.error("Failed to generate suggestions:", error);
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath, setIsGenerating, setSuggestions]);
// Stop generating
const handleStop = useCallback(async () => {
@@ -186,9 +175,9 @@ export function FeatureSuggestionsDialog({
try {
await api.suggestions.stop();
setIsGenerating(false);
toast.info('Generation stopped');
toast.info("Generation stopped");
} catch (error) {
console.error('Failed to stop generation:', error);
console.error("Failed to stop generation:", error);
}
}, [setIsGenerating]);
@@ -230,7 +219,7 @@ export function FeatureSuggestionsDialog({
// Import selected suggestions as features
const handleImport = useCallback(async () => {
if (selectedIds.size === 0) {
toast.warning('No suggestions selected');
toast.warning("No suggestions selected");
return;
}
@@ -238,16 +227,18 @@ export function FeatureSuggestionsDialog({
try {
const api = getElectronAPI();
const selectedSuggestions = suggestions.filter((s) => selectedIds.has(s.id));
const selectedSuggestions = suggestions.filter((s) =>
selectedIds.has(s.id)
);
// Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category,
description: s.description,
status: 'backlog' as const,
steps: s.steps,
status: "backlog" as const,
skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
}));
// Create each new feature using the features API
@@ -273,8 +264,8 @@ export function FeatureSuggestionsDialog({
onClose();
} catch (error) {
console.error('Failed to import features:', error);
toast.error('Failed to import features');
console.error("Failed to import features:", error);
toast.error("Failed to import features");
} finally {
setIsImporting(false);
}
@@ -324,7 +315,7 @@ export function FeatureSuggestionsDialog({
<DialogDescription>
{currentConfig
? currentConfig.description
: 'Analyze your project to discover improvements. Choose a suggestion type below.'}
: "Analyze your project to discover improvements. Choose a suggestion type below."}
</DialogDescription>
</DialogHeader>
@@ -332,35 +323,32 @@ export function FeatureSuggestionsDialog({
// Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center max-w-lg mb-8">
Our AI will analyze your project and generate actionable suggestions. Choose what type
of analysis you want to perform:
Our AI will analyze your project and generate actionable suggestions.
Choose what type of analysis you want to perform:
</p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{(
Object.entries(suggestionTypeConfig) as [
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">
{config.label.replace(' Suggestions', '')}
{(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<Button
key={type}
variant="outline"
className="h-auto py-6 px-6 flex flex-col items-center gap-3 hover:border-primary/50 transition-colors"
onClick={() => handleGenerate(type)}
data-testid={`generate-${type}-btn`}
>
<Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center">
<div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
<div className="text-xs text-muted-foreground mt-1">
{config.description}
</div>
</div>
<div className="text-xs text-muted-foreground mt-1">{config.description}</div>
</div>
</Button>
);
})}
</Button>
);
}
)}
</div>
</div>
) : isGenerating ? (
@@ -382,7 +370,7 @@ export function FeatureSuggestionsDialog({
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
>
<div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')}
{progress.join("")}
</div>
</div>
</div>
@@ -395,10 +383,14 @@ export function FeatureSuggestionsDialog({
{suggestions.length} suggestions generated
</span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'}
{selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
</Button>
</div>
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
</div>
<div
@@ -414,8 +406,8 @@ export function FeatureSuggestionsDialog({
key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
data-testid={`suggestion-${suggestion.id}`}
>
@@ -452,9 +444,25 @@ export function FeatureSuggestionsDialog({
{suggestion.description}
</Label>
{isExpanded && suggestion.reasoning && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p>
{isExpanded && (
<div className="mt-3 space-y-2 text-sm">
{suggestion.reasoning && (
<p className="text-muted-foreground italic">
{suggestion.reasoning}
</p>
)}
{suggestion.steps.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Implementation Steps:
</p>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-0.5">
{suggestion.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
@@ -505,7 +513,7 @@ export function FeatureSuggestionsDialog({
<HotkeyButton
onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && hasSuggestions}
>
{isImporting ? (
@@ -514,7 +522,7 @@ export function FeatureSuggestionsDialog({
<Download className="w-4 h-4 mr-2" />
)}
Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? 's' : ''}
{selectedIds.size !== 1 ? "s" : ""}
</HotkeyButton>
</div>
</div>

View File

@@ -1,20 +1,29 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useAppStore, Feature } from '@/store/app-store';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { Markdown } from '@/components/ui/markdown';
import { useFileBrowser } from '@/contexts/file-browser-context';
import { toast } from 'sonner';
import { useNavigate } from '@tanstack/react-router';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useAppStore, Feature } from "@/store/app-store";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Bot,
Send,
User,
Loader2,
Sparkles,
FileText,
ArrowLeft,
CheckCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
import { toast } from "sonner";
interface InterviewMessage {
id: string;
role: 'user' | 'assistant';
role: "user" | "assistant";
content: string;
timestamp: Date;
}
@@ -30,90 +39,61 @@ interface InterviewState {
// Interview questions flow
const INTERVIEW_QUESTIONS = [
{
id: 'project-description',
question: 'What do you want to build?',
hint: 'Describe your project idea in a few sentences',
field: 'projectDescription' as const,
id: "project-description",
question: "What do you want to build?",
hint: "Describe your project idea in a few sentences",
field: "projectDescription" as const,
},
{
id: 'tech-stack',
question: 'What tech stack would you like to use?',
hint: 'e.g., React, Next.js, Node.js, Python, etc.',
field: 'techStack' as const,
id: "tech-stack",
question: "What tech stack would you like to use?",
hint: "e.g., React, Next.js, Node.js, Python, etc.",
field: "techStack" as const,
},
{
id: 'core-features',
question: 'What are the core features you want to include?',
hint: 'List the main functionalities your app should have',
field: 'features' as const,
id: "core-features",
question: "What are the core features you want to include?",
hint: "List the main functionalities your app should have",
field: "features" as const,
},
{
id: 'additional',
question: 'Any additional requirements or preferences?',
hint: 'Design preferences, integrations, deployment needs, etc.',
field: 'additionalNotes' as const,
id: "additional",
question: "Any additional requirements or preferences?",
hint: "Design preferences, integrations, deployment needs, etc.",
field: "additionalNotes" as const,
},
];
export function InterviewView() {
const { addProject, setCurrentProject, setAppSpec } = useAppStore();
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
useAppStore();
const { openFileBrowser } = useFileBrowser();
const navigate = useNavigate();
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [interviewData, setInterviewData] = useState<InterviewState>({
projectName: '',
projectDescription: '',
projectName: "",
projectDescription: "",
techStack: [],
features: [],
additionalNotes: '',
additionalNotes: "",
});
const [isGenerating, setIsGenerating] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [generatedSpec, setGeneratedSpec] = useState<string | null>(null);
const [projectPath, setProjectPath] = useState('');
const [projectName, setProjectName] = useState('');
const [projectPath, setProjectPath] = useState("");
const [projectName, setProjectName] = useState("");
const [showProjectSetup, setShowProjectSetup] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Default parent directory using workspace config utility
useEffect(() => {
if (projectPath) return;
let isMounted = true;
const loadWorkspaceDir = async () => {
try {
const defaultDir = await getDefaultWorkspaceDirectory();
if (!isMounted || projectPath) {
return;
}
if (defaultDir) {
setProjectPath(defaultDir);
}
} catch (error) {
console.error('Failed to load default workspace directory:', error);
}
};
loadWorkspaceDir();
return () => {
isMounted = false;
};
}, [projectPath]);
// Initialize with first question
useEffect(() => {
if (messages.length === 0) {
const welcomeMessage: InterviewMessage = {
id: 'welcome',
role: 'assistant',
id: "welcome",
role: "assistant",
content: `Hello! I'm here to help you plan your new project. Let's go through a few questions to understand what you want to build.\n\n**${INTERVIEW_QUESTIONS[0].question}**\n\n_${INTERVIEW_QUESTIONS[0].hint}_`,
timestamp: new Date(),
};
@@ -130,7 +110,7 @@ export function InterviewView() {
if (messagesContainerRef.current) {
messagesContainerRef.current.scrollTo({
top: messagesContainerRef.current.scrollHeight,
behavior: 'smooth',
behavior: "smooth",
});
}
}, 100);
@@ -154,7 +134,7 @@ export function InterviewView() {
const userMessage: InterviewMessage = {
id: `user-${Date.now()}`,
role: 'user',
role: "user",
content: input,
timestamp: new Date(),
};
@@ -166,20 +146,25 @@ export function InterviewView() {
if (currentQuestion) {
setInterviewData((prev) => {
const newData = { ...prev };
if (currentQuestion.field === 'techStack' || currentQuestion.field === 'features') {
if (
currentQuestion.field === "techStack" ||
currentQuestion.field === "features"
) {
// Parse comma-separated values into array
newData[currentQuestion.field] = input
.split(',')
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
(newData as Record<string, string | string[]>)[currentQuestion.field] = input;
(newData as Record<string, string | string[]>)[
currentQuestion.field
] = input;
}
return newData;
});
}
setInput('');
setInput("");
// Move to next question or complete
const nextIndex = currentQuestionIndex + 1;
@@ -189,7 +174,7 @@ export function InterviewView() {
const nextQuestion = INTERVIEW_QUESTIONS[nextIndex];
const assistantMessage: InterviewMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
role: "assistant",
content: `Great! **${nextQuestion.question}**\n\n_${nextQuestion.hint}_`,
timestamp: new Date(),
};
@@ -199,30 +184,34 @@ export function InterviewView() {
// All questions answered - generate spec
const summaryMessage: InterviewMessage = {
id: `assistant-summary-${Date.now()}`,
role: 'assistant',
role: "assistant",
content:
'Perfect! I have all the information I need. Now let me generate your project specification...',
"Perfect! I have all the information I need. Now let me generate your project specification...",
timestamp: new Date(),
};
setMessages((prev) => [...prev, summaryMessage]);
generateSpec({
...interviewData,
projectDescription: currentQuestionIndex === 0 ? input : interviewData.projectDescription,
projectDescription:
currentQuestionIndex === 0
? input
: interviewData.projectDescription,
techStack:
currentQuestionIndex === 1
? input
.split(',')
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.techStack,
features:
currentQuestionIndex === 2
? input
.split(',')
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: interviewData.features,
additionalNotes: currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
additionalNotes:
currentQuestionIndex === 3 ? input : interviewData.additionalNotes,
});
}
}, 500);
@@ -244,7 +233,7 @@ export function InterviewView() {
const completionMessage: InterviewMessage = {
id: `assistant-complete-${Date.now()}`,
role: 'assistant',
role: "assistant",
content: `I've generated a draft project specification based on our conversation!\n\nPlease provide a project name and choose where to save your project, then click "Create Project" to get started.`,
timestamp: new Date(),
};
@@ -253,15 +242,15 @@ export function InterviewView() {
const generateAppSpec = (data: InterviewState): string => {
const projectName = data.projectDescription
.split(' ')
.split(" ")
.slice(0, 3)
.join('-')
.join("-")
.toLowerCase()
.replace(/[^a-z0-9-]/g, '');
.replace(/[^a-z0-9-]/g, "");
// Note: Must follow XML format as defined in apps/server/src/lib/app-spec-format.ts
return `<project_specification>
<project_name>${projectName || 'my-project'}</project_name>
<project_name>${projectName || "my-project"}</project_name>
<overview>
${data.projectDescription}
@@ -270,21 +259,25 @@ export function InterviewView() {
<technology_stack>
${
data.techStack.length > 0
? data.techStack.map((tech) => `<technology>${tech}</technology>`).join('\n ')
: '<!-- Define your tech stack -->'
? data.techStack
.map((tech) => `<technology>${tech}</technology>`)
.join("\n ")
: "<!-- Define your tech stack -->"
}
</technology_stack>
<core_capabilities>
${
data.features.length > 0
? data.features.map((feature) => `<capability>${feature}</capability>`).join('\n ')
: '<!-- List core features -->'
? data.features
.map((feature) => `<capability>${feature}</capability>`)
.join("\n ")
: "<!-- List core features -->"
}
</core_capabilities>
<additional_requirements>
${data.additionalNotes || 'None specified'}
${data.additionalNotes || "None specified"}
</additional_requirements>
<development_guidelines>
@@ -298,14 +291,13 @@ export function InterviewView() {
const handleSelectDirectory = async () => {
const selectedPath = await openFileBrowser({
title: 'Select Base Directory',
description: 'Choose the parent directory where your new project will be created',
initialPath: projectPath || undefined,
title: "Select Base Directory",
description:
"Choose the parent directory where your new project will be created",
});
if (selectedPath) {
setProjectPath(selectedPath);
saveLastProjectDirectory(selectedPath);
}
};
@@ -315,41 +307,48 @@ export function InterviewView() {
setIsGenerating(true);
try {
saveLastProjectDirectory(projectPath);
const api = getElectronAPI();
// Use platform-specific path separator
const pathSep =
typeof window !== 'undefined' && (window as any).electronAPI
? navigator.platform.indexOf('Win') !== -1
? '\\'
: '/'
: '/';
typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf("Win") !== -1
? "\\"
: "/"
: "/";
const fullProjectPath = `${projectPath}${pathSep}${projectName}`;
// Create project directory
const mkdirResult = await api.mkdir(fullProjectPath);
if (!mkdirResult.success) {
toast.error('Failed to create project directory', {
description: mkdirResult.error || 'Unknown error occurred',
toast.error("Failed to create project directory", {
description: mkdirResult.error || "Unknown error occurred",
});
setIsGenerating(false);
return;
}
// Write app_spec.txt with generated content
await api.writeFile(`${fullProjectPath}/.automaker/app_spec.txt`, generatedSpec);
await api.writeFile(
`${fullProjectPath}/.automaker/app_spec.txt`,
generatedSpec
);
// Create initial feature in the features folder
const initialFeature: Feature = {
id: crypto.randomUUID(),
category: 'Core',
description: 'Initial project setup',
status: 'backlog' as const,
category: "Core",
description: "Initial project setup",
status: "backlog" as const,
steps: [
"Step 1: Review app_spec.txt",
"Step 2: Set up development environment",
"Step 3: Start implementing features",
],
skipTests: true,
};
if (!api.features) {
throw new Error('Features API not available');
throw new Error("Features API not available");
}
await api.features.create(fullProjectPath, initialFeature);
@@ -367,24 +366,27 @@ export function InterviewView() {
addProject(project);
setCurrentProject(project);
} catch (error) {
console.error('Failed to create project:', error);
console.error("Failed to create project:", error);
setIsGenerating(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleGoBack = () => {
navigate({ to: '/' });
setCurrentView("welcome");
};
return (
<div className="flex-1 flex flex-col content-bg min-h-0" data-testid="interview-view">
<div
className="flex-1 flex flex-col content-bg min-h-0"
data-testid="interview-view"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3">
@@ -402,8 +404,10 @@ export function InterviewView() {
<h1 className="text-xl font-bold">New Project Interview</h1>
<p className="text-sm text-muted-foreground">
{isComplete
? 'Specification generated!'
: `Question ${currentQuestionIndex + 1} of ${INTERVIEW_QUESTIONS.length}`}
? "Specification generated!"
: `Question ${currentQuestionIndex + 1} of ${
INTERVIEW_QUESTIONS.length
}`}
</p>
</div>
</div>
@@ -414,16 +418,18 @@ export function InterviewView() {
<div
key={index}
className={cn(
'w-2 h-2 rounded-full transition-colors',
"w-2 h-2 rounded-full transition-colors",
index < currentQuestionIndex
? 'bg-green-500'
? "bg-green-500"
: index === currentQuestionIndex
? 'bg-primary'
: 'bg-zinc-700'
? "bg-primary"
: "bg-zinc-700"
)}
/>
))}
{isComplete && <CheckCircle className="w-4 h-4 text-green-500 ml-2" />}
{isComplete && (
<CheckCircle className="w-4 h-4 text-green-500 ml-2" />
)}
</div>
</div>
@@ -436,15 +442,18 @@ export function InterviewView() {
{messages.map((message) => (
<div
key={message.id}
className={cn('flex gap-3', message.role === 'user' && 'flex-row-reverse')}
className={cn(
"flex gap-3",
message.role === "user" && "flex-row-reverse"
)}
>
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center shrink-0',
message.role === 'assistant' ? 'bg-primary/10' : 'bg-muted'
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
)}
>
{message.role === 'assistant' ? (
{message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" />
) : (
<User className="w-4 h-4" />
@@ -452,24 +461,28 @@ export function InterviewView() {
</div>
<Card
className={cn(
'max-w-[80%]',
message.role === 'user'
? 'bg-transparent border border-primary text-foreground'
: 'border border-primary/30 bg-card'
"max-w-[80%]",
message.role === "user"
? "bg-transparent border border-primary text-foreground"
: "border-l-4 border-primary bg-card"
)}
>
<CardContent className="px-3 py-2">
{message.role === 'assistant' ? (
{message.role === "assistant" ? (
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
{message.content}
</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
'text-xs mt-1',
message.role === 'user' ? 'text-muted-foreground' : 'text-primary/70'
"text-xs mt-1",
message.role === "user"
? "text-muted-foreground"
: "text-primary/70"
)}
>
{message.timestamp.toLocaleTimeString()}
@@ -484,11 +497,13 @@ export function InterviewView() {
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="w-4 h-4 text-primary" />
</div>
<Card className="border border-primary/30 bg-card">
<Card className="border-l-4 border-primary bg-card">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-primary">Generating specification...</span>
<span className="text-sm text-primary">
Generating specification...
</span>
</div>
</CardContent>
</Card>
@@ -498,7 +513,10 @@ export function InterviewView() {
{/* Project Setup Form */}
{showProjectSetup && (
<div className="mt-6">
<Card className="bg-zinc-900/50 border-white/10" data-testid="project-setup-form">
<Card
className="bg-zinc-900/50 border-white/10"
data-testid="project-setup-form"
>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-5 h-5 text-primary" />
@@ -507,7 +525,10 @@ export function InterviewView() {
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="project-name" className="text-sm font-medium text-zinc-300">
<label
htmlFor="project-name"
className="text-sm font-medium text-zinc-300"
>
Project Name
</label>
<Input
@@ -521,7 +542,10 @@ export function InterviewView() {
</div>
<div className="space-y-2">
<label htmlFor="project-path" className="text-sm font-medium text-zinc-300">
<label
htmlFor="project-path"
className="text-sm font-medium text-zinc-300"
>
Parent Directory
</label>
<div className="flex gap-2">

File diff suppressed because it is too large Load Diff

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