Compare commits

..

1 Commits

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

View File

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

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 {
const fs = require('fs'); S3Client,
const path = require('path'); PutObjectCommand,
const https = require('https'); GetObjectCommand,
const { pipeline } = require('stream/promises'); } = 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({ const s3Client = new S3Client({
region: 'auto', region: "auto",
endpoint: process.env.R2_ENDPOINT, endpoint: process.env.R2_ENDPOINT,
credentials: { credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID, accessKeyId: process.env.R2_ACCESS_KEY_ID,
@@ -24,14 +28,14 @@ async function fetchExistingReleases() {
const response = await s3Client.send( const response = await s3Client.send(
new GetObjectCommand({ new GetObjectCommand({
Bucket: BUCKET, Bucket: BUCKET,
Key: 'releases.json', Key: "releases.json",
}) })
); );
const body = await response.Body.transformToString(); const body = await response.Body.transformToString();
return JSON.parse(body); return JSON.parse(body);
} catch (error) { } catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
console.log('No existing releases.json found, creating new one'); console.log("No existing releases.json found, creating new one");
return { latestVersion: null, releases: [] }; return { latestVersion: null, releases: [] };
} }
throw error; throw error;
@@ -81,7 +85,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
resolve({ resolve({
accessible: false, accessible: false,
statusCode, statusCode,
error: 'Redirect without location header', error: "Redirect without location header",
}); });
return; return;
} }
@@ -89,16 +93,18 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
return https return https
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => { .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
const redirectStatus = redirectResponse.statusCode; 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 // Check if it's actually a file (zip/tar.gz) and not HTML
const isFile = const isFile =
contentType.includes('application/zip') || contentType.includes("application/zip") ||
contentType.includes('application/gzip') || contentType.includes("application/gzip") ||
contentType.includes('application/x-gzip') || contentType.includes("application/x-gzip") ||
contentType.includes('application/x-tar') || contentType.includes("application/x-tar") ||
redirectUrl.includes('.zip') || redirectUrl.includes(".zip") ||
redirectUrl.includes('.tar.gz'); redirectUrl.includes(".tar.gz");
const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile; const isGood =
redirectStatus >= 200 && redirectStatus < 300 && isFile;
redirectResponse.destroy(); redirectResponse.destroy();
resolve({ resolve({
accessible: isGood, accessible: isGood,
@@ -107,38 +113,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
contentType, contentType,
}); });
}) })
.on('error', (error) => { .on("error", (error) => {
resolve({ resolve({
accessible: false, accessible: false,
statusCode, statusCode,
error: error.message, error: error.message,
}); });
}) })
.on('timeout', function () { .on("timeout", function () {
this.destroy(); this.destroy();
resolve({ resolve({
accessible: false, accessible: false,
statusCode, statusCode,
error: 'Timeout following redirect', error: "Timeout following redirect",
}); });
}); });
} }
// Check if status is good (200-299 range) and it's actually a file // 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 = const isFile =
contentType.includes('application/zip') || contentType.includes("application/zip") ||
contentType.includes('application/gzip') || contentType.includes("application/gzip") ||
contentType.includes('application/x-gzip') || contentType.includes("application/x-gzip") ||
contentType.includes('application/x-tar') || contentType.includes("application/x-tar") ||
url.includes('.zip') || url.includes(".zip") ||
url.includes('.tar.gz'); url.includes(".tar.gz");
const isGood = statusCode >= 200 && statusCode < 300 && isFile; const isGood = statusCode >= 200 && statusCode < 300 && isFile;
response.destroy(); response.destroy();
resolve({ accessible: isGood, statusCode, contentType }); resolve({ accessible: isGood, statusCode, contentType });
}); });
request.on('error', (error) => { request.on("error", (error) => {
resolve({ resolve({
accessible: false, accessible: false,
statusCode: null, statusCode: null,
@@ -146,12 +152,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
}); });
}); });
request.on('timeout', () => { request.on("timeout", () => {
request.destroy(); request.destroy();
resolve({ resolve({
accessible: false, accessible: false,
statusCode: null, 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})` `✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
); );
} else { } 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 return result.finalUrl || url; // Return the final URL (after redirects) if available
} else { } else {
const errorMsg = result.error ? ` - ${result.error}` : ''; const errorMsg = result.error ? ` - ${result.error}` : "";
const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : ''; const statusMsg = result.statusCode
const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : ''; ? ` (status: ${result.statusCode})`
console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`); : "";
const contentTypeMsg = result.contentType
? ` [content-type: ${result.contentType}]`
: "";
console.log(
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
);
} }
} catch (error) { } catch (error) {
console.log(`✗ URL ${url} check failed: ${error.message}`); console.log(`✗ URL ${url} check failed: ${error.message}`);
@@ -177,7 +191,9 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, attempt); 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)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
@@ -191,7 +207,12 @@ async function downloadFromGitHub(url, outputPath) {
const statusCode = response.statusCode; const statusCode = response.statusCode;
// Follow redirects (all redirect types) // 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; const redirectUrl = response.headers.location;
response.destroy(); response.destroy();
if (!redirectUrl) { if (!redirectUrl) {
@@ -199,33 +220,39 @@ async function downloadFromGitHub(url, outputPath) {
return; return;
} }
// Resolve relative redirects // Resolve relative redirects
const finalRedirectUrl = redirectUrl.startsWith('http') const finalRedirectUrl = redirectUrl.startsWith("http")
? redirectUrl ? redirectUrl
: new URL(redirectUrl, url).href; : new URL(redirectUrl, url).href;
console.log(` Following redirect: ${finalRedirectUrl}`); console.log(` Following redirect: ${finalRedirectUrl}`);
return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject); return downloadFromGitHub(finalRedirectUrl, outputPath)
.then(resolve)
.catch(reject);
} }
if (statusCode !== 200) { if (statusCode !== 200) {
response.destroy(); response.destroy();
reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`)); reject(
new Error(
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
)
);
return; return;
} }
const fileStream = fs.createWriteStream(outputPath); const fileStream = fs.createWriteStream(outputPath);
response.pipe(fileStream); response.pipe(fileStream);
fileStream.on('finish', () => { fileStream.on("finish", () => {
fileStream.close(); fileStream.close();
resolve(); resolve();
}); });
fileStream.on('error', (error) => { fileStream.on("error", (error) => {
response.destroy(); response.destroy();
reject(error); reject(error);
}); });
}); });
request.on('error', reject); request.on("error", reject);
request.on('timeout', () => { request.on("timeout", () => {
request.destroy(); request.destroy();
reject(new Error(`Request timeout for ${url}`)); reject(new Error(`Request timeout for ${url}`));
}); });
@@ -233,8 +260,8 @@ async function downloadFromGitHub(url, outputPath) {
} }
async function main() { async function main() {
const artifactsDir = 'artifacts'; const artifactsDir = "artifacts";
const tempDir = path.join(artifactsDir, 'temp'); const tempDir = path.join(artifactsDir, "temp");
// Create temp directory for downloaded GitHub archives // Create temp directory for downloaded GitHub archives
if (!fs.existsSync(tempDir)) { if (!fs.existsSync(tempDir)) {
@@ -265,30 +292,40 @@ async function main() {
// Find all artifacts // Find all artifacts
const artifacts = { const artifacts = {
windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/), windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/), macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/), macosArm: findArtifacts(
linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/), path.join(artifactsDir, "macos-builds"),
/-arm64\.dmg$/
),
linux: findArtifacts(
path.join(artifactsDir, "linux-builds"),
/\.AppImage$/
),
sourceZip: [sourceZipPath], sourceZip: [sourceZipPath],
sourceTarGz: [sourceTarGzPath], sourceTarGz: [sourceTarGzPath],
}; };
console.log('Found artifacts:'); console.log("Found artifacts:");
for (const [platform, files] of Object.entries(artifacts)) { for (const [platform, files] of Object.entries(artifacts)) {
console.log( 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 // Upload each artifact to R2
const assets = {}; const assets = {};
const contentTypes = { const contentTypes = {
windows: 'application/x-msdownload', windows: "application/x-msdownload",
macos: 'application/x-apple-diskimage', macos: "application/x-apple-diskimage",
macosArm: 'application/x-apple-diskimage', macosArm: "application/x-apple-diskimage",
linux: 'application/x-executable', linux: "application/x-executable",
sourceZip: 'application/zip', sourceZip: "application/zip",
sourceTarGz: 'application/gzip', sourceTarGz: "application/gzip",
}; };
for (const [platform, files] of Object.entries(artifacts)) { for (const [platform, files] of Object.entries(artifacts)) {
@@ -308,11 +345,11 @@ async function main() {
filename, filename,
size, size,
arch: arch:
platform === 'macosArm' platform === "macosArm"
? 'arm64' ? "arm64"
: platform === 'sourceZip' || platform === 'sourceTarGz' : platform === "sourceZip" || platform === "sourceTarGz"
? 'source' ? "source"
: 'x64', : "x64",
}; };
} }
@@ -327,7 +364,9 @@ async function main() {
}; };
// Remove existing entry for this version if re-running // 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 // Prepend new release
releasesData.releases.unshift(newRelease); releasesData.releases.unshift(newRelease);
@@ -337,19 +376,19 @@ async function main() {
await s3Client.send( await s3Client.send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: BUCKET, Bucket: BUCKET,
Key: 'releases.json', Key: "releases.json",
Body: JSON.stringify(releasesData, null, 2), Body: JSON.stringify(releasesData, null, 2),
ContentType: 'application/json', ContentType: "application/json",
CacheControl: 'public, max-age=60', CacheControl: "public, max-age=60",
}) })
); );
console.log('Successfully updated releases.json'); console.log("Successfully updated releases.json");
console.log(`Latest version: ${VERSION}`); console.log(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`); console.log(`Total releases: ${releasesData.releases.length}`);
} }
main().catch((err) => { main().catch((err) => {
console.error('Failed to upload to R2:', err); console.error("Failed to upload to R2:", err);
process.exit(1); 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: on:
pull_request: pull_request:
branches: branches:
- '*' - "*"
push: push:
branches: branches:
- main - main
@@ -18,15 +18,34 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup project - name: Setup Node.js
uses: ./.github/actions/setup-project uses: actions/setup-node@v4
with: with:
check-lockfile: 'true' node-version: "22"
rebuild-node-pty-path: 'apps/server' 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 - name: Install Playwright browsers
run: npx playwright install --with-deps chromium run: npx playwright install --with-deps chromium
working-directory: apps/ui working-directory: apps/app
- name: Build server - name: Build server
run: npm run build --workspace=apps/server run: npm run build --workspace=apps/server
@@ -52,20 +71,20 @@ jobs:
exit 1 exit 1
- name: Run E2E tests - name: Run E2E tests
# Playwright automatically starts the Vite frontend via webServer config # Playwright automatically starts the Next.js frontend via webServer config
# (see apps/ui/playwright.config.ts) - no need to start it manually # (see apps/app/playwright.config.ts) - no need to start it manually
run: npm run test --workspace=apps/ui run: npm run test --workspace=apps/app
env: env:
CI: true CI: true
VITE_SERVER_URL: http://localhost:3008 NEXT_PUBLIC_SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true' NEXT_PUBLIC_SKIP_SETUP: "true"
- name: Upload Playwright report - name: Upload Playwright report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: apps/ui/playwright-report/ path: apps/app/playwright-report/
retention-days: 7 retention-days: 7
- name: Upload test results - name: Upload test results
@@ -73,5 +92,5 @@ jobs:
if: failure() if: failure()
with: with:
name: test-results name: test-results
path: apps/ui/test-results/ path: apps/app/test-results/
retention-days: 7 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: on:
pull_request: pull_request:
branches: branches:
- '*' - "*"
push: push:
branches: branches:
- main - main
@@ -17,10 +17,30 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup project - name: Setup Node.js
uses: ./.github/actions/setup-project uses: actions/setup-node@v4
with: with:
check-lockfile: 'true' node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json
- name: Run build:electron (dir only - faster CI) - name: Configure Git for HTTPS
run: npm run build:electron:dir # 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: on:
release: push:
types: [published] 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: jobs:
build: build-and-release:
strategy: strategy:
fail-fast: false
matrix: 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 }} runs-on: ${{ matrix.os }}
permissions:
contents: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 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 id: version
shell: bash shell: bash
run: | run: |
# Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3") VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
VERSION="${{ github.event.release.tag_name }}" # Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION#v}" VERSION="${VERSION_TAG#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: ${VERSION}" 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 - name: Build Electron App (macOS)
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)
if: matrix.os == 'macos-latest' if: matrix.os == 'macos-latest'
shell: bash
run: npm run build:electron:mac --workspace=apps/ui
env: 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' if: matrix.os == 'windows-latest'
shell: bash env:
run: npm run build:electron:win --workspace=apps/ui 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' if: matrix.os == 'ubuntu-latest'
shell: bash env:
run: npm run build:electron:linux --workspace=apps/ui GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build:electron -- --linux --x64
- name: Upload macOS artifacts - name: Upload Release Assets
if: matrix.os == 'macos-latest' uses: softprops/action-gh-release@v1
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
with: with:
tag_name: ${{ github.event.inputs.version || github.ref_name }}
files: | files: |
artifacts/macos-builds/* apps/app/dist/*.exe
artifacts/windows-builds/* apps/app/dist/*.dmg
artifacts/linux-builds/* apps/app/dist/*.AppImage
apps/app/dist/*.zip
apps/app/dist/*.deb
apps/app/dist/*.rpm
draft: false
prerelease: false
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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: on:
pull_request: pull_request:
branches: branches:
- '*' - "*"
push: push:
branches: branches:
- main - main
@@ -17,16 +17,30 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup project - name: Setup Node.js
uses: ./.github/actions/setup-project uses: actions/setup-node@v4
with: with:
check-lockfile: 'true' node-version: "22"
rebuild-node-pty-path: 'apps/server' cache: "npm"
cache-dependency-path: package-lock.json
- name: Run package tests - name: Configure Git for HTTPS
run: npm run test:packages # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
env: # This is needed because SSH authentication isn't available in CI
NODE_ENV: test 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 - name: Run server tests with coverage
run: npm run test:server:coverage run: npm run test:server:coverage

68
.gitignore vendored
View File

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

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 - **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 - **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 ### 3. Limit Access
If you must run locally: If you must run locally:

423
README.md
View File

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

BIN
apps/.DS_Store vendored Normal file

Binary file not shown.

View File

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

View File

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

View File

@@ -7,28 +7,24 @@ The Automaker Agent Chat now supports multiple concurrent sessions, allowing you
## Features ## Features
### ✨ Multiple Sessions ### ✨ Multiple Sessions
- Create unlimited agent sessions per project - Create unlimited agent sessions per project
- Each session has its own conversation history - Each session has its own conversation history
- Switch between sessions instantly - Switch between sessions instantly
- Sessions persist across app restarts - Sessions persist across app restarts
### 📋 Session Organization ### 📋 Session Organization
- Custom names for easy identification - Custom names for easy identification
- Last message preview - Last message preview
- Message count tracking - Message count tracking
- Sort by most recently updated - Sort by most recently updated
### 🗄️ Archive & Delete ### 🗄️ Archive & Delete
- Archive old sessions to declutter - Archive old sessions to declutter
- Unarchive when needed - Unarchive when needed
- Permanently delete sessions - Permanently delete sessions
- Confirm before destructive actions - Confirm before destructive actions
### 💾 Automatic Persistence ### 💾 Automatic Persistence
- All sessions auto-save to disk - All sessions auto-save to disk
- Survive Next.js restarts - Survive Next.js restarts
- Survive Electron app 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 4. The new session is immediately active
**Example session names:** **Example session names:**
- "Feature: Dark Mode" - "Feature: Dark Mode"
- "Bug: Login redirect" - "Bug: Login redirect"
- "Refactor: API layer" - "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 3. Toggle **"Show Archived"** to view archived sessions
**When to archive:** **When to archive:**
- Completed features - Completed features
- Resolved bugs - Resolved bugs
- Old experiments - 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: Sessions are stored in your user data directory:
**macOS:** **macOS:**
``` ```
~/Library/Application Support/automaker/agent-sessions/ ~/Library/Application Support/automaker/agent-sessions/
``` ```
**Windows:** **Windows:**
``` ```
%APPDATA%/automaker/agent-sessions/ %APPDATA%/automaker/agent-sessions/
``` ```
**Linux:** **Linux:**
``` ```
~/.config/automaker/agent-sessions/ ~/.config/automaker/agent-sessions/
``` ```
@@ -224,14 +215,12 @@ Use prefixes to organize sessions by type:
### When to Create Multiple Sessions ### When to Create Multiple Sessions
**Do create separate sessions for:** **Do create separate sessions for:**
- ✅ Different features - ✅ Different features
- ✅ Unrelated bugs - ✅ Unrelated bugs
- ✅ Experimental work - ✅ Experimental work
- ✅ Different contexts or approaches - ✅ Different contexts or approaches
**Don't create separate sessions for:** **Don't create separate sessions for:**
- ❌ Same feature, different iterations - ❌ Same feature, different iterations
- ❌ Related bug fixes - ❌ Related bug fixes
- ❌ Continuation of previous work - ❌ Continuation of previous work
@@ -283,7 +272,7 @@ Use prefixes to organize sessions by type:
## Keyboard Shortcuts ## Keyboard Shortcuts
_(Coming soon)_ *(Coming soon)*
- `Cmd/Ctrl + K` - Create new session - `Cmd/Ctrl + K` - Create new session
- `Cmd/Ctrl + [` - Previous session - `Cmd/Ctrl + [` - Previous session
@@ -295,13 +284,11 @@ _(Coming soon)_
### Session Not Saving ### Session Not Saving
**Check:** **Check:**
- Electron has write permissions - Electron has write permissions
- Disk space available - Disk space available
- Check Electron console for errors - Check Electron console for errors
**Solution:** **Solution:**
```bash ```bash
# macOS - Check permissions # macOS - Check permissions
ls -la ~/Library/Application\ Support/automaker/ ls -la ~/Library/Application\ Support/automaker/
@@ -313,13 +300,11 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
### Can't Switch Sessions ### Can't Switch Sessions
**Check:** **Check:**
- Session is not archived - Session is not archived
- No errors in console - No errors in console
- Agent is not currently processing - Agent is not currently processing
**Solution:** **Solution:**
- Wait for current message to complete - Wait for current message to complete
- Check for error messages - Check for error messages
- Try clearing and reloading - Try clearing and reloading
@@ -327,13 +312,11 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
### Session Disappeared ### Session Disappeared
**Check:** **Check:**
- Not filtered by archive status - Not filtered by archive status
- Not accidentally deleted - Not accidentally deleted
- Check backup files - Check backup files
**Recovery:** **Recovery:**
- Toggle "Show Archived" - Toggle "Show Archived"
- Check filesystem for `.json` files - Check filesystem for `.json` files
- Restore from backup if available - Restore from backup if available
@@ -343,17 +326,15 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
For developers integrating session management: For developers integrating session management:
### Create Session ### Create Session
```typescript ```typescript
const result = await window.electronAPI.sessions.create( const result = await window.electronAPI.sessions.create(
'Session Name', "Session Name",
'/project/path', "/project/path",
'/working/directory' "/working/directory"
); );
``` ```
### List Sessions ### List Sessions
```typescript ```typescript
const { sessions } = await window.electronAPI.sessions.list( const { sessions } = await window.electronAPI.sessions.list(
false // includeArchived false // includeArchived
@@ -361,20 +342,21 @@ const { sessions } = await window.electronAPI.sessions.list(
``` ```
### Update Session ### Update Session
```typescript ```typescript
await window.electronAPI.sessions.update(sessionId, 'New Name', ['tag1', 'tag2']); await window.electronAPI.sessions.update(
sessionId,
"New Name",
["tag1", "tag2"]
);
``` ```
### Archive/Unarchive ### Archive/Unarchive
```typescript ```typescript
await window.electronAPI.sessions.archive(sessionId); await window.electronAPI.sessions.archive(sessionId);
await window.electronAPI.sessions.unarchive(sessionId); await window.electronAPI.sessions.unarchive(sessionId);
``` ```
### Delete Session ### Delete Session
```typescript ```typescript
await window.electronAPI.sessions.delete(sessionId); 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", "version": "0.1.0",
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
"homepage": "https://github.com/AutoMaker-Org/automaker", "homepage": "https://github.com/AutoMaker-Org/automaker",
@@ -7,36 +7,33 @@
"type": "git", "type": "git",
"url": "https://github.com/AutoMaker-Org/automaker.git" "url": "https://github.com/AutoMaker-Org/automaker.git"
}, },
"author": "AutoMaker Team", "author": {
"license": "SEE LICENSE IN LICENSE", "name": "Cody Seibert",
"email": "webdevcody@gmail.com"
},
"private": true, "private": true,
"main": "dist-electron/main.js", "license": "Unlicense",
"main": "electron/main.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "next dev -p 3007",
"dev:web": "cross-env VITE_SKIP_ELECTRON=true vite", "dev:web": "next dev -p 3007",
"dev:electron": "vite", "dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
"dev:electron:debug": "cross-env OPEN_DEVTOOLS=true vite", "dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
"build": "vite build", "build": "next build",
"build:electron": "node scripts/prepare-server.mjs && vite build && electron-builder", "build:electron": "node scripts/prepare-server.js && next build && electron-builder",
"build:electron:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --dir", "build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
"build:electron:win": "node scripts/prepare-server.mjs && vite build && electron-builder --win", "build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
"build:electron:win:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --win --dir", "build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
"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",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"preview": "vite preview", "start": "next start",
"lint": "eslint", "lint": "eslint",
"pretest": "node scripts/setup-e2e-fixtures.mjs", "pretest": "node scripts/setup-e2e-fixtures.js",
"test": "playwright test", "test": "playwright test",
"test:headed": "playwright test --headed", "test:headed": "playwright test --headed",
"dev:electron:wsl": "cross-env vite", "dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
"dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite" "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": { "dependencies": {
"@automaker/dependency-resolver": "^1.0.0",
"@automaker/types": "^1.0.0",
"@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-xml": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -44,42 +41,32 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@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-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12", "@tanstack/react-query": "^5.90.12",
"@tanstack/react-router": "^1.141.6",
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-fit": "^0.10.0", "@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/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"dagre": "^0.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"geist": "^1.5.1", "geist": "^1.5.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.556.0",
"react": "19.2.3", "next": "^16.0.10",
"react-dom": "19.2.3", "react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"rehype-raw": "^7.0.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"usehooks-ts": "^3.1.1",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -95,40 +82,32 @@
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^4.0.2", "@electron/rebuild": "^4.0.2",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/postcss": "^4",
"@tanstack/router-plugin": "^1.141.7", "@types/node": "^20",
"@types/dagre": "^0.7.53", "@types/react": "^19",
"@types/node": "^22", "@types/react-dom": "^19",
"@types/react": "^19.2.7", "concurrently": "^9.2.1",
"@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",
"electron": "39.2.7", "electron": "39.2.7",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"eslint": "^9.39.2", "eslint": "^9",
"tailwindcss": "^4.1.18", "eslint-config-next": "16.0.7",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "^7.3.0", "wait-on": "^9.0.3"
"vite-plugin-electron": "^0.29.0",
"vite-plugin-electron-renderer": "^0.14.6"
}, },
"build": { "build": {
"appId": "com.automaker.app", "appId": "com.automaker.app",
"productName": "Automaker", "productName": "Automaker",
"artifactName": "${productName}-${version}-${arch}.${ext}", "artifactName": "${productName}-${version}-${arch}.${ext}",
"npmRebuild": false, "afterPack": "./scripts/rebuild-server-natives.js",
"afterPack": "./scripts/rebuild-server-natives.cjs",
"directories": { "directories": {
"output": "release" "output": "dist"
}, },
"files": [ "files": [
"dist/**/*", "electron/**/*",
"dist-electron/**/*", "out/**/*",
"public/**/*", "public/**/*",
"!node_modules/**/*" "!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 APP_DIR = join(__dirname, '..');
const SERVER_DIR = join(APP_DIR, '..', 'server'); const SERVER_DIR = join(APP_DIR, '..', 'server');
const LIBS_DIR = join(APP_DIR, '..', '..', 'libs');
const BUNDLE_DIR = join(APP_DIR, 'server-bundle'); 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'); console.log('🔧 Preparing server for Electron bundling...\n');
// Step 1: Clean up previous bundle // Step 1: Clean up previous bundle
@@ -47,60 +35,24 @@ execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
console.log('📋 Copying server dist...'); console.log('📋 Copying server dist...');
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true }); cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
// Step 4: Copy local workspace packages // Step 4: Create a minimal package.json for the server
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
console.log('📝 Creating server package.json...'); console.log('📝 Creating server package.json...');
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8')); 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 = { const bundlePkg = {
name: '@automaker/server-bundle', name: '@automaker/server-bundle',
version: serverPkg.version, version: serverPkg.version,
type: 'module', type: 'module',
main: 'dist/index.js', 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...'); console.log('📥 Installing server production dependencies...');
execSync('npm install --omit=dev', { execSync('npm install --omit=dev', {
cwd: BUNDLE_DIR, cwd: BUNDLE_DIR,
@@ -108,23 +60,21 @@ execSync('npm install --omit=dev', {
env: { env: {
...process.env, ...process.env,
// Prevent npm from using workspace resolution // 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 // This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...'); console.log('🔨 Rebuilding native modules for current architecture...');
try { try {
execSync('npm rebuild', { execSync('npm rebuild', {
cwd: BUNDLE_DIR, cwd: BUNDLE_DIR,
stdio: 'inherit', stdio: 'inherit'
}); });
console.log('✅ Native modules rebuilt successfully'); console.log('✅ Native modules rebuilt successfully');
} catch (error) { } catch (error) {
console.warn( console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
'⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.'
);
console.warn(' Error:', error.message); console.warn(' Error:', error.message);
} }

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

@@ -1,36 +1,44 @@
import { useState, useRef, useCallback, useEffect } from 'react'; "use client";
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
import { useState, useRef, useCallback, useEffect } from "react";
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from "@/components/ui/sheet";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Slider } from '@/components/ui/slider'; import { Slider } from "@/components/ui/slider";
import { Label } from '@/components/ui/label'; import { Label } from "@/components/ui/label";
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
import { getHttpApiClient } from '@/lib/http-api-client'; import { getHttpApiClient } from "@/lib/http-api-client";
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings'; import { toast } from "sonner";
import { toast } from 'sonner';
import { const ACCEPTED_IMAGE_TYPES = [
fileToBase64, "image/jpeg",
validateImageFile, "image/jpg",
ACCEPTED_IMAGE_TYPES, "image/png",
DEFAULT_MAX_FILE_SIZE, "image/gif",
} from '@/lib/image-utils'; "image/webp",
];
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
interface BoardBackgroundModalProps { interface BoardBackgroundModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModalProps) { export function BoardBackgroundModal({
const { currentProject, boardBackgroundByProject } = useAppStore(); open,
onOpenChange,
}: BoardBackgroundModalProps) {
const { const {
currentProject,
boardBackgroundByProject,
setBoardBackground, setBoardBackground,
setCardOpacity, setCardOpacity,
setColumnOpacity, setColumnOpacity,
@@ -40,7 +48,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
setCardBorderOpacity, setCardBorderOpacity,
setHideScrollbar, setHideScrollbar,
clearBoardBackground, clearBoardBackground,
} = useBoardBackgroundSettings(); } = useAppStore();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -48,7 +56,8 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
// Get current background settings (live from store) // Get current background settings (live from store)
const backgroundSettings = const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings; (currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings;
const cardOpacity = backgroundSettings.cardOpacity; const cardOpacity = backgroundSettings.cardOpacity;
const columnOpacity = backgroundSettings.columnOpacity; const columnOpacity = backgroundSettings.columnOpacity;
@@ -62,9 +71,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
// Update preview image when background settings change // Update preview image when background settings change
useEffect(() => { useEffect(() => {
if (currentProject && backgroundSettings.imagePath) { 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 // 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( const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`; )}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
@@ -74,17 +86,40 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
} }
}, [currentProject, backgroundSettings.imagePath, imageVersion]); }, [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( const processFile = useCallback(
async (file: File) => { async (file: File) => {
if (!currentProject) { if (!currentProject) {
toast.error('No project selected'); toast.error("No project selected");
return; return;
} }
// Validate file // Validate file type
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE); if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
if (!validation.isValid) { toast.error(
toast.error(validation.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; return;
} }
@@ -105,16 +140,16 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
); );
if (result.success && result.path) { if (result.success && result.path) {
// Update store and persist to server // Update store with the relative path (live update)
await setBoardBackground(currentProject.path, result.path); setBoardBackground(currentProject.path, result.path);
toast.success('Background image saved'); toast.success("Background image saved");
} else { } else {
toast.error(result.error || 'Failed to save background image'); toast.error(result.error || "Failed to save background image");
setPreviewImage(null); setPreviewImage(null);
} }
} catch (error) { } catch (error) {
console.error('Failed to process image:', error); console.error("Failed to process image:", error);
toast.error('Failed to process image'); toast.error("Failed to process image");
setPreviewImage(null); setPreviewImage(null);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
@@ -157,7 +192,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
} }
// Reset the input so the same file can be selected again // Reset the input so the same file can be selected again
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = "";
} }
}, },
[processFile] [processFile]
@@ -175,76 +210,78 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
try { try {
setIsProcessing(true); setIsProcessing(true);
const httpClient = getHttpApiClient(); const httpClient = getHttpApiClient();
const result = await httpClient.deleteBoardBackground(currentProject.path); const result = await httpClient.deleteBoardBackground(
currentProject.path
);
if (result.success) { if (result.success) {
await clearBoardBackground(currentProject.path); clearBoardBackground(currentProject.path);
setPreviewImage(null); setPreviewImage(null);
toast.success('Background image cleared'); toast.success("Background image cleared");
} else { } else {
toast.error(result.error || 'Failed to clear background image'); toast.error(result.error || "Failed to clear background image");
} }
} catch (error) { } catch (error) {
console.error('Failed to clear background:', error); console.error("Failed to clear background:", error);
toast.error('Failed to clear background'); toast.error("Failed to clear background");
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}, [currentProject, clearBoardBackground]); }, [currentProject, clearBoardBackground]);
// Live update opacity when sliders change (with persistence) // Live update opacity when sliders change
const handleCardOpacityChange = useCallback( const handleCardOpacityChange = useCallback(
async (value: number[]) => { (value: number[]) => {
if (!currentProject) return; if (!currentProject) return;
await setCardOpacity(currentProject.path, value[0]); setCardOpacity(currentProject.path, value[0]);
}, },
[currentProject, setCardOpacity] [currentProject, setCardOpacity]
); );
const handleColumnOpacityChange = useCallback( const handleColumnOpacityChange = useCallback(
async (value: number[]) => { (value: number[]) => {
if (!currentProject) return; if (!currentProject) return;
await setColumnOpacity(currentProject.path, value[0]); setColumnOpacity(currentProject.path, value[0]);
}, },
[currentProject, setColumnOpacity] [currentProject, setColumnOpacity]
); );
const handleColumnBorderToggle = useCallback( const handleColumnBorderToggle = useCallback(
async (checked: boolean) => { (checked: boolean) => {
if (!currentProject) return; if (!currentProject) return;
await setColumnBorderEnabled(currentProject.path, checked); setColumnBorderEnabled(currentProject.path, checked);
}, },
[currentProject, setColumnBorderEnabled] [currentProject, setColumnBorderEnabled]
); );
const handleCardGlassmorphismToggle = useCallback( const handleCardGlassmorphismToggle = useCallback(
async (checked: boolean) => { (checked: boolean) => {
if (!currentProject) return; if (!currentProject) return;
await setCardGlassmorphism(currentProject.path, checked); setCardGlassmorphism(currentProject.path, checked);
}, },
[currentProject, setCardGlassmorphism] [currentProject, setCardGlassmorphism]
); );
const handleCardBorderToggle = useCallback( const handleCardBorderToggle = useCallback(
async (checked: boolean) => { (checked: boolean) => {
if (!currentProject) return; if (!currentProject) return;
await setCardBorderEnabled(currentProject.path, checked); setCardBorderEnabled(currentProject.path, checked);
}, },
[currentProject, setCardBorderEnabled] [currentProject, setCardBorderEnabled]
); );
const handleCardBorderOpacityChange = useCallback( const handleCardBorderOpacityChange = useCallback(
async (value: number[]) => { (value: number[]) => {
if (!currentProject) return; if (!currentProject) return;
await setCardBorderOpacity(currentProject.path, value[0]); setCardBorderOpacity(currentProject.path, value[0]);
}, },
[currentProject, setCardBorderOpacity] [currentProject, setCardBorderOpacity]
); );
const handleHideScrollbarToggle = useCallback( const handleHideScrollbarToggle = useCallback(
async (checked: boolean) => { (checked: boolean) => {
if (!currentProject) return; if (!currentProject) return;
await setHideScrollbar(currentProject.path, checked); setHideScrollbar(currentProject.path, checked);
}, },
[currentProject, setHideScrollbar] [currentProject, setHideScrollbar]
); );
@@ -262,7 +299,8 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
Board Background Settings Board Background Settings
</SheetTitle> </SheetTitle>
<SheetDescription className="text-muted-foreground"> <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> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -275,7 +313,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept={ACCEPTED_IMAGE_TYPES.join(',')} accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
disabled={isProcessing} disabled={isProcessing}
@@ -287,13 +325,14 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
className={cn( className={cn(
'relative rounded-lg border-2 border-dashed transition-all duration-200', "relative rounded-lg border-2 border-dashed transition-all duration-200",
{ {
'border-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, isDragOver && !isProcessing,
'border-muted-foreground/25': !isDragOver && !isProcessing, "border-muted-foreground/25": !isDragOver && !isProcessing,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': isProcessing, "border-muted-foreground/10 opacity-50 cursor-not-allowed":
'hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5': isProcessing,
"hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5":
!isProcessing && !isDragOver, !isProcessing && !isDragOver,
} }
)} )}
@@ -341,10 +380,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
> >
<div <div
className={cn( className={cn(
'rounded-full p-3 mb-3', "rounded-full p-3 mb-3",
isDragOver && !isProcessing isDragOver && !isProcessing
? 'bg-brand-500/10 dark:bg-brand-500/20' ? "bg-brand-500/10 dark:bg-brand-500/20"
: 'bg-muted' : "bg-muted"
)} )}
> >
{isProcessing ? ( {isProcessing ? (
@@ -355,12 +394,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isDragOver && !isProcessing {isDragOver && !isProcessing
? 'Drop image here' ? "Drop image here"
: 'Click to upload or drag and drop'} : "Click to upload or drag and drop"}
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
JPG, PNG, GIF, or WebP (max {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))} JPG, PNG, GIF, or WebP (max{" "}
MB) {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)
</p> </p>
</div> </div>
)} )}
@@ -372,7 +411,9 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Card Opacity</Label> <Label>Card Opacity</Label>
<span className="text-sm text-muted-foreground">{cardOpacity}%</span> <span className="text-sm text-muted-foreground">
{cardOpacity}%
</span>
</div> </div>
<Slider <Slider
value={[cardOpacity]} value={[cardOpacity]}
@@ -387,7 +428,9 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Column Opacity</Label> <Label>Column Opacity</Label>
<span className="text-sm text-muted-foreground">{columnOpacity}%</span> <span className="text-sm text-muted-foreground">
{columnOpacity}%
</span>
</div> </div>
<Slider <Slider
value={[columnOpacity]} value={[columnOpacity]}
@@ -418,7 +461,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
checked={cardGlassmorphism} checked={cardGlassmorphism}
onCheckedChange={handleCardGlassmorphismToggle} onCheckedChange={handleCardGlassmorphismToggle}
/> />
<Label htmlFor="card-glassmorphism-toggle" className="cursor-pointer"> <Label
htmlFor="card-glassmorphism-toggle"
className="cursor-pointer"
>
Card Glassmorphism (blur effect) Card Glassmorphism (blur effect)
</Label> </Label>
</div> </div>
@@ -440,7 +486,9 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Card Border Opacity</Label> <Label>Card Border Opacity</Label>
<span className="text-sm text-muted-foreground">{cardBorderOpacity}%</span> <span className="text-sm text-muted-foreground">
{cardBorderOpacity}%
</span>
</div> </div>
<Slider <Slider
value={[cardBorderOpacity]} 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -6,13 +8,13 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { HotkeyButton } from '@/components/ui/hotkey-button'; import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from '@/components/ui/input'; import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label'; import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from '@/components/ui/badge'; import { Badge } from "@/components/ui/badge";
import { import {
FolderPlus, FolderPlus,
FolderOpen, FolderOpen,
@@ -22,12 +24,14 @@ import {
Loader2, Loader2,
Link, Link,
Folder, Folder,
} from 'lucide-react'; } from "lucide-react";
import { starterTemplates, type StarterTemplate } from '@/lib/templates'; import { starterTemplates, type StarterTemplate } from "@/lib/templates";
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from "@/lib/electron";
import { cn } from '@/lib/utils'; import { getHttpApiClient } from "@/lib/http-api-client";
import { useFileBrowser } from '@/contexts/file-browser-context'; import { cn } from "@/lib/utils";
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config'; import { useFileBrowser } from "@/contexts/file-browser-context";
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
interface ValidationErrors { interface ValidationErrors {
projectName?: boolean; projectName?: boolean;
@@ -39,13 +43,20 @@ interface ValidationErrors {
interface NewProjectModalProps { interface NewProjectModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>; onCreateBlankProject: (
projectName: string,
parentDir: string
) => Promise<void>;
onCreateFromTemplate: ( onCreateFromTemplate: (
template: StarterTemplate, template: StarterTemplate,
projectName: string, projectName: string,
parentDir: string parentDir: string
) => Promise<void>; ) => Promise<void>;
onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise<void>; onCreateFromCustomUrl: (
repoUrl: string,
projectName: string,
parentDir: string
) => Promise<void>;
isCreating: boolean; isCreating: boolean;
} }
@@ -57,28 +68,39 @@ export function NewProjectModal({
onCreateFromCustomUrl, onCreateFromCustomUrl,
isCreating, isCreating,
}: NewProjectModalProps) { }: NewProjectModalProps) {
const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank'); const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
const [projectName, setProjectName] = useState(''); const [projectName, setProjectName] = useState("");
const [workspaceDir, setWorkspaceDir] = useState<string>(''); const [workspaceDir, setWorkspaceDir] = useState<string>("");
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false); const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null); const [selectedTemplate, setSelectedTemplate] =
useState<StarterTemplate | null>(null);
const [useCustomUrl, setUseCustomUrl] = useState(false); const [useCustomUrl, setUseCustomUrl] = useState(false);
const [customUrl, setCustomUrl] = useState(''); const [customUrl, setCustomUrl] = useState("");
const [errors, setErrors] = useState<ValidationErrors>({}); const [errors, setErrors] = useState<ValidationErrors>({});
const { openFileBrowser } = useFileBrowser(); const { openFileBrowser } = useFileBrowser();
// Fetch workspace directory when modal opens // Fetch workspace directory when modal opens
useEffect(() => { useEffect(() => {
if (open) { 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); setIsLoadingWorkspace(true);
getDefaultWorkspaceDirectory() const httpClient = getHttpApiClient();
.then((defaultDir) => { httpClient.workspace
if (defaultDir) { .getConfig()
setWorkspaceDir(defaultDir); .then((result) => {
if (result.success && result.workspaceDir) {
setWorkspaceDir(result.workspaceDir);
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to get default workspace directory:', error); console.error("Failed to get workspace config:", error);
}) })
.finally(() => { .finally(() => {
setIsLoadingWorkspace(false); setIsLoadingWorkspace(false);
@@ -89,11 +111,11 @@ export function NewProjectModal({
// Reset form when modal closes // Reset form when modal closes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setProjectName(''); setProjectName("");
setSelectedTemplate(null); setSelectedTemplate(null);
setUseCustomUrl(false); setUseCustomUrl(false);
setCustomUrl(''); setCustomUrl("");
setActiveTab('blank'); setActiveTab("blank");
setErrors({}); setErrors({});
} }
}, [open]); }, [open]);
@@ -106,7 +128,10 @@ export function NewProjectModal({
}, [projectName, errors.projectName]); }, [projectName, errors.projectName]);
useEffect(() => { useEffect(() => {
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) { if (
(selectedTemplate || (useCustomUrl && customUrl)) &&
errors.templateSelection
) {
setErrors((prev) => ({ ...prev, templateSelection: false })); setErrors((prev) => ({ ...prev, templateSelection: false }));
} }
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]); }, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
@@ -131,7 +156,7 @@ export function NewProjectModal({
} }
// Check template selection (only for template tab) // Check template selection (only for template tab)
if (activeTab === 'template') { if (activeTab === "template") {
if (useCustomUrl) { if (useCustomUrl) {
if (!customUrl.trim()) { if (!customUrl.trim()) {
newErrors.customUrl = true; newErrors.customUrl = true;
@@ -150,7 +175,7 @@ export function NewProjectModal({
// Clear errors and proceed // Clear errors and proceed
setErrors({}); setErrors({});
if (activeTab === 'blank') { if (activeTab === "blank") {
await onCreateBlankProject(projectName, workspaceDir); await onCreateBlankProject(projectName, workspaceDir);
} else if (useCustomUrl && customUrl) { } else if (useCustomUrl && customUrl) {
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir); await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
@@ -167,7 +192,7 @@ export function NewProjectModal({
const handleSelectTemplate = (template: StarterTemplate) => { const handleSelectTemplate = (template: StarterTemplate) => {
setSelectedTemplate(template); setSelectedTemplate(template);
setUseCustomUrl(false); setUseCustomUrl(false);
setCustomUrl(''); setCustomUrl("");
}; };
const handleToggleCustomUrl = () => { const handleToggleCustomUrl = () => {
@@ -179,14 +204,15 @@ export function NewProjectModal({
const handleBrowseDirectory = async () => { const handleBrowseDirectory = async () => {
const selectedPath = await openFileBrowser({ const selectedPath = await openFileBrowser({
title: 'Select Base Project Directory', title: "Select Base Project Directory",
description: 'Choose the parent directory where your project will be created', description:
"Choose the parent directory where your project will be created",
initialPath: workspaceDir || undefined, initialPath: workspaceDir || undefined,
}); });
if (selectedPath) { if (selectedPath) {
setWorkspaceDir(selectedPath); setWorkspaceDir(selectedPath);
// Save to localStorage for next time // 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 // Clear any workspace error when a valid directory is selected
if (errors.workspaceDir) { if (errors.workspaceDir) {
setErrors((prev) => ({ ...prev, workspaceDir: false })); setErrors((prev) => ({ ...prev, workspaceDir: false }));
@@ -196,12 +222,15 @@ export function NewProjectModal({
// Use platform-specific path separator // Use platform-specific path separator
const pathSep = const pathSep =
typeof window !== 'undefined' && (window as any).electronAPI typeof window !== "undefined" && (window as any).electronAPI
? navigator.platform.indexOf('Win') !== -1 ? navigator.platform.indexOf("Win") !== -1
? '\\' ? "\\"
: '/' : "/"
: '/'; : "/";
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : ''; const projectPath =
workspaceDir && projectName
? `${workspaceDir}${pathSep}${projectName}`
: "";
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -210,7 +239,9 @@ export function NewProjectModal({
data-testid="new-project-modal" data-testid="new-project-modal"
> >
<DialogHeader className="pb-2"> <DialogHeader className="pb-2">
<DialogTitle className="text-foreground">Create New Project</DialogTitle> <DialogTitle className="text-foreground">
Create New Project
</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Start with a blank project or choose from a starter template. Start with a blank project or choose from a starter template.
</DialogDescription> </DialogDescription>
@@ -221,9 +252,13 @@ export function NewProjectModal({
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
htmlFor="project-name" 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> </Label>
<Input <Input
id="project-name" id="project-name"
@@ -231,39 +266,40 @@ export function NewProjectModal({
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} onChange={(e) => setProjectName(e.target.value)}
className={cn( className={cn(
'bg-input text-foreground placeholder:text-muted-foreground', "bg-input text-foreground placeholder:text-muted-foreground",
errors.projectName errors.projectName
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' ? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: 'border-border' : "border-border"
)} )}
data-testid="project-name-input" data-testid="project-name-input"
autoFocus autoFocus
/> />
{errors.projectName && <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> </div>
{/* Workspace Directory Display */} {/* Workspace Directory Display */}
<div <div
className={cn( className={cn(
'flex items-start gap-2 text-sm', "flex items-center gap-2 text-sm",
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground' errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
)} )}
> >
<Folder className="w-4 h-4 shrink-0 mt-0.5" /> <Folder className="w-4 h-4 shrink-0" />
<span className="flex-1 min-w-0 flex flex-col gap-1"> <span className="flex-1 min-w-0">
{isLoadingWorkspace ? ( {isLoadingWorkspace ? (
'Loading workspace...' "Loading workspace..."
) : workspaceDir ? ( ) : workspaceDir ? (
<> <>
<span>Will be created at:</span> Will be created at:{" "}
<code <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
title={projectPath || workspaceDir}
>
{projectPath || workspaceDir} {projectPath || workspaceDir}
</code> </code>
</> </>
) : null} ) : (
<span className="text-red-500">No workspace configured</span>
)}
</span> </span>
<Button <Button
type="button" type="button"
@@ -282,7 +318,7 @@ export function NewProjectModal({
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(v) => setActiveTab(v as 'blank' | 'template')} onValueChange={(v) => setActiveTab(v as "blank" | "template")}
className="flex-1 flex flex-col overflow-hidden" className="flex-1 flex flex-col overflow-hidden"
> >
<TabsList className="w-full justify-start"> <TabsList className="w-full justify-start">
@@ -300,8 +336,9 @@ export function NewProjectModal({
<TabsContent value="blank" className="mt-0"> <TabsContent value="blank" className="mt-0">
<div className="p-4 rounded-lg bg-muted/50 border border-border"> <div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Create an empty project with the standard .automaker directory structure. Perfect Create an empty project with the standard .automaker directory
for starting from scratch or importing an existing codebase. structure. Perfect for starting from scratch or importing an
existing codebase.
</p> </p>
</div> </div>
</TabsContent> </TabsContent>
@@ -318,18 +355,18 @@ export function NewProjectModal({
{/* Preset Templates */} {/* Preset Templates */}
<div <div
className={cn( className={cn(
'space-y-3 rounded-lg p-1 -m-1', "space-y-3 rounded-lg p-1 -m-1",
errors.templateSelection && 'ring-2 ring-red-500/50' errors.templateSelection && "ring-2 ring-red-500/50"
)} )}
> >
{starterTemplates.map((template) => ( {starterTemplates.map((template) => (
<div <div
key={template.id} key={template.id}
className={cn( className={cn(
'p-4 rounded-lg border cursor-pointer transition-all', "p-4 rounded-lg border cursor-pointer transition-all",
selectedTemplate?.id === template.id && !useCustomUrl selectedTemplate?.id === template.id && !useCustomUrl
? 'border-brand-500 bg-brand-500/10' ? "border-brand-500 bg-brand-500/10"
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50' : "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
)} )}
onClick={() => handleSelectTemplate(template)} onClick={() => handleSelectTemplate(template)}
data-testid={`template-${template.id}`} data-testid={`template-${template.id}`}
@@ -337,8 +374,11 @@ export function NewProjectModal({
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-foreground">{template.name}</h4> <h4 className="font-medium text-foreground">
{selectedTemplate?.id === template.id && !useCustomUrl && ( {template.name}
</h4>
{selectedTemplate?.id === template.id &&
!useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" /> <Check className="w-4 h-4 text-brand-500" />
)} )}
</div> </div>
@@ -349,7 +389,11 @@ export function NewProjectModal({
{/* Tech Stack */} {/* Tech Stack */}
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="flex flex-wrap gap-1.5 mb-3">
{template.techStack.slice(0, 6).map((tech) => ( {template.techStack.slice(0, 6).map((tech) => (
<Badge key={tech} variant="secondary" className="text-xs"> <Badge
key={tech}
variant="secondary"
className="text-xs"
>
{tech} {tech}
</Badge> </Badge>
))} ))}
@@ -363,7 +407,7 @@ export function NewProjectModal({
{/* Key Features */} {/* Key Features */}
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
<span className="font-medium">Features: </span> <span className="font-medium">Features: </span>
{template.features.slice(0, 3).join(' · ')} {template.features.slice(0, 3).join(" · ")}
{template.features.length > 3 && {template.features.length > 3 &&
` · +${template.features.length - 3} more`} ` · +${template.features.length - 3} more`}
</div> </div>
@@ -388,38 +432,47 @@ export function NewProjectModal({
{/* Custom URL Option */} {/* Custom URL Option */}
<div <div
className={cn( className={cn(
'p-4 rounded-lg border cursor-pointer transition-all', "p-4 rounded-lg border cursor-pointer transition-all",
useCustomUrl useCustomUrl
? 'border-brand-500 bg-brand-500/10' ? "border-brand-500 bg-brand-500/10"
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50' : "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
)} )}
onClick={handleToggleCustomUrl} onClick={handleToggleCustomUrl}
> >
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Link className="w-4 h-4 text-muted-foreground" /> <Link className="w-4 h-4 text-muted-foreground" />
<h4 className="font-medium text-foreground">Custom GitHub URL</h4> <h4 className="font-medium text-foreground">
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />} Custom GitHub URL
</h4>
{useCustomUrl && (
<Check className="w-4 h-4 text-brand-500" />
)}
</div> </div>
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">
Clone any public GitHub repository as a starting point. Clone any public GitHub repository as a starting point.
</p> </p>
{useCustomUrl && ( {useCustomUrl && (
<div onClick={(e) => e.stopPropagation()} className="space-y-1"> <div
onClick={(e) => e.stopPropagation()}
className="space-y-1"
>
<Input <Input
placeholder="https://github.com/username/repository" placeholder="https://github.com/username/repository"
value={customUrl} value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)} onChange={(e) => setCustomUrl(e.target.value)}
className={cn( className={cn(
'bg-input text-foreground placeholder:text-muted-foreground', "bg-input text-foreground placeholder:text-muted-foreground",
errors.customUrl errors.customUrl
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' ? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
: 'border-border' : "border-border"
)} )}
data-testid="custom-url-input" data-testid="custom-url-input"
/> />
{errors.customUrl && ( {errors.customUrl && (
<p className="text-xs text-red-500">GitHub URL is required</p> <p className="text-xs text-red-500">
GitHub URL is required
</p>
)} )}
</div> </div>
)} )}
@@ -442,14 +495,14 @@ export function NewProjectModal({
onClick={validateAndCreate} onClick={validateAndCreate}
disabled={isCreating} disabled={isCreating}
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0" className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
hotkey={{ key: 'Enter', cmdCtrl: true }} hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open} hotkeyActive={open}
data-testid="confirm-create-project" data-testid="confirm-create-project"
> >
{isCreating ? ( {isCreating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
{activeTab === 'template' ? 'Cloning...' : 'Creating...'} {activeTab === "template" ? "Cloning..." : "Creating..."}
</> </>
) : ( ) : (
<>Create Project</> <>Create Project</>

View File

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

View File

@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-empty-object-type */ "use client";
import * as React from 'react'; import * as React from "react";
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from "lucide-react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
type AccordionType = 'single' | 'multiple'; type AccordionType = "single" | "multiple";
interface AccordionContextValue { interface AccordionContextValue {
type: AccordionType; type: AccordionType;
@@ -13,10 +13,12 @@ interface AccordionContextValue {
collapsible?: boolean; collapsible?: boolean;
} }
const AccordionContext = React.createContext<AccordionContextValue | null>(null); const AccordionContext = React.createContext<AccordionContextValue | null>(
null
);
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> { interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
type?: 'single' | 'multiple'; type?: "single" | "multiple";
value?: string | string[]; value?: string | string[];
defaultValue?: string | string[]; defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void; onValueChange?: (value: string | string[]) => void;
@@ -26,7 +28,7 @@ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>( const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
( (
{ {
type = 'single', type = "single",
value, value,
defaultValue, defaultValue,
onValueChange, onValueChange,
@@ -37,11 +39,13 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
}, },
ref ref
) => { ) => {
const [internalValue, setInternalValue] = React.useState<string | string[]>(() => { const [internalValue, setInternalValue] = React.useState<string | string[]>(
() => {
if (value !== undefined) return value; if (value !== undefined) return value;
if (defaultValue !== undefined) return defaultValue; if (defaultValue !== undefined) return defaultValue;
return type === 'single' ? '' : []; return type === "single" ? "" : [];
}); }
);
const currentValue = value !== undefined ? value : internalValue; const currentValue = value !== undefined ? value : internalValue;
@@ -49,9 +53,9 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
(itemValue: string) => { (itemValue: string) => {
let newValue: string | string[]; let newValue: string | string[];
if (type === 'single') { if (type === "single") {
if (currentValue === itemValue && collapsible) { if (currentValue === itemValue && collapsible) {
newValue = ''; newValue = "";
} else if (currentValue === itemValue && !collapsible) { } else if (currentValue === itemValue && !collapsible) {
return; return;
} else { } else {
@@ -88,21 +92,27 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
return ( return (
<AccordionContext.Provider value={contextValue}> <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} {children}
</div> </div>
</AccordionContext.Provider> </AccordionContext.Provider>
); );
} }
); );
Accordion.displayName = 'Accordion'; Accordion.displayName = "Accordion";
interface AccordionItemContextValue { interface AccordionItemContextValue {
value: string; value: string;
isOpen: boolean; isOpen: boolean;
} }
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null); const AccordionItemContext =
React.createContext<AccordionItemContextValue | null>(null);
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> { interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
value: string; value: string;
@@ -113,22 +123,25 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
const accordionContext = React.useContext(AccordionContext); const accordionContext = React.useContext(AccordionContext);
if (!accordionContext) { if (!accordionContext) {
throw new Error('AccordionItem must be used within an Accordion'); throw new Error("AccordionItem must be used within an Accordion");
} }
const isOpen = Array.isArray(accordionContext.value) const isOpen = Array.isArray(accordionContext.value)
? accordionContext.value.includes(value) ? accordionContext.value.includes(value)
: accordionContext.value === value; : accordionContext.value === value;
const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]); const contextValue = React.useMemo(
() => ({ value, isOpen }),
[value, isOpen]
);
return ( return (
<AccordionItemContext.Provider value={contextValue}> <AccordionItemContext.Provider value={contextValue}>
<div <div
ref={ref} ref={ref}
data-slot="accordion-item" data-slot="accordion-item"
data-state={isOpen ? 'open' : 'closed'} data-state={isOpen ? "open" : "closed"}
className={cn('border-b border-border', className)} className={cn("border-b border-border", className)}
{...props} {...props}
> >
{children} {children}
@@ -137,17 +150,20 @@ 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>( const AccordionTrigger = React.forwardRef<
({ className, children, ...props }, ref) => { HTMLButtonElement,
AccordionTriggerProps
>(({ className, children, ...props }, ref) => {
const accordionContext = React.useContext(AccordionContext); const accordionContext = React.useContext(AccordionContext);
const itemContext = React.useContext(AccordionItemContext); const itemContext = React.useContext(AccordionItemContext);
if (!accordionContext || !itemContext) { if (!accordionContext || !itemContext) {
throw new Error('AccordionTrigger must be used within an AccordionItem'); throw new Error("AccordionTrigger must be used within an AccordionItem");
} }
const { onValueChange } = accordionContext; const { onValueChange } = accordionContext;
@@ -159,11 +175,11 @@ const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerPro
ref={ref} ref={ref}
type="button" type="button"
data-slot="accordion-trigger" data-slot="accordion-trigger"
data-state={isOpen ? 'open' : 'closed'} data-state={isOpen ? "open" : "closed"}
aria-expanded={isOpen} aria-expanded={isOpen}
onClick={() => onValueChange(value)} onClick={() => onValueChange(value)}
className={cn( className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180', "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className className
)} )}
{...props} {...props}
@@ -173,9 +189,8 @@ const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerPro
</button> </button>
</div> </div>
); );
} });
); AccordionTrigger.displayName = "AccordionTrigger";
AccordionTrigger.displayName = 'AccordionTrigger';
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {} interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
@@ -186,7 +201,7 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
const [height, setHeight] = React.useState<number | undefined>(undefined); const [height, setHeight] = React.useState<number | undefined>(undefined);
if (!itemContext) { if (!itemContext) {
throw new Error('AccordionContent must be used within an AccordionItem'); throw new Error("AccordionContent must be used within an AccordionItem");
} }
const { isOpen } = itemContext; const { isOpen } = itemContext;
@@ -206,16 +221,16 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
return ( return (
<div <div
data-slot="accordion-content" data-slot="accordion-content"
data-state={isOpen ? 'open' : 'closed'} data-state={isOpen ? "open" : "closed"}
className="overflow-hidden text-sm transition-all duration-200 ease-out" className="overflow-hidden text-sm transition-all duration-200 ease-out"
style={{ style={{
height: isOpen ? (height !== undefined ? `${height}px` : 'auto') : 0, height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
opacity: isOpen ? 1 : 0, opacity: isOpen ? 1 : 0,
}} }}
{...props} {...props}
> >
<div ref={contentRef}> <div ref={contentRef}>
<div ref={ref} className={cn('pb-4 pt-0', className)}> <div ref={ref} className={cn("pb-4 pt-0", className)}>
{children} {children}
</div> </div>
</div> </div>
@@ -223,6 +238,6 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
); );
} }
); );
AccordionContent.displayName = 'AccordionContent'; AccordionContent.displayName = "AccordionContent";
export { Accordion, AccordionItem, AccordionTrigger, 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 * as React from "react";
import { Slot } from '@radix-ui/react-slot'; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from 'class-variance-authority'; import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from 'lucide-react'; import { Loader2 } from "lucide-react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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]", "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: { variants: {
variant: { variant: {
default: 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: 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: 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', "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', secondary:
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
link: 'text-primary underline-offset-4 hover:underline active:scale-100', ghost:
'animated-outline': 'relative overflow-hidden rounded-xl hover:bg-transparent shadow-none', "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: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', 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', 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', lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: 'size-9', icon: "size-9",
'icon-sm': 'size-8', "icon-sm": "size-8",
'icon-lg': 'size-10', "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: 'default', variant: "default",
size: 'default', size: "default",
}, },
} }
); );
// Loading spinner component // Loading spinner component
function ButtonSpinner({ className }: { className?: string }) { 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({ function Button({
@@ -51,7 +59,7 @@ function Button({
disabled, disabled,
children, children,
...props ...props
}: React.ComponentProps<'button'> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
loading?: boolean; loading?: boolean;
@@ -59,28 +67,28 @@ function Button({
const isDisabled = disabled || loading; const isDisabled = disabled || loading;
// Special handling for animated-outline variant // Special handling for animated-outline variant
if (variant === 'animated-outline' && !asChild) { if (variant === "animated-outline" && !asChild) {
return ( return (
<button <button
className={cn( className={cn(
buttonVariants({ variant, size }), 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 className
)} )}
data-slot="button" data-slot="button"
disabled={isDisabled} disabled={isDisabled}
{...props} {...props}
> >
{/* Animated rotating gradient border - only animates on hover for GPU efficiency */} {/* Animated rotating gradient border - smoother animation */}
<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" /> <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 */} {/* Inner content container */}
<span <span
className={cn( 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', "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 === "sm" && "px-3 text-xs gap-1.5",
size === 'lg' && 'px-8', size === "lg" && "px-8",
size === 'icon' && 'p-0 gap-0' size === "icon" && "p-0 gap-0"
)} )}
> >
{loading && <ButtonSpinner />} {loading && <ButtonSpinner />}
@@ -90,7 +98,7 @@ function Button({
); );
} }
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : "button";
return ( return (
<Comp <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'; "use client"
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, 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 ( return (
<CommandPrimitive <CommandPrimitive
data-slot="command" data-slot="command"
className={cn( 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 className
)} )}
{...props} {...props}
/> />
); )
} }
function CommandDialog({ function CommandDialog({
title = 'Command Palette', title = "Command Palette",
description = 'Search for a command to run...', description = "Search for a command to run...",
children, children,
className, className,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof Dialog> & { }: React.ComponentProps<typeof Dialog> & {
title?: string; title?: string
description?: string; description?: string
className?: string; className?: string
showCloseButton?: boolean; showCloseButton?: boolean
}) { }) {
return ( return (
<Dialog {...props}> <Dialog {...props}>
@@ -44,7 +49,7 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogContent <DialogContent
className={cn('overflow-hidden p-0', className)} className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton} 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"> <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> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); )
} }
function CommandInput({ function CommandInput({
@@ -60,38 +65,49 @@ function CommandInput({
...props ...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) { }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return ( 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" /> <SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( 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 className
)} )}
{...props} {...props}
/> />
</div> </div>
); )
} }
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) { function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return ( return (
<CommandPrimitive.List <CommandPrimitive.List
data-slot="command-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} {...props}
/> />
); )
} }
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) { function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return ( return (
<CommandPrimitive.Empty <CommandPrimitive.Empty
data-slot="command-empty" data-slot="command-empty"
className="py-6 text-center text-sm" className="py-6 text-center text-sm"
{...props} {...props}
/> />
); )
} }
function CommandGroup({ function CommandGroup({
@@ -102,12 +118,12 @@ function CommandGroup({
<CommandPrimitive.Group <CommandPrimitive.Group
data-slot="command-group" data-slot="command-group"
className={cn( 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 className
)} )}
{...props} {...props}
/> />
); )
} }
function CommandSeparator({ function CommandSeparator({
@@ -117,13 +133,16 @@ function CommandSeparator({
return ( return (
<CommandPrimitive.Separator <CommandPrimitive.Separator
data-slot="command-separator" data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)} className={cn("bg-border -mx-1 h-px", className)}
{...props} {...props}
/> />
); )
} }
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) { function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return ( return (
<CommandPrimitive.Item <CommandPrimitive.Item
data-slot="command-item" data-slot="command-item"
@@ -133,17 +152,23 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
)} )}
{...props} {...props}
/> />
); )
} }
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="command-shortcut" 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} {...props}
/> />
); )
} }
export { export {
@@ -156,4 +181,4 @@ export {
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator,
}; }

View File

@@ -1,5 +1,7 @@
import { useState, useEffect } from 'react'; "use client";
import { Clock } from 'lucide-react';
import { useState, useEffect } from "react";
import { Clock } from "lucide-react";
interface CountUpTimerProps { interface CountUpTimerProps {
startedAt: string; // ISO timestamp string startedAt: string; // ISO timestamp string
@@ -15,8 +17,8 @@ function formatElapsedTime(seconds: number): string {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
const paddedMinutes = minutes.toString().padStart(2, '0'); const paddedMinutes = minutes.toString().padStart(2, "0");
const paddedSeconds = remainingSeconds.toString().padStart(2, '0'); const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
return `${paddedMinutes}:${paddedSeconds}`; return `${paddedMinutes}:${paddedSeconds}`;
} }
@@ -25,7 +27,7 @@ function formatElapsedTime(seconds: number): string {
* CountUpTimer component that displays elapsed time since a given start time * CountUpTimer component that displays elapsed time since a given start time
* Updates every second to show the current elapsed time in MM:SS format * 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); const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => { 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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -6,10 +6,10 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { HotkeyButton } from '@/components/ui/hotkey-button'; import { HotkeyButton } from "@/components/ui/hotkey-button";
import type { ReactNode } from 'react'; import type { ReactNode } from "react";
interface DeleteConfirmDialogProps { interface DeleteConfirmDialogProps {
open: boolean; open: boolean;
@@ -34,9 +34,9 @@ export function DeleteConfirmDialog({
title, title,
description, description,
children, children,
confirmText = 'Delete', confirmText = "Delete",
testId = 'delete-confirm-dialog', testId = "delete-confirm-dialog",
confirmTestId = 'confirm-delete-button', confirmTestId = "confirm-delete-button",
}: DeleteConfirmDialogProps) { }: DeleteConfirmDialogProps) {
const handleConfirm = () => { const handleConfirm = () => {
onConfirm(); onConfirm();
@@ -45,13 +45,18 @@ export function DeleteConfirmDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Trash2 className="w-5 h-5 text-destructive" /> <Trash2 className="w-5 h-5 text-destructive" />
{title} {title}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground">{description}</DialogDescription> <DialogDescription className="text-muted-foreground">
{description}
</DialogDescription>
</DialogHeader> </DialogHeader>
{children} {children}
@@ -69,7 +74,7 @@ export function DeleteConfirmDialog({
variant="destructive" variant="destructive"
onClick={handleConfirm} onClick={handleConfirm}
data-testid={confirmTestId} data-testid={confirmTestId}
hotkey={{ key: 'Enter', cmdCtrl: true }} hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open} hotkeyActive={open}
className="px-4" 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'; "use client";
import { cn } from '@/lib/utils';
import { ImageIcon, X, Upload } from 'lucide-react'; import React, { useState, useRef, useCallback } from "react";
import { import { cn } from "@/lib/utils";
fileToBase64, import { ImageIcon, X, Upload } from "lucide-react";
generateImageId,
ACCEPTED_IMAGE_TYPES,
DEFAULT_MAX_FILE_SIZE,
DEFAULT_MAX_FILES,
validateImageFile,
} from '@/lib/image-utils';
export interface FeatureImage { export interface FeatureImage {
id: string; id: string;
@@ -27,10 +21,19 @@ interface FeatureImageUploadProps {
disabled?: boolean; 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({ export function FeatureImageUpload({
images, images,
onImagesChange, onImagesChange,
maxFiles = DEFAULT_MAX_FILES, maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE, maxFileSize = DEFAULT_MAX_FILE_SIZE,
className, className,
disabled = false, disabled = false,
@@ -39,6 +42,21 @@ export function FeatureImageUpload({
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); 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( const processFiles = useCallback(
async (files: FileList) => { async (files: FileList) => {
if (disabled || isProcessing) return; if (disabled || isProcessing) return;
@@ -48,10 +66,20 @@ export function FeatureImageUpload({
const errors: string[] = []; const errors: string[] = [];
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
// Validate file // Validate file type
const validation = validateImageFile(file, maxFileSize); if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
if (!validation.isValid) { errors.push(
errors.push(validation.error!); `${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; continue;
} }
@@ -64,20 +92,20 @@ export function FeatureImageUpload({
try { try {
const base64 = await fileToBase64(file); const base64 = await fileToBase64(file);
const imageAttachment: FeatureImage = { const imageAttachment: FeatureImage = {
id: generateImageId(), id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64, data: base64,
mimeType: file.type, mimeType: file.type,
filename: file.name, filename: file.name,
size: file.size, size: file.size,
}; };
newImages.push(imageAttachment); newImages.push(imageAttachment);
} catch { } catch (error) {
errors.push(`${file.name}: Failed to process image.`); errors.push(`${file.name}: Failed to process image.`);
} }
} }
if (errors.length > 0) { if (errors.length > 0) {
console.warn('Image upload errors:', errors); console.warn("Image upload errors:", errors);
} }
if (newImages.length > 0) { if (newImages.length > 0) {
@@ -130,7 +158,7 @@ export function FeatureImageUpload({
} }
// Reset the input so the same file can be selected again // Reset the input so the same file can be selected again
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = "";
} }
}, },
[processFiles] [processFiles]
@@ -153,14 +181,22 @@ export function FeatureImageUpload({
onImagesChange([]); onImagesChange([]);
}, [onImagesChange]); }, [onImagesChange]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
return ( return (
<div className={cn('relative', className)}> <div className={cn("relative", className)}>
{/* Hidden file input */} {/* Hidden file input */}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept={ACCEPTED_IMAGE_TYPES.join(',')} accept={ACCEPTED_IMAGE_TYPES.join(",")}
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
disabled={disabled} disabled={disabled}
@@ -174,12 +210,13 @@ export function FeatureImageUpload({
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onClick={handleBrowseClick} onClick={handleBrowseClick}
className={cn( className={cn(
'relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer', "relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
{ {
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled, "border-blue-400 bg-blue-50 dark:bg-blue-950/20":
'border-muted-foreground/25': !isDragOver && !disabled, isDragOver && !disabled,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled, "border-muted-foreground/25": !isDragOver && !disabled,
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10': "border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
!disabled && !isDragOver, !disabled && !isDragOver,
} }
)} )}
@@ -188,8 +225,10 @@ export function FeatureImageUpload({
<div className="flex flex-col items-center justify-center p-4 text-center"> <div className="flex flex-col items-center justify-center p-4 text-center">
<div <div
className={cn( className={cn(
'rounded-full p-2 mb-2', "rounded-full p-2 mb-2",
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted' isDragOver && !disabled
? "bg-blue-100 dark:bg-blue-900/30"
: "bg-muted"
)} )}
> >
{isProcessing ? ( {isProcessing ? (
@@ -199,10 +238,13 @@ export function FeatureImageUpload({
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <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>
<p className="text-xs text-muted-foreground mt-1"> <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> </p>
</div> </div>
</div> </div>
@@ -212,7 +254,7 @@ export function FeatureImageUpload({
<div className="mt-3 space-y-2" data-testid="feature-image-previews"> <div className="mt-3 space-y-2" data-testid="feature-image-previews">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground"> <p className="text-xs font-medium text-foreground">
{images.length} image{images.length > 1 ? 's' : ''} selected {images.length} image{images.length > 1 ? "s" : ""} selected
</p> </p>
<button <button
type="button" type="button"
@@ -254,7 +296,9 @@ export function FeatureImageUpload({
)} )}
{/* Filename tooltip on hover */} {/* Filename tooltip on hover */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[10px] text-white truncate">{image.filename}</p> <p className="text-[10px] text-white truncate">
{image.filename}
</p>
</div> </div>
</div> </div>
))} ))}

View File

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

View File

@@ -1,7 +1,10 @@
import React, { useEffect, useCallback, useRef } from 'react'; "use client";
import { Button, buttonVariants } from './button';
import { cn } from '@/lib/utils'; import * as React from "react";
import type { VariantProps } from 'class-variance-authority'; 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 { export interface HotkeyConfig {
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */ /** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
@@ -17,7 +20,8 @@ export interface HotkeyConfig {
} }
export interface HotkeyButtonProps 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 configuration - can be a simple key string or a full config object */
hotkey?: string | HotkeyConfig; hotkey?: string | HotkeyConfig;
/** Whether to show the hotkey indicator badge */ /** Whether to show the hotkey indicator badge */
@@ -36,14 +40,14 @@ export interface HotkeyButtonProps
* Get the modifier key symbol based on platform * Get the modifier key symbol based on platform
*/ */
function getModifierSymbol(isMac: boolean): string { function getModifierSymbol(isMac: boolean): string {
return isMac ? '⌘' : 'Ctrl'; return isMac ? "⌘" : "Ctrl";
} }
/** /**
* Parse hotkey config into a normalized format * Parse hotkey config into a normalized format
*/ */
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig { function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
if (typeof hotkey === 'string') { if (typeof hotkey === "string") {
return { key: hotkey }; return { key: hotkey };
} }
return hotkey; return hotkey;
@@ -52,7 +56,10 @@ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
/** /**
* Generate the display label for the hotkey * 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) { if (config.label) {
return config.label; return config.label;
} }
@@ -69,7 +76,10 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
if (config.shift) { if (config.shift) {
parts.push( 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> </span>
); );
@@ -78,7 +88,7 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
if (config.alt) { if (config.alt) {
parts.push( parts.push(
<span key="alt" className="leading-none flex items-center justify-center"> <span key="alt" className="leading-none flex items-center justify-center">
{isMac ? '⌥' : 'Alt'} {isMac ? "⌥" : "Alt"}
</span> </span>
); );
} }
@@ -86,36 +96,36 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
// Convert key to display format // Convert key to display format
let keyDisplay = config.key; let keyDisplay = config.key;
switch (config.key.toLowerCase()) { switch (config.key.toLowerCase()) {
case 'enter': case "enter":
keyDisplay = '↵'; keyDisplay = "↵";
break; break;
case 'escape': case "escape":
case 'esc': case "esc":
keyDisplay = 'Esc'; keyDisplay = "Esc";
break; break;
case 'arrowup': case "arrowup":
keyDisplay = '↑'; keyDisplay = "↑";
break; break;
case 'arrowdown': case "arrowdown":
keyDisplay = '↓'; keyDisplay = "↓";
break; break;
case 'arrowleft': case "arrowleft":
keyDisplay = '←'; keyDisplay = "←";
break; break;
case 'arrowright': case "arrowright":
keyDisplay = '→'; keyDisplay = "→";
break; break;
case 'backspace': case "backspace":
keyDisplay = '⌫'; keyDisplay = "⌫";
break; break;
case 'delete': case "delete":
keyDisplay = '⌦'; keyDisplay = "⌦";
break; break;
case 'tab': case "tab":
keyDisplay = '⇥'; keyDisplay = "⇥";
break; break;
case ' ': case " ":
keyDisplay = 'Space'; keyDisplay = "Space";
break; break;
default: default:
// Capitalize single letters // Capitalize single letters
@@ -140,16 +150,16 @@ function isInputElement(element: Element | null): boolean {
if (!element) return false; if (!element) return false;
const tagName = element.tagName.toLowerCase(); const tagName = element.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') { if (tagName === "input" || tagName === "textarea" || tagName === "select") {
return true; return true;
} }
if (element.getAttribute('contenteditable') === 'true') { if (element.getAttribute("contenteditable") === "true") {
return true; return true;
} }
const role = element.getAttribute('role'); const role = element.getAttribute("role");
if (role === 'textbox' || role === 'searchbox' || role === 'combobox') { if (role === "textbox" || role === "searchbox" || role === "combobox") {
return true; return true;
} }
@@ -186,7 +196,7 @@ export function HotkeyButton({
// Detect platform on mount // Detect platform on mount
useEffect(() => { useEffect(() => {
setIsMac(navigator.platform.toLowerCase().includes('mac')); setIsMac(navigator.platform.toLowerCase().includes("mac"));
}, []); }, []);
const config = hotkey ? parseHotkeyConfig(hotkey) : null; 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) // 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 // 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; return;
} }
@@ -221,7 +235,8 @@ export function HotkeyButton({
if (scopeRef && scopeRef.current) { if (scopeRef && scopeRef.current) {
const scopeEl = scopeRef.current; const scopeEl = scopeRef.current;
const isVisible = const isVisible =
scopeEl.offsetParent !== null || getComputedStyle(scopeEl).display !== 'none'; scopeEl.offsetParent !== null ||
getComputedStyle(scopeEl).display !== "none";
if (!isVisible) return; if (!isVisible) return;
} }
@@ -244,9 +259,9 @@ export function HotkeyButton({
useEffect(() => { useEffect(() => {
if (!config || !hotkeyActive) return; if (!config || !hotkeyActive) return;
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [config, hotkeyActive, handleKeyDown]); }, [config, hotkeyActive, handleKeyDown]);
@@ -272,7 +287,7 @@ export function HotkeyButton({
asChild={asChild} asChild={asChild}
{...props} {...props}
> >
{typeof children === 'string' ? ( {typeof children === "string" ? (
<> <>
{children} {children}
{hotkeyIndicator} {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; startAddon?: React.ReactNode;
endAddon?: React.ReactNode; endAddon?: React.ReactNode;
} }
@@ -15,17 +15,17 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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 // 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 // Animated focus ring
'transition-[color,box-shadow,border-color] duration-200 ease-out', "transition-[color,box-shadow,border-color] duration-200 ease-out",
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive', "aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
// Adjust padding for addons // Adjust padding for addons
startAddon && 'pl-0', startAddon && "pl-0",
endAddon && 'pr-0', endAddon && "pr-0",
hasAddons && 'border-0 shadow-none focus-visible:ring-0', hasAddons && "border-0 shadow-none focus-visible:ring-0",
className className
)} )}
{...props} {...props}
@@ -39,12 +39,12 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
return ( return (
<div <div
className={cn( className={cn(
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs', "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)]', "shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
'transition-[box-shadow,border-color] duration-200 ease-out', "transition-[box-shadow,border-color] duration-200 ease-out",
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]', "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:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive' "has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive"
)} )}
> >
{startAddon && ( {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 React from "react";
import * as SheetPrimitive from '@radix-ui/react-dialog'; import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from 'lucide-react'; import { XIcon } from "lucide-react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />; return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) { function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />; 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} />; 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} />; return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
@@ -28,13 +34,13 @@ interface SheetOverlayProps extends React.HTMLAttributes<HTMLDivElement> {
const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => { const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
const Overlay = SheetPrimitive.Overlay as React.ComponentType< const Overlay = SheetPrimitive.Overlay as React.ComponentType<
SheetOverlayProps & { 'data-slot': string } SheetOverlayProps & { "data-slot": string }
>; >;
return ( return (
<Overlay <Overlay
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className
)} )}
{...props} {...props}
@@ -43,16 +49,21 @@ const SheetOverlay = ({ className, ...props }: SheetOverlayProps) => {
}; };
interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> { interface SheetContentProps extends React.HTMLAttributes<HTMLDivElement> {
side?: 'top' | 'right' | 'bottom' | 'left'; side?: "top" | "right" | "bottom" | "left";
forceMount?: true; forceMount?: true;
onEscapeKeyDown?: (event: KeyboardEvent) => void; onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: PointerEvent) => void; onPointerDownOutside?: (event: PointerEvent) => void;
onInteractOutside?: (event: Event) => void; onInteractOutside?: (event: Event) => void;
} }
const SheetContent = ({ className, children, side = 'right', ...props }: SheetContentProps) => { const SheetContent = ({
className,
children,
side = "right",
...props
}: SheetContentProps) => {
const Content = SheetPrimitive.Content as React.ComponentType< const Content = SheetPrimitive.Content as React.ComponentType<
SheetContentProps & { 'data-slot': string } SheetContentProps & { "data-slot": string }
>; >;
const Close = SheetPrimitive.Close as React.ComponentType<{ const Close = SheetPrimitive.Close as React.ComponentType<{
className: string; className: string;
@@ -65,15 +76,15 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo
<Content <Content
data-slot="sheet-content" data-slot="sheet-content"
className={cn( className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500', "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === 'right' && side === "right" &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm', "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === 'left' && side === "left" &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm', "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === 'top' && side === "top" &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b', "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === 'bottom' && side === "bottom" &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t', "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className
)} )}
{...props} {...props}
@@ -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 ( return (
<div <div
data-slot="sheet-header" data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
); );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="sheet-footer" data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
); );
@@ -112,27 +123,28 @@ interface SheetTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}
const SheetTitle = ({ className, ...props }: SheetTitleProps) => { const SheetTitle = ({ className, ...props }: SheetTitleProps) => {
const Title = SheetPrimitive.Title as React.ComponentType< const Title = SheetPrimitive.Title as React.ComponentType<
SheetTitleProps & { 'data-slot': string } SheetTitleProps & { "data-slot": string }
>; >;
return ( return (
<Title <Title
data-slot="sheet-title" data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
); );
}; };
interface SheetDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {} interface SheetDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {}
const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => { const SheetDescription = ({ className, ...props }: SheetDescriptionProps) => {
const Description = SheetPrimitive.Description as React.ComponentType< const Description = SheetPrimitive.Description as React.ComponentType<
SheetDescriptionProps & { 'data-slot': string } SheetDescriptionProps & { "data-slot": string }
>; >;
return ( return (
<Description <Description
data-slot="sheet-description" data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
); );

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 ( return (
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( 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 // 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 // Animated focus ring
'transition-[color,box-shadow,border-color] duration-200 ease-out', "transition-[color,box-shadow,border-color] duration-200 ease-out",
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', "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', "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className
)} )}
{...props} {...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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { Loader2, List, FileText, GitBranch } from 'lucide-react'; import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from '@/components/ui/log-viewer'; import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from '@/components/ui/git-diff-panel'; import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { TaskProgressPanel } from '@/components/ui/task-progress-panel'; import { useAppStore } from "@/store/app-store";
import { useAppStore } from '@/store/app-store'; import type { AutoModeEvent } from "@/types/electron";
import type { AutoModeEvent } from '@/types/electron';
interface AgentOutputModalProps { interface AgentOutputModalProps {
open: boolean; open: boolean;
@@ -25,7 +26,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void; onNumberKeyPress?: (key: string) => void;
} }
type ViewMode = 'parsed' | 'raw' | 'changes'; type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({ export function AgentOutputModal({
open, open,
@@ -35,13 +36,13 @@ export function AgentOutputModal({
featureStatus, featureStatus,
onNumberKeyPress, onNumberKeyPress,
}: AgentOutputModalProps) { }: AgentOutputModalProps) {
const [output, setOutput] = useState<string>(''); const [output, setOutput] = useState<string>("");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>('parsed'); const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>(''); const [projectPath, setProjectPath] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true); const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>(''); const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees); const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes // Auto-scroll to bottom when output changes
@@ -74,19 +75,22 @@ export function AgentOutputModal({
// Use features API to get agent output // Use features API to get agent output
if (api.features) { if (api.features) {
const result = await api.features.getAgentOutput(currentProject.path, featureId); const result = await api.features.getAgentOutput(
currentProject.path,
featureId
);
if (result.success) { if (result.success) {
setOutput(result.content || ''); setOutput(result.content || "");
} else { } else {
setOutput(''); setOutput("");
} }
} else { } else {
setOutput(''); setOutput("");
} }
} catch (error) { } catch (error) {
console.error('Failed to load output:', error); console.error("Failed to load output:", error);
setOutput(''); setOutput("");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -95,6 +99,24 @@ export function AgentOutputModal({
loadOutput(); loadOutput();
}, [open, featureId]); }, [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 // Listen to auto mode events and update output
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -104,32 +126,38 @@ export function AgentOutputModal({
const unsubscribe = api.autoMode.onEvent((event) => { const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only (skip events without featureId) // Filter events for this specific feature only (skip events without featureId)
if ('featureId' in event && event.featureId !== featureId) { if ("featureId" in event && event.featureId !== featureId) {
return; return;
} }
let newContent = ''; let newContent = "";
switch (event.type) { switch (event.type) {
case 'auto_mode_progress': case "auto_mode_progress":
newContent = event.content || ''; newContent = event.content || "";
break; break;
case 'auto_mode_tool': { case "auto_mode_tool":
const toolName = event.tool || 'Unknown Tool'; const toolName = event.tool || "Unknown Tool";
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : ''; const toolInput = event.input
newContent = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`; ? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : ""
}`;
break; break;
} case "auto_mode_phase":
case 'auto_mode_phase': {
const phaseEmoji = const phaseEmoji =
event.phase === 'planning' ? '📋' : event.phase === 'action' ? '⚡' : '✅'; event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
newContent = `\n${phaseEmoji} ${event.message}\n`; newContent = `\n${phaseEmoji} ${event.message}\n`;
break; break;
} case "auto_mode_error":
case 'auto_mode_error':
newContent = `\n❌ Error: ${event.error}\n`; newContent = `\n❌ Error: ${event.error}\n`;
break; break;
case 'auto_mode_ultrathink_preparation': { case "auto_mode_ultrathink_preparation":
// Format thinking level preparation information // Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`; let prepContent = `\n🧠 Ultrathink Preparation\n`;
@@ -159,74 +187,8 @@ export function AgentOutputModal({
newContent = prepContent; newContent = prepContent;
break; break;
} case "auto_mode_feature_complete":
case 'planning_started': { const emoji = event.passes ? "✅" : "⚠️";
// 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 ? '✅' : '⚠️';
newContent = `\n${emoji} Task completed: ${event.message}\n`; newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true) // Close the modal when the feature is verified (passes = true)
@@ -238,11 +200,13 @@ export function AgentOutputModal({
} }
break; break;
} }
}
if (newContent) { if (newContent) {
// Only update local state - server is the single source of truth for file writes setOutput((prev) => {
setOutput((prev) => prev + newContent); const updated = prev + newContent;
saveOutput(updated);
return updated;
});
} }
}); });
@@ -266,15 +230,20 @@ export function AgentOutputModal({
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
// Check if a number key (0-9) was pressed without modifiers // Check if a number key (0-9) was pressed without modifiers
if (!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(); event.preventDefault();
onNumberKeyPress(event.key); onNumberKeyPress(event.key);
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener("keydown", handleKeyDown);
}; };
}, [open, onNumberKeyPress]); }, [open, onNumberKeyPress]);
@@ -287,18 +256,19 @@ export function AgentOutputModal({
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && ( {featureStatus !== "verified" &&
featureStatus !== "waiting_approval" && (
<Loader2 className="w-5 h-5 text-primary animate-spin" /> <Loader2 className="w-5 h-5 text-primary animate-spin" />
)} )}
Agent Output Agent Output
</DialogTitle> </DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1"> <div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button <button
onClick={() => setViewMode('parsed')} onClick={() => setViewMode("parsed")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'parsed' viewMode === "parsed"
? 'bg-primary/20 text-primary shadow-sm' ? "bg-primary/20 text-primary shadow-sm"
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : "text-muted-foreground hover:text-foreground hover:bg-accent"
}`} }`}
data-testid="view-mode-parsed" data-testid="view-mode-parsed"
> >
@@ -306,11 +276,11 @@ export function AgentOutputModal({
Logs Logs
</button> </button>
<button <button
onClick={() => setViewMode('changes')} onClick={() => setViewMode("changes")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'changes' viewMode === "changes"
? 'bg-primary/20 text-primary shadow-sm' ? "bg-primary/20 text-primary shadow-sm"
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : "text-muted-foreground hover:text-foreground hover:bg-accent"
}`} }`}
data-testid="view-mode-changes" data-testid="view-mode-changes"
> >
@@ -318,11 +288,11 @@ export function AgentOutputModal({
Changes Changes
</button> </button>
<button <button
onClick={() => setViewMode('raw')} onClick={() => setViewMode("raw")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === 'raw' viewMode === "raw"
? 'bg-primary/20 text-primary shadow-sm' ? "bg-primary/20 text-primary shadow-sm"
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : "text-muted-foreground hover:text-foreground hover:bg-accent"
}`} }`}
data-testid="view-mode-raw" data-testid="view-mode-raw"
> >
@@ -339,14 +309,7 @@ export function AgentOutputModal({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* Task Progress Panel - shows when tasks are being executed */} {viewMode === "changes" ? (
<TaskProgressPanel
featureId={featureId}
projectPath={projectPath}
className="flex-shrink-0 mx-1"
/>
{viewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible"> <div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? ( {projectPath ? (
<GitDiffPanel <GitDiffPanel
@@ -379,17 +342,19 @@ export function AgentOutputModal({
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works. No output yet. The agent will stream output here as it works.
</div> </div>
) : viewMode === 'parsed' ? ( ) : viewMode === "parsed" ? (
<LogViewer output={output} /> <LogViewer output={output} />
) : ( ) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div> <div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)} )}
</div> </div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0"> <div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current {autoScrollRef.current
? 'Auto-scrolling enabled' ? "Auto-scrolling enabled"
: 'Scroll to bottom to enable auto-scroll'} : "Scroll to bottom to enable auto-scroll"}
</div> </div>
</> </>
)} )}

View File

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

View File

@@ -1,12 +1,15 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; "use client";
import { useAppStore, type AgentModel } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Input } from '@/components/ui/input'; import { useAppStore } from "@/store/app-store";
import { ImageDropZone } from '@/components/ui/image-drop-zone'; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ImageDropZone } from "@/components/ui/image-drop-zone";
import { import {
Bot, Bot,
Send, Send,
User, User,
Loader2,
Sparkles, Sparkles,
Wrench, Wrench,
Trash2, Trash2,
@@ -14,56 +17,28 @@ import {
PanelLeft, PanelLeft,
Paperclip, Paperclip,
X, X,
ImageIcon, } from "lucide-react";
ChevronDown, import { cn } from "@/lib/utils";
FileText, import { useElectronAgent } from "@/hooks/use-electron-agent";
Square, import { SessionManager } from "@/components/session-manager";
ListOrdered, import { Markdown } from "@/components/ui/markdown";
} from 'lucide-react'; import type { ImageAttachment } from "@/store/app-store";
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';
import { import {
useKeyboardShortcuts, useKeyboardShortcuts,
useKeyboardShortcutsConfig, useKeyboardShortcutsConfig,
KeyboardShortcut, KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts'; } from "@/hooks/use-keyboard-shortcuts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CLAUDE_MODELS } from '@/components/views/board-view/shared/model-constants';
export function AgentView() { export function AgentView() {
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore(); const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]); const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [selectedTextFiles, setSelectedTextFiles] = useState<TextFileAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false); const [showImageDropZone, setShowImageDropZone] = useState(false);
const [currentTool, setCurrentTool] = useState<string | null>(null); const [currentTool, setCurrentTool] = useState<string | null>(null);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionManager, setShowSessionManager] = useState(true); const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [selectedModel, setSelectedModel] = useState<AgentModel>('sonnet');
// Track if initial session has been loaded // Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false); const initialSessionLoadedRef = useRef(false);
@@ -85,16 +60,10 @@ export function AgentView() {
isConnected, isConnected,
sendMessage, sendMessage,
clearHistory, clearHistory,
stopExecution,
error: agentError, error: agentError,
serverQueue,
addToServerQueue,
removeFromServerQueue,
clearServerQueue,
} = useElectronAgent({ } = useElectronAgent({
sessionId: currentSessionId || '', sessionId: currentSessionId || "",
workingDirectory: currentProject?.path, workingDirectory: currentProject?.path,
model: selectedModel,
onToolUse: (toolName) => { onToolUse: (toolName) => {
setCurrentTool(toolName); setCurrentTool(toolName);
setTimeout(() => setCurrentTool(null), 2000); setTimeout(() => setCurrentTool(null), 2000);
@@ -102,16 +71,13 @@ export function AgentView() {
}); });
// Handle session selection with persistence // Handle session selection with persistence
const handleSelectSession = useCallback( const handleSelectSession = useCallback((sessionId: string | null) => {
(sessionId: string | null) => {
setCurrentSessionId(sessionId); setCurrentSessionId(sessionId);
// Persist the selection for this project // Persist the selection for this project
if (currentProject?.path) { if (currentProject?.path) {
setLastSelectedSession(currentProject.path, sessionId); setLastSelectedSession(currentProject.path, sessionId);
} }
}, }, [currentProject?.path, setLastSelectedSession]);
[currentProject?.path, setLastSelectedSession]
);
// Restore last selected session when switching to Agent view or when project changes // Restore last selected session when switching to Agent view or when project changes
useEffect(() => { useEffect(() => {
@@ -128,7 +94,7 @@ export function AgentView() {
const lastSessionId = getLastSelectedSession(currentProject.path); const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) { if (lastSessionId) {
console.log('[AgentView] Restoring last selected session:', lastSessionId); console.log("[AgentView] Restoring last selected session:", lastSessionId);
setCurrentSessionId(lastSessionId); setCurrentSessionId(lastSessionId);
} }
}, [currentProject?.path, getLastSelectedSession]); }, [currentProject?.path, getLastSelectedSession]);
@@ -139,24 +105,17 @@ export function AgentView() {
}, [currentProject?.path]); }, [currentProject?.path]);
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) return; if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
const messageContent = input; const messageContent = input;
const messageImages = selectedImages; const messageImages = selectedImages;
const messageTextFiles = selectedTextFiles;
setInput(''); setInput("");
setSelectedImages([]); setSelectedImages([]);
setSelectedTextFiles([]);
setShowImageDropZone(false); setShowImageDropZone(false);
// If already processing, add to server queue instead await sendMessage(messageContent, messageImages);
if (isProcessing) { }, [input, selectedImages, isProcessing, sendMessage]);
await addToServerQueue(messageContent, messageImages, messageTextFiles);
} else {
await sendMessage(messageContent, messageImages, messageTextFiles);
}
}, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage, addToServerQueue]);
const handleImagesSelected = useCallback((images: ImageAttachment[]) => { const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
setSelectedImages(images); setSelectedImages(images);
@@ -166,99 +125,88 @@ export function AgentView() {
setShowImageDropZone(!showImageDropZone); setShowImageDropZone(!showImageDropZone);
}, [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( const processDroppedFiles = useCallback(
async (files: FileList) => { async (files: FileList) => {
if (isProcessing) return; 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 newImages: ImageAttachment[] = [];
const newTextFiles: TextFileAttachment[] = [];
const errors: string[] = []; const errors: string[] = [];
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
// Check if it's a text file // Validate file type
if (isTextFile(file)) { if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
const validation = validateTextFile(file); errors.push(
if (!validation.isValid) { `${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
errors.push(validation.error!); );
continue;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024);
errors.push(
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
);
continue; continue;
} }
// Check if we've reached max files // Check if we've reached max files
const totalFiles = if (newImages.length + selectedImages.length >= MAX_FILES) {
newImages.length + errors.push(`Maximum ${MAX_FILES} images allowed.`);
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.`);
}
}
// 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; break;
} }
try { try {
const base64 = await fileToBase64(file); const base64 = await fileToBase64(file);
const imageAttachment: ImageAttachment = { const imageAttachment: ImageAttachment = {
id: generateImageId(), id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
data: base64, data: base64,
mimeType: file.type, mimeType: file.type,
filename: file.name, filename: file.name,
size: file.size, size: file.size,
}; };
newImages.push(imageAttachment); newImages.push(imageAttachment);
} catch { } catch (error) {
errors.push(`${file.name}: Failed to process image.`); errors.push(`${file.name}: Failed to process image.`);
} }
} else {
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
}
} }
if (errors.length > 0) { if (errors.length > 0) {
console.warn('File upload errors:', errors); console.warn("Image upload errors:", errors);
} }
if (newImages.length > 0) { if (newImages.length > 0) {
setSelectedImages((prev) => [...prev, ...newImages]); setSelectedImages((prev) => [...prev, ...newImages]);
} }
if (newTextFiles.length > 0) {
setSelectedTextFiles((prev) => [...prev, ...newTextFiles]);
}
}, },
[isProcessing, selectedImages, selectedTextFiles] [isProcessing, selectedImages, fileToBase64]
); );
// Remove individual image // Remove individual image
@@ -266,11 +214,6 @@ export function AgentView() {
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId)); 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 // Drag and drop handlers for the input area
const handleDragEnter = useCallback( const handleDragEnter = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
@@ -279,7 +222,7 @@ export function AgentView() {
if (isProcessing || !isConnected) return; if (isProcessing || !isConnected) return;
// Check if dragged items contain files // Check if dragged items contain files
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true); setIsDragOver(true);
} }
}, },
@@ -325,7 +268,7 @@ export function AgentView() {
if (items && items.length > 0) { if (items && items.length > 0) {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (item.kind === 'file') { if (item.kind === "file") {
const file = item.getAsFile(); const file = item.getAsFile();
if (file) { if (file) {
const dataTransfer = new DataTransfer(); const dataTransfer = new DataTransfer();
@@ -349,9 +292,9 @@ export function AgentView() {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const item = items[i]; const item = items[i];
if (item.kind === 'file') { if (item.kind === "file") {
const file = item.getAsFile(); const file = item.getAsFile();
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith("image/")) {
e.preventDefault(); // Prevent default paste of file path e.preventDefault(); // Prevent default paste of file path
files.push(file); files.push(file);
} }
@@ -369,14 +312,14 @@ export function AgentView() {
); );
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
}; };
const handleClearChat = async () => { const handleClearChat = async () => {
if (!confirm('Are you sure you want to clear this conversation?')) return; if (!confirm("Are you sure you want to clear this conversation?")) return;
await clearHistory(); await clearHistory();
}; };
@@ -387,13 +330,14 @@ export function AgentView() {
const threshold = 50; // 50px threshold for "near bottom" const threshold = 50; // 50px threshold for "near bottom"
const isAtBottom = const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight <= threshold; container.scrollHeight - container.scrollTop - container.clientHeight <=
threshold;
setIsUserAtBottom(isAtBottom); setIsUserAtBottom(isAtBottom);
}, []); }, []);
// Scroll to bottom function // Scroll to bottom function
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (!container) return; if (!container) return;
@@ -414,7 +358,7 @@ export function AgentView() {
if (isUserAtBottom && messages.length > 0) { if (isUserAtBottom && messages.length > 0) {
// Use a small delay to ensure DOM is updated // Use a small delay to ensure DOM is updated
setTimeout(() => { setTimeout(() => {
scrollToBottom('smooth'); scrollToBottom("smooth");
}, 100); }, 100);
} }
}, [messages, isUserAtBottom, scrollToBottom]); }, [messages, isUserAtBottom, scrollToBottom]);
@@ -424,7 +368,7 @@ export function AgentView() {
if (currentSessionId && messages.length > 0) { if (currentSessionId && messages.length > 0) {
// Scroll immediately without animation when switching sessions // Scroll immediately without animation when switching sessions
setTimeout(() => { setTimeout(() => {
scrollToBottom('auto'); scrollToBottom("auto");
setIsUserAtBottom(true); setIsUserAtBottom(true);
}, 100); }, 100);
} }
@@ -453,7 +397,7 @@ export function AgentView() {
quickCreateSessionRef.current(); quickCreateSessionRef.current();
} }
}, },
description: 'Create new session', description: "Create new session",
}); });
} }
@@ -487,8 +431,8 @@ export function AgentView() {
messages.length === 0 messages.length === 0
? [ ? [
{ {
id: 'welcome', id: "welcome",
role: 'assistant' as const, role: "assistant" as const,
content: content:
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?", "Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -497,7 +441,10 @@ export function AgentView() {
: messages; : messages;
return ( 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 */} {/* Session Manager Sidebar */}
{showSessionManager && currentProject && ( {showSessionManager && currentProject && (
<div className="w-80 border-r border-border flex-shrink-0 bg-card/50"> <div className="w-80 border-r border-border flex-shrink-0 bg-card/50">
@@ -535,7 +482,7 @@ export function AgentView() {
<h1 className="text-lg font-semibold text-foreground">AI Agent</h1> <h1 className="text-lg font-semibold text-foreground">AI Agent</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{currentProject.name} {currentProject.name}
{currentSessionId && !isConnected && ' - Connecting...'} {currentSessionId && !isConnected && " - Connecting..."}
</p> </p>
</div> </div>
</div> </div>
@@ -576,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"> <div className="w-16 h-16 rounded-2xl bg-muted/50 flex items-center justify-center mx-auto mb-6">
<Bot className="w-8 h-8 text-muted-foreground" /> <Bot className="w-8 h-8 text-muted-foreground" />
</div> </div>
<h2 className="text-lg font-semibold mb-3 text-foreground">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"> <p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Create or select a session to start chatting with the AI agent Create or select a session to start chatting with the AI agent
</p> </p>
@@ -586,7 +535,7 @@ export function AgentView() {
className="gap-2" className="gap-2"
> >
<PanelLeft className="w-4 h-4" /> <PanelLeft className="w-4 h-4" />
{showSessionManager ? 'View' : 'Show'} Sessions {showSessionManager ? "View" : "Show"} Sessions
</Button> </Button>
</div> </div>
</div> </div>
@@ -601,20 +550,20 @@ export function AgentView() {
<div <div
key={message.id} key={message.id}
className={cn( className={cn(
'flex gap-4 max-w-4xl', "flex gap-4 max-w-4xl",
message.role === 'user' ? 'flex-row-reverse ml-auto' : '' message.role === "user" ? "flex-row-reverse ml-auto" : ""
)} )}
> >
{/* Avatar */} {/* Avatar */}
<div <div
className={cn( className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm', "w-9 h-9 rounded-xl flex items-center justify-center shrink-0 shadow-sm",
message.role === 'assistant' message.role === "assistant"
? 'bg-primary/10 ring-1 ring-primary/20' ? "bg-primary/10 ring-1 ring-primary/20"
: 'bg-muted ring-1 ring-border' : "bg-muted ring-1 ring-border"
)} )}
> >
{message.role === 'assistant' ? ( {message.role === "assistant" ? (
<Bot className="w-4 h-4 text-primary" /> <Bot className="w-4 h-4 text-primary" />
) : ( ) : (
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
@@ -624,67 +573,32 @@ export function AgentView() {
{/* Message Bubble */} {/* Message Bubble */}
<div <div
className={cn( className={cn(
'flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm', "flex-1 max-w-[85%] rounded-2xl px-4 py-3 shadow-sm",
message.role === 'user' message.role === "user"
? 'bg-primary text-primary-foreground' ? "bg-primary text-primary-foreground"
: 'bg-card border border-border' : "bg-card border border-border"
)} )}
> >
{message.role === 'assistant' ? ( {message.role === "assistant" ? (
<Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded"> <Markdown className="text-sm text-foreground prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-code:text-primary prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded">
{message.content} {message.content}
</Markdown> </Markdown>
) : ( ) : (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{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 <p
className={cn( className={cn(
'text-[11px] mt-2 font-medium', "text-[11px] mt-2 font-medium",
message.role === 'user' message.role === "user"
? 'text-primary-foreground/70' ? "text-primary-foreground/70"
: 'text-muted-foreground' : "text-muted-foreground"
)} )}
> >
{new Date(message.timestamp).toLocaleTimeString([], { {new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
})} })}
</p> </p>
</div> </div>
@@ -700,20 +614,13 @@ export function AgentView() {
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm"> <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-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span <span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "0ms" }} />
className="w-2 h-2 rounded-full bg-primary animate-pulse" <span className="w-2 h-2 rounded-full bg-primary animate-pulse" style={{ animationDelay: "150ms" }} />
style={{ animationDelay: '0ms' }} <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: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div> </div>
<span className="text-sm text-muted-foreground">Thinking...</span> <span className="text-sm text-muted-foreground">
Thinking...
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -731,72 +638,27 @@ export function AgentView() {
images={selectedImages} images={selectedImages}
maxFiles={5} maxFiles={5}
className="mb-4" className="mb-4"
disabled={!isConnected} disabled={isProcessing || !isConnected}
/> />
)} )}
{/* Queued Prompts List */} {/* Selected Images Preview */}
{serverQueue.length > 0 && ( {selectedImages.length > 0 && (
<div className="mb-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{serverQueue.length} prompt{serverQueue.length > 1 ? 's' : ''} queued
</p>
<button
onClick={clearServerQueue}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
</div>
<div className="space-y-1.5">
{serverQueue.map((item, index) => (
<div
key={item.id}
className="group flex items-center gap-2 text-sm bg-muted/50 rounded-lg px-3 py-2 border border-border"
>
<span className="text-xs text-muted-foreground font-medium min-w-[1.5rem]">
{index + 1}.
</span>
<span className="flex-1 truncate text-foreground">{item.message}</span>
{item.imagePaths && item.imagePaths.length > 0 && (
<span className="text-xs text-muted-foreground">
+{item.imagePaths.length} file{item.imagePaths.length > 1 ? 's' : ''}
</span>
)}
<button
onClick={() => removeFromServerQueue(item.id)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-all"
title="Remove from queue"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */}
{(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (
<div className="mb-4 space-y-2"> <div className="mb-4 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground"> <p className="text-xs font-medium text-foreground">
{selectedImages.length + selectedTextFiles.length} file {selectedImages.length} image
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached {selectedImages.length > 1 ? "s" : ""} attached
</p> </p>
<button <button
onClick={() => { onClick={() => setSelectedImages([])}
setSelectedImages([]);
setSelectedTextFiles([]);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
disabled={isProcessing}
> >
Clear all Clear all
</button> </button>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* Image attachments */}
{selectedImages.map((image) => ( {selectedImages.map((image) => (
<div <div
key={image.id} key={image.id}
@@ -815,46 +677,13 @@ export function AgentView() {
<p className="text-xs font-medium text-foreground truncate max-w-24"> <p className="text-xs font-medium text-foreground truncate max-w-24">
{image.filename} {image.filename}
</p> </p>
{image.size !== undefined && (
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{formatFileSize(image.size)} {formatFileSize(image.size)}
</p> </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)}
</p>
</div> </div>
{/* Remove button */} {/* Remove button */}
<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" 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} disabled={isProcessing}
> >
@@ -869,8 +698,8 @@ export function AgentView() {
{/* Text Input and Controls */} {/* Text Input and Controls */}
<div <div
className={cn( className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1', "flex gap-2 transition-all duration-200 rounded-xl p-1",
isDragOver && 'bg-primary/5 ring-2 ring-primary/30' isDragOver && "bg-primary/5 ring-2 ring-primary/30"
)} )}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@@ -882,29 +711,25 @@ export function AgentView() {
ref={inputRef} ref={inputRef}
placeholder={ placeholder={
isDragOver isDragOver
? 'Drop your files here...' ? "Drop your images here..."
: isProcessing : "Describe what you want to build..."
? 'Type to queue another prompt...'
: 'Describe what you want to build...'
} }
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
onPaste={handlePaste} onPaste={handlePaste}
disabled={!isConnected} disabled={isProcessing || !isConnected}
data-testid="agent-input" data-testid="agent-input"
className={cn( className={cn(
'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all', "h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all",
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50', "focus:ring-2 focus:ring-primary/20 focus:border-primary/50",
(selectedImages.length > 0 || selectedTextFiles.length > 0) && selectedImages.length > 0 && "border-primary/30",
'border-primary/30', isDragOver && "border-primary bg-primary/5"
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"> <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} image{selectedImages.length > 1 ? "s" : ""}
{selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
</div> </div>
)} )}
{isDragOver && ( {isDragOver && (
@@ -915,92 +740,40 @@ export function AgentView() {
)} )}
</div> </div>
{/* Model Selector */} {/* Image Attachment Button */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-11 gap-1 text-xs font-medium rounded-xl border-border px-2.5"
data-testid="model-selector"
>
{CLAUDE_MODELS.find((m) => m.id === selectedModel)?.label.replace(
'Claude ',
''
) || 'Sonnet'}
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{CLAUDE_MODELS.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={cn('cursor-pointer', selectedModel === model.id && 'bg-accent')}
data-testid={`model-option-${model.id}`}
>
<div className="flex flex-col">
<span className="font-medium">{model.label}</span>
<span className="text-xs text-muted-foreground">{model.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* File Attachment Button */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={toggleImageDropZone} onClick={toggleImageDropZone}
disabled={!isConnected} disabled={isProcessing || !isConnected}
className={cn( className={cn(
'h-11 w-11 rounded-xl border-border', "h-11 w-11 rounded-xl border-border",
showImageDropZone && 'bg-primary/10 text-primary border-primary/30', showImageDropZone && "bg-primary/10 text-primary border-primary/30",
(selectedImages.length > 0 || selectedTextFiles.length > 0) && selectedImages.length > 0 && "border-primary/30 text-primary"
'border-primary/30 text-primary'
)} )}
title="Attach files (images, .txt, .md)" title="Attach images"
> >
<Paperclip className="w-4 h-4" /> <Paperclip className="w-4 h-4" />
</Button> </Button>
{/* Stop Button (only when processing) */} {/* Send Button */}
{isProcessing && (
<Button
onClick={stopExecution}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button <Button
onClick={handleSend} onClick={handleSend}
disabled={ disabled={
(!input.trim() && (!input.trim() && selectedImages.length === 0) ||
selectedImages.length === 0 && isProcessing ||
selectedTextFiles.length === 0) ||
!isConnected !isConnected
} }
className="h-11 px-4 rounded-xl" className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message" data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
> >
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />} <Send className="w-4 h-4" />
</Button> </Button>
</div> </div>
{/* Keyboard hint */} {/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center"> <p className="text-[11px] text-muted-foreground mt-2 text-center">
Press{' '} Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to send
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send
</p> </p>
</div> </div>
)} )}
@@ -1008,3 +781,12 @@ export function AgentView() {
</div> </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'; "use client";
import { useAppStore, FileTreeNode, ProjectAnalysis } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { useCallback, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import {
import { Button } from '@/components/ui/button'; 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 { import {
Folder, Folder,
FolderOpen, FolderOpen,
@@ -18,29 +31,29 @@ import {
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
ListChecks, ListChecks,
} from 'lucide-react'; } from "lucide-react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
const IGNORE_PATTERNS = [ const IGNORE_PATTERNS = [
'node_modules', "node_modules",
'.git', ".git",
'.next', ".next",
'dist', "dist",
'build', "build",
'.DS_Store', ".DS_Store",
'*.log', "*.log",
'.cache', ".cache",
'coverage', "coverage",
'__pycache__', "__pycache__",
'.pytest_cache', ".pytest_cache",
'.venv', ".venv",
'venv', "venv",
'.env', ".env",
]; ];
const shouldIgnore = (name: string) => { const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => { return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith('*')) { if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1)); return name.endsWith(pattern.slice(1));
} }
return name === pattern; return name === pattern;
@@ -48,8 +61,8 @@ const shouldIgnore = (name: string) => {
}; };
const getExtension = (filename: string): string => { const getExtension = (filename: string): string => {
const parts = filename.split('.'); const parts = filename.split(".");
return parts.length > 1 ? parts.pop() || '' : ''; return parts.length > 1 ? parts.pop() || "" : "";
}; };
export function AnalysisView() { export function AnalysisView() {
@@ -62,7 +75,9 @@ export function AnalysisView() {
clearAnalysis, clearAnalysis,
} = useAppStore(); } = useAppStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [isGeneratingSpec, setIsGeneratingSpec] = useState(false); const [isGeneratingSpec, setIsGeneratingSpec] = useState(false);
const [specGenerated, setSpecGenerated] = useState(false); const [specGenerated, setSpecGenerated] = useState(false);
const [specError, setSpecError] = useState<string | null>(null); const [specError, setSpecError] = useState<string | null>(null);
@@ -109,7 +124,7 @@ export function AnalysisView() {
return nodes; return nodes;
} catch (error) { } catch (error) {
console.error('Failed to scan directory:', path, error); console.error("Failed to scan directory:", path, error);
return []; return [];
} }
}, },
@@ -134,7 +149,7 @@ export function AnalysisView() {
if (item.extension) { if (item.extension) {
byExt[item.extension] = (byExt[item.extension] || 0) + 1; byExt[item.extension] = (byExt[item.extension] || 0) + 1;
} else { } else {
byExt['(no extension)'] = (byExt['(no extension)'] || 0) + 1; byExt["(no extension)"] = (byExt["(no extension)"] || 0) + 1;
} }
} }
} }
@@ -165,11 +180,17 @@ export function AnalysisView() {
setProjectAnalysis(analysis); setProjectAnalysis(analysis);
} catch (error) { } catch (error) {
console.error('Analysis failed:', error); console.error("Analysis failed:", error);
} finally { } finally {
setIsAnalyzing(false); setIsAnalyzing(false);
} }
}, [currentProject, setIsAnalyzing, clearAnalysis, scanDirectory, setProjectAnalysis]); }, [
currentProject,
setIsAnalyzing,
clearAnalysis,
scanDirectory,
setProjectAnalysis,
]);
// Generate app_spec.txt from analysis // Generate app_spec.txt from analysis
const generateSpec = useCallback(async () => { const generateSpec = useCallback(async () => {
@@ -184,7 +205,7 @@ export function AnalysisView() {
// Read key files to understand the project better // Read key files to understand the project better
const fileContents: Record<string, string> = {}; const fileContents: Record<string, string> = {};
const keyFiles = ['package.json', 'README.md', 'tsconfig.json']; const keyFiles = ["package.json", "README.md", "tsconfig.json"];
// Collect file paths from analysis // Collect file paths from analysis
const collectFilePaths = ( const collectFilePaths = (
@@ -197,13 +218,15 @@ export function AnalysisView() {
if (!node.isDirectory) { if (!node.isDirectory) {
paths.push(node.path); paths.push(node.path);
} else if (node.children && currentDepth < maxDepth) { } else if (node.children && currentDepth < maxDepth) {
paths.push(...collectFilePaths(node.children, maxDepth, currentDepth + 1)); paths.push(
...collectFilePaths(node.children, maxDepth, currentDepth + 1)
);
} }
} }
return paths; return paths;
}; };
collectFilePaths(projectAnalysis.fileTree); const allFilePaths = collectFilePaths(projectAnalysis.fileTree);
// Try to read key configuration files // Try to read key configuration files
for (const keyFile of keyFiles) { for (const keyFile of keyFiles) {
@@ -223,34 +246,40 @@ export function AnalysisView() {
const extensions = projectAnalysis.filesByExtension; const extensions = projectAnalysis.filesByExtension;
// Check package.json for dependencies // Check package.json for dependencies
if (fileContents['package.json']) { if (fileContents["package.json"]) {
try { try {
const pkg = JSON.parse(fileContents['package.json']); const pkg = JSON.parse(fileContents["package.json"]);
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) stack.push('React'); if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"])
if (pkg.dependencies?.next) stack.push('Next.js'); stack.push("React");
if (pkg.dependencies?.vue) stack.push('Vue'); if (pkg.dependencies?.next) stack.push("Next.js");
if (pkg.dependencies?.angular) stack.push('Angular'); if (pkg.dependencies?.vue) stack.push("Vue");
if (pkg.dependencies?.express) stack.push('Express'); if (pkg.dependencies?.angular) stack.push("Angular");
if (pkg.dependencies?.electron) stack.push('Electron'); if (pkg.dependencies?.express) stack.push("Express");
if (pkg.dependencies?.electron) stack.push("Electron");
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript)
stack.push('TypeScript'); stack.push("TypeScript");
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) if (
stack.push('Tailwind CSS'); pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
)
stack.push("Tailwind CSS");
if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright) if (pkg.devDependencies?.playwright || pkg.dependencies?.playwright)
stack.push('Playwright'); stack.push("Playwright");
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) stack.push('Jest'); if (pkg.devDependencies?.jest || pkg.dependencies?.jest)
stack.push("Jest");
} catch { } catch {
// Ignore JSON parse errors // Ignore JSON parse errors
} }
} }
// Detect by file extensions // Detect by file extensions
if (extensions['ts'] || extensions['tsx']) stack.push('TypeScript'); if (extensions["ts"] || extensions["tsx"]) stack.push("TypeScript");
if (extensions['py']) stack.push('Python'); if (extensions["py"]) stack.push("Python");
if (extensions['go']) stack.push('Go'); if (extensions["go"]) stack.push("Go");
if (extensions['rs']) stack.push('Rust'); if (extensions["rs"]) stack.push("Rust");
if (extensions['java']) stack.push('Java'); if (extensions["java"]) stack.push("Java");
if (extensions['css'] || extensions['scss'] || extensions['sass']) stack.push('CSS/SCSS'); if (extensions["css"] || extensions["scss"] || extensions["sass"])
stack.push("CSS/SCSS");
// Remove duplicates // Remove duplicates
return [...new Set(stack)]; return [...new Set(stack)];
@@ -258,9 +287,9 @@ export function AnalysisView() {
// Get project name from package.json or folder name // Get project name from package.json or folder name
const getProjectName = () => { const getProjectName = () => {
if (fileContents['package.json']) { if (fileContents["package.json"]) {
try { try {
const pkg = JSON.parse(fileContents['package.json']); const pkg = JSON.parse(fileContents["package.json"]);
if (pkg.name) return pkg.name; if (pkg.name) return pkg.name;
} catch { } catch {
// Ignore JSON parse errors // Ignore JSON parse errors
@@ -272,30 +301,30 @@ export function AnalysisView() {
// Get project description from package.json or README // Get project description from package.json or README
const getProjectDescription = () => { const getProjectDescription = () => {
if (fileContents['package.json']) { if (fileContents["package.json"]) {
try { try {
const pkg = JSON.parse(fileContents['package.json']); const pkg = JSON.parse(fileContents["package.json"]);
if (pkg.description) return pkg.description; if (pkg.description) return pkg.description;
} catch { } catch {
// Ignore JSON parse errors // Ignore JSON parse errors
} }
} }
if (fileContents['README.md']) { if (fileContents["README.md"]) {
// Extract first paragraph from README // Extract first paragraph from README
const lines = fileContents['README.md'].split('\n'); const lines = fileContents["README.md"].split("\n");
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if ( if (
trimmed && trimmed &&
!trimmed.startsWith('#') && !trimmed.startsWith("#") &&
!trimmed.startsWith('!') && !trimmed.startsWith("!") &&
trimmed.length > 20 trimmed.length > 20
) { ) {
return trimmed.substring(0, 200); return trimmed.substring(0, 200);
} }
} }
} }
return 'A software project'; return "A software project";
}; };
// Group files by directory for structure analysis // Group files by directory for structure analysis
@@ -308,7 +337,7 @@ export function AnalysisView() {
for (const dir of topLevelDirs) { for (const dir of topLevelDirs) {
structure.push(` <directory name="${dir}" />`); structure.push(` <directory name="${dir}" />`);
} }
return structure.join('\n'); return structure.join("\n");
}; };
const projectName = getProjectName(); const projectName = getProjectName();
@@ -328,15 +357,20 @@ export function AnalysisView() {
<languages> <languages>
${Object.entries(projectAnalysis.filesByExtension) ${Object.entries(projectAnalysis.filesByExtension)
.filter(([ext]: [string, number]) => .filter(([ext]: [string, number]) =>
['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'cpp', 'c'].includes(ext) ["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
ext
)
) )
.sort((a: [string, number], b: [string, number]) => b[1] - a[1]) .sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5) .slice(0, 5)
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`) .map(
.join('\n')} ([ext, count]: [string, number]) =>
` <language ext=".${ext}" count="${count}" />`
)
.join("\n")}
</languages> </languages>
<frameworks> <frameworks>
${techStack.map((tech) => ` <framework>${tech}</framework>`).join('\n')} ${techStack.map((tech) => ` <framework>${tech}</framework>`).join("\n")}
</frameworks> </frameworks>
</technology_stack> </technology_stack>
@@ -354,9 +388,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
.slice(0, 10) .slice(0, 10)
.map( .map(
([ext, count]: [string, number]) => ([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> </file_breakdown>
<analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at> <analyzed_at>${projectAnalysis.analyzedAt}</analyzed_at>
@@ -370,11 +406,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
if (writeResult.success) { if (writeResult.success) {
setSpecGenerated(true); setSpecGenerated(true);
} else { } else {
setSpecError(writeResult.error || 'Failed to write spec file'); setSpecError(writeResult.error || "Failed to write spec file");
} }
} catch (error) { } catch (error) {
console.error('Failed to generate spec:', error); console.error("Failed to generate spec:", error);
setSpecError(error instanceof Error ? error.message : 'Failed to generate spec'); setSpecError(
error instanceof Error ? error.message : "Failed to generate spec"
);
} finally { } finally {
setIsGeneratingSpec(false); setIsGeneratingSpec(false);
} }
@@ -393,7 +431,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Read key files to understand the project // Read key files to understand the project
const fileContents: Record<string, string> = {}; const fileContents: Record<string, string> = {};
const keyFiles = ['package.json', 'README.md']; const keyFiles = ["package.json", "README.md"];
// Try to read key configuration files // Try to read key configuration files
for (const keyFile of keyFiles) { for (const keyFile of keyFiles) {
@@ -426,6 +464,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
interface DetectedFeature { interface DetectedFeature {
category: string; category: string;
description: string; description: string;
steps: string[];
passes: boolean; passes: boolean;
} }
@@ -443,59 +482,86 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for test directories and files // Check for test directories and files
const hasTests = const hasTests =
topLevelDirs.includes('tests') || topLevelDirs.includes("tests") ||
topLevelDirs.includes('test') || topLevelDirs.includes("test") ||
topLevelDirs.includes('__tests__') || topLevelDirs.includes("__tests__") ||
allFilePaths.some((p) => p.includes('.spec.') || p.includes('.test.')); allFilePaths.some(
(p) => p.includes(".spec.") || p.includes(".test.")
);
if (hasTests) { if (hasTests) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Testing', category: "Testing",
description: 'Automated test suite', description: "Automated test suite",
steps: [
"Step 1: Tests directory exists",
"Step 2: Test files are present",
"Step 3: Run test suite",
],
passes: true, passes: true,
}); });
} }
// Check for components directory (UI components) // Check for components directory (UI components)
const hasComponents = const hasComponents =
topLevelDirs.includes('components') || topLevelDirs.includes("components") ||
allFilePaths.some((p) => p.toLowerCase().includes('/components/')); allFilePaths.some((p) => p.toLowerCase().includes("/components/"));
if (hasComponents) { if (hasComponents) {
detectedFeatures.push({ detectedFeatures.push({
category: 'UI/Design', category: "UI/Design",
description: 'Component-based UI architecture', description: "Component-based UI architecture",
steps: [
"Step 1: Components directory exists",
"Step 2: UI components are defined",
"Step 3: Components are reusable",
],
passes: true, passes: true,
}); });
} }
// Check for src directory (organized source code) // Check for src directory (organized source code)
if (topLevelDirs.includes('src')) { if (topLevelDirs.includes("src")) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Project Structure', category: "Project Structure",
description: 'Organized source code structure', description: "Organized source code structure",
steps: [
"Step 1: Source directory exists",
"Step 2: Code is properly organized",
"Step 3: Follows best practices",
],
passes: true, passes: true,
}); });
} }
// Check package.json for dependencies and detect features // Check package.json for dependencies and detect features
if (fileContents['package.json']) { if (fileContents["package.json"]) {
try { try {
const pkg = JSON.parse(fileContents['package.json']); const pkg = JSON.parse(fileContents["package.json"]);
// React/Next.js app detection // React/Next.js app detection
if (pkg.dependencies?.react || pkg.dependencies?.['react-dom']) { if (pkg.dependencies?.react || pkg.dependencies?.["react-dom"]) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Frontend', category: "Frontend",
description: 'React-based user interface', description: "React-based user interface",
steps: [
"Step 1: React is installed",
"Step 2: Components render correctly",
"Step 3: State management works",
],
passes: true, passes: true,
}); });
} }
if (pkg.dependencies?.next) { if (pkg.dependencies?.next) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Framework', category: "Framework",
description: 'Next.js framework integration', description: "Next.js framework integration",
steps: [
"Step 1: Next.js is configured",
"Step 2: Pages/routes are defined",
"Step 3: Server-side rendering works",
],
passes: true, passes: true,
}); });
} }
@@ -504,21 +570,34 @@ ${Object.entries(projectAnalysis.filesByExtension)
if ( if (
pkg.devDependencies?.typescript || pkg.devDependencies?.typescript ||
pkg.dependencies?.typescript || pkg.dependencies?.typescript ||
extensions['ts'] || extensions["ts"] ||
extensions['tsx'] extensions["tsx"]
) { ) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Developer Experience', category: "Developer Experience",
description: 'TypeScript type safety', description: "TypeScript type safety",
steps: [
"Step 1: TypeScript is configured",
"Step 2: Type definitions exist",
"Step 3: Code compiles without errors",
],
passes: true, passes: true,
}); });
} }
// Tailwind CSS // Tailwind CSS
if (pkg.devDependencies?.tailwindcss || pkg.dependencies?.tailwindcss) { if (
pkg.devDependencies?.tailwindcss ||
pkg.dependencies?.tailwindcss
) {
detectedFeatures.push({ detectedFeatures.push({
category: 'UI/Design', category: "UI/Design",
description: 'Tailwind CSS styling', description: "Tailwind CSS styling",
steps: [
"Step 1: Tailwind is configured",
"Step 2: Styles are applied",
"Step 3: Responsive design works",
],
passes: true, passes: true,
}); });
} }
@@ -526,8 +605,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
// ESLint/Prettier (code quality) // ESLint/Prettier (code quality)
if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) { if (pkg.devDependencies?.eslint || pkg.devDependencies?.prettier) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Developer Experience', category: "Developer Experience",
description: 'Code quality tools', description: "Code quality tools",
steps: [
"Step 1: Linter is configured",
"Step 2: Code passes lint checks",
"Step 3: Formatting is consistent",
],
passes: true, passes: true,
}); });
} }
@@ -535,17 +619,30 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Electron (desktop app) // Electron (desktop app)
if (pkg.dependencies?.electron || pkg.devDependencies?.electron) { if (pkg.dependencies?.electron || pkg.devDependencies?.electron) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Platform', category: "Platform",
description: 'Electron desktop application', description: "Electron desktop application",
steps: [
"Step 1: Electron is configured",
"Step 2: Main process runs",
"Step 3: Renderer process loads",
],
passes: true, passes: true,
}); });
} }
// Playwright testing // Playwright testing
if (pkg.devDependencies?.playwright || pkg.devDependencies?.['@playwright/test']) { if (
pkg.devDependencies?.playwright ||
pkg.devDependencies?.["@playwright/test"]
) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Testing', category: "Testing",
description: 'Playwright end-to-end testing', description: "Playwright end-to-end testing",
steps: [
"Step 1: Playwright is configured",
"Step 2: E2E tests are defined",
"Step 3: Tests pass successfully",
],
passes: true, passes: true,
}); });
} }
@@ -555,37 +652,58 @@ ${Object.entries(projectAnalysis.filesByExtension)
} }
// Check for documentation // Check for documentation
if (topLevelFiles.includes('readme.md') || topLevelDirs.includes('docs')) { if (
topLevelFiles.includes("readme.md") ||
topLevelDirs.includes("docs")
) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Documentation', category: "Documentation",
description: 'Project documentation', description: "Project documentation",
steps: [
"Step 1: README exists",
"Step 2: Documentation is comprehensive",
"Step 3: Setup instructions are clear",
],
passes: true, passes: true,
}); });
} }
// Check for CI/CD configuration // Check for CI/CD configuration
const hasCICD = const hasCICD =
topLevelDirs.includes('.github') || topLevelDirs.includes(".github") ||
topLevelFiles.includes('.gitlab-ci.yml') || topLevelFiles.includes(".gitlab-ci.yml") ||
topLevelFiles.includes('.travis.yml'); topLevelFiles.includes(".travis.yml");
if (hasCICD) { if (hasCICD) {
detectedFeatures.push({ detectedFeatures.push({
category: 'DevOps', category: "DevOps",
description: 'CI/CD pipeline configuration', description: "CI/CD pipeline configuration",
steps: [
"Step 1: CI config exists",
"Step 2: Pipeline runs on push",
"Step 3: Automated checks pass",
],
passes: true, passes: true,
}); });
} }
// Check for API routes (Next.js API or Express) // Check for API routes (Next.js API or Express)
const hasAPIRoutes = allFilePaths.some( const hasAPIRoutes = allFilePaths.some(
(p) => p.includes('/api/') || p.includes('/routes/') || p.includes('/endpoints/') (p) =>
p.includes("/api/") ||
p.includes("/routes/") ||
p.includes("/endpoints/")
); );
if (hasAPIRoutes) { if (hasAPIRoutes) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Backend', category: "Backend",
description: 'API endpoints', description: "API endpoints",
steps: [
"Step 1: API routes are defined",
"Step 2: Endpoints respond correctly",
"Step 3: Error handling is implemented",
],
passes: true, passes: true,
}); });
} }
@@ -593,25 +711,38 @@ ${Object.entries(projectAnalysis.filesByExtension)
// Check for state management // Check for state management
const hasStateManagement = allFilePaths.some( const hasStateManagement = allFilePaths.some(
(p) => (p) =>
p.includes('/store/') || p.includes("/store/") ||
p.includes('/stores/') || p.includes("/stores/") ||
p.includes('/redux/') || p.includes("/redux/") ||
p.includes('/context/') p.includes("/context/")
); );
if (hasStateManagement) { if (hasStateManagement) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Architecture', category: "Architecture",
description: 'State management system', description: "State management system",
steps: [
"Step 1: Store is configured",
"Step 2: State updates correctly",
"Step 3: Components access state",
],
passes: true, passes: true,
}); });
} }
// Check for configuration files // Check for configuration files
if (topLevelFiles.includes('tsconfig.json') || topLevelFiles.includes('package.json')) { if (
topLevelFiles.includes("tsconfig.json") ||
topLevelFiles.includes("package.json")
) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Configuration', category: "Configuration",
description: 'Project configuration files', description: "Project configuration files",
steps: [
"Step 1: Config files exist",
"Step 2: Configuration is valid",
"Step 3: Build process works",
],
passes: true, passes: true,
}); });
} }
@@ -622,15 +753,20 @@ ${Object.entries(projectAnalysis.filesByExtension)
// If no features were detected, add a default feature // If no features were detected, add a default feature
if (detectedFeatures.length === 0) { if (detectedFeatures.length === 0) {
detectedFeatures.push({ detectedFeatures.push({
category: 'Core', category: "Core",
description: 'Basic project structure', description: "Basic project structure",
steps: [
"Step 1: Project directory exists",
"Step 2: Files are present",
"Step 3: Project can be loaded",
],
passes: true, passes: true,
}); });
} }
// Create each feature using the features API // Create each feature using the features API
if (!api.features) { if (!api.features) {
throw new Error('Features API not available'); throw new Error("Features API not available");
} }
for (const detectedFeature of detectedFeatures) { for (const detectedFeature of detectedFeatures) {
@@ -638,15 +774,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
id: crypto.randomUUID(), id: crypto.randomUUID(),
category: detectedFeature.category, category: detectedFeature.category,
description: detectedFeature.description, description: detectedFeature.description,
status: 'backlog', steps: detectedFeature.steps,
status: "backlog",
}); });
} }
setFeatureListGenerated(true); setFeatureListGenerated(true);
} catch (error) { } catch (error) {
console.error('Failed to generate feature list:', error); console.error("Failed to generate feature list:", error);
setFeatureListError( setFeatureListError(
error instanceof Error ? error.message : 'Failed to generate feature list' error instanceof Error
? error.message
: "Failed to generate feature list"
); );
} finally { } finally {
setIsGeneratingFeatureList(false); setIsGeneratingFeatureList(false);
@@ -672,7 +811,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<div key={node.path} data-testid={`analysis-node-${node.name}`}> <div key={node.path} data-testid={`analysis-node-${node.name}`}>
<div <div
className={cn( className={cn(
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm' "flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50 text-sm"
)} )}
style={{ paddingLeft: `${depth * 16 + 8}px` }} style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => { onClick={() => {
@@ -702,11 +841,17 @@ ${Object.entries(projectAnalysis.filesByExtension)
)} )}
<span className="truncate">{node.name}</span> <span className="truncate">{node.name}</span>
{node.extension && ( {node.extension && (
<span className="text-xs text-muted-foreground ml-auto">.{node.extension}</span> <span className="text-xs text-muted-foreground ml-auto">
.{node.extension}
</span>
)} )}
</div> </div>
{node.isDirectory && isExpanded && node.children && ( {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> </div>
); );
@@ -724,17 +869,26 @@ ${Object.entries(projectAnalysis.filesByExtension)
} }
return ( 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 */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md"> <div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Search className="w-5 h-5 text-muted-foreground" /> <Search className="w-5 h-5 text-muted-foreground" />
<div> <div>
<h1 className="text-xl font-bold">Project Analysis</h1> <h1 className="text-xl font-bold">Project Analysis</h1>
<p className="text-sm text-muted-foreground">{currentProject.name}</p> <p className="text-sm text-muted-foreground">
{currentProject.name}
</p>
</div> </div>
</div> </div>
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button"> <Button
onClick={runAnalysis}
disabled={isAnalyzing}
data-testid="analyze-project-button"
>
{isAnalyzing ? ( {isAnalyzing ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -756,10 +910,13 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Search className="w-16 h-16 text-muted-foreground/50 mb-4" /> <Search className="w-16 h-16 text-muted-foreground/50 mb-4" />
<h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2> <h2 className="text-lg font-semibold mb-2">No Analysis Yet</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md"> <p className="text-sm text-muted-foreground mb-4 max-w-md">
Click &quot;Analyze Project&quot; to scan your codebase and get insights about its Click &quot;Analyze Project&quot; to scan your codebase and get
structure. insights about its structure.
</p> </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" /> <Search className="w-4 h-4 mr-2" />
Start Analysis Start Analysis
</Button> </Button>
@@ -780,19 +937,27 @@ ${Object.entries(projectAnalysis.filesByExtension)
Statistics Statistics
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Analyzed {new Date(projectAnalysis.analyzedAt).toLocaleString()} Analyzed{" "}
{new Date(projectAnalysis.analyzedAt).toLocaleString()}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground">Total Files</span> <span className="text-sm text-muted-foreground">
Total Files
</span>
<span className="font-medium" data-testid="total-files"> <span className="font-medium" data-testid="total-files">
{projectAnalysis.totalFiles} {projectAnalysis.totalFiles}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-muted-foreground">Total Directories</span> <span className="text-sm text-muted-foreground">
<span className="font-medium" data-testid="total-directories"> Total Directories
</span>
<span
className="font-medium"
data-testid="total-directories"
>
{projectAnalysis.totalDirectories} {projectAnalysis.totalDirectories}
</span> </span>
</div> </div>
@@ -809,12 +974,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension) {Object.entries(projectAnalysis.filesByExtension)
.sort((a: [string, number], b: [string, number]) => b[1] - a[1]) .sort(
(a: [string, number], b: [string, number]) =>
b[1] - a[1]
)
.slice(0, 15) .slice(0, 15)
.map(([ext, count]: [string, number]) => ( .map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm"> <div key={ext} className="flex justify-between text-sm">
<span className="text-muted-foreground font-mono"> <span className="text-muted-foreground font-mono">
{ext.startsWith('(') ? ext : `.${ext}`} {ext.startsWith("(") ? ext : `.${ext}`}
</span> </span>
<span>{count}</span> <span>{count}</span>
</div> </div>
@@ -830,12 +998,14 @@ ${Object.entries(projectAnalysis.filesByExtension)
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
Generate Specification Generate Specification
</CardTitle> </CardTitle>
<CardDescription>Create app_spec.txt from analysis</CardDescription> <CardDescription>
Create app_spec.txt from analysis
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Generate a project specification file based on the analyzed codebase structure Generate a project specification file based on the analyzed
and detected technologies. codebase structure and detected technologies.
</p> </p>
<Button <Button
onClick={generateSpec} onClick={generateSpec}
@@ -883,12 +1053,15 @@ ${Object.entries(projectAnalysis.filesByExtension)
<ListChecks className="w-4 h-4" /> <ListChecks className="w-4 h-4" />
Generate Feature List Generate Feature List
</CardTitle> </CardTitle>
<CardDescription>Create features from analysis</CardDescription> <CardDescription>
Create features from analysis
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Automatically detect and generate a feature list based on the analyzed codebase Automatically detect and generate a feature list based on
structure, dependencies, and project configuration. the analyzed codebase structure, dependencies, and project
configuration.
</p> </p>
<Button <Button
onClick={generateFeatureList} onClick={generateFeatureList}
@@ -938,13 +1111,18 @@ ${Object.entries(projectAnalysis.filesByExtension)
File Tree File Tree
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{projectAnalysis.totalFiles} files in {projectAnalysis.totalDirectories}{' '} {projectAnalysis.totalFiles} files in{" "}
directories {projectAnalysis.totalDirectories} directories
</CardDescription> </CardDescription>
</CardHeader> </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"> <div className="p-2">
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))} {projectAnalysis.fileTree.map((node: FileTreeNode) =>
renderNode(node)
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,10 +1,20 @@
import { useEffect, useState, useCallback } from 'react'; "use client";
import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { useEffect, useState, useCallback } from "react";
import { Card, CardContent } from '@/components/ui/card'; import { useAppStore } from "@/store/app-store";
import { Button } from '@/components/ui/button'; import { getElectronAPI } from "@/lib/electron";
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react'; import { Card, CardContent } from "@/components/ui/card";
import { cn } from '@/lib/utils'; import { Button } from "@/components/ui/button";
import {
File,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
RefreshCw,
Code,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface FileTreeNode { interface FileTreeNode {
name: string; name: string;
@@ -14,11 +24,19 @@ interface FileTreeNode {
isExpanded?: boolean; 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) => { const shouldIgnore = (name: string) => {
return IGNORE_PATTERNS.some((pattern) => { return IGNORE_PATTERNS.some((pattern) => {
if (pattern.startsWith('*')) { if (pattern.startsWith("*")) {
return name.endsWith(pattern.slice(1)); return name.endsWith(pattern.slice(1));
} }
return name === pattern; return name === pattern;
@@ -29,9 +47,11 @@ export function CodeView() {
const { currentProject } = useAppStore(); const { currentProject } = useAppStore();
const [fileTree, setFileTree] = useState<FileTreeNode[]>([]); const [fileTree, setFileTree] = useState<FileTreeNode[]>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null); const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>(''); const [fileContent, setFileContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
// Load directory tree // Load directory tree
const loadTree = useCallback(async () => { const loadTree = useCallback(async () => {
@@ -60,7 +80,7 @@ export function CodeView() {
setFileTree(entries); setFileTree(entries);
} }
} catch (error) { } catch (error) {
console.error('Failed to load file tree:', error); console.error("Failed to load file tree:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -91,7 +111,7 @@ export function CodeView() {
})); }));
} }
} catch (error) { } catch (error) {
console.error('Failed to load subdirectory:', error); console.error("Failed to load subdirectory:", error);
} }
return []; return [];
}; };
@@ -107,7 +127,7 @@ export function CodeView() {
setSelectedFile(path); setSelectedFile(path);
} }
} catch (error) { } 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 key={node.path}>
<div <div
className={cn( className={cn(
'flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50', "flex items-center gap-2 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && 'bg-muted' isSelected && "bg-muted"
)} )}
style={{ paddingLeft: `${depth * 16 + 8}px` }} style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={() => { onClick={() => {
@@ -186,7 +206,9 @@ export function CodeView() {
<span className="text-sm truncate">{node.name}</span> <span className="text-sm truncate">{node.name}</span>
</div> </div>
{node.isDirectory && isExpanded && node.children && ( {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> </div>
); );
@@ -194,7 +216,10 @@ export function CodeView() {
if (!currentProject) { if (!currentProject) {
return ( 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> <p className="text-muted-foreground">No project selected</p>
</div> </div>
); );
@@ -202,24 +227,37 @@ export function CodeView() {
if (isLoading) { if (isLoading) {
return ( 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" /> <RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
</div> </div>
); );
} }
return ( 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 */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md"> <div className="flex items-center justify-between p-4 border-b border-white/10 bg-zinc-950/50 backdrop-blur-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Code className="w-5 h-5 text-muted-foreground" /> <Code className="w-5 h-5 text-muted-foreground" />
<div> <div>
<h1 className="text-xl font-bold">Code Explorer</h1> <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>
</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" /> <RefreshCw className="w-4 h-4 mr-2" />
Refresh Refresh
</Button> </Button>
@@ -238,7 +276,7 @@ export function CodeView() {
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="px-4 py-2 border-b bg-muted/30"> <div className="px-4 py-2 border-b bg-muted/30">
<p className="text-sm font-mono text-muted-foreground truncate"> <p className="text-sm font-mono text-muted-foreground truncate">
{selectedFile.replace(currentProject.path, '')} {selectedFile.replace(currentProject.path, "")}
</p> </p>
</div> </div>
<Card className="flex-1 m-4 overflow-hidden"> <Card className="flex-1 m-4 overflow-hidden">
@@ -251,7 +289,9 @@ export function CodeView() {
</div> </div>
) : ( ) : (
<div className="flex-1 flex items-center justify-center"> <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>
)} )}
</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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -6,11 +8,11 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { HotkeyButton } from '@/components/ui/hotkey-button'; import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from '@/components/ui/label'; import { Label } from "@/components/ui/label";
import { import {
Loader2, Loader2,
Lightbulb, Lightbulb,
@@ -21,18 +23,10 @@ import {
RefreshCw, RefreshCw,
Shield, Shield,
Zap, Zap,
List, } from "lucide-react";
FileText, import { getElectronAPI, FeatureSuggestion, SuggestionsEvent, SuggestionType } from "@/lib/electron";
} from 'lucide-react'; import { useAppStore, Feature } from "@/store/app-store";
import { import { toast } from "sonner";
getElectronAPI,
FeatureSuggestion,
SuggestionsEvent,
SuggestionType,
} from '@/lib/electron';
import { useAppStore, Feature } from '@/store/app-store';
import { toast } from 'sonner';
import { LogViewer } from '@/components/ui/log-viewer';
interface FeatureSuggestionsDialogProps { interface FeatureSuggestionsDialogProps {
open: boolean; open: boolean;
@@ -46,38 +40,35 @@ interface FeatureSuggestionsDialogProps {
} }
// Configuration for each suggestion type // Configuration for each suggestion type
const suggestionTypeConfig: Record< const suggestionTypeConfig: Record<SuggestionType, {
SuggestionType,
{
label: string; label: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
description: string; description: string;
color: string; color: string;
} }> = {
> = {
features: { features: {
label: 'Feature Suggestions', label: "Feature Suggestions",
icon: Lightbulb, icon: Lightbulb,
description: 'Discover missing features and improvements', description: "Discover missing features and improvements",
color: 'text-yellow-500', color: "text-yellow-500",
}, },
refactoring: { refactoring: {
label: 'Refactoring Suggestions', label: "Refactoring Suggestions",
icon: RefreshCw, icon: RefreshCw,
description: 'Find code smells and refactoring opportunities', description: "Find code smells and refactoring opportunities",
color: 'text-blue-500', color: "text-blue-500",
}, },
security: { security: {
label: 'Security Suggestions', label: "Security Suggestions",
icon: Shield, icon: Shield,
description: 'Identify security vulnerabilities and issues', description: "Identify security vulnerabilities and issues",
color: 'text-red-500', color: "text-red-500",
}, },
performance: { performance: {
label: 'Performance Suggestions', label: "Performance Suggestions",
icon: Zap, icon: Zap,
description: 'Discover performance bottlenecks and optimizations', description: "Discover performance bottlenecks and optimizations",
color: 'text-green-500', color: "text-green-500",
}, },
}; };
@@ -95,7 +86,6 @@ export function FeatureSuggestionsDialog({
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null); const [currentSuggestionType, setCurrentSuggestionType] = useState<SuggestionType | null>(null);
const [viewMode, setViewMode] = useState<'parsed' | 'raw'>('parsed');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true); const autoScrollRef = useRef(true);
@@ -123,27 +113,23 @@ export function FeatureSuggestionsDialog({
if (!api?.suggestions) return; if (!api?.suggestions) return;
const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => { const unsubscribe = api.suggestions.onEvent((event: SuggestionsEvent) => {
if (event.type === 'suggestions_progress') { if (event.type === "suggestions_progress") {
setProgress((prev) => [...prev, event.content || '']); setProgress((prev) => [...prev, event.content || ""]);
} else if (event.type === 'suggestions_tool') { } else if (event.type === "suggestions_tool") {
const toolName = event.tool || 'Unknown Tool'; const toolName = event.tool || "Unknown Tool";
const toolInput = event.input ? JSON.stringify(event.input, null, 2) : ''; setProgress((prev) => [...prev, `Using tool: ${toolName}\n`]);
const formattedTool = `\n🔧 Tool: ${toolName}\n${toolInput ? `Input: ${toolInput}\n` : ''}`; } else if (event.type === "suggestions_complete") {
setProgress((prev) => [...prev, formattedTool]);
} else if (event.type === 'suggestions_complete') {
setIsGenerating(false); setIsGenerating(false);
if (event.suggestions && event.suggestions.length > 0) { if (event.suggestions && event.suggestions.length > 0) {
setSuggestions(event.suggestions); setSuggestions(event.suggestions);
// Select all by default // Select all by default
setSelectedIds(new Set(event.suggestions.map((s) => s.id))); setSelectedIds(new Set(event.suggestions.map((s) => s.id)));
const typeLabel = currentSuggestionType const typeLabel = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType].label.toLowerCase() : "suggestions";
? suggestionTypeConfig[currentSuggestionType].label.toLowerCase()
: 'suggestions';
toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`); toast.success(`Generated ${event.suggestions.length} ${typeLabel}!`);
} else { } 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); setIsGenerating(false);
toast.error(`Error: ${event.error}`); toast.error(`Error: ${event.error}`);
} }
@@ -155,11 +141,10 @@ export function FeatureSuggestionsDialog({
}, [open, setSuggestions, setIsGenerating, currentSuggestionType]); }, [open, setSuggestions, setIsGenerating, currentSuggestionType]);
// Start generating suggestions for a specific type // Start generating suggestions for a specific type
const handleGenerate = useCallback( const handleGenerate = useCallback(async (suggestionType: SuggestionType) => {
async (suggestionType: SuggestionType) => {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.suggestions) { if (!api?.suggestions) {
toast.error('Suggestions API not available'); toast.error("Suggestions API not available");
return; return;
} }
@@ -172,17 +157,15 @@ export function FeatureSuggestionsDialog({
try { try {
const result = await api.suggestions.generate(projectPath, suggestionType); const result = await api.suggestions.generate(projectPath, suggestionType);
if (!result.success) { if (!result.success) {
toast.error(result.error || 'Failed to start generation'); toast.error(result.error || "Failed to start generation");
setIsGenerating(false); setIsGenerating(false);
} }
} catch (error) { } catch (error) {
console.error('Failed to generate suggestions:', error); console.error("Failed to generate suggestions:", error);
toast.error('Failed to start generation'); toast.error("Failed to start generation");
setIsGenerating(false); setIsGenerating(false);
} }
}, }, [projectPath, setIsGenerating, setSuggestions]);
[projectPath, setIsGenerating, setSuggestions]
);
// Stop generating // Stop generating
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
@@ -192,9 +175,9 @@ export function FeatureSuggestionsDialog({
try { try {
await api.suggestions.stop(); await api.suggestions.stop();
setIsGenerating(false); setIsGenerating(false);
toast.info('Generation stopped'); toast.info("Generation stopped");
} catch (error) { } catch (error) {
console.error('Failed to stop generation:', error); console.error("Failed to stop generation:", error);
} }
}, [setIsGenerating]); }, [setIsGenerating]);
@@ -236,7 +219,7 @@ export function FeatureSuggestionsDialog({
// Import selected suggestions as features // Import selected suggestions as features
const handleImport = useCallback(async () => { const handleImport = useCallback(async () => {
if (selectedIds.size === 0) { if (selectedIds.size === 0) {
toast.warning('No suggestions selected'); toast.warning("No suggestions selected");
return; return;
} }
@@ -244,17 +227,18 @@ export function FeatureSuggestionsDialog({
try { try {
const api = getElectronAPI(); 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 // Create new features from selected suggestions
const newFeatures: Feature[] = selectedSuggestions.map((s) => ({ const newFeatures: Feature[] = selectedSuggestions.map((s) => ({
id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
category: s.category, category: s.category,
description: s.description, description: s.description,
steps: [], // Required empty steps array for new features steps: s.steps,
status: 'backlog' as const, status: "backlog" as const,
skipTests: true, // As specified, testing mode true skipTests: true, // As specified, testing mode true
priority: s.priority, // Preserve priority from suggestion
})); }));
// Create each new feature using the features API // Create each new feature using the features API
@@ -280,8 +264,8 @@ export function FeatureSuggestionsDialog({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Failed to import features:', error); console.error("Failed to import features:", error);
toast.error('Failed to import features'); toast.error("Failed to import features");
} finally { } finally {
setIsImporting(false); setIsImporting(false);
} }
@@ -304,7 +288,7 @@ export function FeatureSuggestionsDialog({
setCurrentSuggestionType(null); setCurrentSuggestionType(null);
}, [setSuggestions]); }, [setSuggestions]);
const hasStarted = isGenerating || progress.length > 0 || suggestions.length > 0; const hasStarted = progress.length > 0 || suggestions.length > 0;
const hasSuggestions = suggestions.length > 0; const hasSuggestions = suggestions.length > 0;
const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null; const currentConfig = currentSuggestionType ? suggestionTypeConfig[currentSuggestionType] : null;
@@ -331,7 +315,7 @@ export function FeatureSuggestionsDialog({
<DialogDescription> <DialogDescription>
{currentConfig {currentConfig
? currentConfig.description ? 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> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -339,16 +323,12 @@ export function FeatureSuggestionsDialog({
// Initial state - show suggestion type buttons // Initial state - show suggestion type buttons
<div className="flex-1 flex flex-col items-center justify-center py-8"> <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"> <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 Our AI will analyze your project and generate actionable suggestions.
of analysis you want to perform: Choose what type of analysis you want to perform:
</p> </p>
<div className="grid grid-cols-2 gap-4 w-full max-w-2xl"> <div className="grid grid-cols-2 gap-4 w-full max-w-2xl">
{( {(Object.entries(suggestionTypeConfig) as [SuggestionType, typeof suggestionTypeConfig[SuggestionType]][]).map(
Object.entries(suggestionTypeConfig) as [ ([type, config]) => {
SuggestionType,
(typeof suggestionTypeConfig)[SuggestionType],
][]
).map(([type, config]) => {
const Icon = config.icon; const Icon = config.icon;
return ( return (
<Button <Button
@@ -360,14 +340,15 @@ export function FeatureSuggestionsDialog({
> >
<Icon className={`w-8 h-8 ${config.color}`} /> <Icon className={`w-8 h-8 ${config.color}`} />
<div className="text-center"> <div className="text-center">
<div className="font-semibold"> <div className="font-semibold">{config.label.replace(" Suggestions", "")}</div>
{config.label.replace(' Suggestions', '')} <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> </div>
</Button> </Button>
); );
})} }
)}
</div> </div>
</div> </div>
) : isGenerating ? ( ) : isGenerating ? (
@@ -378,56 +359,19 @@ export function FeatureSuggestionsDialog({
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Analyzing project... Analyzing project...
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-2 py-1 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'
}`}
data-testid="view-mode-parsed"
>
<List className="w-3 h-3" />
Logs
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-2 py-1 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'
}`}
data-testid="view-mode-raw"
>
<FileText className="w-3 h-3" />
Raw
</button>
</div>
<Button variant="destructive" size="sm" onClick={handleStop}> <Button variant="destructive" size="sm" onClick={handleStop}>
<StopCircle className="w-4 h-4 mr-2" /> <StopCircle className="w-4 h-4 mr-2" />
Stop Stop
</Button> </Button>
</div> </div>
</div>
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]" className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[200px] max-h-[400px]"
> >
{progress.length === 0 ? (
<div className="flex items-center justify-center min-h-[168px] text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Waiting for AI response...
</div>
) : viewMode === 'parsed' ? (
<LogViewer output={progress.join('')} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300"> <div className="whitespace-pre-wrap break-words text-zinc-300">
{progress.join('')} {progress.join("")}
</div> </div>
)}
</div> </div>
</div> </div>
) : hasSuggestions ? ( ) : hasSuggestions ? (
@@ -439,10 +383,14 @@ export function FeatureSuggestionsDialog({
{suggestions.length} suggestions generated {suggestions.length} suggestions generated
</span> </span>
<Button variant="ghost" size="sm" onClick={toggleSelectAll}> <Button variant="ghost" size="sm" onClick={toggleSelectAll}>
{selectedIds.size === suggestions.length ? 'Deselect All' : 'Select All'} {selectedIds.size === suggestions.length
? "Deselect All"
: "Select All"}
</Button> </Button>
</div> </div>
<span className="text-sm font-medium">{selectedIds.size} selected</span> <span className="text-sm font-medium">
{selectedIds.size} selected
</span>
</div> </div>
<div <div
@@ -458,8 +406,8 @@ export function FeatureSuggestionsDialog({
key={suggestion.id} key={suggestion.id}
className={`border rounded-lg p-3 transition-colors ${ className={`border rounded-lg p-3 transition-colors ${
isSelected isSelected
? 'border-primary bg-primary/5' ? "border-primary bg-primary/5"
: 'border-border hover:border-primary/50' : "border-border hover:border-primary/50"
}`} }`}
data-testid={`suggestion-${suggestion.id}`} data-testid={`suggestion-${suggestion.id}`}
> >
@@ -496,9 +444,25 @@ export function FeatureSuggestionsDialog({
{suggestion.description} {suggestion.description}
</Label> </Label>
{isExpanded && suggestion.reasoning && ( {isExpanded && (
<div className="mt-3 text-sm"> <div className="mt-3 space-y-2 text-sm">
<p className="text-muted-foreground italic">{suggestion.reasoning}</p> {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>
)} )}
</div> </div>
@@ -549,7 +513,7 @@ export function FeatureSuggestionsDialog({
<HotkeyButton <HotkeyButton
onClick={handleImport} onClick={handleImport}
disabled={selectedIds.size === 0 || isImporting} disabled={selectedIds.size === 0 || isImporting}
hotkey={{ key: 'Enter', cmdCtrl: true }} hotkey={{ key: "Enter", cmdCtrl: true }}
hotkeyActive={open && hasSuggestions} hotkeyActive={open && hasSuggestions}
> >
{isImporting ? ( {isImporting ? (
@@ -558,7 +522,7 @@ export function FeatureSuggestionsDialog({
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
)} )}
Import {selectedIds.size} Feature Import {selectedIds.size} Feature
{selectedIds.size !== 1 ? 's' : ''} {selectedIds.size !== 1 ? "s" : ""}
</HotkeyButton> </HotkeyButton>
</div> </div>
</div> </div>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import { memo } from 'react'; "use client";
import { useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils'; import { memo } from "react";
import type { ReactNode } from 'react'; import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
interface KanbanColumnProps { interface KanbanColumnProps {
id: string; id: string;
@@ -13,8 +15,6 @@ interface KanbanColumnProps {
opacity?: number; opacity?: number;
showBorder?: boolean; showBorder?: boolean;
hideScrollbar?: boolean; hideScrollbar?: boolean;
/** Custom width in pixels. If not provided, defaults to 288px (w-72) */
width?: number;
} }
export const KanbanColumn = memo(function KanbanColumn({ export const KanbanColumn = memo(function KanbanColumn({
@@ -27,32 +27,24 @@ export const KanbanColumn = memo(function KanbanColumn({
opacity = 100, opacity = 100,
showBorder = true, showBorder = true,
hideScrollbar = false, hideScrollbar = false,
width,
}: KanbanColumnProps) { }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id }); const { setNodeRef, isOver } = useDroppable({ id });
// Use inline style for width if provided, otherwise use default w-72
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
className={cn( className={cn(
'relative flex flex-col h-full rounded-xl', "relative flex flex-col h-full rounded-xl transition-all duration-200 w-72",
// Only transition ring/shadow for drag-over effect, not width showBorder && "border border-border/60",
'transition-[box-shadow,ring] duration-200', isOver && "ring-2 ring-primary/30 ring-offset-1 ring-offset-background"
!width && 'w-72', // Only apply w-72 if no custom width
showBorder && 'border border-border/60',
isOver && 'ring-2 ring-primary/30 ring-offset-1 ring-offset-background'
)} )}
style={widthStyle}
data-testid={`kanban-column-${id}`} data-testid={`kanban-column-${id}`}
> >
{/* Background layer with opacity */} {/* Background layer with opacity */}
<div <div
className={cn( className={cn(
'absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200', "absolute inset-0 rounded-xl backdrop-blur-sm transition-colors duration-200",
isOver ? 'bg-accent/80' : 'bg-card/80' isOver ? "bg-accent/80" : "bg-card/80"
)} )}
style={{ opacity: opacity / 100 }} style={{ opacity: opacity / 100 }}
/> />
@@ -60,11 +52,11 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Header */} {/* Column Header */}
<div <div
className={cn( className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5', "relative z-10 flex items-center gap-3 px-3 py-2.5",
showBorder && 'border-b border-border/40' showBorder && "border-b border-border/40"
)} )}
> >
<div className={cn('w-2.5 h-2.5 rounded-full shrink-0', colorClass)} /> <div className={cn("w-2.5 h-2.5 rounded-full shrink-0", colorClass)} />
<h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3> <h3 className="font-semibold text-sm text-foreground/90 flex-1 tracking-tight">{title}</h3>
{headerAction} {headerAction}
<span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums"> <span className="text-xs font-medium text-muted-foreground/80 bg-muted/50 px-2 py-0.5 rounded-md tabular-nums">
@@ -75,11 +67,11 @@ export const KanbanColumn = memo(function KanbanColumn({
{/* Column Content */} {/* Column Content */}
<div <div
className={cn( className={cn(
'relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5', "relative z-10 flex-1 overflow-y-auto p-2 space-y-2.5",
hideScrollbar && hideScrollbar &&
'[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]', "[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
// Smooth scrolling // Smooth scrolling
'scroll-smooth' "scroll-smooth"
)} )}
> >
{children} {children}

View File

@@ -1,20 +1,25 @@
import { useState, useMemo, useCallback } from 'react'; "use client";
import { useAppStore, AIProfile } from '@/store/app-store';
import { useState, useMemo, useCallback } from "react";
import {
useAppStore,
AIProfile,
} from "@/store/app-store";
import { import {
useKeyboardShortcuts, useKeyboardShortcuts,
useKeyboardShortcutsConfig, useKeyboardShortcutsConfig,
KeyboardShortcut, KeyboardShortcut,
} from '@/hooks/use-keyboard-shortcuts'; } from "@/hooks/use-keyboard-shortcuts";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from "@/components/ui/dialog";
import { Sparkles } from 'lucide-react'; import { Sparkles } from "lucide-react";
import { toast } from 'sonner'; import { toast } from "sonner";
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@@ -22,9 +27,16 @@ import {
useSensor, useSensor,
useSensors, useSensors,
closestCenter, closestCenter,
} from '@dnd-kit/core'; } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import {
import { SortableProfileCard, ProfileForm, ProfilesHeader } from './profiles-view/components'; SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
SortableProfileCard,
ProfileForm,
ProfilesHeader,
} from "./profiles-view/components";
export function ProfilesView() { export function ProfilesView() {
const { const {
@@ -51,8 +63,14 @@ export function ProfilesView() {
); );
// Separate built-in and custom profiles // Separate built-in and custom profiles
const builtInProfiles = useMemo(() => aiProfiles.filter((p) => p.isBuiltIn), [aiProfiles]); const builtInProfiles = useMemo(
const customProfiles = useMemo(() => aiProfiles.filter((p) => !p.isBuiltIn), [aiProfiles]); () => aiProfiles.filter((p) => p.isBuiltIn),
[aiProfiles]
);
const customProfiles = useMemo(
() => aiProfiles.filter((p) => !p.isBuiltIn),
[aiProfiles]
);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
@@ -70,19 +88,19 @@ export function ProfilesView() {
[aiProfiles, reorderAIProfiles] [aiProfiles, reorderAIProfiles]
); );
const handleAddProfile = (profile: Omit<AIProfile, 'id'>) => { const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
addAIProfile(profile); addAIProfile(profile);
setShowAddDialog(false); setShowAddDialog(false);
toast.success('Profile created', { toast.success("Profile created", {
description: `Created "${profile.name}" profile`, description: `Created "${profile.name}" profile`,
}); });
}; };
const handleUpdateProfile = (profile: Omit<AIProfile, 'id'>) => { const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
if (editingProfile) { if (editingProfile) {
updateAIProfile(editingProfile.id, profile); updateAIProfile(editingProfile.id, profile);
setEditingProfile(null); setEditingProfile(null);
toast.success('Profile updated', { toast.success("Profile updated", {
description: `Updated "${profile.name}" profile`, description: `Updated "${profile.name}" profile`,
}); });
} }
@@ -92,7 +110,7 @@ export function ProfilesView() {
if (!profileToDelete) return; if (!profileToDelete) return;
removeAIProfile(profileToDelete.id); removeAIProfile(profileToDelete.id);
toast.success('Profile deleted', { toast.success("Profile deleted", {
description: `Deleted "${profileToDelete.name}" profile`, description: `Deleted "${profileToDelete.name}" profile`,
}); });
setProfileToDelete(null); setProfileToDelete(null);
@@ -100,8 +118,8 @@ export function ProfilesView() {
const handleResetProfiles = () => { const handleResetProfiles = () => {
resetAIProfiles(); resetAIProfiles();
toast.success('Profiles refreshed', { toast.success("Profiles refreshed", {
description: 'Default profiles have been updated to the latest version', description: "Default profiles have been updated to the latest version",
}); });
}; };
@@ -113,7 +131,7 @@ export function ProfilesView() {
shortcutsList.push({ shortcutsList.push({
key: shortcuts.addProfile, key: shortcuts.addProfile,
action: () => setShowAddDialog(true), action: () => setShowAddDialog(true),
description: 'Create new profile', description: "Create new profile",
}); });
return shortcutsList; return shortcutsList;
@@ -123,7 +141,10 @@ export function ProfilesView() {
useKeyboardShortcuts(profilesShortcuts); useKeyboardShortcuts(profilesShortcuts);
return ( return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="profiles-view"> <div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
{/* Header Section */} {/* Header Section */}
<ProfilesHeader <ProfilesHeader
onResetProfiles={handleResetProfiles} onResetProfiles={handleResetProfiles}
@@ -137,7 +158,9 @@ export function ProfilesView() {
{/* Custom Profiles Section */} {/* Custom Profiles Section */}
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">Custom Profiles</h2> <h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary"> <span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length} {customProfiles.length}
</span> </span>
@@ -180,13 +203,16 @@ export function ProfilesView() {
{/* Built-in Profiles Section */} {/* Built-in Profiles Section */}
<div> <div>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">Built-in Profiles</h2> <h2 className="text-lg font-semibold text-foreground">
Built-in Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground"> <span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length} {builtInProfiles.length}
</span> </span>
</div> </div>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be edited or deleted. Pre-configured profiles for common use cases. These cannot be
edited or deleted.
</p> </p>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -215,13 +241,12 @@ export function ProfilesView() {
{/* Add Profile Dialog */} {/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}> <Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent <DialogContent data-testid="add-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
data-testid="add-profile-dialog"
className="flex flex-col max-h-[calc(100vh-4rem)]"
>
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle>Create New Profile</DialogTitle> <DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>Define a reusable model configuration preset.</DialogDescription> <DialogDescription>
Define a reusable model configuration preset.
</DialogDescription>
</DialogHeader> </DialogHeader>
<ProfileForm <ProfileForm
profile={{}} profile={{}}
@@ -234,11 +259,11 @@ export function ProfilesView() {
</Dialog> </Dialog>
{/* Edit Profile Dialog */} {/* Edit Profile Dialog */}
<Dialog open={!!editingProfile} onOpenChange={() => setEditingProfile(null)}> <Dialog
<DialogContent open={!!editingProfile}
data-testid="edit-profile-dialog" onOpenChange={() => setEditingProfile(null)}
className="flex flex-col max-h-[calc(100vh-4rem)]"
> >
<DialogContent data-testid="edit-profile-dialog" className="flex flex-col max-h-[calc(100vh-4rem)]">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle>Edit Profile</DialogTitle> <DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription> <DialogDescription>Modify your profile settings.</DialogDescription>
@@ -264,7 +289,7 @@ export function ProfilesView() {
description={ description={
profileToDelete profileToDelete
? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.` ? `Are you sure you want to delete "${profileToDelete.name}"? This action cannot be undone.`
: '' : ""
} }
confirmText="Delete Profile" confirmText="Delete Profile"
testId="delete-profile-confirm-dialog" testId="delete-profile-confirm-dialog"

View File

@@ -0,0 +1,3 @@
export { SortableProfileCard } from "./sortable-profile-card";
export { ProfileForm } from "./profile-form";
export { ProfilesHeader } from "./profiles-header";

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