Compare commits
1 Commits
coderabbit
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b8b14b714 |
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dir \"C:\\Users\\Ben\\Desktop\\appdev\\git\\automaker\\apps\\app\\public\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
71
.github/actions/setup-project/action.yml
vendored
@@ -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 }}
|
|
||||||
179
.github/scripts/upload-to-r2.js
vendored
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
49
.github/workflows/claude.yml
vendored
@@ -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:*)'
|
|
||||||
45
.github/workflows/e2e-tests.yml
vendored
@@ -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
|
||||||
|
|||||||
31
.github/workflows/format-check.yml
vendored
@@ -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
|
|
||||||
32
.github/workflows/pr-check.yml
vendored
@@ -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
|
||||||
|
|||||||
237
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
30
.github/workflows/security-audit.yml
vendored
@@ -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
|
|
||||||
32
.github/workflows/test.yml
vendored
@@ -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
@@ -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/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
npx lint-staged
|
|
||||||
@@ -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/
|
|
||||||
10
.prettierrc
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 100,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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
18
apps/ui/.gitignore → apps/app/.gitignore
vendored
@@ -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
|
|
||||||
@@ -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:
|
||||||
@@ -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);
|
||||||
```
|
```
|
||||||
5
apps/app/electron/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
},
|
||||||
|
};
|
||||||
435
apps/app/electron/main.js
Normal 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}`;
|
||||||
|
});
|
||||||
37
apps/app/electron/preload.js
Normal 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)");
|
||||||
20
apps/app/eslint.config.mjs
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "export",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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/**/*"
|
||||||
],
|
],
|
||||||
39
apps/app/playwright.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
30
apps/app/playwright.config.ts.bak
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
7
apps/app/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
BIN
apps/app/public/sounds/ding.mp3
Normal file
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -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();
|
||||||
97
apps/app/src/app/api/claude/test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
apps/app/src/app/api/gemini/test/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/app/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
2554
apps/app/src/app/globals.css
Normal file
26
apps/app/src/app/layout.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
@@ -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]}
|
||||||
316
apps/app/src/components/dialogs/file-browser-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
apps/app/src/components/layout/project-setup-dialog.tsx
Normal 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'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'll analyze your project'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2419
apps/app/src/components/layout/sidebar.tsx
Normal 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</>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
57
apps/app/src/components/ui/badge.tsx
Normal 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 };
|
||||||
@@ -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
|
||||||
100
apps/app/src/components/ui/card.tsx
Normal 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,
|
||||||
|
};
|
||||||
91
apps/app/src/components/ui/category-autocomplete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/app/src/components/ui/checkbox.tsx
Normal 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 };
|
||||||
@@ -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,
|
||||||
};
|
}
|
||||||
@@ -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(() => {
|
||||||
88
apps/app/src/components/ui/course-promo-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
422
apps/app/src/components/ui/description-image-dropzone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/app/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
200
apps/app/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
@@ -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}
|
||||||
290
apps/app/src/components/ui/image-drop-zone.tsx
Normal 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];
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
660
apps/app/src/components/ui/keyboard-map.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/app/src/components/ui/label.tsx
Normal 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 }
|
||||||
284
apps/app/src/components/ui/log-viewer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/app/src/components/ui/markdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/app/src/components/ui/popover.tsx
Normal 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 }
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
41
apps/app/src/components/ui/slider.tsx
Normal 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 };
|
||||||
71
apps/app/src/components/ui/tabs.tsx
Normal 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 }
|
||||||
@@ -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 }
|
||||||
42
apps/app/src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
120
apps/app/src/components/ui/xml-syntax-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -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's developer console to see detailed agent tool logs.
|
Open your browser'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>
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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 "Analyze Project" to scan your codebase and get insights about its
|
Click "Analyze Project" 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>
|
||||||
3325
apps/app/src/components/views/board-view.tsx
Normal 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>
|
||||||
@@ -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>
|
||||||
735
apps/app/src/components/views/context-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
1180
apps/app/src/components/views/kanban-card.tsx
Normal 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}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { SortableProfileCard } from "./sortable-profile-card";
|
||||||
|
export { ProfileForm } from "./profile-form";
|
||||||
|
export { ProfilesHeader } from "./profiles-header";
|
||||||