mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Compare commits
15 Commits
v0.3.0
...
test-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
935316cb51 | ||
|
|
e608f46a49 | ||
|
|
8de4056417 | ||
|
|
9196a1afb4 | ||
|
|
eaef95c4a3 | ||
|
|
3dd10aa8c7 | ||
|
|
104f478f89 | ||
|
|
b32af0c86b | ||
|
|
c991d5f2f7 | ||
|
|
b3a4fd2be1 | ||
|
|
28328d7d1e | ||
|
|
346c38d6da | ||
|
|
ca4809ca06 | ||
|
|
5544031164 | ||
|
|
be4aadb632 |
172
.github/scripts/upload-to-r2.js
vendored
172
.github/scripts/upload-to-r2.js
vendored
@@ -1,10 +1,16 @@
|
|||||||
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
const {
|
||||||
const fs = require('fs');
|
S3Client,
|
||||||
const path = require('path');
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
} = require("@aws-sdk/client-s3");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const https = require("https");
|
||||||
|
const { pipeline } = require("stream/promises");
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: 'auto',
|
region: "auto",
|
||||||
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
endpoint: process.env.R2_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
||||||
@@ -14,19 +20,22 @@ const s3Client = new S3Client({
|
|||||||
const BUCKET = process.env.R2_BUCKET_NAME;
|
const BUCKET = process.env.R2_BUCKET_NAME;
|
||||||
const PUBLIC_URL = process.env.R2_PUBLIC_URL;
|
const PUBLIC_URL = process.env.R2_PUBLIC_URL;
|
||||||
const VERSION = process.env.RELEASE_VERSION;
|
const VERSION = process.env.RELEASE_VERSION;
|
||||||
|
const RELEASE_TAG = process.env.RELEASE_TAG || `v${VERSION}`;
|
||||||
const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
|
const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
|
||||||
|
|
||||||
async function fetchExistingReleases() {
|
async function fetchExistingReleases() {
|
||||||
try {
|
try {
|
||||||
const response = await s3Client.send(new GetObjectCommand({
|
const response = await s3Client.send(
|
||||||
Bucket: BUCKET,
|
new GetObjectCommand({
|
||||||
Key: 'releases.json',
|
Bucket: BUCKET,
|
||||||
}));
|
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;
|
||||||
@@ -37,12 +46,14 @@ async function uploadFile(localPath, r2Key, contentType) {
|
|||||||
const fileBuffer = fs.readFileSync(localPath);
|
const fileBuffer = fs.readFileSync(localPath);
|
||||||
const stats = fs.statSync(localPath);
|
const stats = fs.statSync(localPath);
|
||||||
|
|
||||||
await s3Client.send(new PutObjectCommand({
|
await s3Client.send(
|
||||||
Bucket: BUCKET,
|
new PutObjectCommand({
|
||||||
Key: r2Key,
|
Bucket: BUCKET,
|
||||||
Body: fileBuffer,
|
Key: r2Key,
|
||||||
ContentType: contentType,
|
Body: fileBuffer,
|
||||||
}));
|
ContentType: contentType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`);
|
console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`);
|
||||||
return stats.size;
|
return stats.size;
|
||||||
@@ -51,44 +62,100 @@ async function uploadFile(localPath, r2Key, contentType) {
|
|||||||
function findArtifacts(dir, pattern) {
|
function findArtifacts(dir, pattern) {
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir);
|
||||||
return files.filter(f => pattern.test(f)).map(f => path.join(dir, f));
|
return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromGitHub(url, outputPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https
|
||||||
|
.get(url, (response) => {
|
||||||
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
|
// Follow redirect
|
||||||
|
return downloadFromGitHub(response.headers.location, outputPath)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
}
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Failed to download ${url}: ${response.statusCode} ${response.statusMessage}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileStream = fs.createWriteStream(outputPath);
|
||||||
|
response.pipe(fileStream);
|
||||||
|
fileStream.on("finish", () => {
|
||||||
|
fileStream.close();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
fileStream.on("error", reject);
|
||||||
|
})
|
||||||
|
.on("error", reject);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const artifactsDir = 'artifacts';
|
const artifactsDir = "artifacts";
|
||||||
|
const tempDir = path.join(artifactsDir, "temp");
|
||||||
|
|
||||||
|
// Create temp directory for downloaded GitHub archives
|
||||||
|
if (!fs.existsSync(tempDir)) {
|
||||||
|
fs.mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download source archives from GitHub
|
||||||
|
const githubZipUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.zip`;
|
||||||
|
const githubTarGzUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.tar.gz`;
|
||||||
|
|
||||||
|
const sourceZipPath = path.join(tempDir, `automaker-${VERSION}.zip`);
|
||||||
|
const sourceTarGzPath = path.join(tempDir, `automaker-${VERSION}.tar.gz`);
|
||||||
|
|
||||||
|
console.log(`Downloading source archives from GitHub...`);
|
||||||
|
console.log(` ZIP: ${githubZipUrl}`);
|
||||||
|
console.log(` TAR.GZ: ${githubTarGzUrl}`);
|
||||||
|
|
||||||
|
await downloadFromGitHub(githubZipUrl, sourceZipPath);
|
||||||
|
await downloadFromGitHub(githubTarGzUrl, sourceTarGzPath);
|
||||||
|
|
||||||
|
console.log(`Downloaded source archives successfully`);
|
||||||
|
|
||||||
// Find all artifacts
|
// Find all artifacts
|
||||||
const artifacts = {
|
const artifacts = {
|
||||||
windows: findArtifacts(
|
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
|
||||||
path.join(artifactsDir, 'windows-builds'),
|
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
|
||||||
/\.exe$/
|
|
||||||
),
|
|
||||||
macos: findArtifacts(
|
|
||||||
path.join(artifactsDir, 'macos-builds'),
|
|
||||||
/-x64\.dmg$/
|
|
||||||
),
|
|
||||||
macosArm: findArtifacts(
|
macosArm: findArtifacts(
|
||||||
path.join(artifactsDir, 'macos-builds'),
|
path.join(artifactsDir, "macos-builds"),
|
||||||
/-arm64\.dmg$/
|
/-arm64\.dmg$/
|
||||||
),
|
),
|
||||||
linux: findArtifacts(
|
linux: findArtifacts(
|
||||||
path.join(artifactsDir, 'linux-builds'),
|
path.join(artifactsDir, "linux-builds"),
|
||||||
/\.AppImage$/
|
/\.AppImage$/
|
||||||
),
|
),
|
||||||
|
sourceZip: [sourceZipPath],
|
||||||
|
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(` ${platform}: ${files.length > 0 ? files.map(f => path.basename(f)).join(', ') : 'none'}`);
|
console.log(
|
||||||
|
` ${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",
|
||||||
|
sourceTarGz: "application/gzip",
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [platform, files] of Object.entries(artifacts)) {
|
for (const [platform, files] of Object.entries(artifacts)) {
|
||||||
@@ -107,7 +174,12 @@ async function main() {
|
|||||||
url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`,
|
url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`,
|
||||||
filename,
|
filename,
|
||||||
size,
|
size,
|
||||||
arch: platform === 'macosArm' ? 'arm64' : 'x64',
|
arch:
|
||||||
|
platform === "macosArm"
|
||||||
|
? "arm64"
|
||||||
|
: platform === "sourceZip" || platform === "sourceTarGz"
|
||||||
|
? "source"
|
||||||
|
: "x64",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,31 +190,35 @@ async function main() {
|
|||||||
version: VERSION,
|
version: VERSION,
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
assets,
|
assets,
|
||||||
githubReleaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${VERSION}`,
|
githubReleaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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);
|
||||||
releasesData.latestVersion = VERSION;
|
releasesData.latestVersion = VERSION;
|
||||||
|
|
||||||
// Upload updated releases.json
|
// Upload updated releases.json
|
||||||
await s3Client.send(new PutObjectCommand({
|
await s3Client.send(
|
||||||
Bucket: BUCKET,
|
new PutObjectCommand({
|
||||||
Key: 'releases.json',
|
Bucket: BUCKET,
|
||||||
Body: JSON.stringify(releasesData, null, 2),
|
Key: "releases.json",
|
||||||
ContentType: 'application/json',
|
Body: JSON.stringify(releasesData, null, 2),
|
||||||
CacheControl: 'public, max-age=60',
|
ContentType: "application/json",
|
||||||
}));
|
CacheControl: "public, max-age=60",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
console.log('Successfully updated releases.json');
|
console.log("Successfully updated releases.json");
|
||||||
console.log(`Latest version: ${VERSION}`);
|
console.log(`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);
|
||||||
});
|
});
|
||||||
|
|||||||
29
.github/workflows/release.yml
vendored
29
.github/workflows/release.yml
vendored
@@ -48,6 +48,20 @@ jobs:
|
|||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Extract and set version
|
||||||
|
id: version
|
||||||
|
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 "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: Build Electron App (macOS)
|
- name: Build Electron App (macOS)
|
||||||
if: matrix.os == 'macos-latest'
|
if: matrix.os == 'macos-latest'
|
||||||
env:
|
env:
|
||||||
@@ -127,13 +141,24 @@ jobs:
|
|||||||
- name: Install AWS SDK
|
- name: Install AWS SDK
|
||||||
run: npm install @aws-sdk/client-s3
|
run: npm install @aws-sdk/client-s3
|
||||||
|
|
||||||
|
- name: Extract version
|
||||||
|
id: version
|
||||||
|
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
|
- name: Upload to R2 and update releases.json
|
||||||
env:
|
env:
|
||||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||||
RELEASE_VERSION: ${{ github.event.inputs.version || github.ref_name }}
|
RELEASE_VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
run: node .github/scripts/upload-to-r2.js
|
run: node .github/scripts/upload-to-r2.js
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,7 +7,6 @@ node_modules/
|
|||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
node_modules
|
|
||||||
.automaker/images/
|
.automaker/images/
|
||||||
.automaker/
|
.automaker/
|
||||||
/.automaker/*
|
/.automaker/*
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
@custom-variant catppuccin (&:is(.catppuccin *));
|
@custom-variant catppuccin (&:is(.catppuccin *));
|
||||||
@custom-variant onedark (&:is(.onedark *));
|
@custom-variant onedark (&:is(.onedark *));
|
||||||
@custom-variant synthwave (&:is(.synthwave *));
|
@custom-variant synthwave (&:is(.synthwave *));
|
||||||
|
@custom-variant red (&:is(.red *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -1072,6 +1073,75 @@
|
|||||||
--running-indicator-text: oklch(0.75 0.26 350);
|
--running-indicator-text: oklch(0.75 0.26 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Red Theme - Bold crimson/red aesthetic */
|
||||||
|
.red {
|
||||||
|
--background: oklch(0.12 0.03 15); /* Deep dark red-tinted black */
|
||||||
|
--background-50: oklch(0.12 0.03 15 / 0.5);
|
||||||
|
--background-80: oklch(0.12 0.03 15 / 0.8);
|
||||||
|
|
||||||
|
--foreground: oklch(0.95 0.01 15); /* Off-white with warm tint */
|
||||||
|
--foreground-secondary: oklch(0.7 0.02 15);
|
||||||
|
--foreground-muted: oklch(0.5 0.03 15);
|
||||||
|
|
||||||
|
--card: oklch(0.18 0.04 15); /* Slightly lighter dark red */
|
||||||
|
--card-foreground: oklch(0.95 0.01 15);
|
||||||
|
--popover: oklch(0.15 0.035 15);
|
||||||
|
--popover-foreground: oklch(0.95 0.01 15);
|
||||||
|
|
||||||
|
--primary: oklch(0.55 0.25 25); /* Vibrant crimson red */
|
||||||
|
--primary-foreground: oklch(0.98 0 0);
|
||||||
|
|
||||||
|
--brand-400: oklch(0.6 0.23 25);
|
||||||
|
--brand-500: oklch(0.55 0.25 25); /* Crimson */
|
||||||
|
--brand-600: oklch(0.5 0.27 25);
|
||||||
|
|
||||||
|
--secondary: oklch(0.22 0.05 15);
|
||||||
|
--secondary-foreground: oklch(0.95 0.01 15);
|
||||||
|
|
||||||
|
--muted: oklch(0.22 0.05 15);
|
||||||
|
--muted-foreground: oklch(0.5 0.03 15);
|
||||||
|
|
||||||
|
--accent: oklch(0.28 0.06 15);
|
||||||
|
--accent-foreground: oklch(0.95 0.01 15);
|
||||||
|
|
||||||
|
--destructive: oklch(0.6 0.28 30); /* Bright orange-red for destructive */
|
||||||
|
|
||||||
|
--border: oklch(0.35 0.08 15);
|
||||||
|
--border-glass: oklch(0.55 0.25 25 / 0.3);
|
||||||
|
|
||||||
|
--input: oklch(0.18 0.04 15);
|
||||||
|
--ring: oklch(0.55 0.25 25);
|
||||||
|
|
||||||
|
--chart-1: oklch(0.55 0.25 25); /* Crimson */
|
||||||
|
--chart-2: oklch(0.7 0.2 50); /* Orange */
|
||||||
|
--chart-3: oklch(0.8 0.18 80); /* Gold */
|
||||||
|
--chart-4: oklch(0.6 0.22 0); /* Pure red */
|
||||||
|
--chart-5: oklch(0.65 0.2 350); /* Pink-red */
|
||||||
|
|
||||||
|
--sidebar: oklch(0.1 0.025 15);
|
||||||
|
--sidebar-foreground: oklch(0.95 0.01 15);
|
||||||
|
--sidebar-primary: oklch(0.55 0.25 25);
|
||||||
|
--sidebar-primary-foreground: oklch(0.98 0 0);
|
||||||
|
--sidebar-accent: oklch(0.22 0.05 15);
|
||||||
|
--sidebar-accent-foreground: oklch(0.95 0.01 15);
|
||||||
|
--sidebar-border: oklch(0.35 0.08 15);
|
||||||
|
--sidebar-ring: oklch(0.55 0.25 25);
|
||||||
|
|
||||||
|
/* Action button colors - Red theme */
|
||||||
|
--action-view: oklch(0.55 0.25 25); /* Crimson */
|
||||||
|
--action-view-hover: oklch(0.5 0.27 25);
|
||||||
|
--action-followup: oklch(0.7 0.2 50); /* Orange */
|
||||||
|
--action-followup-hover: oklch(0.65 0.22 50);
|
||||||
|
--action-commit: oklch(0.6 0.2 140); /* Green for positive actions */
|
||||||
|
--action-commit-hover: oklch(0.55 0.22 140);
|
||||||
|
--action-verify: oklch(0.6 0.2 140); /* Green */
|
||||||
|
--action-verify-hover: oklch(0.55 0.22 140);
|
||||||
|
|
||||||
|
/* Running indicator - Crimson */
|
||||||
|
--running-indicator: oklch(0.55 0.25 25);
|
||||||
|
--running-indicator-text: oklch(0.6 0.23 25);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -1327,6 +1397,39 @@
|
|||||||
.text-running-indicator {
|
.text-running-indicator {
|
||||||
color: var(--running-indicator-text);
|
color: var(--running-indicator-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animated border for in-progress cards */
|
||||||
|
@keyframes border-rotate {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-border-wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--running-indicator),
|
||||||
|
color-mix(in oklch, var(--running-indicator), transparent 50%),
|
||||||
|
var(--running-indicator),
|
||||||
|
color-mix(in oklch, var(--running-indicator), transparent 50%),
|
||||||
|
var(--running-indicator)
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: border-rotate 3s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-border-wrapper > * {
|
||||||
|
border-radius: calc(0.75rem - 2px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Retro Overrides for Utilities */
|
/* Retro Overrides for Utilities */
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { RunningAgentsView } from "@/components/views/running-agents-view";
|
|||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
import { useSetupStore } from "@/store/setup-store";
|
||||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||||
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
|
import {
|
||||||
|
FileBrowserProvider,
|
||||||
|
useFileBrowser,
|
||||||
|
setGlobalFileBrowser,
|
||||||
|
} from "@/contexts/file-browser-context";
|
||||||
|
|
||||||
function HomeContent() {
|
function HomeContent() {
|
||||||
const {
|
const {
|
||||||
@@ -24,6 +28,8 @@ function HomeContent() {
|
|||||||
setIpcConnected,
|
setIpcConnected,
|
||||||
theme,
|
theme,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
previewTheme,
|
||||||
|
getEffectiveTheme,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { isFirstRun, setupComplete } = useSetupStore();
|
const { isFirstRun, setupComplete } = useSetupStore();
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
@@ -72,9 +78,9 @@ function HomeContent() {
|
|||||||
};
|
};
|
||||||
}, [handleStreamerPanelShortcut]);
|
}, [handleStreamerPanelShortcut]);
|
||||||
|
|
||||||
// Compute the effective theme: project theme takes priority over global theme
|
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
|
||||||
// This is reactive because it depends on currentProject and theme from the store
|
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
|
||||||
const effectiveTheme = currentProject?.theme || theme;
|
const effectiveTheme = getEffectiveTheme();
|
||||||
|
|
||||||
// Prevent hydration issues
|
// Prevent hydration issues
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +128,7 @@ function HomeContent() {
|
|||||||
testConnection();
|
testConnection();
|
||||||
}, [setIpcConnected]);
|
}, [setIpcConnected]);
|
||||||
|
|
||||||
// Apply theme class to document (uses effective theme - project-specific or global)
|
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.remove(
|
root.classList.remove(
|
||||||
@@ -137,7 +143,8 @@ function HomeContent() {
|
|||||||
"gruvbox",
|
"gruvbox",
|
||||||
"catppuccin",
|
"catppuccin",
|
||||||
"onedark",
|
"onedark",
|
||||||
"synthwave"
|
"synthwave",
|
||||||
|
"red"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (effectiveTheme === "dark") {
|
if (effectiveTheme === "dark") {
|
||||||
@@ -162,6 +169,8 @@ function HomeContent() {
|
|||||||
root.classList.add("onedark");
|
root.classList.add("onedark");
|
||||||
} else if (effectiveTheme === "synthwave") {
|
} else if (effectiveTheme === "synthwave") {
|
||||||
root.classList.add("synthwave");
|
root.classList.add("synthwave");
|
||||||
|
} else if (effectiveTheme === "red") {
|
||||||
|
root.classList.add("red");
|
||||||
} else if (effectiveTheme === "light") {
|
} else if (effectiveTheme === "light") {
|
||||||
root.classList.add("light");
|
root.classList.add("light");
|
||||||
} else if (effectiveTheme === "system") {
|
} else if (effectiveTheme === "system") {
|
||||||
@@ -173,7 +182,7 @@ function HomeContent() {
|
|||||||
root.classList.add("light");
|
root.classList.add("light");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [effectiveTheme]);
|
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||||
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
switch (currentView) {
|
switch (currentView) {
|
||||||
|
|||||||
520
apps/app/src/components/dialogs/board-background-modal.tsx
Normal file
520
apps/app/src/components/dialogs/board-background-modal.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||||
|
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const ACCEPTED_IMAGE_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
interface BoardBackgroundModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardBackgroundModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: BoardBackgroundModalProps) {
|
||||||
|
const {
|
||||||
|
currentProject,
|
||||||
|
boardBackgroundByProject,
|
||||||
|
setBoardBackground,
|
||||||
|
setCardOpacity,
|
||||||
|
setColumnOpacity,
|
||||||
|
setColumnBorderEnabled,
|
||||||
|
setCardGlassmorphism,
|
||||||
|
setCardBorderEnabled,
|
||||||
|
setCardBorderOpacity,
|
||||||
|
setHideScrollbar,
|
||||||
|
clearBoardBackground,
|
||||||
|
} = useAppStore();
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get current background settings (live from store)
|
||||||
|
const backgroundSettings =
|
||||||
|
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||||
|
defaultBackgroundSettings;
|
||||||
|
|
||||||
|
const cardOpacity = backgroundSettings.cardOpacity;
|
||||||
|
const columnOpacity = backgroundSettings.columnOpacity;
|
||||||
|
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
|
||||||
|
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
|
||||||
|
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
|
||||||
|
const cardBorderOpacity = backgroundSettings.cardBorderOpacity;
|
||||||
|
const hideScrollbar = backgroundSettings.hideScrollbar;
|
||||||
|
const imageVersion = backgroundSettings.imageVersion;
|
||||||
|
|
||||||
|
// Update preview image when background settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentProject && backgroundSettings.imagePath) {
|
||||||
|
const serverUrl =
|
||||||
|
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||||
|
// Add cache-busting query parameter to force browser to reload image
|
||||||
|
const cacheBuster = imageVersion
|
||||||
|
? `&v=${imageVersion}`
|
||||||
|
: `&v=${Date.now()}`;
|
||||||
|
const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent(
|
||||||
|
backgroundSettings.imagePath
|
||||||
|
)}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`;
|
||||||
|
setPreviewImage(imagePath);
|
||||||
|
} else {
|
||||||
|
setPreviewImage(null);
|
||||||
|
}
|
||||||
|
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
|
||||||
|
|
||||||
|
const fileToBase64 = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to read file as base64"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const processFile = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
if (!currentProject) {
|
||||||
|
toast.error("No project selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
toast.error(
|
||||||
|
"Unsupported file type. Please use JPG, PNG, GIF, or WebP."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > DEFAULT_MAX_FILE_SIZE) {
|
||||||
|
const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024);
|
||||||
|
toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(file);
|
||||||
|
|
||||||
|
// Set preview immediately
|
||||||
|
setPreviewImage(base64);
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const result = await httpClient.saveBoardBackground(
|
||||||
|
base64,
|
||||||
|
file.name,
|
||||||
|
file.type,
|
||||||
|
currentProject.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.path) {
|
||||||
|
// Update store with the relative path (live update)
|
||||||
|
setBoardBackground(currentProject.path, result.path);
|
||||||
|
toast.success("Background image saved");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to save background image");
|
||||||
|
setPreviewImage(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process image:", error);
|
||||||
|
toast.error("Failed to process image");
|
||||||
|
setPreviewImage(null);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[currentProject, setBoardBackground]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
processFile(files[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
processFile(files[0]);
|
||||||
|
}
|
||||||
|
// Reset the input so the same file can be selected again
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[processFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBrowseClick = useCallback(() => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClear = useCallback(async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const result = await httpClient.deleteBoardBackground(
|
||||||
|
currentProject.path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
clearBoardBackground(currentProject.path);
|
||||||
|
setPreviewImage(null);
|
||||||
|
toast.success("Background image cleared");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to clear background image");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear background:", error);
|
||||||
|
toast.error("Failed to clear background");
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [currentProject, clearBoardBackground]);
|
||||||
|
|
||||||
|
// Live update opacity when sliders change
|
||||||
|
const handleCardOpacityChange = useCallback(
|
||||||
|
(value: number[]) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setCardOpacity(currentProject.path, value[0]);
|
||||||
|
},
|
||||||
|
[currentProject, setCardOpacity]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleColumnOpacityChange = useCallback(
|
||||||
|
(value: number[]) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setColumnOpacity(currentProject.path, value[0]);
|
||||||
|
},
|
||||||
|
[currentProject, setColumnOpacity]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleColumnBorderToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setColumnBorderEnabled(currentProject.path, checked);
|
||||||
|
},
|
||||||
|
[currentProject, setColumnBorderEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardGlassmorphismToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setCardGlassmorphism(currentProject.path, checked);
|
||||||
|
},
|
||||||
|
[currentProject, setCardGlassmorphism]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardBorderToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setCardBorderEnabled(currentProject.path, checked);
|
||||||
|
},
|
||||||
|
[currentProject, setCardBorderEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardBorderOpacityChange = useCallback(
|
||||||
|
(value: number[]) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setCardBorderOpacity(currentProject.path, value[0]);
|
||||||
|
},
|
||||||
|
[currentProject, setCardBorderOpacity]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHideScrollbarToggle = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
setHideScrollbar(currentProject.path, checked);
|
||||||
|
},
|
||||||
|
[currentProject, setHideScrollbar]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentProject) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
|
||||||
|
<SheetHeader className="px-6 pt-6">
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-5 h-5 text-brand-500" />
|
||||||
|
Board Background Settings
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-muted-foreground">
|
||||||
|
Set a custom background image for your kanban board and adjust
|
||||||
|
card/column opacity
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 px-6 pb-6">
|
||||||
|
{/* Image Upload Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Background Image</Label>
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isProcessing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drop zone */}
|
||||||
|
<div
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
||||||
|
{
|
||||||
|
"border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10":
|
||||||
|
isDragOver && !isProcessing,
|
||||||
|
"border-muted-foreground/25": !isDragOver && !isProcessing,
|
||||||
|
"border-muted-foreground/10 opacity-50 cursor-not-allowed":
|
||||||
|
isProcessing,
|
||||||
|
"hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5":
|
||||||
|
!isProcessing && !isDragOver,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{previewImage ? (
|
||||||
|
<div className="relative p-4">
|
||||||
|
<div className="relative w-full h-48 rounded-md overflow-hidden border border-border bg-muted">
|
||||||
|
<img
|
||||||
|
src={previewImage}
|
||||||
|
alt="Background preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBrowseClick}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Change Image
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={handleBrowseClick}
|
||||||
|
className="flex flex-col items-center justify-center p-8 text-center cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-full p-3 mb-3",
|
||||||
|
isDragOver && !isProcessing
|
||||||
|
? "bg-brand-500/10 dark:bg-brand-500/20"
|
||||||
|
: "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 text-muted-foreground">
|
||||||
|
{isDragOver && !isProcessing
|
||||||
|
? "Drop image here"
|
||||||
|
: "Click to upload or drag and drop"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
JPG, PNG, GIF, or WebP (max{" "}
|
||||||
|
{Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opacity Controls */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Card Opacity</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{cardOpacity}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[cardOpacity]}
|
||||||
|
onValueChange={handleCardOpacityChange}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Column Opacity</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{columnOpacity}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[columnOpacity]}
|
||||||
|
onValueChange={handleColumnOpacityChange}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Border Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="column-border-toggle"
|
||||||
|
checked={columnBorderEnabled}
|
||||||
|
onCheckedChange={handleColumnBorderToggle}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="column-border-toggle" className="cursor-pointer">
|
||||||
|
Show Column Borders
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Glassmorphism Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="card-glassmorphism-toggle"
|
||||||
|
checked={cardGlassmorphism}
|
||||||
|
onCheckedChange={handleCardGlassmorphismToggle}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="card-glassmorphism-toggle"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
Card Glassmorphism (blur effect)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Border Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="card-border-toggle"
|
||||||
|
checked={cardBorderEnabled}
|
||||||
|
onCheckedChange={handleCardBorderToggle}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="card-border-toggle" className="cursor-pointer">
|
||||||
|
Show Card Borders
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Border Opacity - only show when border is enabled */}
|
||||||
|
{cardBorderEnabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Card Border Opacity</Label>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{cardBorderOpacity}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[cardBorderOpacity]}
|
||||||
|
onValueChange={handleCardBorderOpacityChange}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hide Scrollbar Toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="hide-scrollbar-toggle"
|
||||||
|
checked={hideScrollbar}
|
||||||
|
onCheckedChange={handleHideScrollbarToggle}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hide-scrollbar-toggle" className="cursor-pointer">
|
||||||
|
Hide Board Scrollbar
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAppStore, formatShortcut } from "@/store/app-store";
|
import { useAppStore, formatShortcut, type ThemeMode } from "@/store/app-store";
|
||||||
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
|
import { CoursePromoBadge } from "@/components/ui/course-promo-badge";
|
||||||
|
import { IS_MARKETING } from "@/config/app-config";
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -26,22 +27,11 @@ import {
|
|||||||
UserCircle,
|
UserCircle,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Palette,
|
Palette,
|
||||||
Moon,
|
|
||||||
Sun,
|
|
||||||
Terminal,
|
|
||||||
Ghost,
|
|
||||||
Snowflake,
|
|
||||||
Flame,
|
|
||||||
Sparkles as TokyoNightIcon,
|
|
||||||
Eclipse,
|
|
||||||
Trees,
|
|
||||||
Cat,
|
|
||||||
Atom,
|
|
||||||
Radio,
|
|
||||||
Monitor,
|
Monitor,
|
||||||
Search,
|
Search,
|
||||||
Bug,
|
Bug,
|
||||||
Activity,
|
Activity,
|
||||||
|
Recycle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -70,7 +60,12 @@ import {
|
|||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
KeyboardShortcut,
|
KeyboardShortcut,
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
} from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
|
import {
|
||||||
|
getElectronAPI,
|
||||||
|
Project,
|
||||||
|
TrashedProject,
|
||||||
|
RunningAgent,
|
||||||
|
} from "@/lib/electron";
|
||||||
import {
|
import {
|
||||||
initializeProject,
|
initializeProject,
|
||||||
hasAppSpec,
|
hasAppSpec,
|
||||||
@@ -78,8 +73,10 @@ import {
|
|||||||
} from "@/lib/project-init";
|
} from "@/lib/project-init";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Sparkles, Loader2 } from "lucide-react";
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { themeOptions } from "@/config/theme-options";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
import type { SpecRegenerationEvent } from "@/types/electron";
|
||||||
|
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
@@ -173,21 +170,14 @@ function SortableProjectItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme options for project theme selector
|
// Theme options for project theme selector - derived from the shared config
|
||||||
const PROJECT_THEME_OPTIONS = [
|
const PROJECT_THEME_OPTIONS = [
|
||||||
{ value: "", label: "Use Global", icon: Monitor },
|
{ value: "", label: "Use Global", icon: Monitor },
|
||||||
{ value: "dark", label: "Dark", icon: Moon },
|
...themeOptions.map((opt) => ({
|
||||||
{ value: "light", label: "Light", icon: Sun },
|
value: opt.value,
|
||||||
{ value: "retro", label: "Retro", icon: Terminal },
|
label: opt.label,
|
||||||
{ value: "dracula", label: "Dracula", icon: Ghost },
|
icon: opt.Icon,
|
||||||
{ value: "nord", label: "Nord", icon: Snowflake },
|
})),
|
||||||
{ value: "monokai", label: "Monokai", icon: Flame },
|
|
||||||
{ value: "tokyonight", label: "Tokyo Night", icon: TokyoNightIcon },
|
|
||||||
{ value: "solarized", label: "Solarized", icon: Eclipse },
|
|
||||||
{ value: "gruvbox", label: "Gruvbox", icon: Trees },
|
|
||||||
{ value: "catppuccin", label: "Catppuccin", icon: Cat },
|
|
||||||
{ value: "onedark", label: "One Dark", icon: Atom },
|
|
||||||
{ value: "synthwave", label: "Synthwave", icon: Radio },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
@@ -198,7 +188,7 @@ export function Sidebar() {
|
|||||||
currentView,
|
currentView,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
projectHistory,
|
projectHistory,
|
||||||
addProject,
|
upsertAndSetCurrentProject,
|
||||||
setCurrentProject,
|
setCurrentProject,
|
||||||
setCurrentView,
|
setCurrentView,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
@@ -211,7 +201,9 @@ export function Sidebar() {
|
|||||||
clearProjectHistory,
|
clearProjectHistory,
|
||||||
setProjectTheme,
|
setProjectTheme,
|
||||||
setTheme,
|
setTheme,
|
||||||
|
setPreviewTheme,
|
||||||
theme: globalTheme,
|
theme: globalTheme,
|
||||||
|
moveProjectToTrash,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
// Get customizable keyboard shortcuts
|
// Get customizable keyboard shortcuts
|
||||||
@@ -225,6 +217,12 @@ export function Sidebar() {
|
|||||||
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
|
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
|
||||||
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
|
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
|
||||||
|
|
||||||
|
// State for delete project confirmation dialog
|
||||||
|
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
|
||||||
|
|
||||||
|
// State for running agents count
|
||||||
|
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
|
||||||
|
|
||||||
// State for new project setup dialog
|
// State for new project setup dialog
|
||||||
const [showSetupDialog, setShowSetupDialog] = useState(false);
|
const [showSetupDialog, setShowSetupDialog] = useState(false);
|
||||||
const [setupProjectPath, setSetupProjectPath] = useState("");
|
const [setupProjectPath, setSetupProjectPath] = useState("");
|
||||||
@@ -334,6 +332,49 @@ export function Sidebar() {
|
|||||||
};
|
};
|
||||||
}, [setCurrentView]);
|
}, [setCurrentView]);
|
||||||
|
|
||||||
|
// Fetch running agents count function - used for initial load and event-driven updates
|
||||||
|
const fetchRunningAgentsCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api.runningAgents) {
|
||||||
|
const result = await api.runningAgents.getAll();
|
||||||
|
if (result.success && result.runningAgents) {
|
||||||
|
setRunningAgentsCount(result.runningAgents.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Sidebar] Error fetching running agents count:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Subscribe to auto-mode events to update running agents count in real-time
|
||||||
|
useEffect(() => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.autoMode) {
|
||||||
|
// If autoMode is not available, still fetch initial count
|
||||||
|
fetchRunningAgentsCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
|
fetchRunningAgentsCount();
|
||||||
|
|
||||||
|
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||||
|
// When a feature starts, completes, or errors, refresh the count
|
||||||
|
if (
|
||||||
|
event.type === "auto_mode_feature_complete" ||
|
||||||
|
event.type === "auto_mode_error" ||
|
||||||
|
event.type === "auto_mode_feature_start"
|
||||||
|
) {
|
||||||
|
fetchRunningAgentsCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [fetchRunningAgentsCount]);
|
||||||
|
|
||||||
// Handle creating initial spec for new project
|
// Handle creating initial spec for new project
|
||||||
const handleCreateInitialSpec = useCallback(async () => {
|
const handleCreateInitialSpec = useCallback(async () => {
|
||||||
if (!setupProjectPath || !projectOverview.trim()) return;
|
if (!setupProjectPath || !projectOverview.trim()) return;
|
||||||
@@ -414,38 +455,14 @@ export function Sidebar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project already exists (by path) to preserve theme and other settings
|
// Upsert project and set as current (handles both create and update cases)
|
||||||
const existingProject = projects.find((p) => p.path === path);
|
// Theme preservation is handled by the store action
|
||||||
|
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||||
let project: Project;
|
const effectiveTheme =
|
||||||
if (existingProject) {
|
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||||
// Update existing project, preserving theme and other properties
|
(currentProject?.theme as ThemeMode | undefined) ||
|
||||||
project = {
|
globalTheme;
|
||||||
...existingProject,
|
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||||
name, // Update name in case it changed
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
// Update the project in the store (this will update the existing entry)
|
|
||||||
const updatedProjects = projects.map((p) =>
|
|
||||||
p.id === existingProject.id ? project : p
|
|
||||||
);
|
|
||||||
useAppStore.setState({ projects: updatedProjects });
|
|
||||||
} else {
|
|
||||||
// Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
|
|
||||||
// Then fall back to current effective theme, then global theme
|
|
||||||
const trashedProject = trashedProjects.find((p) => p.path === path);
|
|
||||||
const effectiveTheme = trashedProject?.theme || currentProject?.theme || globalTheme;
|
|
||||||
project = {
|
|
||||||
id: `project-${Date.now()}`,
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
theme: effectiveTheme,
|
|
||||||
};
|
|
||||||
addProject(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentProject(project);
|
|
||||||
|
|
||||||
// Check if app_spec.txt exists
|
// Check if app_spec.txt exists
|
||||||
const specExists = await hasAppSpec(path);
|
const specExists = await hasAppSpec(path);
|
||||||
@@ -479,7 +496,12 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]);
|
}, [
|
||||||
|
trashedProjects,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
|
currentProject,
|
||||||
|
globalTheme,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleRestoreProject = useCallback(
|
const handleRestoreProject = useCallback(
|
||||||
(projectId: string) => {
|
(projectId: string) => {
|
||||||
@@ -534,14 +556,14 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
"Clear all trashed projects from Automaker? This does not delete folders from disk."
|
"Clear all projects from recycle bin? This does not delete folders from disk."
|
||||||
);
|
);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setIsEmptyingTrash(true);
|
setIsEmptyingTrash(true);
|
||||||
try {
|
try {
|
||||||
emptyTrash();
|
emptyTrash();
|
||||||
toast.success("Trash cleared");
|
toast.success("Recycle bin cleared");
|
||||||
setShowTrashDialog(false);
|
setShowTrashDialog(false);
|
||||||
} finally {
|
} finally {
|
||||||
setIsEmptyingTrash(false);
|
setIsEmptyingTrash(false);
|
||||||
@@ -761,7 +783,9 @@ export function Sidebar() {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-20 border-b border-sidebar-border shrink-0 titlebar-drag-region",
|
"h-20 border-b border-sidebar-border shrink-0 titlebar-drag-region",
|
||||||
sidebarOpen ? "pt-8 px-3 lg:px-6 flex items-center justify-between" : "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
|
sidebarOpen
|
||||||
|
? "pt-8 px-3 lg:px-6 flex items-center justify-between"
|
||||||
|
: "pt-2 pb-2 px-3 flex flex-col items-center justify-center gap-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -785,14 +809,24 @@ export function Sidebar() {
|
|||||||
sidebarOpen ? "hidden lg:block" : "hidden"
|
sidebarOpen ? "hidden lg:block" : "hidden"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Auto<span className="text-brand-500">maker</span>
|
{IS_MARKETING ? (
|
||||||
|
<>
|
||||||
|
https://<span className="text-brand-500">automaker</span>.app
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Auto<span className="text-brand-500">maker</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Bug Report Button */}
|
{/* Bug Report Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
api.openExternalLink("https://github.com/AutoMaker-Org/automaker/issues");
|
api.openExternalLink(
|
||||||
|
"https://github.com/AutoMaker-Org/automaker/issues"
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all"
|
className="titlebar-no-drag p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 transition-all"
|
||||||
title="Report Bug / Feature Request"
|
title="Report Bug / Feature Request"
|
||||||
@@ -830,10 +864,10 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowTrashDialog(true)}
|
onClick={() => setShowTrashDialog(true)}
|
||||||
className="group flex items-center justify-center px-3 h-[42px] rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-primary hover:bg-destructive/10 border border-sidebar-border"
|
className="group flex items-center justify-center px-3 h-[42px] rounded-lg relative overflow-hidden transition-all text-muted-foreground hover:text-primary hover:bg-destructive/10 border border-sidebar-border"
|
||||||
title="Trash"
|
title="Recycle Bin"
|
||||||
data-testid="trash-button"
|
data-testid="trash-button"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 shrink-0" />
|
<Recycle className="size-4 shrink-0" />
|
||||||
{trashedProjects.length > 0 && (
|
{trashedProjects.length > 0 && (
|
||||||
<span className="absolute -top-[2px] -right-[2px] flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full text-brand-500">
|
<span className="absolute -top-[2px] -right-[2px] flex items-center justify-center w-5 h-5 text-[10px] font-medium rounded-full text-brand-500">
|
||||||
{trashedProjects.length > 9 ? "9+" : trashedProjects.length}
|
{trashedProjects.length > 9 ? "9+" : trashedProjects.length}
|
||||||
@@ -934,7 +968,14 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* Project Options Menu - theme and history */}
|
{/* Project Options Menu - theme and history */}
|
||||||
{currentProject && (
|
{currentProject && (
|
||||||
<DropdownMenu>
|
<DropdownMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
// Clear preview theme when the menu closes
|
||||||
|
if (!open) {
|
||||||
|
setPreviewTheme(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="hidden lg:flex items-center justify-center w-8 h-[42px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag"
|
className="hidden lg:flex items-center justify-center w-8 h-[42px] rounded-lg text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50 border border-sidebar-border transition-all titlebar-no-drag"
|
||||||
@@ -957,8 +998,12 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent
|
<DropdownMenuSubContent
|
||||||
className="w-48"
|
className="w-56"
|
||||||
data-testid="project-theme-menu"
|
data-testid="project-theme-menu"
|
||||||
|
onPointerLeave={() => {
|
||||||
|
// Clear preview theme when leaving the dropdown
|
||||||
|
setPreviewTheme(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
Select theme for this project
|
Select theme for this project
|
||||||
@@ -968,9 +1013,14 @@ export function Sidebar() {
|
|||||||
value={currentProject.theme || ""}
|
value={currentProject.theme || ""}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
|
// Clear preview theme when a theme is selected
|
||||||
|
setPreviewTheme(null);
|
||||||
// If selecting an actual theme (not "Use Global"), also update global
|
// If selecting an actual theme (not "Use Global"), also update global
|
||||||
if (value !== "") {
|
if (value !== "") {
|
||||||
setTheme(value as any);
|
setTheme(value as any);
|
||||||
|
} else {
|
||||||
|
// Restore to global theme when "Use Global" is selected
|
||||||
|
setTheme(globalTheme);
|
||||||
}
|
}
|
||||||
setProjectTheme(
|
setProjectTheme(
|
||||||
currentProject.id,
|
currentProject.id,
|
||||||
@@ -981,22 +1031,54 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
{PROJECT_THEME_OPTIONS.map((option) => {
|
{PROJECT_THEME_OPTIONS.map((option) => {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
|
const themeValue =
|
||||||
|
option.value === "" ? globalTheme : option.value;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuRadioItem
|
<div
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.value}
|
onPointerEnter={() => {
|
||||||
data-testid={`project-theme-${
|
// Preview the theme on hover
|
||||||
option.value || "global"
|
setPreviewTheme(themeValue as any);
|
||||||
}`}
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
// Clear preview theme when leaving this item
|
||||||
|
// Only clear if we're not moving to another theme item
|
||||||
|
const relatedTarget =
|
||||||
|
e.relatedTarget as HTMLElement;
|
||||||
|
if (
|
||||||
|
!relatedTarget ||
|
||||||
|
!relatedTarget.closest(
|
||||||
|
'[data-testid^="project-theme-"]'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setPreviewTheme(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4 mr-2" />
|
<DropdownMenuRadioItem
|
||||||
<span>{option.label}</span>
|
value={option.value}
|
||||||
{option.value === "" && (
|
data-testid={`project-theme-${
|
||||||
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
|
option.value || "global"
|
||||||
({globalTheme})
|
}`}
|
||||||
</span>
|
onFocus={() => {
|
||||||
)}
|
// Preview the theme on keyboard navigation
|
||||||
</DropdownMenuRadioItem>
|
setPreviewTheme(themeValue as any);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Clear preview theme when losing focus
|
||||||
|
// If moving to another item, its onFocus will set it again
|
||||||
|
setPreviewTheme(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-2" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{option.value === "" && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
|
||||||
|
({globalTheme})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
@@ -1039,6 +1121,17 @@ export function Sidebar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Move to Trash Section */}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setShowDeleteProjectDialog(true)}
|
||||||
|
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||||
|
data-testid="move-project-to-trash"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
<span>Move to Trash</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
@@ -1163,14 +1256,25 @@ export function Sidebar() {
|
|||||||
{isActiveRoute("running-agents") && (
|
{isActiveRoute("running-agents") && (
|
||||||
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
|
||||||
)}
|
)}
|
||||||
<Activity
|
<div className="relative">
|
||||||
className={cn(
|
<Activity
|
||||||
"w-4 h-4 shrink-0 transition-colors",
|
className={cn(
|
||||||
isActiveRoute("running-agents")
|
"w-4 h-4 shrink-0 transition-colors",
|
||||||
? "text-brand-500"
|
isActiveRoute("running-agents")
|
||||||
: "group-hover:text-brand-400"
|
? "text-brand-500"
|
||||||
|
: "group-hover:text-brand-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Running agents count badge - shown in collapsed state */}
|
||||||
|
{!sidebarOpen && runningAgentsCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1.5 -right-1.5 flex items-center justify-center min-w-5 h-5 px-1 text-[10px] font-semibold rounded-full bg-brand-500 text-white"
|
||||||
|
data-testid="running-agents-count-collapsed"
|
||||||
|
>
|
||||||
|
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-2.5 font-medium text-sm flex-1 text-left",
|
"ml-2.5 font-medium text-sm flex-1 text-left",
|
||||||
@@ -1179,6 +1283,18 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
Running Agents
|
Running Agents
|
||||||
</span>
|
</span>
|
||||||
|
{/* Running agents count badge - shown in expanded state */}
|
||||||
|
{sidebarOpen && runningAgentsCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden lg:flex items-center justify-center min-w-6 h-6 px-1.5 text-xs font-semibold rounded-full bg-brand-500 text-white",
|
||||||
|
isActiveRoute("running-agents") && "bg-brand-600"
|
||||||
|
)}
|
||||||
|
data-testid="running-agents-count"
|
||||||
|
>
|
||||||
|
{runningAgentsCount > 99 ? "99" : runningAgentsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!sidebarOpen && (
|
{!sidebarOpen && (
|
||||||
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
|
<span className="absolute left-full ml-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-border">
|
||||||
Running Agents
|
Running Agents
|
||||||
@@ -1242,7 +1358,7 @@ export function Sidebar() {
|
|||||||
<Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}>
|
<Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}>
|
||||||
<DialogContent className="bg-popover border-border max-w-2xl">
|
<DialogContent className="bg-popover border-border max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Trash</DialogTitle>
|
<DialogTitle>Recycle Bin</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Restore projects to the sidebar or delete their folders using your
|
Restore projects to the sidebar or delete their folders using your
|
||||||
system Trash.
|
system Trash.
|
||||||
@@ -1250,7 +1366,9 @@ export function Sidebar() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{trashedProjects.length === 0 ? (
|
{trashedProjects.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Trash is empty.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Recycle bin is empty.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
|
<div className="space-y-3 max-h-[360px] overflow-y-auto pr-1">
|
||||||
{trashedProjects.map((project) => (
|
{trashedProjects.map((project) => (
|
||||||
@@ -1318,7 +1436,7 @@ export function Sidebar() {
|
|||||||
disabled={isEmptyingTrash}
|
disabled={isEmptyingTrash}
|
||||||
data-testid="empty-trash"
|
data-testid="empty-trash"
|
||||||
>
|
>
|
||||||
{isEmptyingTrash ? "Clearing..." : "Empty Trash"}
|
{isEmptyingTrash ? "Clearing..." : "Empty Recycle Bin"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -1421,6 +1539,14 @@ export function Sidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Project Confirmation Dialog */}
|
||||||
|
<DeleteProjectDialog
|
||||||
|
open={showDeleteProjectDialog}
|
||||||
|
onOpenChange={setShowDeleteProjectDialog}
|
||||||
|
project={currentProject}
|
||||||
|
onConfirm={moveProjectToTrash}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
453
apps/app/src/components/new-project-modal.tsx
Normal file
453
apps/app/src/components/new-project-modal.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
FolderPlus,
|
||||||
|
FolderOpen,
|
||||||
|
Rocket,
|
||||||
|
ExternalLink,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Link,
|
||||||
|
Folder,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { starterTemplates, type StarterTemplate } from "@/lib/templates";
|
||||||
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
|
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||||
|
|
||||||
|
interface ValidationErrors {
|
||||||
|
projectName?: boolean;
|
||||||
|
workspaceDir?: boolean;
|
||||||
|
templateSelection?: boolean;
|
||||||
|
customUrl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewProjectModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
|
||||||
|
onCreateFromTemplate: (
|
||||||
|
template: StarterTemplate,
|
||||||
|
projectName: string,
|
||||||
|
parentDir: string
|
||||||
|
) => Promise<void>;
|
||||||
|
onCreateFromCustomUrl: (
|
||||||
|
repoUrl: string,
|
||||||
|
projectName: string,
|
||||||
|
parentDir: string
|
||||||
|
) => Promise<void>;
|
||||||
|
isCreating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewProjectModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCreateBlankProject,
|
||||||
|
onCreateFromTemplate,
|
||||||
|
onCreateFromCustomUrl,
|
||||||
|
isCreating,
|
||||||
|
}: NewProjectModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
|
||||||
|
const [projectName, setProjectName] = useState("");
|
||||||
|
const [workspaceDir, setWorkspaceDir] = useState<string>("");
|
||||||
|
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
|
||||||
|
const [useCustomUrl, setUseCustomUrl] = useState(false);
|
||||||
|
const [customUrl, setCustomUrl] = useState("");
|
||||||
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||||
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
|
||||||
|
// Fetch workspace directory when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setIsLoadingWorkspace(true);
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
httpClient.workspace.getConfig()
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.workspaceDir) {
|
||||||
|
setWorkspaceDir(result.workspaceDir);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to get workspace config:", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingWorkspace(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Reset form when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setProjectName("");
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setUseCustomUrl(false);
|
||||||
|
setCustomUrl("");
|
||||||
|
setActiveTab("blank");
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Clear specific errors when user fixes them
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectName && errors.projectName) {
|
||||||
|
setErrors((prev) => ({ ...prev, projectName: false }));
|
||||||
|
}
|
||||||
|
}, [projectName, errors.projectName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
|
||||||
|
setErrors((prev) => ({ ...prev, templateSelection: false }));
|
||||||
|
}
|
||||||
|
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (customUrl && errors.customUrl) {
|
||||||
|
setErrors((prev) => ({ ...prev, customUrl: false }));
|
||||||
|
}
|
||||||
|
}, [customUrl, errors.customUrl]);
|
||||||
|
|
||||||
|
const validateAndCreate = async () => {
|
||||||
|
const newErrors: ValidationErrors = {};
|
||||||
|
|
||||||
|
// Check project name
|
||||||
|
if (!projectName.trim()) {
|
||||||
|
newErrors.projectName = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check workspace dir
|
||||||
|
if (!workspaceDir) {
|
||||||
|
newErrors.workspaceDir = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check template selection (only for template tab)
|
||||||
|
if (activeTab === "template") {
|
||||||
|
if (useCustomUrl) {
|
||||||
|
if (!customUrl.trim()) {
|
||||||
|
newErrors.customUrl = true;
|
||||||
|
}
|
||||||
|
} else if (!selectedTemplate) {
|
||||||
|
newErrors.templateSelection = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are errors, show them and don't proceed
|
||||||
|
if (Object.values(newErrors).some(Boolean)) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear errors and proceed
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
if (activeTab === "blank") {
|
||||||
|
await onCreateBlankProject(projectName, workspaceDir);
|
||||||
|
} else if (useCustomUrl && customUrl) {
|
||||||
|
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
|
||||||
|
} else if (selectedTemplate) {
|
||||||
|
await onCreateFromTemplate(selectedTemplate, projectName, workspaceDir);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenRepo = (url: string) => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
api.openExternalLink(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectTemplate = (template: StarterTemplate) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setUseCustomUrl(false);
|
||||||
|
setCustomUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleCustomUrl = () => {
|
||||||
|
setUseCustomUrl(!useCustomUrl);
|
||||||
|
if (!useCustomUrl) {
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrowseDirectory = async () => {
|
||||||
|
const selectedPath = await openFileBrowser({
|
||||||
|
title: "Select Base Project Directory",
|
||||||
|
description: "Choose the parent directory where your project will be created",
|
||||||
|
});
|
||||||
|
if (selectedPath) {
|
||||||
|
setWorkspaceDir(selectedPath);
|
||||||
|
// Clear any workspace error when a valid directory is selected
|
||||||
|
if (errors.workspaceDir) {
|
||||||
|
setErrors((prev) => ({ ...prev, workspaceDir: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectPath = workspaceDir && projectName ? `${workspaceDir}/${projectName}` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="bg-card border-border max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||||
|
data-testid="new-project-modal"
|
||||||
|
>
|
||||||
|
<DialogHeader className="pb-2">
|
||||||
|
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
Start with a blank project or choose from a starter template.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Project Name Input - Always visible at top */}
|
||||||
|
<div className="space-y-3 pb-4 border-b border-border">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="project-name" className={cn("text-foreground", errors.projectName && "text-red-500")}>
|
||||||
|
Project Name {errors.projectName && <span className="text-red-500">*</span>}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="project-name"
|
||||||
|
placeholder="my-awesome-project"
|
||||||
|
value={projectName}
|
||||||
|
onChange={(e) => setProjectName(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||||
|
errors.projectName
|
||||||
|
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
data-testid="project-name-input"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{errors.projectName && (
|
||||||
|
<p className="text-xs text-red-500">Project name is required</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace Directory Display */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-2 text-sm",
|
||||||
|
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
<Folder className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
{isLoadingWorkspace ? (
|
||||||
|
"Loading workspace..."
|
||||||
|
) : workspaceDir ? (
|
||||||
|
<>Will be created at: <code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">{projectPath || "..."}</code></>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-500">No workspace configured</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBrowseDirectory}
|
||||||
|
disabled={isLoadingWorkspace}
|
||||||
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
|
data-testid="browse-directory-button"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Browse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
|
||||||
|
className="flex-1 flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full justify-start">
|
||||||
|
<TabsTrigger value="blank" className="gap-2">
|
||||||
|
<FolderPlus className="w-4 h-4" />
|
||||||
|
Blank Project
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="template" className="gap-2">
|
||||||
|
<Rocket className="w-4 h-4" />
|
||||||
|
Starter Kit
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
<TabsContent value="blank" className="mt-0">
|
||||||
|
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create an empty project with the standard .automaker directory
|
||||||
|
structure. Perfect for starting from scratch or importing an
|
||||||
|
existing codebase.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="template" className="mt-0">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Error message for template selection */}
|
||||||
|
{errors.templateSelection && (
|
||||||
|
<p className="text-sm text-red-500">Please select a template or enter a custom GitHub URL</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preset Templates */}
|
||||||
|
<div className={cn(
|
||||||
|
"space-y-3 rounded-lg p-1 -m-1",
|
||||||
|
errors.templateSelection && "ring-2 ring-red-500/50"
|
||||||
|
)}>
|
||||||
|
{starterTemplates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||||
|
selectedTemplate?.id === template.id && !useCustomUrl
|
||||||
|
? "border-brand-500 bg-brand-500/10"
|
||||||
|
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectTemplate(template)}
|
||||||
|
data-testid={`template-${template.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="font-medium text-foreground">
|
||||||
|
{template.name}
|
||||||
|
</h4>
|
||||||
|
{selectedTemplate?.id === template.id && !useCustomUrl && (
|
||||||
|
<Check className="w-4 h-4 text-brand-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tech Stack */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{template.techStack.slice(0, 6).map((tech) => (
|
||||||
|
<Badge
|
||||||
|
key={tech}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{template.techStack.length > 6 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
+{template.techStack.length - 6} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Features */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">Features: </span>
|
||||||
|
{template.features.slice(0, 3).join(" · ")}
|
||||||
|
{template.features.length > 3 &&
|
||||||
|
` · +${template.features.length - 3} more`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenRepo(template.repoUrl);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-1" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Custom URL Option */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||||
|
useCustomUrl
|
||||||
|
? "border-brand-500 bg-brand-500/10"
|
||||||
|
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={handleToggleCustomUrl}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Link className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
|
||||||
|
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Clone any public GitHub repository as a starting point.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{useCustomUrl && (
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
|
||||||
|
<Input
|
||||||
|
placeholder="https://github.com/username/repository"
|
||||||
|
value={customUrl}
|
||||||
|
onChange={(e) => setCustomUrl(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||||
|
errors.customUrl
|
||||||
|
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
data-testid="custom-url-input"
|
||||||
|
/>
|
||||||
|
{errors.customUrl && (
|
||||||
|
<p className="text-xs text-red-500">GitHub URL is required</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<HotkeyButton
|
||||||
|
onClick={validateAndCreate}
|
||||||
|
disabled={isCreating}
|
||||||
|
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||||
|
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||||
|
hotkeyActive={open}
|
||||||
|
data-testid="confirm-create-project"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
{activeTab === "template" ? "Cloning..." : "Creating..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Create Project</>
|
||||||
|
)}
|
||||||
|
</HotkeyButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
AgentModel,
|
AgentModel,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
|
defaultBackgroundSettings,
|
||||||
} from "@/store/app-store";
|
} from "@/store/app-store";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
import { cn, modelSupportsThinking } from "@/lib/utils";
|
||||||
@@ -58,6 +59,7 @@ import { KanbanColumn } from "./kanban-column";
|
|||||||
import { KanbanCard } from "./kanban-card";
|
import { KanbanCard } from "./kanban-card";
|
||||||
import { AgentOutputModal } from "./agent-output-modal";
|
import { AgentOutputModal } from "./agent-output-modal";
|
||||||
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||||
|
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -86,6 +88,7 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Shuffle,
|
Shuffle,
|
||||||
|
ImageIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
@@ -206,6 +209,7 @@ export function BoardView() {
|
|||||||
aiProfiles,
|
aiProfiles,
|
||||||
kanbanCardDetailLevel,
|
kanbanCardDetailLevel,
|
||||||
setKanbanCardDetailLevel,
|
setKanbanCardDetailLevel,
|
||||||
|
boardBackgroundByProject,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
const shortcuts = useKeyboardShortcutsConfig();
|
||||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
@@ -230,6 +234,8 @@ export function BoardView() {
|
|||||||
);
|
);
|
||||||
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
|
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
|
||||||
|
useState(false);
|
||||||
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
|
||||||
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
|
||||||
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
|
||||||
@@ -400,7 +406,8 @@ export function BoardView() {
|
|||||||
|
|
||||||
const currentPath = currentProject.path;
|
const currentPath = currentProject.path;
|
||||||
const previousPath = prevProjectPathRef.current;
|
const previousPath = prevProjectPathRef.current;
|
||||||
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
|
const isProjectSwitch =
|
||||||
|
previousPath !== null && currentPath !== previousPath;
|
||||||
|
|
||||||
// Get cached features from store (without adding to dependencies)
|
// Get cached features from store (without adding to dependencies)
|
||||||
const cachedFeatures = useAppStore.getState().features;
|
const cachedFeatures = useAppStore.getState().features;
|
||||||
@@ -556,7 +563,8 @@ export function BoardView() {
|
|||||||
const unsubscribe = api.autoMode.onEvent((event) => {
|
const unsubscribe = api.autoMode.onEvent((event) => {
|
||||||
// Use event's projectPath or projectId if available, otherwise use current project
|
// Use event's projectPath or projectId if available, otherwise use current project
|
||||||
// Board view only reacts to events for the currently selected project
|
// Board view only reacts to events for the currently selected project
|
||||||
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
|
const eventProjectId =
|
||||||
|
("projectId" in event && event.projectId) || projectId;
|
||||||
|
|
||||||
if (event.type === "auto_mode_feature_complete") {
|
if (event.type === "auto_mode_feature_complete") {
|
||||||
// Reload features when a feature is completed
|
// Reload features when a feature is completed
|
||||||
@@ -585,15 +593,16 @@ export function BoardView() {
|
|||||||
loadFeatures();
|
loadFeatures();
|
||||||
|
|
||||||
// Check for authentication errors and show a more helpful message
|
// Check for authentication errors and show a more helpful message
|
||||||
const isAuthError = event.errorType === "authentication" ||
|
const isAuthError =
|
||||||
(event.error && (
|
event.errorType === "authentication" ||
|
||||||
event.error.includes("Authentication failed") ||
|
(event.error &&
|
||||||
event.error.includes("Invalid API key")
|
(event.error.includes("Authentication failed") ||
|
||||||
));
|
event.error.includes("Invalid API key")));
|
||||||
|
|
||||||
if (isAuthError) {
|
if (isAuthError) {
|
||||||
toast.error("Authentication Failed", {
|
toast.error("Authentication Failed", {
|
||||||
description: "Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
description:
|
||||||
|
"Your API key is invalid or expired. Please check Settings or run 'claude login' in terminal.",
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -867,7 +876,11 @@ export function BoardView() {
|
|||||||
// features often have skipTests=true, and we want status-based handling first
|
// features often have skipTests=true, and we want status-based handling first
|
||||||
if (targetStatus === "verified") {
|
if (targetStatus === "verified") {
|
||||||
moveFeature(featureId, "verified");
|
moveFeature(featureId, "verified");
|
||||||
persistFeatureUpdate(featureId, { status: "verified" });
|
// Clear justFinishedAt timestamp when manually verifying via drag
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "verified",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
toast.success("Feature verified", {
|
toast.success("Feature verified", {
|
||||||
description: `Manually verified: ${draggedFeature.description.slice(
|
description: `Manually verified: ${draggedFeature.description.slice(
|
||||||
0,
|
0,
|
||||||
@@ -877,7 +890,11 @@ export function BoardView() {
|
|||||||
} else if (targetStatus === "backlog") {
|
} else if (targetStatus === "backlog") {
|
||||||
// Allow moving waiting_approval cards back to backlog
|
// Allow moving waiting_approval cards back to backlog
|
||||||
moveFeature(featureId, "backlog");
|
moveFeature(featureId, "backlog");
|
||||||
persistFeatureUpdate(featureId, { status: "backlog" });
|
// Clear justFinishedAt timestamp when moving back to backlog
|
||||||
|
persistFeatureUpdate(featureId, {
|
||||||
|
status: "backlog",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
toast.info("Feature moved to backlog", {
|
toast.info("Feature moved to backlog", {
|
||||||
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
description: `Moved to Backlog: ${draggedFeature.description.slice(
|
||||||
0,
|
0,
|
||||||
@@ -1198,7 +1215,11 @@ export function BoardView() {
|
|||||||
description: feature.description,
|
description: feature.description,
|
||||||
});
|
});
|
||||||
moveFeature(feature.id, "verified");
|
moveFeature(feature.id, "verified");
|
||||||
persistFeatureUpdate(feature.id, { status: "verified" });
|
// Clear justFinishedAt timestamp when manually verifying
|
||||||
|
persistFeatureUpdate(feature.id, {
|
||||||
|
status: "verified",
|
||||||
|
justFinishedAt: undefined,
|
||||||
|
});
|
||||||
toast.success("Feature verified", {
|
toast.success("Feature verified", {
|
||||||
description: `Marked as verified: ${feature.description.slice(0, 50)}${
|
description: `Marked as verified: ${feature.description.slice(0, 50)}${
|
||||||
feature.description.length > 50 ? "..." : ""
|
feature.description.length > 50 ? "..." : ""
|
||||||
@@ -1264,9 +1285,11 @@ export function BoardView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move feature back to in_progress before sending follow-up
|
// Move feature back to in_progress before sending follow-up
|
||||||
|
// Clear justFinishedAt timestamp since user is now interacting with it
|
||||||
const updates = {
|
const updates = {
|
||||||
status: "in_progress" as const,
|
status: "in_progress" as const,
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
|
justFinishedAt: undefined,
|
||||||
};
|
};
|
||||||
updateFeature(featureId, updates);
|
updateFeature(featureId, updates);
|
||||||
persistFeatureUpdate(featureId, updates);
|
persistFeatureUpdate(featureId, updates);
|
||||||
@@ -1626,7 +1649,7 @@ export function BoardView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuresToStart = backlogFeatures.slice(0, availableSlots);
|
const featuresToStart = backlogFeatures.slice(0, 1);
|
||||||
|
|
||||||
for (const feature of featuresToStart) {
|
for (const feature of featuresToStart) {
|
||||||
// Update the feature status with startedAt timestamp
|
// Update the feature status with startedAt timestamp
|
||||||
@@ -1835,202 +1858,282 @@ export function BoardView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kanban Card Detail Level Toggle */}
|
{/* Board Background & Detail Level Controls */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div
|
<div className="flex items-center gap-2 ml-4">
|
||||||
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
|
{/* Board Background Button */}
|
||||||
data-testid="kanban-detail-toggle"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setKanbanCardDetailLevel("minimal")}
|
variant="outline"
|
||||||
className={cn(
|
size="sm"
|
||||||
"p-2 rounded-l-lg transition-colors",
|
onClick={() => setShowBoardBackgroundModal(true)}
|
||||||
kanbanCardDetailLevel === "minimal"
|
className="h-8 px-2"
|
||||||
? "bg-brand-500/20 text-brand-500"
|
data-testid="board-background-button"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid="kanban-toggle-minimal"
|
|
||||||
>
|
>
|
||||||
<Minimize2 className="w-4 h-4" />
|
<ImageIcon className="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Minimal - Title & category only</p>
|
<p>Board Background Settings</p>
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => setKanbanCardDetailLevel("standard")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 transition-colors",
|
|
||||||
kanbanCardDetailLevel === "standard"
|
|
||||||
? "bg-brand-500/20 text-brand-500"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid="kanban-toggle-standard"
|
|
||||||
>
|
|
||||||
<Square className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Standard - Steps & progress</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={() => setKanbanCardDetailLevel("detailed")}
|
|
||||||
className={cn(
|
|
||||||
"p-2 rounded-r-lg transition-colors",
|
|
||||||
kanbanCardDetailLevel === "detailed"
|
|
||||||
? "bg-brand-500/20 text-brand-500"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
data-testid="kanban-toggle-detailed"
|
|
||||||
>
|
|
||||||
<Maximize2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Detailed - Model, tools & tasks</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Kanban Card Detail Level Toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center rounded-lg bg-secondary border border-border"
|
||||||
|
data-testid="kanban-detail-toggle"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => setKanbanCardDetailLevel("minimal")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-l-lg transition-colors",
|
||||||
|
kanbanCardDetailLevel === "minimal"
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-minimal"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Minimal - Title & category only</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => setKanbanCardDetailLevel("standard")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 transition-colors",
|
||||||
|
kanbanCardDetailLevel === "standard"
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-standard"
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Standard - Steps & progress</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => setKanbanCardDetailLevel("detailed")}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-r-lg transition-colors",
|
||||||
|
kanbanCardDetailLevel === "detailed"
|
||||||
|
? "bg-brand-500/20 text-brand-500"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
)}
|
||||||
|
data-testid="kanban-toggle-detailed"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Detailed - Model, tools & tasks</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Kanban Columns */}
|
{/* Kanban Columns */}
|
||||||
<div className="flex-1 overflow-x-auto px-4 pb-4">
|
{(() => {
|
||||||
<DndContext
|
// Get background settings for current project
|
||||||
sensors={sensors}
|
const backgroundSettings =
|
||||||
collisionDetection={collisionDetectionStrategy}
|
(currentProject && boardBackgroundByProject[currentProject.path]) ||
|
||||||
onDragStart={handleDragStart}
|
defaultBackgroundSettings;
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
// Build background image style if image exists
|
||||||
<div className="flex gap-4 h-full min-w-max">
|
const backgroundImageStyle = backgroundSettings.imagePath
|
||||||
{COLUMNS.map((column) => {
|
? {
|
||||||
const columnFeatures = getColumnFeatures(column.id);
|
backgroundImage: `url(${
|
||||||
return (
|
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
|
||||||
<KanbanColumn
|
}/api/fs/image?path=${encodeURIComponent(
|
||||||
key={column.id}
|
backgroundSettings.imagePath
|
||||||
id={column.id}
|
)}&projectPath=${encodeURIComponent(
|
||||||
title={column.title}
|
currentProject?.path || ""
|
||||||
color={column.color}
|
)}${
|
||||||
count={columnFeatures.length}
|
backgroundSettings.imageVersion
|
||||||
headerAction={
|
? `&v=${backgroundSettings.imageVersion}`
|
||||||
column.id === "verified" && columnFeatures.length > 0 ? (
|
: ""
|
||||||
<Button
|
})`,
|
||||||
variant="ghost"
|
backgroundSize: "cover",
|
||||||
size="sm"
|
backgroundPosition: "center",
|
||||||
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
backgroundRepeat: "no-repeat",
|
||||||
onClick={() => setShowDeleteAllVerifiedDialog(true)}
|
}
|
||||||
data-testid="delete-all-verified-button"
|
: {};
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
return (
|
||||||
Delete All
|
<div
|
||||||
</Button>
|
className="flex-1 overflow-x-auto px-4 pb-4 relative"
|
||||||
) : column.id === "backlog" ? (
|
style={backgroundImageStyle}
|
||||||
<div className="flex items-center gap-1">
|
>
|
||||||
<Button
|
<DndContext
|
||||||
variant="ghost"
|
sensors={sensors}
|
||||||
size="sm"
|
collisionDetection={collisionDetectionStrategy}
|
||||||
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
onDragStart={handleDragStart}
|
||||||
onClick={() => setShowSuggestionsDialog(true)}
|
onDragEnd={handleDragEnd}
|
||||||
title="Feature Suggestions"
|
>
|
||||||
data-testid="feature-suggestions-button"
|
<div className="flex gap-4 h-full min-w-max">
|
||||||
>
|
{COLUMNS.map((column) => {
|
||||||
<Lightbulb className="w-3.5 h-3.5" />
|
const columnFeatures = getColumnFeatures(column.id);
|
||||||
{suggestionsCount > 0 && (
|
return (
|
||||||
<span
|
<KanbanColumn
|
||||||
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
key={column.id}
|
||||||
data-testid="suggestions-count"
|
id={column.id}
|
||||||
>
|
title={column.title}
|
||||||
{suggestionsCount}
|
color={column.color}
|
||||||
</span>
|
count={columnFeatures.length}
|
||||||
)}
|
opacity={backgroundSettings.columnOpacity}
|
||||||
</Button>
|
showBorder={backgroundSettings.columnBorderEnabled}
|
||||||
{columnFeatures.length > 0 && (
|
hideScrollbar={backgroundSettings.hideScrollbar}
|
||||||
<HotkeyButton
|
headerAction={
|
||||||
|
column.id === "verified" &&
|
||||||
|
columnFeatures.length > 0 ? (
|
||||||
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
onClick={handleStartNextFeatures}
|
onClick={() =>
|
||||||
hotkey={shortcuts.startNext}
|
setShowDeleteAllVerifiedDialog(true)
|
||||||
hotkeyActive={false}
|
}
|
||||||
data-testid="start-next-button"
|
data-testid="delete-all-verified-button"
|
||||||
>
|
>
|
||||||
<FastForward className="w-3 h-3 mr-1" />
|
<Trash2 className="w-3 h-3 mr-1" />
|
||||||
Pull Top
|
Delete All
|
||||||
</HotkeyButton>
|
</Button>
|
||||||
)}
|
) : column.id === "backlog" ? (
|
||||||
</div>
|
<div className="flex items-center gap-1">
|
||||||
) : undefined
|
<Button
|
||||||
}
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<SortableContext
|
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
|
||||||
items={columnFeatures.map((f) => f.id)}
|
onClick={() => setShowSuggestionsDialog(true)}
|
||||||
strategy={verticalListSortingStrategy}
|
title="Feature Suggestions"
|
||||||
>
|
data-testid="feature-suggestions-button"
|
||||||
{columnFeatures.map((feature, index) => {
|
>
|
||||||
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
<Lightbulb className="w-3.5 h-3.5" />
|
||||||
let shortcutKey: string | undefined;
|
{suggestionsCount > 0 && (
|
||||||
if (column.id === "in_progress" && index < 10) {
|
<span
|
||||||
shortcutKey = index === 9 ? "0" : String(index + 1);
|
className="absolute -top-1 -right-1 w-4 h-4 text-[9px] font-mono rounded-full bg-yellow-500 text-black flex items-center justify-center"
|
||||||
|
data-testid="suggestions-count"
|
||||||
|
>
|
||||||
|
{suggestionsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{columnFeatures.length > 0 && (
|
||||||
|
<HotkeyButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs text-primary hover:text-primary hover:bg-primary/10"
|
||||||
|
onClick={handleStartNextFeatures}
|
||||||
|
hotkey={shortcuts.startNext}
|
||||||
|
hotkeyActive={false}
|
||||||
|
data-testid="start-next-button"
|
||||||
|
>
|
||||||
|
<FastForward className="w-3 h-3 mr-1" />
|
||||||
|
Pull Top
|
||||||
|
</HotkeyButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
return (
|
>
|
||||||
<KanbanCard
|
<SortableContext
|
||||||
key={feature.id}
|
items={columnFeatures.map((f) => f.id)}
|
||||||
feature={feature}
|
strategy={verticalListSortingStrategy}
|
||||||
onEdit={() => setEditingFeature(feature)}
|
>
|
||||||
onDelete={() => handleDeleteFeature(feature.id)}
|
{columnFeatures.map((feature, index) => {
|
||||||
onViewOutput={() => handleViewOutput(feature)}
|
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
|
||||||
onVerify={() => handleVerifyFeature(feature)}
|
let shortcutKey: string | undefined;
|
||||||
onResume={() => handleResumeFeature(feature)}
|
if (column.id === "in_progress" && index < 10) {
|
||||||
onForceStop={() => handleForceStopFeature(feature)}
|
shortcutKey =
|
||||||
onManualVerify={() => handleManualVerify(feature)}
|
index === 9 ? "0" : String(index + 1);
|
||||||
onMoveBackToInProgress={() =>
|
|
||||||
handleMoveBackToInProgress(feature)
|
|
||||||
}
|
}
|
||||||
onFollowUp={() => handleOpenFollowUp(feature)}
|
return (
|
||||||
onCommit={() => handleCommitFeature(feature)}
|
<KanbanCard
|
||||||
onRevert={() => handleRevertFeature(feature)}
|
key={feature.id}
|
||||||
onMerge={() => handleMergeFeature(feature)}
|
feature={feature}
|
||||||
hasContext={featuresWithContext.has(feature.id)}
|
onEdit={() => setEditingFeature(feature)}
|
||||||
isCurrentAutoTask={runningAutoTasks.includes(
|
onDelete={() => handleDeleteFeature(feature.id)}
|
||||||
feature.id
|
onViewOutput={() => handleViewOutput(feature)}
|
||||||
)}
|
onVerify={() => handleVerifyFeature(feature)}
|
||||||
shortcutKey={shortcutKey}
|
onResume={() => handleResumeFeature(feature)}
|
||||||
/>
|
onForceStop={() =>
|
||||||
);
|
handleForceStopFeature(feature)
|
||||||
})}
|
}
|
||||||
</SortableContext>
|
onManualVerify={() =>
|
||||||
</KanbanColumn>
|
handleManualVerify(feature)
|
||||||
);
|
}
|
||||||
})}
|
onMoveBackToInProgress={() =>
|
||||||
</div>
|
handleMoveBackToInProgress(feature)
|
||||||
|
}
|
||||||
|
onFollowUp={() => handleOpenFollowUp(feature)}
|
||||||
|
onCommit={() => handleCommitFeature(feature)}
|
||||||
|
onRevert={() => handleRevertFeature(feature)}
|
||||||
|
onMerge={() => handleMergeFeature(feature)}
|
||||||
|
hasContext={featuresWithContext.has(feature.id)}
|
||||||
|
isCurrentAutoTask={runningAutoTasks.includes(
|
||||||
|
feature.id
|
||||||
|
)}
|
||||||
|
shortcutKey={shortcutKey}
|
||||||
|
opacity={backgroundSettings.cardOpacity}
|
||||||
|
glassmorphism={
|
||||||
|
backgroundSettings.cardGlassmorphism
|
||||||
|
}
|
||||||
|
cardBorderEnabled={
|
||||||
|
backgroundSettings.cardBorderEnabled
|
||||||
|
}
|
||||||
|
cardBorderOpacity={
|
||||||
|
backgroundSettings.cardBorderOpacity
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</KanbanColumn>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeFeature && (
|
{activeFeature && (
|
||||||
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
|
||||||
<CardHeader className="p-3">
|
<CardHeader className="p-3">
|
||||||
<CardTitle className="text-sm">
|
<CardTitle className="text-sm">
|
||||||
{activeFeature.description}
|
{activeFeature.description}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
{activeFeature.category}
|
{activeFeature.category}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Board Background Modal */}
|
||||||
|
<BoardBackgroundModal
|
||||||
|
open={showBoardBackgroundModal}
|
||||||
|
onOpenChange={setShowBoardBackgroundModal}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Add Feature Dialog */}
|
{/* Add Feature Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showAddDialog}
|
open={showAddDialog}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import { useFileBrowser } from "@/contexts/file-browser-context";
|
||||||
|
|
||||||
interface InterviewMessage {
|
interface InterviewMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,6 +66,7 @@ const INTERVIEW_QUESTIONS = [
|
|||||||
export function InterviewView() {
|
export function InterviewView() {
|
||||||
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
|
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
|
||||||
useAppStore();
|
useAppStore();
|
||||||
|
const { openFileBrowser } = useFileBrowser();
|
||||||
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);
|
||||||
@@ -286,11 +288,13 @@ export function InterviewView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
const handleSelectDirectory = async () => {
|
||||||
const api = getElectronAPI();
|
const selectedPath = await openFileBrowser({
|
||||||
const result = await api.openDirectory();
|
title: "Select Base Directory",
|
||||||
|
description: "Choose the parent directory where your new project will be created",
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
if (selectedPath) {
|
||||||
setProjectPath(result.filePaths[0]);
|
setProjectPath(selectedPath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, memo } from "react";
|
import { useState, useEffect, useMemo, memo } from "react";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Feature, useAppStore } from "@/store/app-store";
|
import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store";
|
||||||
import {
|
import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Edit,
|
Edit,
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
GitMerge,
|
GitMerge,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Brain,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
import { CountUpTimer } from "@/components/ui/count-up-timer";
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
import { getElectronAPI } from "@/lib/electron";
|
||||||
@@ -73,6 +74,21 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats thinking level for compact display
|
||||||
|
*/
|
||||||
|
function formatThinkingLevel(level: ThinkingLevel | undefined): string {
|
||||||
|
if (!level || level === "none") return "";
|
||||||
|
const labels: Record<ThinkingLevel, string> = {
|
||||||
|
none: "",
|
||||||
|
low: "Low",
|
||||||
|
medium: "Med",
|
||||||
|
high: "High",
|
||||||
|
ultrathink: "Ultra",
|
||||||
|
};
|
||||||
|
return labels[level];
|
||||||
|
}
|
||||||
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
@@ -94,6 +110,14 @@ interface KanbanCardProps {
|
|||||||
contextContent?: string;
|
contextContent?: string;
|
||||||
/** Feature summary from agent completion */
|
/** Feature summary from agent completion */
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
/** Opacity percentage (0-100) */
|
||||||
|
opacity?: number;
|
||||||
|
/** Whether to use glassmorphism (backdrop-blur) effect */
|
||||||
|
glassmorphism?: boolean;
|
||||||
|
/** Whether to show card borders */
|
||||||
|
cardBorderEnabled?: boolean;
|
||||||
|
/** Card border opacity percentage (0-100) */
|
||||||
|
cardBorderOpacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanCard = memo(function KanbanCard({
|
export const KanbanCard = memo(function KanbanCard({
|
||||||
@@ -115,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
shortcutKey,
|
shortcutKey,
|
||||||
contextContent,
|
contextContent,
|
||||||
summary,
|
summary,
|
||||||
|
opacity = 100,
|
||||||
|
glassmorphism = true,
|
||||||
|
cardBorderEnabled = true,
|
||||||
|
cardBorderOpacity = 100,
|
||||||
}: KanbanCardProps) {
|
}: KanbanCardProps) {
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
||||||
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
|
||||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||||
const { kanbanCardDetailLevel } = useAppStore();
|
const { kanbanCardDetailLevel } = useAppStore();
|
||||||
|
|
||||||
// Check if feature has worktree
|
// Check if feature has worktree
|
||||||
@@ -132,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
kanbanCardDetailLevel === "detailed";
|
kanbanCardDetailLevel === "detailed";
|
||||||
const showAgentInfo = kanbanCardDetailLevel === "detailed";
|
const showAgentInfo = kanbanCardDetailLevel === "detailed";
|
||||||
|
|
||||||
|
// Helper to check if "just finished" badge should be shown (within 2 minutes)
|
||||||
|
const isJustFinished = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!feature.justFinishedAt ||
|
||||||
|
feature.status !== "waiting_approval" ||
|
||||||
|
feature.error
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||||
|
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
|
||||||
|
return currentTime - finishedTime < twoMinutes;
|
||||||
|
}, [feature.justFinishedAt, feature.status, feature.error, currentTime]);
|
||||||
|
|
||||||
|
// Update current time periodically to check if badge should be hidden
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feature.justFinishedAt || feature.status !== "waiting_approval") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishedTime = new Date(feature.justFinishedAt).getTime();
|
||||||
|
const twoMinutes = 2 * 60 * 1000; // 2 minutes in milliseconds
|
||||||
|
const timeRemaining = twoMinutes - (currentTime - finishedTime);
|
||||||
|
|
||||||
|
if (timeRemaining <= 0) {
|
||||||
|
// Already past 2 minutes
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update time every second to check if 2 minutes have passed
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTime(Date.now());
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [feature.justFinishedAt, feature.status, currentTime]);
|
||||||
|
|
||||||
// Load context file for in_progress, waiting_approval, and verified features
|
// Load context file for in_progress, waiting_approval, and verified features
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadContext = async () => {
|
const loadContext = async () => {
|
||||||
@@ -168,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
} else {
|
} else {
|
||||||
// Fallback to direct file read for backward compatibility
|
// Fallback to direct file read for backward compatibility
|
||||||
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
|
const contextPath = `${currentProject.path}/.automaker/features/${feature.id}/agent-output.md`;
|
||||||
const result = await api.readFile(contextPath);
|
const result = await api.readFile(contextPath);
|
||||||
|
|
||||||
if (result.success && result.content) {
|
if (result.success && result.content) {
|
||||||
const info = parseAgentContext(result.content);
|
const info = parseAgentContext(result.content);
|
||||||
setAgentInfo(info);
|
setAgentInfo(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -225,17 +291,48 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Calculate border style based on enabled state and opacity
|
||||||
|
const borderStyle: React.CSSProperties = { ...style };
|
||||||
|
if (!cardBorderEnabled) {
|
||||||
|
(borderStyle as Record<string, string>).borderWidth = "0px";
|
||||||
|
(borderStyle as Record<string, string>).borderColor = "transparent";
|
||||||
|
} else if (cardBorderOpacity !== 100) {
|
||||||
|
// Apply border opacity using color-mix to blend the border color with transparent
|
||||||
|
// The --border variable uses oklch format, so we use color-mix in oklch space
|
||||||
|
// Ensure border width is set (1px is the default Tailwind border width)
|
||||||
|
(borderStyle as Record<string, string>).borderWidth = "1px";
|
||||||
|
(
|
||||||
|
borderStyle as Record<string, string>
|
||||||
|
).borderColor = `color-mix(in oklch, var(--border) ${cardBorderOpacity}%, transparent)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardElement = (
|
||||||
<Card
|
<Card
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={isCurrentAutoTask ? style : borderStyle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
|
"cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
|
||||||
isDragging && "opacity-50 scale-105 shadow-lg",
|
// Apply border class when border is enabled and opacity is 100%
|
||||||
isCurrentAutoTask &&
|
// When opacity is not 100%, we use inline styles for border color
|
||||||
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
|
// Skip border classes when animated border is active (isCurrentAutoTask)
|
||||||
|
!isCurrentAutoTask &&
|
||||||
|
cardBorderEnabled &&
|
||||||
|
cardBorderOpacity === 100 &&
|
||||||
|
"border-border",
|
||||||
|
// When border is enabled but opacity is not 100%, we still need border width
|
||||||
|
!isCurrentAutoTask &&
|
||||||
|
cardBorderEnabled &&
|
||||||
|
cardBorderOpacity !== 100 &&
|
||||||
|
"border",
|
||||||
|
// Remove default background when using opacity overlay
|
||||||
|
!isDragging && "bg-transparent",
|
||||||
|
// Remove default backdrop-blur-sm from Card component when glassmorphism is disabled
|
||||||
|
!glassmorphism && "backdrop-blur-[0px]!",
|
||||||
|
isDragging && "scale-105 shadow-lg",
|
||||||
|
// Error state border (only when not in progress)
|
||||||
feature.error &&
|
feature.error &&
|
||||||
!isCurrentAutoTask &&
|
!isCurrentAutoTask &&
|
||||||
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
|
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
|
||||||
@@ -246,6 +343,16 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...(isDraggable ? listeners : {})}
|
{...(isDraggable ? listeners : {})}
|
||||||
>
|
>
|
||||||
|
{/* Background overlay with opacity - only affects background, not content */}
|
||||||
|
{!isDragging && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-xl bg-card -z-10",
|
||||||
|
glassmorphism && "backdrop-blur-sm"
|
||||||
|
)}
|
||||||
|
style={{ opacity: opacity / 100 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Skip Tests indicator badge */}
|
{/* Skip Tests indicator badge */}
|
||||||
{feature.skipTests && !feature.error && (
|
{feature.skipTests && !feature.error && (
|
||||||
<div
|
<div
|
||||||
@@ -276,6 +383,21 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<span>Errored</span>
|
<span>Errored</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Just Finished indicator badge - shows when agent just completed work (for 2 minutes) */}
|
||||||
|
{isJustFinished && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10",
|
||||||
|
feature.skipTests ? "top-8 left-2" : "top-2 left-2",
|
||||||
|
"bg-green-500/20 border border-green-500/50 text-green-400 animate-pulse"
|
||||||
|
)}
|
||||||
|
data-testid={`just-finished-badge-${feature.id}`}
|
||||||
|
title="Agent just finished working on this feature"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
<span>Fresh Baked</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Branch badge - show when feature has a worktree */}
|
{/* Branch badge - show when feature has a worktree */}
|
||||||
{hasWorktree && !isCurrentAutoTask && (
|
{hasWorktree && !isCurrentAutoTask && (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
@@ -285,19 +407,23 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
|
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
|
||||||
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
|
||||||
// Position below error badge if present, otherwise use normal position
|
// Position below other badges if present, otherwise use normal position
|
||||||
feature.error || feature.skipTests
|
feature.error || feature.skipTests || isJustFinished
|
||||||
? "top-8 left-2"
|
? "top-8 left-2"
|
||||||
: "top-2 left-2"
|
: "top-2 left-2"
|
||||||
)}
|
)}
|
||||||
data-testid={`branch-badge-${feature.id}`}
|
data-testid={`branch-badge-${feature.id}`}
|
||||||
>
|
>
|
||||||
<GitBranch className="w-3 h-3 shrink-0" />
|
<GitBranch className="w-3 h-3 shrink-0" />
|
||||||
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
|
<span className="truncate max-w-[80px]">
|
||||||
|
{feature.branchName?.replace("feature/", "")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="max-w-[300px]">
|
<TooltipContent side="bottom" className="max-w-[300px]">
|
||||||
<p className="font-mono text-xs break-all">{feature.branchName}</p>
|
<p className="font-mono text-xs break-all">
|
||||||
|
{feature.branchName}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -306,14 +432,19 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
|
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
|
||||||
// Add extra top padding when badges are present to prevent text overlap
|
// Add extra top padding when badges are present to prevent text overlap
|
||||||
(feature.skipTests || feature.error) && "pt-10",
|
(feature.skipTests || feature.error || isJustFinished) && "pt-10",
|
||||||
// Add even more top padding when both badges and branch are shown
|
// Add even more top padding when both badges and branch are shown
|
||||||
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
|
hasWorktree &&
|
||||||
|
(feature.skipTests || feature.error || isJustFinished) &&
|
||||||
|
"pt-14"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCurrentAutoTask && (
|
{isCurrentAutoTask && (
|
||||||
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
|
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
|
||||||
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
|
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
|
||||||
|
<span className="text-xs text-running-indicator font-medium">
|
||||||
|
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||||
|
</span>
|
||||||
{feature.startedAt && (
|
{feature.startedAt && (
|
||||||
<CountUpTimer
|
<CountUpTimer
|
||||||
startedAt={feature.startedAt}
|
startedAt={feature.startedAt}
|
||||||
@@ -437,7 +568,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
) : (
|
) : (
|
||||||
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
|
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
|
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{feature.steps.length > 3 && (
|
{feature.steps.length > 3 && (
|
||||||
@@ -448,6 +581,28 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Model/Preset Info for Backlog Cards - Show in Detailed mode */}
|
||||||
|
{showAgentInfo && feature.status === "backlog" && (
|
||||||
|
<div className="mb-3 space-y-2 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 text-cyan-400">
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatModelName(feature.model ?? DEFAULT_MODEL)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{feature.thinkingLevel && feature.thinkingLevel !== "none" && (
|
||||||
|
<div className="flex items-center gap-1 text-purple-400">
|
||||||
|
<Brain className="w-3 h-3" />
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatThinkingLevel(feature.thinkingLevel)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
|
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
|
||||||
{/* Detailed mode: Show all agent info */}
|
{/* Detailed mode: Show all agent info */}
|
||||||
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
|
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
|
||||||
@@ -509,7 +664,8 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
todo.status === "completed" &&
|
todo.status === "completed" &&
|
||||||
"text-muted-foreground line-through",
|
"text-muted-foreground line-through",
|
||||||
todo.status === "in_progress" && "text-amber-400",
|
todo.status === "in_progress" && "text-amber-400",
|
||||||
todo.status === "pending" && "text-foreground-secondary"
|
todo.status === "pending" &&
|
||||||
|
"text-foreground-secondary"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{todo.content}
|
{todo.content}
|
||||||
@@ -822,9 +978,13 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
<Sparkles className="w-5 h-5 text-green-400" />
|
<Sparkles className="w-5 h-5 text-green-400" />
|
||||||
Implementation Summary
|
Implementation Summary
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm" title={feature.description || feature.summary || ""}>
|
<DialogDescription
|
||||||
|
className="text-sm"
|
||||||
|
title={feature.description || feature.summary || ""}
|
||||||
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const displayText = feature.description || feature.summary || "No description";
|
const displayText =
|
||||||
|
feature.description || feature.summary || "No description";
|
||||||
return displayText.length > 100
|
return displayText.length > 100
|
||||||
? `${displayText.slice(0, 100)}...`
|
? `${displayText.slice(0, 100)}...`
|
||||||
: displayText;
|
: displayText;
|
||||||
@@ -860,10 +1020,15 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
Revert Changes
|
Revert Changes
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will discard all changes made by the agent and move the feature back to the backlog.
|
This will discard all changes made by the agent and move the
|
||||||
|
feature back to the backlog.
|
||||||
{feature.branchName && (
|
{feature.branchName && (
|
||||||
<span className="block mt-2 font-medium">
|
<span className="block mt-2 font-medium">
|
||||||
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
|
Branch{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded">
|
||||||
|
{feature.branchName}
|
||||||
|
</code>{" "}
|
||||||
|
will be deleted.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="block mt-2 text-red-400 font-medium">
|
<span className="block mt-2 text-red-400 font-medium">
|
||||||
@@ -895,4 +1060,11 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Wrap with animated border when in progress
|
||||||
|
if (isCurrentAutoTask) {
|
||||||
|
return <div className="animated-border-wrapper">{cardElement}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cardElement;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ interface KanbanColumnProps {
|
|||||||
count: number;
|
count: number;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerAction?: ReactNode;
|
headerAction?: ReactNode;
|
||||||
|
opacity?: number; // Opacity percentage (0-100) - only affects background
|
||||||
|
showBorder?: boolean; // Whether to show column border
|
||||||
|
hideScrollbar?: boolean; // Whether to hide the column scrollbar
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
export const KanbanColumn = memo(function KanbanColumn({
|
||||||
@@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
count,
|
count,
|
||||||
children,
|
children,
|
||||||
headerAction,
|
headerAction,
|
||||||
|
opacity = 100,
|
||||||
|
showBorder = true,
|
||||||
|
hideScrollbar = false,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
|
||||||
@@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-full rounded-lg bg-card backdrop-blur-sm border border-border transition-colors w-72",
|
"relative flex flex-col h-full rounded-lg transition-colors w-72",
|
||||||
isOver && "bg-accent"
|
showBorder && "border border-border"
|
||||||
)}
|
)}
|
||||||
data-testid={`kanban-column-${id}`}
|
data-testid={`kanban-column-${id}`}
|
||||||
>
|
>
|
||||||
{/* Column Header */}
|
{/* Background layer with opacity - only this layer is affected by opacity */}
|
||||||
<div className="flex items-center gap-2 p-3 border-b border-border">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
|
||||||
|
isOver ? "bg-accent" : "bg-card"
|
||||||
|
)}
|
||||||
|
style={{ opacity: opacity / 100 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Column Header - positioned above the background */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex items-center gap-2 p-3",
|
||||||
|
showBorder && "border-b border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className={cn("w-3 h-3 rounded-full", color)} />
|
<div className={cn("w-3 h-3 rounded-full", color)} />
|
||||||
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
||||||
{headerAction}
|
{headerAction}
|
||||||
@@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Content */}
|
{/* Column Content - positioned above the background */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
|
||||||
|
hideScrollbar &&
|
||||||
|
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export type Theme =
|
|||||||
| "gruvbox"
|
| "gruvbox"
|
||||||
| "catppuccin"
|
| "catppuccin"
|
||||||
| "onedark"
|
| "onedark"
|
||||||
| "synthwave";
|
| "synthwave"
|
||||||
|
| "red";
|
||||||
|
|
||||||
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -13,14 +10,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { useAppStore, type ThemeMode } from "@/store/app-store";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { getElectronAPI, type Project } from "@/lib/electron";
|
import { getElectronAPI, type Project } from "@/lib/electron";
|
||||||
import { initializeProject } from "@/lib/project-init";
|
import { initializeProject } from "@/lib/project-init";
|
||||||
import {
|
import {
|
||||||
@@ -41,14 +31,22 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
|
||||||
|
import { NewProjectModal } from "@/components/new-project-modal";
|
||||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||||
|
import type { StarterTemplate } from "@/lib/templates";
|
||||||
|
|
||||||
export function WelcomeView() {
|
export function WelcomeView() {
|
||||||
const { projects, addProject, setCurrentProject, setCurrentView } =
|
const {
|
||||||
useAppStore();
|
projects,
|
||||||
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
|
trashedProjects,
|
||||||
const [newProjectName, setNewProjectName] = useState("");
|
currentProject,
|
||||||
const [newProjectPath, setNewProjectPath] = useState("");
|
upsertAndSetCurrentProject,
|
||||||
|
addProject,
|
||||||
|
setCurrentProject,
|
||||||
|
setCurrentView,
|
||||||
|
theme: globalTheme,
|
||||||
|
} = useAppStore();
|
||||||
|
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isOpening, setIsOpening] = useState(false);
|
const [isOpening, setIsOpening] = useState(false);
|
||||||
const [showInitDialog, setShowInitDialog] = useState(false);
|
const [showInitDialog, setShowInitDialog] = useState(false);
|
||||||
@@ -108,35 +106,14 @@ export function WelcomeView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if project already exists (by path) to preserve theme and other settings
|
// Upsert project and set as current (handles both create and update cases)
|
||||||
const existingProject = projects.find((p) => p.path === path);
|
// Theme preservation is handled by the store action
|
||||||
|
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||||
let project: Project;
|
const effectiveTheme =
|
||||||
if (existingProject) {
|
(trashedProject?.theme as ThemeMode | undefined) ||
|
||||||
// Update existing project, preserving theme and other properties
|
(currentProject?.theme as ThemeMode | undefined) ||
|
||||||
project = {
|
globalTheme;
|
||||||
...existingProject,
|
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
|
||||||
name, // Update name in case it changed
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
// Update the project in the store (this will update the existing entry)
|
|
||||||
const updatedProjects = projects.map((p) =>
|
|
||||||
p.id === existingProject.id ? project : p
|
|
||||||
);
|
|
||||||
// We need to manually update projects since addProject would create a duplicate
|
|
||||||
useAppStore.setState({ projects: updatedProjects });
|
|
||||||
} else {
|
|
||||||
// Create new project
|
|
||||||
project = {
|
|
||||||
id: `project-${Date.now()}`,
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
addProject(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentProject(project);
|
|
||||||
|
|
||||||
// Show initialization dialog if files were created
|
// Show initialization dialog if files were created
|
||||||
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
|
||||||
@@ -171,7 +148,13 @@ export function WelcomeView() {
|
|||||||
setIsOpening(false);
|
setIsOpening(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projects, addProject, setCurrentProject, analyzeProject]
|
[
|
||||||
|
trashedProjects,
|
||||||
|
currentProject,
|
||||||
|
globalTheme,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
|
analyzeProject,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenProject = useCallback(async () => {
|
const handleOpenProject = useCallback(async () => {
|
||||||
@@ -191,7 +174,8 @@ export function WelcomeView() {
|
|||||||
if (!result.canceled && result.filePaths[0]) {
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
const path = result.filePaths[0];
|
const path = result.filePaths[0];
|
||||||
// Extract folder name from path (works on both Windows and Mac/Linux)
|
// Extract folder name from path (works on both Windows and Mac/Linux)
|
||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
const name =
|
||||||
|
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||||
await initializeAndOpenProject(path, name);
|
await initializeAndOpenProject(path, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +187,8 @@ export function WelcomeView() {
|
|||||||
|
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
if (!result.canceled && result.filePaths[0]) {
|
||||||
const path = result.filePaths[0];
|
const path = result.filePaths[0];
|
||||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
const name =
|
||||||
|
path.split(/[/\\]/).filter(Boolean).pop() || "Untitled Project";
|
||||||
await initializeAndOpenProject(path, name);
|
await initializeAndOpenProject(path, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,31 +216,24 @@ export function WelcomeView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleNewProject = () => {
|
const handleNewProject = () => {
|
||||||
setNewProjectName("");
|
setShowNewProjectModal(true);
|
||||||
setNewProjectPath("");
|
|
||||||
setShowNewProjectDialog(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInteractiveMode = () => {
|
const handleInteractiveMode = () => {
|
||||||
setCurrentView("interview");
|
setCurrentView("interview");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
/**
|
||||||
const api = getElectronAPI();
|
* Create a blank project with just .automaker directory structure
|
||||||
const result = await api.openDirectory();
|
*/
|
||||||
|
const handleCreateBlankProject = async (
|
||||||
if (!result.canceled && result.filePaths[0]) {
|
projectName: string,
|
||||||
setNewProjectPath(result.filePaths[0]);
|
parentDir: string
|
||||||
}
|
) => {
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateProject = async () => {
|
|
||||||
if (!newProjectName || !newProjectPath) return;
|
|
||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const projectPath = `${newProjectPath}/${newProjectName}`;
|
const projectPath = `${parentDir}/${projectName}`;
|
||||||
|
|
||||||
// Create project directory
|
// Create project directory
|
||||||
await api.mkdir(projectPath);
|
await api.mkdir(projectPath);
|
||||||
@@ -274,7 +252,7 @@ export function WelcomeView() {
|
|||||||
await api.writeFile(
|
await api.writeFile(
|
||||||
`${projectPath}/.automaker/app_spec.txt`,
|
`${projectPath}/.automaker/app_spec.txt`,
|
||||||
`<project_specification>
|
`<project_specification>
|
||||||
<project_name>${newProjectName}</project_name>
|
<project_name>${projectName}</project_name>
|
||||||
|
|
||||||
<overview>
|
<overview>
|
||||||
Describe your project here. This file will be analyzed by an AI agent
|
Describe your project here. This file will be analyzed by an AI agent
|
||||||
@@ -297,24 +275,24 @@ export function WelcomeView() {
|
|||||||
|
|
||||||
const project = {
|
const project = {
|
||||||
id: `project-${Date.now()}`,
|
id: `project-${Date.now()}`,
|
||||||
name: newProjectName,
|
name: projectName,
|
||||||
path: projectPath,
|
path: projectPath,
|
||||||
lastOpened: new Date().toISOString(),
|
lastOpened: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
addProject(project);
|
addProject(project);
|
||||||
setCurrentProject(project);
|
setCurrentProject(project);
|
||||||
setShowNewProjectDialog(false);
|
setShowNewProjectModal(false);
|
||||||
|
|
||||||
toast.success("Project created", {
|
toast.success("Project created", {
|
||||||
description: `Created ${newProjectName} with .automaker directory`,
|
description: `Created ${projectName} with .automaker directory`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set init status to show the dialog
|
// Set init status to show the dialog
|
||||||
setInitStatus({
|
setInitStatus({
|
||||||
isNewProject: true,
|
isNewProject: true,
|
||||||
createdFiles: initResult.createdFiles || [],
|
createdFiles: initResult.createdFiles || [],
|
||||||
projectName: newProjectName,
|
projectName: projectName,
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
});
|
});
|
||||||
setShowInitDialog(true);
|
setShowInitDialog(true);
|
||||||
@@ -328,6 +306,210 @@ export function WelcomeView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a project from a GitHub starter template
|
||||||
|
*/
|
||||||
|
const handleCreateFromTemplate = async (
|
||||||
|
template: StarterTemplate,
|
||||||
|
projectName: string,
|
||||||
|
parentDir: string
|
||||||
|
) => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// Clone the template repository
|
||||||
|
const cloneResult = await httpClient.templates.clone(
|
||||||
|
template.repoUrl,
|
||||||
|
projectName,
|
||||||
|
parentDir
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||||
|
toast.error("Failed to clone template", {
|
||||||
|
description: cloneResult.error || "Unknown error occurred",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPath = cloneResult.projectPath;
|
||||||
|
|
||||||
|
// Initialize .automaker directory with all necessary files
|
||||||
|
const initResult = await initializeProject(projectPath);
|
||||||
|
|
||||||
|
if (!initResult.success) {
|
||||||
|
toast.error("Failed to initialize project", {
|
||||||
|
description: initResult.error || "Unknown error occurred",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the app_spec.txt with template-specific info
|
||||||
|
await api.writeFile(
|
||||||
|
`${projectPath}/.automaker/app_spec.txt`,
|
||||||
|
`<project_specification>
|
||||||
|
<project_name>${projectName}</project_name>
|
||||||
|
|
||||||
|
<overview>
|
||||||
|
This project was created from the "${template.name}" starter template.
|
||||||
|
${template.description}
|
||||||
|
</overview>
|
||||||
|
|
||||||
|
<technology_stack>
|
||||||
|
${template.techStack
|
||||||
|
.map((tech) => `<technology>${tech}</technology>`)
|
||||||
|
.join("\n ")}
|
||||||
|
</technology_stack>
|
||||||
|
|
||||||
|
<core_capabilities>
|
||||||
|
${template.features
|
||||||
|
.map((feature) => `<capability>${feature}</capability>`)
|
||||||
|
.join("\n ")}
|
||||||
|
</core_capabilities>
|
||||||
|
|
||||||
|
<implemented_features>
|
||||||
|
<!-- The AI agent will populate this based on code analysis -->
|
||||||
|
</implemented_features>
|
||||||
|
</project_specification>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
id: `project-${Date.now()}`,
|
||||||
|
name: projectName,
|
||||||
|
path: projectPath,
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
addProject(project);
|
||||||
|
setCurrentProject(project);
|
||||||
|
setShowNewProjectModal(false);
|
||||||
|
|
||||||
|
toast.success("Project created from template", {
|
||||||
|
description: `Created ${projectName} from ${template.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set init status to show the dialog
|
||||||
|
setInitStatus({
|
||||||
|
isNewProject: true,
|
||||||
|
createdFiles: initResult.createdFiles || [],
|
||||||
|
projectName: projectName,
|
||||||
|
projectPath: projectPath,
|
||||||
|
});
|
||||||
|
setShowInitDialog(true);
|
||||||
|
|
||||||
|
// Kick off project analysis
|
||||||
|
analyzeProject(projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create project from template:", error);
|
||||||
|
toast.error("Failed to create project", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a project from a custom GitHub URL
|
||||||
|
*/
|
||||||
|
const handleCreateFromCustomUrl = async (
|
||||||
|
repoUrl: string,
|
||||||
|
projectName: string,
|
||||||
|
parentDir: string
|
||||||
|
) => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const httpClient = getHttpApiClient();
|
||||||
|
const api = getElectronAPI();
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
const cloneResult = await httpClient.templates.clone(
|
||||||
|
repoUrl,
|
||||||
|
projectName,
|
||||||
|
parentDir
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cloneResult.success || !cloneResult.projectPath) {
|
||||||
|
toast.error("Failed to clone repository", {
|
||||||
|
description: cloneResult.error || "Unknown error occurred",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPath = cloneResult.projectPath;
|
||||||
|
|
||||||
|
// Initialize .automaker directory with all necessary files
|
||||||
|
const initResult = await initializeProject(projectPath);
|
||||||
|
|
||||||
|
if (!initResult.success) {
|
||||||
|
toast.error("Failed to initialize project", {
|
||||||
|
description: initResult.error || "Unknown error occurred",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the app_spec.txt with basic info
|
||||||
|
await api.writeFile(
|
||||||
|
`${projectPath}/.automaker/app_spec.txt`,
|
||||||
|
`<project_specification>
|
||||||
|
<project_name>${projectName}</project_name>
|
||||||
|
|
||||||
|
<overview>
|
||||||
|
This project was cloned from ${repoUrl}.
|
||||||
|
The AI agent will analyze the project structure.
|
||||||
|
</overview>
|
||||||
|
|
||||||
|
<technology_stack>
|
||||||
|
<!-- The AI agent will fill this in after analyzing your project -->
|
||||||
|
</technology_stack>
|
||||||
|
|
||||||
|
<core_capabilities>
|
||||||
|
<!-- List core features and capabilities -->
|
||||||
|
</core_capabilities>
|
||||||
|
|
||||||
|
<implemented_features>
|
||||||
|
<!-- The AI agent will populate this based on code analysis -->
|
||||||
|
</implemented_features>
|
||||||
|
</project_specification>`
|
||||||
|
);
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
id: `project-${Date.now()}`,
|
||||||
|
name: projectName,
|
||||||
|
path: projectPath,
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
addProject(project);
|
||||||
|
setCurrentProject(project);
|
||||||
|
setShowNewProjectModal(false);
|
||||||
|
|
||||||
|
toast.success("Project created from repository", {
|
||||||
|
description: `Created ${projectName} from ${repoUrl}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set init status to show the dialog
|
||||||
|
setInitStatus({
|
||||||
|
isNewProject: true,
|
||||||
|
createdFiles: initResult.createdFiles || [],
|
||||||
|
projectName: projectName,
|
||||||
|
projectPath: projectPath,
|
||||||
|
});
|
||||||
|
setShowInitDialog(true);
|
||||||
|
|
||||||
|
// Kick off project analysis
|
||||||
|
analyzeProject(projectPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create project from custom URL:", error);
|
||||||
|
toast.error("Failed to create project", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const recentProjects = [...projects]
|
const recentProjects = [...projects]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
|
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
|
||||||
@@ -508,82 +690,15 @@ export function WelcomeView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Project Dialog */}
|
{/* New Project Modal */}
|
||||||
<Dialog
|
<NewProjectModal
|
||||||
open={showNewProjectDialog}
|
open={showNewProjectModal}
|
||||||
onOpenChange={setShowNewProjectDialog}
|
onOpenChange={setShowNewProjectModal}
|
||||||
>
|
onCreateBlankProject={handleCreateBlankProject}
|
||||||
<DialogContent
|
onCreateFromTemplate={handleCreateFromTemplate}
|
||||||
className="bg-card border-border"
|
onCreateFromCustomUrl={handleCreateFromCustomUrl}
|
||||||
data-testid="new-project-dialog"
|
isCreating={isCreating}
|
||||||
>
|
/>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-foreground">
|
|
||||||
Create New Project
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
|
||||||
Set up a new project directory with initial configuration files.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="project-name" className="text-foreground">
|
|
||||||
Project Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="project-name"
|
|
||||||
placeholder="my-awesome-project"
|
|
||||||
value={newProjectName}
|
|
||||||
onChange={(e) => setNewProjectName(e.target.value)}
|
|
||||||
className="bg-input border-border text-foreground placeholder:text-muted-foreground"
|
|
||||||
data-testid="project-name-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="project-path" className="text-foreground">
|
|
||||||
Parent Directory
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="project-path"
|
|
||||||
placeholder="/path/to/projects"
|
|
||||||
value={newProjectPath}
|
|
||||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
|
||||||
className="flex-1 bg-input border-border text-foreground placeholder:text-muted-foreground"
|
|
||||||
data-testid="project-path-input"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={handleSelectDirectory}
|
|
||||||
className="bg-secondary hover:bg-secondary/80 text-foreground border border-border"
|
|
||||||
data-testid="browse-directory"
|
|
||||||
>
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setShowNewProjectDialog(false)}
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleCreateProject}
|
|
||||||
disabled={!newProjectName || !newProjectPath || isCreating}
|
|
||||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={showNewProjectDialog}
|
|
||||||
data-testid="confirm-create-project"
|
|
||||||
>
|
|
||||||
{isCreating ? "Creating..." : "Create Project"}
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Project Initialization Dialog */}
|
{/* Project Initialization Dialog */}
|
||||||
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>
|
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>
|
||||||
|
|||||||
6
apps/app/src/config/app-config.ts
Normal file
6
apps/app/src/config/app-config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Marketing mode flag
|
||||||
|
* When set to true, displays "https://automaker.app" with "maker" in theme color
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const IS_MARKETING = process.env.NEXT_PUBLIC_IS_MARKETING === "true";
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Eclipse,
|
Eclipse,
|
||||||
Flame,
|
Flame,
|
||||||
Ghost,
|
Ghost,
|
||||||
|
Heart,
|
||||||
Moon,
|
Moon,
|
||||||
Radio,
|
Radio,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
@@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
|
|||||||
Icon: Radio,
|
Icon: Radio,
|
||||||
testId: "synthwave-mode-button",
|
testId: "synthwave-mode-button",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "red",
|
||||||
|
label: "Red",
|
||||||
|
Icon: Heart,
|
||||||
|
testId: "red-mode-button",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
||||||
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
|
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
|
||||||
|
|
||||||
|
interface FileBrowserOptions {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface FileBrowserContextValue {
|
interface FileBrowserContextValue {
|
||||||
openFileBrowser: () => Promise<string | null>;
|
openFileBrowser: (options?: FileBrowserOptions) => Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
|
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
|
||||||
@@ -12,9 +17,11 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
|
|||||||
export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
|
const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null);
|
||||||
|
const [dialogOptions, setDialogOptions] = useState<FileBrowserOptions>({});
|
||||||
|
|
||||||
const openFileBrowser = useCallback((): Promise<string | null> => {
|
const openFileBrowser = useCallback((options?: FileBrowserOptions): Promise<string | null> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
setDialogOptions(options || {});
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setResolver(() => resolve);
|
setResolver(() => resolve);
|
||||||
});
|
});
|
||||||
@@ -26,6 +33,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
|||||||
setResolver(null);
|
setResolver(null);
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
setDialogOptions({});
|
||||||
}, [resolver]);
|
}, [resolver]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback((open: boolean) => {
|
const handleOpenChange = useCallback((open: boolean) => {
|
||||||
@@ -34,6 +42,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
|||||||
setResolver(null);
|
setResolver(null);
|
||||||
}
|
}
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setDialogOptions({});
|
||||||
|
}
|
||||||
}, [resolver]);
|
}, [resolver]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +54,8 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
|
|||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
title={dialogOptions.title}
|
||||||
|
description={dialogOptions.description}
|
||||||
/>
|
/>
|
||||||
</FileBrowserContext.Provider>
|
</FileBrowserContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -57,12 +70,15 @@ export function useFileBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Global reference for non-React code (like HttpApiClient)
|
// Global reference for non-React code (like HttpApiClient)
|
||||||
let globalFileBrowserFn: (() => Promise<string | null>) | null = null;
|
let globalFileBrowserFn: ((options?: FileBrowserOptions) => Promise<string | null>) | null = null;
|
||||||
|
|
||||||
export function setGlobalFileBrowser(fn: () => Promise<string | null>) {
|
export function setGlobalFileBrowser(fn: (options?: FileBrowserOptions) => Promise<string | null>) {
|
||||||
globalFileBrowserFn = fn;
|
globalFileBrowserFn = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGlobalFileBrowser() {
|
export function getGlobalFileBrowser() {
|
||||||
return globalFileBrowserFn;
|
return globalFileBrowserFn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export the options type for consumers
|
||||||
|
export type { FileBrowserOptions };
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import type {
|
|||||||
} from "@/types/electron";
|
} from "@/types/electron";
|
||||||
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
|
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
|
||||||
|
|
||||||
|
|
||||||
// Server URL - configurable via environment variable
|
// Server URL - configurable via environment variable
|
||||||
const getServerUrl = (): string => {
|
const getServerUrl = (): string => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -43,7 +42,6 @@ const getServerUrl = (): string => {
|
|||||||
return "http://localhost:3008";
|
return "http://localhost:3008";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Get API key from environment variable
|
// Get API key from environment variable
|
||||||
const getApiKey = (): string | null => {
|
const getApiKey = (): string | null => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -76,7 +74,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connectWebSocket(): void {
|
private connectWebSocket(): void {
|
||||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
if (
|
||||||
|
this.isConnecting ||
|
||||||
|
(this.ws && this.ws.readyState === WebSocket.OPEN)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +104,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
callbacks.forEach((cb) => cb(data.payload));
|
callbacks.forEach((cb) => cb(data.payload));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[HttpApiClient] Failed to parse WebSocket message:", error);
|
console.error(
|
||||||
|
"[HttpApiClient] Failed to parse WebSocket message:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +134,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
|
private subscribeToEvent(
|
||||||
|
type: EventType,
|
||||||
|
callback: EventCallback
|
||||||
|
): () => void {
|
||||||
if (!this.eventCallbacks.has(type)) {
|
if (!this.eventCallbacks.has(type)) {
|
||||||
this.eventCallbacks.set(type, new Set());
|
this.eventCallbacks.set(type, new Set());
|
||||||
}
|
}
|
||||||
@@ -196,7 +203,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
return result.status === "ok" ? "pong" : "error";
|
return result.status === "ok" ? "pong" : "error";
|
||||||
}
|
}
|
||||||
|
|
||||||
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
async openExternalLink(
|
||||||
|
url: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
// Open in new tab
|
// Open in new tab
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -301,7 +310,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
async getPath(name: string): Promise<string> {
|
async getPath(name: string): Promise<string> {
|
||||||
// Server provides data directory
|
// Server provides data directory
|
||||||
if (name === "userData") {
|
if (name === "userData") {
|
||||||
const result = await this.get<{ dataDir: string }>("/api/health/detailed");
|
const result = await this.get<{ dataDir: string }>(
|
||||||
|
"/api/health/detailed"
|
||||||
|
);
|
||||||
return result.dataDir || "/data";
|
return result.dataDir || "/data";
|
||||||
}
|
}
|
||||||
return `/data/${name}`;
|
return `/data/${name}`;
|
||||||
@@ -313,7 +324,32 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
mimeType: string,
|
mimeType: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
): Promise<SaveImageResult> {
|
): Promise<SaveImageResult> {
|
||||||
return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
|
return this.post("/api/fs/save-image", {
|
||||||
|
data,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBoardBackground(
|
||||||
|
data: string,
|
||||||
|
filename: string,
|
||||||
|
mimeType: string,
|
||||||
|
projectPath: string
|
||||||
|
): Promise<{ success: boolean; path?: string; error?: string }> {
|
||||||
|
return this.post("/api/fs/save-board-background", {
|
||||||
|
data,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
projectPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBoardBackground(
|
||||||
|
projectPath: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.post("/api/fs/delete-board-background", { projectPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI checks - server-side
|
// CLI checks - server-side
|
||||||
@@ -444,14 +480,19 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
output?: string;
|
output?: string;
|
||||||
}> => this.post("/api/setup/auth-claude"),
|
}> => this.post("/api/setup/auth-claude"),
|
||||||
|
|
||||||
authCodex: (apiKey?: string): Promise<{
|
authCodex: (
|
||||||
|
apiKey?: string
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
requiresManualAuth?: boolean;
|
requiresManualAuth?: boolean;
|
||||||
command?: string;
|
command?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/auth-codex", { apiKey }),
|
}> => this.post("/api/setup/auth-codex", { apiKey }),
|
||||||
|
|
||||||
storeApiKey: (provider: string, apiKey: string): Promise<{
|
storeApiKey: (
|
||||||
|
provider: string,
|
||||||
|
apiKey: string
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
||||||
@@ -463,7 +504,9 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
}> => this.get("/api/setup/api-keys"),
|
}> => this.get("/api/setup/api-keys"),
|
||||||
|
|
||||||
configureCodexMcp: (projectPath: string): Promise<{
|
configureCodexMcp: (
|
||||||
|
projectPath: string
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -496,8 +539,11 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post("/api/features/get", { projectPath, featureId }),
|
this.post("/api/features/get", { projectPath, featureId }),
|
||||||
create: (projectPath: string, feature: Feature) =>
|
create: (projectPath: string, feature: Feature) =>
|
||||||
this.post("/api/features/create", { projectPath, feature }),
|
this.post("/api/features/create", { projectPath, feature }),
|
||||||
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
|
update: (
|
||||||
this.post("/api/features/update", { projectPath, featureId, updates }),
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
updates: Partial<Feature>
|
||||||
|
) => this.post("/api/features/update", { projectPath, featureId, updates }),
|
||||||
delete: (projectPath: string, featureId: string) =>
|
delete: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/features/delete", { projectPath, featureId }),
|
this.post("/api/features/delete", { projectPath, featureId }),
|
||||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||||
@@ -514,8 +560,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
this.post("/api/auto-mode/stop-feature", { featureId }),
|
this.post("/api/auto-mode/stop-feature", { featureId }),
|
||||||
status: (projectPath?: string) =>
|
status: (projectPath?: string) =>
|
||||||
this.post("/api/auto-mode/status", { projectPath }),
|
this.post("/api/auto-mode/status", { projectPath }),
|
||||||
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
|
runFeature: (
|
||||||
this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
|
projectPath: string,
|
||||||
|
featureId: string,
|
||||||
|
useWorktrees?: boolean
|
||||||
|
) =>
|
||||||
|
this.post("/api/auto-mode/run-feature", {
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
useWorktrees,
|
||||||
|
}),
|
||||||
verifyFeature: (projectPath: string, featureId: string) =>
|
verifyFeature: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
||||||
resumeFeature: (projectPath: string, featureId: string) =>
|
resumeFeature: (projectPath: string, featureId: string) =>
|
||||||
@@ -539,7 +593,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
commitFeature: (projectPath: string, featureId: string) =>
|
commitFeature: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
||||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||||
return this.subscribeToEvent("auto-mode:event", callback as EventCallback);
|
return this.subscribeToEvent(
|
||||||
|
"auto-mode:event",
|
||||||
|
callback as EventCallback
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,7 +615,11 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
getDiffs: (projectPath: string, featureId: string) =>
|
getDiffs: (projectPath: string, featureId: string) =>
|
||||||
this.post("/api/worktree/diffs", { projectPath, featureId }),
|
this.post("/api/worktree/diffs", { projectPath, featureId }),
|
||||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
||||||
this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }),
|
this.post("/api/worktree/file-diff", {
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
filePath,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Git API
|
// Git API
|
||||||
@@ -576,20 +637,30 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
stop: () => this.post("/api/suggestions/stop"),
|
stop: () => this.post("/api/suggestions/stop"),
|
||||||
status: () => this.get("/api/suggestions/status"),
|
status: () => this.get("/api/suggestions/status"),
|
||||||
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
||||||
return this.subscribeToEvent("suggestions:event", callback as EventCallback);
|
return this.subscribeToEvent(
|
||||||
|
"suggestions:event",
|
||||||
|
callback as EventCallback
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Spec Regeneration API
|
// Spec Regeneration API
|
||||||
specRegeneration: SpecRegenerationAPI = {
|
specRegeneration: SpecRegenerationAPI = {
|
||||||
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
|
create: (
|
||||||
|
projectPath: string,
|
||||||
|
projectOverview: string,
|
||||||
|
generateFeatures?: boolean
|
||||||
|
) =>
|
||||||
this.post("/api/spec-regeneration/create", {
|
this.post("/api/spec-regeneration/create", {
|
||||||
projectPath,
|
projectPath,
|
||||||
projectOverview,
|
projectOverview,
|
||||||
generateFeatures,
|
generateFeatures,
|
||||||
}),
|
}),
|
||||||
generate: (projectPath: string, projectDefinition: string) =>
|
generate: (projectPath: string, projectDefinition: string) =>
|
||||||
this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
|
this.post("/api/spec-regeneration/generate", {
|
||||||
|
projectPath,
|
||||||
|
projectDefinition,
|
||||||
|
}),
|
||||||
generateFeatures: (projectPath: string) =>
|
generateFeatures: (projectPath: string) =>
|
||||||
this.post("/api/spec-regeneration/generate-features", { projectPath }),
|
this.post("/api/spec-regeneration/generate-features", { projectPath }),
|
||||||
stop: () => this.post("/api/spec-regeneration/stop"),
|
stop: () => this.post("/api/spec-regeneration/stop"),
|
||||||
@@ -636,7 +707,10 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
// Agent API
|
// Agent API
|
||||||
agent = {
|
agent = {
|
||||||
start: (sessionId: string, workingDirectory?: string): Promise<{
|
start: (
|
||||||
|
sessionId: string,
|
||||||
|
workingDirectory?: string
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -648,9 +722,16 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
workingDirectory?: string,
|
workingDirectory?: string,
|
||||||
imagePaths?: string[]
|
imagePaths?: string[]
|
||||||
): Promise<{ success: boolean; error?: string }> =>
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.post("/api/agent/send", { sessionId, message, workingDirectory, imagePaths }),
|
this.post("/api/agent/send", {
|
||||||
|
sessionId,
|
||||||
|
message,
|
||||||
|
workingDirectory,
|
||||||
|
imagePaths,
|
||||||
|
}),
|
||||||
|
|
||||||
getHistory: (sessionId: string): Promise<{
|
getHistory: (
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
messages?: Message[];
|
messages?: Message[];
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
@@ -668,9 +749,26 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Templates API
|
||||||
|
templates = {
|
||||||
|
clone: (
|
||||||
|
repoUrl: string,
|
||||||
|
projectName: string,
|
||||||
|
parentDir: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
projectPath?: string;
|
||||||
|
projectName?: string;
|
||||||
|
error?: string;
|
||||||
|
}> =>
|
||||||
|
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
|
||||||
|
};
|
||||||
|
|
||||||
// Sessions API
|
// Sessions API
|
||||||
sessions = {
|
sessions = {
|
||||||
list: (includeArchived?: boolean): Promise<{
|
list: (
|
||||||
|
includeArchived?: boolean
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
sessions?: SessionListItem[];
|
sessions?: SessionListItem[];
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -700,13 +798,19 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
): Promise<{ success: boolean; error?: string }> =>
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
||||||
|
|
||||||
archive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
archive: (
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
||||||
|
|
||||||
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
unarchive: (
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
||||||
|
|
||||||
delete: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
delete: (
|
||||||
|
sessionId: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> =>
|
||||||
this.httpDelete(`/api/sessions/${sessionId}`),
|
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
apps/app/src/lib/templates.ts
Normal file
62
apps/app/src/lib/templates.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Starter Kit Templates
|
||||||
|
*
|
||||||
|
* Define GitHub templates that users can clone when creating new projects.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StarterTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
repoUrl: string;
|
||||||
|
techStack: string[];
|
||||||
|
features: string[];
|
||||||
|
category: "fullstack" | "frontend" | "backend" | "ai" | "other";
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const starterTemplates: StarterTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "agentic-jumpstart",
|
||||||
|
name: "Agentic Jumpstart",
|
||||||
|
description: "A starter template for building agentic AI applications with a pre-configured development environment including database setup, Docker support, and TypeScript configuration.",
|
||||||
|
repoUrl: "https://github.com/webdevcody/agentic-jumpstart-starter-kit",
|
||||||
|
techStack: ["TypeScript", "Vite", "Drizzle ORM", "Docker", "PostCSS"],
|
||||||
|
features: [
|
||||||
|
"Pre-configured VS Code settings",
|
||||||
|
"Docker Compose setup",
|
||||||
|
"Database migrations with Drizzle",
|
||||||
|
"Type-safe development",
|
||||||
|
"Environment setup with .env.example"
|
||||||
|
],
|
||||||
|
category: "ai",
|
||||||
|
author: "webdevcody"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "full-stack-campus",
|
||||||
|
name: "Full Stack Campus",
|
||||||
|
description: "A feature-driven development template for building community platforms. Includes authentication, Stripe payments, file uploads, and real-time features using TanStack Start.",
|
||||||
|
repoUrl: "https://github.com/webdevcody/full-stack-campus",
|
||||||
|
techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"],
|
||||||
|
features: [
|
||||||
|
"Community posts with comments and reactions",
|
||||||
|
"User profiles and portfolios",
|
||||||
|
"Calendar event management",
|
||||||
|
"Direct messaging",
|
||||||
|
"Member discovery directory",
|
||||||
|
"Real-time notifications",
|
||||||
|
"Tiered subscriptions (free/basic/pro)",
|
||||||
|
"File uploads with presigned URLs"
|
||||||
|
],
|
||||||
|
category: "fullstack",
|
||||||
|
author: "webdevcody"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTemplateById(id: string): StarterTemplate | undefined {
|
||||||
|
return starterTemplates.find(t => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplatesByCategory(category: StarterTemplate["category"]): StarterTemplate[] {
|
||||||
|
return starterTemplates.filter(t => t.category === category);
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@ export type ThemeMode =
|
|||||||
| "gruvbox"
|
| "gruvbox"
|
||||||
| "catppuccin"
|
| "catppuccin"
|
||||||
| "onedark"
|
| "onedark"
|
||||||
| "synthwave";
|
| "synthwave"
|
||||||
|
| "red";
|
||||||
|
|
||||||
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
|
||||||
|
|
||||||
@@ -39,23 +40,39 @@ export interface ApiKeys {
|
|||||||
|
|
||||||
// Keyboard Shortcut with optional modifiers
|
// Keyboard Shortcut with optional modifiers
|
||||||
export interface ShortcutKey {
|
export interface ShortcutKey {
|
||||||
key: string; // The main key (e.g., "K", "N", "1")
|
key: string; // The main key (e.g., "K", "N", "1")
|
||||||
shift?: boolean; // Shift key modifier
|
shift?: boolean; // Shift key modifier
|
||||||
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
|
||||||
alt?: boolean; // Alt/Option key modifier
|
alt?: boolean; // Alt/Option key modifier
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to parse shortcut string to ShortcutKey object
|
// Helper to parse shortcut string to ShortcutKey object
|
||||||
export function parseShortcut(shortcut: string): ShortcutKey {
|
export function parseShortcut(shortcut: string): ShortcutKey {
|
||||||
const parts = shortcut.split("+").map(p => p.trim());
|
const parts = shortcut.split("+").map((p) => p.trim());
|
||||||
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
const result: ShortcutKey = { key: parts[parts.length - 1] };
|
||||||
|
|
||||||
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
|
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
const modifier = parts[i].toLowerCase();
|
const modifier = parts[i].toLowerCase();
|
||||||
if (modifier === "shift") result.shift = true;
|
if (modifier === "shift") result.shift = true;
|
||||||
else if (modifier === "cmd" || modifier === "ctrl" || modifier === "win" || modifier === "super" || modifier === "⌘" || modifier === "^" || modifier === "⊞" || modifier === "◆") result.cmdCtrl = true;
|
else if (
|
||||||
else if (modifier === "alt" || modifier === "opt" || modifier === "option" || modifier === "⌥") result.alt = true;
|
modifier === "cmd" ||
|
||||||
|
modifier === "ctrl" ||
|
||||||
|
modifier === "win" ||
|
||||||
|
modifier === "super" ||
|
||||||
|
modifier === "⌘" ||
|
||||||
|
modifier === "^" ||
|
||||||
|
modifier === "⊞" ||
|
||||||
|
modifier === "◆"
|
||||||
|
)
|
||||||
|
result.cmdCtrl = true;
|
||||||
|
else if (
|
||||||
|
modifier === "alt" ||
|
||||||
|
modifier === "opt" ||
|
||||||
|
modifier === "option" ||
|
||||||
|
modifier === "⌥"
|
||||||
|
)
|
||||||
|
result.alt = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -67,36 +84,49 @@ export function formatShortcut(shortcut: string, forDisplay = false): string {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
// Prefer User-Agent Client Hints when available; fall back to legacy
|
// Prefer User-Agent Client Hints when available; fall back to legacy
|
||||||
const platform: 'darwin' | 'win32' | 'linux' = (() => {
|
const platform: "darwin" | "win32" | "linux" = (() => {
|
||||||
if (typeof navigator === 'undefined') return 'linux';
|
if (typeof navigator === "undefined") return "linux";
|
||||||
|
|
||||||
const uaPlatform = (navigator as Navigator & { userAgentData?: { platform?: string } })
|
const uaPlatform = (
|
||||||
.userAgentData?.platform?.toLowerCase?.();
|
navigator as Navigator & { userAgentData?: { platform?: string } }
|
||||||
|
).userAgentData?.platform?.toLowerCase?.();
|
||||||
const legacyPlatform = navigator.platform?.toLowerCase?.();
|
const legacyPlatform = navigator.platform?.toLowerCase?.();
|
||||||
const platformString = uaPlatform || legacyPlatform || '';
|
const platformString = uaPlatform || legacyPlatform || "";
|
||||||
|
|
||||||
if (platformString.includes('mac')) return 'darwin';
|
if (platformString.includes("mac")) return "darwin";
|
||||||
if (platformString.includes('win')) return 'win32';
|
if (platformString.includes("win")) return "win32";
|
||||||
return 'linux';
|
return "linux";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Primary modifier - OS-specific
|
// Primary modifier - OS-specific
|
||||||
if (parsed.cmdCtrl) {
|
if (parsed.cmdCtrl) {
|
||||||
if (forDisplay) {
|
if (forDisplay) {
|
||||||
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
|
parts.push(
|
||||||
|
platform === "darwin" ? "⌘" : platform === "win32" ? "⊞" : "◆"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
|
parts.push(
|
||||||
|
platform === "darwin" ? "Cmd" : platform === "win32" ? "Win" : "Super"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt/Option
|
// Alt/Option
|
||||||
if (parsed.alt) {
|
if (parsed.alt) {
|
||||||
parts.push(forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : (platform === 'darwin' ? 'Opt' : 'Alt'));
|
parts.push(
|
||||||
|
forDisplay
|
||||||
|
? platform === "darwin"
|
||||||
|
? "⌥"
|
||||||
|
: "Alt"
|
||||||
|
: platform === "darwin"
|
||||||
|
? "Opt"
|
||||||
|
: "Alt"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shift
|
// Shift
|
||||||
if (parsed.shift) {
|
if (parsed.shift) {
|
||||||
parts.push(forDisplay ? '⇧' : 'Shift');
|
parts.push(forDisplay ? "⇧" : "Shift");
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(parsed.key.toUpperCase());
|
parts.push(parsed.key.toUpperCase());
|
||||||
@@ -139,22 +169,22 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|||||||
context: "C",
|
context: "C",
|
||||||
settings: "S",
|
settings: "S",
|
||||||
profiles: "M",
|
profiles: "M",
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
toggleSidebar: "`",
|
toggleSidebar: "`",
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
|
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
|
||||||
// This is intentional as they are context-specific and only active in their respective views
|
// This is intentional as they are context-specific and only active in their respective views
|
||||||
addFeature: "N", // Only active in board view
|
addFeature: "N", // Only active in board view
|
||||||
addContextFile: "N", // Only active in context view
|
addContextFile: "N", // Only active in context view
|
||||||
startNext: "G", // Only active in board view
|
startNext: "G", // Only active in board view
|
||||||
newSession: "N", // Only active in agent view
|
newSession: "N", // Only active in agent view
|
||||||
openProject: "O", // Global shortcut
|
openProject: "O", // Global shortcut
|
||||||
projectPicker: "P", // Global shortcut
|
projectPicker: "P", // Global shortcut
|
||||||
cyclePrevProject: "Q", // Global shortcut
|
cyclePrevProject: "Q", // Global shortcut
|
||||||
cycleNextProject: "E", // Global shortcut
|
cycleNextProject: "E", // Global shortcut
|
||||||
addProfile: "N", // Only active in profiles view
|
addProfile: "N", // Only active in profiles view
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ImageAttachment {
|
export interface ImageAttachment {
|
||||||
@@ -245,6 +275,7 @@ export interface Feature {
|
|||||||
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
// Worktree info - set when a feature is being worked on in an isolated git worktree
|
||||||
worktreePath?: string; // Path to the worktree directory
|
worktreePath?: string; // Path to the worktree directory
|
||||||
branchName?: string; // Name of the feature branch
|
branchName?: string; // Name of the feature branch
|
||||||
|
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File tree node for project analysis
|
// File tree node for project analysis
|
||||||
@@ -301,10 +332,13 @@ export interface AppState {
|
|||||||
chatHistoryOpen: boolean;
|
chatHistoryOpen: boolean;
|
||||||
|
|
||||||
// Auto Mode (per-project state, keyed by project ID)
|
// Auto Mode (per-project state, keyed by project ID)
|
||||||
autoModeByProject: Record<string, {
|
autoModeByProject: Record<
|
||||||
isRunning: boolean;
|
string,
|
||||||
runningTasks: string[]; // Feature IDs being worked on
|
{
|
||||||
}>;
|
isRunning: boolean;
|
||||||
|
runningTasks: string[]; // Feature IDs being worked on
|
||||||
|
}
|
||||||
|
>;
|
||||||
autoModeActivityLog: AutoModeActivity[];
|
autoModeActivityLog: AutoModeActivity[];
|
||||||
maxConcurrency: number; // Maximum number of concurrent agent tasks
|
maxConcurrency: number; // Maximum number of concurrent agent tasks
|
||||||
|
|
||||||
@@ -332,8 +366,49 @@ export interface AppState {
|
|||||||
// Project Analysis
|
// Project Analysis
|
||||||
projectAnalysis: ProjectAnalysis | null;
|
projectAnalysis: ProjectAnalysis | null;
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
|
|
||||||
|
// Board Background Settings (per-project, keyed by project path)
|
||||||
|
boardBackgroundByProject: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
imagePath: string | null; // Path to background image in .automaker directory
|
||||||
|
imageVersion?: number; // Timestamp to bust browser cache when image is updated
|
||||||
|
cardOpacity: number; // Opacity of cards (0-100)
|
||||||
|
columnOpacity: number; // Opacity of columns (0-100)
|
||||||
|
columnBorderEnabled: boolean; // Whether to show column borders
|
||||||
|
cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards
|
||||||
|
cardBorderEnabled: boolean; // Whether to show card borders
|
||||||
|
cardBorderOpacity: number; // Opacity of card borders (0-100)
|
||||||
|
hideScrollbar: boolean; // Whether to hide the board scrollbar
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Theme Preview (for hover preview in theme selectors)
|
||||||
|
previewTheme: ThemeMode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default background settings for board backgrounds
|
||||||
|
export const defaultBackgroundSettings: {
|
||||||
|
imagePath: string | null;
|
||||||
|
imageVersion?: number;
|
||||||
|
cardOpacity: number;
|
||||||
|
columnOpacity: number;
|
||||||
|
columnBorderEnabled: boolean;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
hideScrollbar: boolean;
|
||||||
|
} = {
|
||||||
|
imagePath: null,
|
||||||
|
cardOpacity: 100,
|
||||||
|
columnOpacity: 100,
|
||||||
|
columnBorderEnabled: true,
|
||||||
|
cardGlassmorphism: true,
|
||||||
|
cardBorderEnabled: true,
|
||||||
|
cardBorderOpacity: 100,
|
||||||
|
hideScrollbar: false,
|
||||||
|
};
|
||||||
|
|
||||||
export interface AutoModeActivity {
|
export interface AutoModeActivity {
|
||||||
id: string;
|
id: string;
|
||||||
featureId: string;
|
featureId: string;
|
||||||
@@ -364,6 +439,11 @@ export interface AppActions {
|
|||||||
deleteTrashedProject: (projectId: string) => void;
|
deleteTrashedProject: (projectId: string) => void;
|
||||||
emptyTrash: () => void;
|
emptyTrash: () => void;
|
||||||
setCurrentProject: (project: Project | null) => void;
|
setCurrentProject: (project: Project | null) => void;
|
||||||
|
upsertAndSetCurrentProject: (
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
theme?: ThemeMode
|
||||||
|
) => Project; // Upsert project by path and set as current
|
||||||
reorderProjects: (oldIndex: number, newIndex: number) => void;
|
reorderProjects: (oldIndex: number, newIndex: number) => void;
|
||||||
cyclePrevProject: () => void; // Cycle back through project history (Q)
|
cyclePrevProject: () => void; // Cycle back through project history (Q)
|
||||||
cycleNextProject: () => void; // Cycle forward through project history (E)
|
cycleNextProject: () => void; // Cycle forward through project history (E)
|
||||||
@@ -377,7 +457,8 @@ export interface AppActions {
|
|||||||
// Theme actions
|
// Theme actions
|
||||||
setTheme: (theme: ThemeMode) => void;
|
setTheme: (theme: ThemeMode) => void;
|
||||||
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
|
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
|
||||||
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project or global)
|
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set)
|
||||||
|
setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear)
|
||||||
|
|
||||||
// Feature actions
|
// Feature actions
|
||||||
setFeatures: (features: Feature[]) => void;
|
setFeatures: (features: Feature[]) => void;
|
||||||
@@ -413,7 +494,10 @@ export interface AppActions {
|
|||||||
addRunningTask: (projectId: string, taskId: string) => void;
|
addRunningTask: (projectId: string, taskId: string) => void;
|
||||||
removeRunningTask: (projectId: string, taskId: string) => void;
|
removeRunningTask: (projectId: string, taskId: string) => void;
|
||||||
clearRunningTasks: (projectId: string) => void;
|
clearRunningTasks: (projectId: string) => void;
|
||||||
getAutoModeState: (projectId: string) => { isRunning: boolean; runningTasks: string[] };
|
getAutoModeState: (projectId: string) => {
|
||||||
|
isRunning: boolean;
|
||||||
|
runningTasks: string[];
|
||||||
|
};
|
||||||
addAutoModeActivity: (
|
addAutoModeActivity: (
|
||||||
activity: Omit<AutoModeActivity, "id" | "timestamp">
|
activity: Omit<AutoModeActivity, "id" | "timestamp">
|
||||||
) => void;
|
) => void;
|
||||||
@@ -452,9 +536,33 @@ export interface AppActions {
|
|||||||
clearAnalysis: () => void;
|
clearAnalysis: () => void;
|
||||||
|
|
||||||
// Agent Session actions
|
// Agent Session actions
|
||||||
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
|
setLastSelectedSession: (
|
||||||
|
projectPath: string,
|
||||||
|
sessionId: string | null
|
||||||
|
) => void;
|
||||||
getLastSelectedSession: (projectPath: string) => string | null;
|
getLastSelectedSession: (projectPath: string) => string | null;
|
||||||
|
|
||||||
|
// Board Background actions
|
||||||
|
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
|
||||||
|
setCardOpacity: (projectPath: string, opacity: number) => void;
|
||||||
|
setColumnOpacity: (projectPath: string, opacity: number) => void;
|
||||||
|
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||||
|
getBoardBackground: (projectPath: string) => {
|
||||||
|
imagePath: string | null;
|
||||||
|
cardOpacity: number;
|
||||||
|
columnOpacity: number;
|
||||||
|
columnBorderEnabled: boolean;
|
||||||
|
cardGlassmorphism: boolean;
|
||||||
|
cardBorderEnabled: boolean;
|
||||||
|
cardBorderOpacity: number;
|
||||||
|
hideScrollbar: boolean;
|
||||||
|
};
|
||||||
|
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
|
||||||
|
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
|
||||||
|
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
|
||||||
|
setHideScrollbar: (projectPath: string, hide: boolean) => void;
|
||||||
|
clearBoardBackground: (projectPath: string) => void;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -464,7 +572,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
|||||||
{
|
{
|
||||||
id: "profile-heavy-task",
|
id: "profile-heavy-task",
|
||||||
name: "Heavy Task",
|
name: "Heavy Task",
|
||||||
description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
description:
|
||||||
|
"Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
|
||||||
model: "opus",
|
model: "opus",
|
||||||
thinkingLevel: "ultrathink",
|
thinkingLevel: "ultrathink",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -474,7 +583,8 @@ const DEFAULT_AI_PROFILES: AIProfile[] = [
|
|||||||
{
|
{
|
||||||
id: "profile-balanced",
|
id: "profile-balanced",
|
||||||
name: "Balanced",
|
name: "Balanced",
|
||||||
description: "Claude Sonnet with medium thinking for typical development tasks.",
|
description:
|
||||||
|
"Claude Sonnet with medium thinking for typical development tasks.",
|
||||||
model: "sonnet",
|
model: "sonnet",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -546,6 +656,8 @@ const initialState: AppState = {
|
|||||||
aiProfiles: DEFAULT_AI_PROFILES,
|
aiProfiles: DEFAULT_AI_PROFILES,
|
||||||
projectAnalysis: null,
|
projectAnalysis: null,
|
||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
|
boardBackgroundByProject: {},
|
||||||
|
previewTheme: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()(
|
export const useAppStore = create<AppState & AppActions>()(
|
||||||
@@ -671,7 +783,9 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Add to project history (MRU order)
|
// Add to project history (MRU order)
|
||||||
const currentHistory = get().projectHistory;
|
const currentHistory = get().projectHistory;
|
||||||
// Remove this project if it's already in history
|
// Remove this project if it's already in history
|
||||||
const filteredHistory = currentHistory.filter((id) => id !== project.id);
|
const filteredHistory = currentHistory.filter(
|
||||||
|
(id) => id !== project.id
|
||||||
|
);
|
||||||
// Add to the front (most recent)
|
// Add to the front (most recent)
|
||||||
const newHistory = [project.id, ...filteredHistory];
|
const newHistory = [project.id, ...filteredHistory];
|
||||||
// Reset history index to 0 (current project)
|
// Reset history index to 0 (current project)
|
||||||
@@ -681,6 +795,58 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
upsertAndSetCurrentProject: (path, name, theme) => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
trashedProjects,
|
||||||
|
currentProject,
|
||||||
|
theme: globalTheme,
|
||||||
|
} = get();
|
||||||
|
const existingProject = projects.find((p) => p.path === path);
|
||||||
|
let project: Project;
|
||||||
|
|
||||||
|
if (existingProject) {
|
||||||
|
// Update existing project, preserving theme and other properties
|
||||||
|
project = {
|
||||||
|
...existingProject,
|
||||||
|
name, // Update name in case it changed
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
// Update the project in the store
|
||||||
|
const updatedProjects = projects.map((p) =>
|
||||||
|
p.id === existingProject.id ? project : p
|
||||||
|
);
|
||||||
|
set({ projects: updatedProjects });
|
||||||
|
} else {
|
||||||
|
// Create new project - check for trashed project with same path first (preserves theme if deleted/recreated)
|
||||||
|
// Then fall back to provided theme, then current project theme, then global theme
|
||||||
|
const trashedProject = trashedProjects.find((p) => p.path === path);
|
||||||
|
const effectiveTheme =
|
||||||
|
theme ||
|
||||||
|
trashedProject?.theme ||
|
||||||
|
currentProject?.theme ||
|
||||||
|
globalTheme;
|
||||||
|
project = {
|
||||||
|
id: `project-${Date.now()}`,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
theme: effectiveTheme,
|
||||||
|
};
|
||||||
|
// Add the new project to the store
|
||||||
|
set({
|
||||||
|
projects: [
|
||||||
|
...projects,
|
||||||
|
{ ...project, lastOpened: new Date().toISOString() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as current project (this will also update history and view)
|
||||||
|
get().setCurrentProject(project);
|
||||||
|
return project;
|
||||||
|
},
|
||||||
|
|
||||||
cyclePrevProject: () => {
|
cyclePrevProject: () => {
|
||||||
const { projectHistory, projectHistoryIndex, projects } = get();
|
const { projectHistory, projectHistoryIndex, projects } = get();
|
||||||
|
|
||||||
@@ -711,7 +877,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
currentProject: targetProject,
|
currentProject: targetProject,
|
||||||
projectHistory: validHistory,
|
projectHistory: validHistory,
|
||||||
projectHistoryIndex: newIndex,
|
projectHistoryIndex: newIndex,
|
||||||
currentView: "board"
|
currentView: "board",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -736,9 +902,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
if (currentIndex === -1) currentIndex = 0;
|
if (currentIndex === -1) currentIndex = 0;
|
||||||
|
|
||||||
// Move to the previous index (going forward = lower index), wrapping around
|
// Move to the previous index (going forward = lower index), wrapping around
|
||||||
const newIndex = currentIndex <= 0
|
const newIndex =
|
||||||
? validHistory.length - 1
|
currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1;
|
||||||
: currentIndex - 1;
|
|
||||||
const targetProjectId = validHistory[newIndex];
|
const targetProjectId = validHistory[newIndex];
|
||||||
const targetProject = projects.find((p) => p.id === targetProjectId);
|
const targetProject = projects.find((p) => p.id === targetProjectId);
|
||||||
|
|
||||||
@@ -748,7 +913,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
currentProject: targetProject,
|
currentProject: targetProject,
|
||||||
projectHistory: validHistory,
|
projectHistory: validHistory,
|
||||||
projectHistoryIndex: newIndex,
|
projectHistoryIndex: newIndex,
|
||||||
currentView: "board"
|
currentView: "board",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -800,6 +965,11 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
getEffectiveTheme: () => {
|
getEffectiveTheme: () => {
|
||||||
|
// If preview theme is set, use it (for hover preview)
|
||||||
|
const previewTheme = get().previewTheme;
|
||||||
|
if (previewTheme) {
|
||||||
|
return previewTheme;
|
||||||
|
}
|
||||||
const currentProject = get().currentProject;
|
const currentProject = get().currentProject;
|
||||||
// If current project has a theme set, use it
|
// If current project has a theme set, use it
|
||||||
if (currentProject?.theme) {
|
if (currentProject?.theme) {
|
||||||
@@ -809,6 +979,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
return get().theme;
|
return get().theme;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setPreviewTheme: (theme) => set({ previewTheme: theme }),
|
||||||
|
|
||||||
// Feature actions
|
// Feature actions
|
||||||
setFeatures: (features) => set({ features }),
|
setFeatures: (features) => set({ features }),
|
||||||
|
|
||||||
@@ -960,7 +1132,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
// Auto Mode actions (per-project)
|
// Auto Mode actions (per-project)
|
||||||
setAutoModeRunning: (projectId, running) => {
|
setAutoModeRunning: (projectId, running) => {
|
||||||
const current = get().autoModeByProject;
|
const current = get().autoModeByProject;
|
||||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
const projectState = current[projectId] || {
|
||||||
|
isRunning: false,
|
||||||
|
runningTasks: [],
|
||||||
|
};
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByProject: {
|
||||||
...current,
|
...current,
|
||||||
@@ -971,7 +1146,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
addRunningTask: (projectId, taskId) => {
|
addRunningTask: (projectId, taskId) => {
|
||||||
const current = get().autoModeByProject;
|
const current = get().autoModeByProject;
|
||||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
const projectState = current[projectId] || {
|
||||||
|
isRunning: false,
|
||||||
|
runningTasks: [],
|
||||||
|
};
|
||||||
if (!projectState.runningTasks.includes(taskId)) {
|
if (!projectState.runningTasks.includes(taskId)) {
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByProject: {
|
||||||
@@ -987,13 +1165,18 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
removeRunningTask: (projectId, taskId) => {
|
removeRunningTask: (projectId, taskId) => {
|
||||||
const current = get().autoModeByProject;
|
const current = get().autoModeByProject;
|
||||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
const projectState = current[projectId] || {
|
||||||
|
isRunning: false,
|
||||||
|
runningTasks: [],
|
||||||
|
};
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByProject: {
|
||||||
...current,
|
...current,
|
||||||
[projectId]: {
|
[projectId]: {
|
||||||
...projectState,
|
...projectState,
|
||||||
runningTasks: projectState.runningTasks.filter((id) => id !== taskId),
|
runningTasks: projectState.runningTasks.filter(
|
||||||
|
(id) => id !== taskId
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1001,7 +1184,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
clearRunningTasks: (projectId) => {
|
clearRunningTasks: (projectId) => {
|
||||||
const current = get().autoModeByProject;
|
const current = get().autoModeByProject;
|
||||||
const projectState = current[projectId] || { isRunning: false, runningTasks: [] };
|
const projectState = current[projectId] || {
|
||||||
|
isRunning: false,
|
||||||
|
runningTasks: [],
|
||||||
|
};
|
||||||
set({
|
set({
|
||||||
autoModeByProject: {
|
autoModeByProject: {
|
||||||
...current,
|
...current,
|
||||||
@@ -1116,7 +1302,9 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
const current = get().lastSelectedSessionByProject;
|
const current = get().lastSelectedSessionByProject;
|
||||||
if (sessionId === null) {
|
if (sessionId === null) {
|
||||||
// Remove the entry for this project
|
// Remove the entry for this project
|
||||||
const { [projectPath]: _, ...rest } = current;
|
const rest = Object.fromEntries(
|
||||||
|
Object.entries(current).filter(([key]) => key !== projectPath)
|
||||||
|
);
|
||||||
set({ lastSelectedSessionByProject: rest });
|
set({ lastSelectedSessionByProject: rest });
|
||||||
} else {
|
} else {
|
||||||
set({
|
set({
|
||||||
@@ -1131,6 +1319,151 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
getLastSelectedSession: (projectPath) => {
|
getLastSelectedSession: (projectPath) => {
|
||||||
return get().lastSelectedSessionByProject[projectPath] || null;
|
return get().lastSelectedSessionByProject[projectPath] || null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Board Background actions
|
||||||
|
setBoardBackground: (projectPath, imagePath) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || {
|
||||||
|
imagePath: null,
|
||||||
|
cardOpacity: 100,
|
||||||
|
columnOpacity: 100,
|
||||||
|
columnBorderEnabled: true,
|
||||||
|
cardGlassmorphism: true,
|
||||||
|
cardBorderEnabled: true,
|
||||||
|
cardBorderOpacity: 100,
|
||||||
|
hideScrollbar: false,
|
||||||
|
};
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
imagePath,
|
||||||
|
// Update imageVersion timestamp to bust browser cache when image changes
|
||||||
|
imageVersion: imagePath ? Date.now() : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCardOpacity: (projectPath, opacity) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
cardOpacity: opacity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setColumnOpacity: (projectPath, opacity) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
columnOpacity: opacity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getBoardBackground: (projectPath) => {
|
||||||
|
const settings = get().boardBackgroundByProject[projectPath];
|
||||||
|
return settings || defaultBackgroundSettings;
|
||||||
|
},
|
||||||
|
|
||||||
|
setColumnBorderEnabled: (projectPath, enabled) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
columnBorderEnabled: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCardGlassmorphism: (projectPath, enabled) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
cardGlassmorphism: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCardBorderEnabled: (projectPath, enabled) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
cardBorderEnabled: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCardBorderOpacity: (projectPath, opacity) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
cardBorderOpacity: opacity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setHideScrollbar: (projectPath, hide) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
hideScrollbar: hide,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBoardBackground: (projectPath) => {
|
||||||
|
const current = get().boardBackgroundByProject;
|
||||||
|
const existing = current[projectPath] || defaultBackgroundSettings;
|
||||||
|
set({
|
||||||
|
boardBackgroundByProject: {
|
||||||
|
...current,
|
||||||
|
[projectPath]: {
|
||||||
|
...existing,
|
||||||
|
imagePath: null, // Only clear the image, preserve other settings
|
||||||
|
imageVersion: undefined, // Clear version when clearing image
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}),
|
}),
|
||||||
@@ -1178,6 +1511,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
chatSessions: state.chatSessions,
|
chatSessions: state.chatSessions,
|
||||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||||
|
// Board background settings
|
||||||
|
boardBackgroundByProject: state.boardBackgroundByProject,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -357,6 +357,50 @@
|
|||||||
.download-subtitle a:hover {
|
.download-subtitle a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Video Demo Section */
|
||||||
|
.video-demo {
|
||||||
|
margin-top: 3rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
margin-left: -2rem;
|
||||||
|
margin-right: -2rem;
|
||||||
|
width: calc(100% + 4rem);
|
||||||
|
padding-bottom: 66.67%; /* Taller aspect ratio to show more height */
|
||||||
|
background: rgba(30, 41, 59, 0.5);
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container video {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.video-demo {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -382,6 +426,15 @@
|
|||||||
<a href="https://github.com/AutoMaker-Org/automaker#getting-started" class="btn btn-secondary" target="_blank">Get Started</a>
|
<a href="https://github.com/AutoMaker-Org/automaker#getting-started" class="btn btn-secondary" target="_blank">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="video-demo">
|
||||||
|
<div class="video-container">
|
||||||
|
<video controls autoplay muted loop playsinline>
|
||||||
|
<source src="https://releases.automaker.app/demo.mp4" type="video/mp4">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="download-section" id="downloadSection" style="display: none;">
|
<div class="download-section" id="downloadSection" style="display: none;">
|
||||||
<p class="download-label">Download for your platform:</p>
|
<p class="download-label">Download for your platform:</p>
|
||||||
<div class="download-buttons">
|
<div class="download-buttons">
|
||||||
@@ -492,7 +545,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.dev/releases.json';
|
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.app/releases.json';
|
||||||
|
|
||||||
async function loadLatestRelease() {
|
async function loadLatestRelease() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -321,7 +321,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.dev/releases.json';
|
const R2_RELEASES_URL = window.RELEASES_JSON_URL || 'https://releases.automaker.app/releases.json';
|
||||||
|
|
||||||
const platformIcons = {
|
const platformIcons = {
|
||||||
windows: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/></svg>',
|
windows: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801"/></svg>',
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ import { createModelsRoutes } from "./routes/models.js";
|
|||||||
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
|
||||||
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
|
||||||
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
import { createWorkspaceRoutes } from "./routes/workspace.js";
|
||||||
|
import { createTemplatesRoutes } from "./routes/templates.js";
|
||||||
import { AgentService } from "./services/agent-service.js";
|
import { AgentService } from "./services/agent-service.js";
|
||||||
import { FeatureLoader } from "./services/feature-loader.js";
|
import { FeatureLoader } from "./services/feature-loader.js";
|
||||||
|
import { AutoModeService } from "./services/auto-mode-service.js";
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
@@ -86,6 +88,7 @@ const events: EventEmitter = createEventEmitter();
|
|||||||
// Create services
|
// Create services
|
||||||
const agentService = new AgentService(DATA_DIR, events);
|
const agentService = new AgentService(DATA_DIR, events);
|
||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
|
const autoModeService = new AutoModeService(events);
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -103,15 +106,16 @@ app.use("/api/fs", createFsRoutes(events));
|
|||||||
app.use("/api/agent", createAgentRoutes(agentService, events));
|
app.use("/api/agent", createAgentRoutes(agentService, events));
|
||||||
app.use("/api/sessions", createSessionsRoutes(agentService));
|
app.use("/api/sessions", createSessionsRoutes(agentService));
|
||||||
app.use("/api/features", createFeaturesRoutes(featureLoader));
|
app.use("/api/features", createFeaturesRoutes(featureLoader));
|
||||||
app.use("/api/auto-mode", createAutoModeRoutes(events));
|
app.use("/api/auto-mode", createAutoModeRoutes(autoModeService));
|
||||||
app.use("/api/worktree", createWorktreeRoutes());
|
app.use("/api/worktree", createWorktreeRoutes());
|
||||||
app.use("/api/git", createGitRoutes());
|
app.use("/api/git", createGitRoutes());
|
||||||
app.use("/api/setup", createSetupRoutes());
|
app.use("/api/setup", createSetupRoutes());
|
||||||
app.use("/api/suggestions", createSuggestionsRoutes(events));
|
app.use("/api/suggestions", createSuggestionsRoutes(events));
|
||||||
app.use("/api/models", createModelsRoutes());
|
app.use("/api/models", createModelsRoutes());
|
||||||
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||||
app.use("/api/running-agents", createRunningAgentsRoutes());
|
app.use("/api/running-agents", createRunningAgentsRoutes(autoModeService));
|
||||||
app.use("/api/workspace", createWorkspaceRoutes());
|
app.use("/api/workspace", createWorkspaceRoutes());
|
||||||
|
app.use("/api/templates", createTemplatesRoutes());
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import type { EventEmitter } from "../lib/events.js";
|
import type { AutoModeService } from "../services/auto-mode-service.js";
|
||||||
import { AutoModeService } from "../services/auto-mode-service.js";
|
|
||||||
|
|
||||||
export function createAutoModeRoutes(events: EventEmitter): Router {
|
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const autoModeService = new AutoModeService(events);
|
|
||||||
|
|
||||||
// Start auto mode loop
|
// Start auto mode loop
|
||||||
router.post("/start", async (req: Request, res: Response) => {
|
router.post("/start", async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -500,6 +500,86 @@ export function createFsRoutes(_events: EventEmitter): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Save board background image to .automaker/board directory
|
||||||
|
router.post("/save-board-background", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { data, filename, mimeType, projectPath } = req.body as {
|
||||||
|
data: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
projectPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || !filename || !projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "data, filename, and projectPath are required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .automaker/board directory if it doesn't exist
|
||||||
|
const boardDir = path.join(projectPath, ".automaker", "board");
|
||||||
|
await fs.mkdir(boardDir, { recursive: true });
|
||||||
|
|
||||||
|
// Decode base64 data (remove data URL prefix if present)
|
||||||
|
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
|
||||||
|
const buffer = Buffer.from(base64Data, "base64");
|
||||||
|
|
||||||
|
// Use a fixed filename for the board background (overwrite previous)
|
||||||
|
const ext = path.extname(filename) || ".png";
|
||||||
|
const uniqueFilename = `background${ext}`;
|
||||||
|
const filePath = path.join(boardDir, uniqueFilename);
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
// Add project path to allowed paths if not already
|
||||||
|
addAllowedPath(projectPath);
|
||||||
|
|
||||||
|
// Return the relative path for storage
|
||||||
|
const relativePath = `.automaker/board/${uniqueFilename}`;
|
||||||
|
res.json({ success: true, path: relativePath });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete board background image
|
||||||
|
router.post("/delete-board-background", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = req.body as { projectPath: string };
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "projectPath is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardDir = path.join(projectPath, ".automaker", "board");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to remove all files in the board directory
|
||||||
|
const files = await fs.readdir(boardDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.startsWith("background")) {
|
||||||
|
await fs.unlink(path.join(boardDir, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Directory may not exist, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Browse directories for file picker
|
// Browse directories for file picker
|
||||||
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
|
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
|
||||||
router.post("/browse", async (req: Request, res: Response) => {
|
router.post("/browse", async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -3,32 +3,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, type Request, type Response } from "express";
|
import { Router, type Request, type Response } from "express";
|
||||||
import path from "path";
|
import type { AutoModeService } from "../services/auto-mode-service.js";
|
||||||
|
|
||||||
interface RunningAgent {
|
export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
|
||||||
featureId: string;
|
|
||||||
projectPath: string;
|
|
||||||
projectName: string;
|
|
||||||
isAutoMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory tracking of running agents (shared with auto-mode service via reference)
|
|
||||||
const runningAgentsMap = new Map<string, RunningAgent>();
|
|
||||||
let autoLoopRunning = false;
|
|
||||||
|
|
||||||
export function createRunningAgentsRoutes(): Router {
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Get all running agents
|
// Get all running agents
|
||||||
router.get("/", async (_req: Request, res: Response) => {
|
router.get("/", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const runningAgents = Array.from(runningAgentsMap.values());
|
const runningAgents = autoModeService.getRunningAgents();
|
||||||
|
const status = autoModeService.getStatus();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
runningAgents,
|
runningAgents,
|
||||||
totalCount: runningAgents.length,
|
totalCount: runningAgents.length,
|
||||||
autoLoopRunning,
|
autoLoopRunning: status.autoLoopRunning,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
@@ -38,33 +28,3 @@ export function createRunningAgentsRoutes(): Router {
|
|||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export functions to update running agents from other services
|
|
||||||
export function registerRunningAgent(
|
|
||||||
featureId: string,
|
|
||||||
projectPath: string,
|
|
||||||
isAutoMode: boolean
|
|
||||||
): void {
|
|
||||||
runningAgentsMap.set(featureId, {
|
|
||||||
featureId,
|
|
||||||
projectPath,
|
|
||||||
projectName: path.basename(projectPath),
|
|
||||||
isAutoMode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterRunningAgent(featureId: string): void {
|
|
||||||
runningAgentsMap.delete(featureId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAutoLoopRunning(running: boolean): void {
|
|
||||||
autoLoopRunning = running;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRunningAgentsCount(): number {
|
|
||||||
return runningAgentsMap.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAgentRunning(featureId: string): boolean {
|
|
||||||
return runningAgentsMap.has(featureId);
|
|
||||||
}
|
|
||||||
|
|||||||
181
apps/server/src/routes/templates.ts
Normal file
181
apps/server/src/routes/templates.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Templates routes
|
||||||
|
* Provides API for cloning GitHub starter templates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, type Request, type Response } from "express";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import { addAllowedPath } from "../lib/security.js";
|
||||||
|
|
||||||
|
export function createTemplatesRoutes(): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a GitHub template to a new project directory
|
||||||
|
* POST /api/templates/clone
|
||||||
|
* Body: { repoUrl: string, projectName: string, parentDir: string }
|
||||||
|
*/
|
||||||
|
router.post("/clone", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { repoUrl, projectName, parentDir } = req.body as {
|
||||||
|
repoUrl: string;
|
||||||
|
projectName: string;
|
||||||
|
parentDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (!repoUrl || !projectName || !parentDir) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "repoUrl, projectName, and parentDir are required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate repo URL is a valid GitHub URL
|
||||||
|
const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/;
|
||||||
|
if (!githubUrlPattern.test(repoUrl)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid GitHub repository URL",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize project name (allow alphanumeric, dash, underscore)
|
||||||
|
const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-");
|
||||||
|
if (sanitizedName !== projectName) {
|
||||||
|
console.log(
|
||||||
|
`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full project path
|
||||||
|
const projectPath = path.join(parentDir, sanitizedName);
|
||||||
|
|
||||||
|
const resolvedParent = path.resolve(parentDir);
|
||||||
|
const resolvedProject = path.resolve(projectPath);
|
||||||
|
const relativePath = path.relative(resolvedParent, resolvedProject);
|
||||||
|
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid project name; potential path traversal attempt.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory already exists
|
||||||
|
try {
|
||||||
|
await fs.access(projectPath);
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Directory "${sanitizedName}" already exists in ${parentDir}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist, which is what we want
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
try {
|
||||||
|
await fs.mkdir(parentDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Templates] Failed to create parent directory:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Failed to create parent directory",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Templates] Cloning ${repoUrl} to ${projectPath}`);
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
const cloneResult = await new Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}>((resolve) => {
|
||||||
|
const gitProcess = spawn("git", ["clone", repoUrl, projectPath], {
|
||||||
|
cwd: parentDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
gitProcess.stderr.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on("close", (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve({ success: true });
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: stderr || `Git clone failed with code ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on("error", (error) => {
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: `Failed to spawn git: ${error.message}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cloneResult.success) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: cloneResult.error || "Failed to clone repository",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove .git directory to start fresh
|
||||||
|
try {
|
||||||
|
const gitDir = path.join(projectPath, ".git");
|
||||||
|
await fs.rm(gitDir, { recursive: true, force: true });
|
||||||
|
console.log("[Templates] Removed .git directory");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[Templates] Could not remove .git directory:", error);
|
||||||
|
// Continue anyway - not critical
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a fresh git repository
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const gitInit = spawn("git", ["init"], {
|
||||||
|
cwd: projectPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
gitInit.on("close", () => {
|
||||||
|
console.log("[Templates] Initialized fresh git repository");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
gitInit.on("error", () => {
|
||||||
|
console.warn("[Templates] Could not initialize git");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to allowed paths
|
||||||
|
addAllowedPath(projectPath);
|
||||||
|
|
||||||
|
console.log(`[Templates] Successfully cloned template to ${projectPath}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
projectPath,
|
||||||
|
projectName: sanitizedName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Templates] Clone error:", error);
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
* - Verification and merge workflows
|
* - Verification and merge workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
import {
|
||||||
|
query,
|
||||||
|
AbortError,
|
||||||
|
type Options,
|
||||||
|
} from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -20,11 +24,13 @@ const execAsync = promisify(exec);
|
|||||||
|
|
||||||
interface Feature {
|
interface Feature {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
category: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
steps?: string[];
|
||||||
status: string;
|
status: string;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
spec?: string;
|
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||||
|
[key: string]: unknown; // Allow additional fields
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RunningFeature {
|
interface RunningFeature {
|
||||||
@@ -85,7 +91,11 @@ export class AutoModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async runAutoLoop(): Promise<void> {
|
private async runAutoLoop(): Promise<void> {
|
||||||
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
|
while (
|
||||||
|
this.autoLoopRunning &&
|
||||||
|
this.autoLoopAbortController &&
|
||||||
|
!this.autoLoopAbortController.signal.aborted
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// Check if we have capacity
|
// Check if we have capacity
|
||||||
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
|
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
|
||||||
@@ -94,7 +104,9 @@ export class AutoModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load pending features
|
// Load pending features
|
||||||
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
|
const pendingFeatures = await this.loadPendingFeatures(
|
||||||
|
this.config!.projectPath
|
||||||
|
);
|
||||||
|
|
||||||
if (pendingFeatures.length === 0) {
|
if (pendingFeatures.length === 0) {
|
||||||
this.emitAutoModeEvent("auto_mode_complete", {
|
this.emitAutoModeEvent("auto_mode_complete", {
|
||||||
@@ -105,7 +117,9 @@ export class AutoModeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find a feature not currently running
|
// Find a feature not currently running
|
||||||
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
|
const nextFeature = pendingFeatures.find(
|
||||||
|
(f) => !this.runningFeatures.has(f.id)
|
||||||
|
);
|
||||||
|
|
||||||
if (nextFeature) {
|
if (nextFeature) {
|
||||||
// Start feature execution in background
|
// Start feature execution in background
|
||||||
@@ -164,7 +178,11 @@ export class AutoModeService {
|
|||||||
|
|
||||||
// Setup worktree if enabled
|
// Setup worktree if enabled
|
||||||
if (useWorktrees) {
|
if (useWorktrees) {
|
||||||
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
|
worktreePath = await this.setupWorktree(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
branchName
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workDir = worktreePath || projectPath;
|
const workDir = worktreePath || projectPath;
|
||||||
@@ -183,7 +201,11 @@ export class AutoModeService {
|
|||||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
|
feature: {
|
||||||
|
id: featureId,
|
||||||
|
title: "Loading...",
|
||||||
|
description: "Feature is starting",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -199,20 +221,38 @@ export class AutoModeService {
|
|||||||
// Build the prompt
|
// Build the prompt
|
||||||
const prompt = this.buildFeaturePrompt(feature);
|
const prompt = this.buildFeaturePrompt(feature);
|
||||||
|
|
||||||
// Run the agent
|
// Extract image paths from feature
|
||||||
await this.runAgent(workDir, featureId, prompt, abortController);
|
const imagePaths = this.extractImagePaths(feature.imagePaths, workDir);
|
||||||
|
|
||||||
|
// Run the agent with image paths
|
||||||
|
await this.runAgent(
|
||||||
|
workDir,
|
||||||
|
featureId,
|
||||||
|
prompt,
|
||||||
|
abortController,
|
||||||
|
imagePaths
|
||||||
|
);
|
||||||
|
|
||||||
// Mark as waiting_approval for user review
|
// Mark as waiting_approval for user review
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
await this.updateFeatureStatus(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
"waiting_approval"
|
||||||
|
);
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
featureId,
|
featureId,
|
||||||
passes: true,
|
passes: true,
|
||||||
message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`,
|
message: `Feature completed in ${Math.round(
|
||||||
|
(Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000
|
||||||
|
)}s`,
|
||||||
projectPath,
|
projectPath,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
|
if (
|
||||||
|
error instanceof AbortError ||
|
||||||
|
(error as Error)?.name === "AbortError"
|
||||||
|
) {
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
featureId,
|
featureId,
|
||||||
passes: false,
|
passes: false,
|
||||||
@@ -221,9 +261,10 @@ export class AutoModeService {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = (error as Error).message || "Unknown error";
|
const errorMessage = (error as Error).message || "Unknown error";
|
||||||
const isAuthError = errorMessage.includes("Authentication failed") ||
|
const isAuthError =
|
||||||
errorMessage.includes("Invalid API key") ||
|
errorMessage.includes("Authentication failed") ||
|
||||||
errorMessage.includes("authentication_failed");
|
errorMessage.includes("Invalid API key") ||
|
||||||
|
errorMessage.includes("authentication_failed");
|
||||||
|
|
||||||
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "backlog");
|
await this.updateFeatureStatus(projectPath, featureId, "backlog");
|
||||||
@@ -280,7 +321,12 @@ export class AutoModeService {
|
|||||||
if (hasContext) {
|
if (hasContext) {
|
||||||
// Load previous context and continue
|
// Load previous context and continue
|
||||||
const context = await fs.readFile(contextPath, "utf-8");
|
const context = await fs.readFile(contextPath, "utf-8");
|
||||||
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
return this.executeFeatureWithContext(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
context,
|
||||||
|
useWorktrees
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No context, start fresh
|
// No context, start fresh
|
||||||
@@ -303,7 +349,12 @@ export class AutoModeService {
|
|||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
||||||
// Check if worktree exists
|
// Check if worktree exists
|
||||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
const worktreePath = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"worktrees",
|
||||||
|
featureId
|
||||||
|
);
|
||||||
let workDir = projectPath;
|
let workDir = projectPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -366,14 +417,28 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
feature: feature || { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) },
|
feature: feature || {
|
||||||
|
id: featureId,
|
||||||
|
title: "Follow-up",
|
||||||
|
description: prompt.substring(0, 100),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.runAgent(workDir, featureId, fullPrompt, abortController, imagePaths);
|
await this.runAgent(
|
||||||
|
workDir,
|
||||||
|
featureId,
|
||||||
|
fullPrompt,
|
||||||
|
abortController,
|
||||||
|
imagePaths
|
||||||
|
);
|
||||||
|
|
||||||
// Mark as waiting_approval for user review
|
// Mark as waiting_approval for user review
|
||||||
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
|
await this.updateFeatureStatus(
|
||||||
|
projectPath,
|
||||||
|
featureId,
|
||||||
|
"waiting_approval"
|
||||||
|
);
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
featureId,
|
featureId,
|
||||||
@@ -397,8 +462,16 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
/**
|
/**
|
||||||
* Verify a feature's implementation
|
* Verify a feature's implementation
|
||||||
*/
|
*/
|
||||||
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
|
async verifyFeature(
|
||||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const worktreePath = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"worktrees",
|
||||||
|
featureId
|
||||||
|
);
|
||||||
let workDir = projectPath;
|
let workDir = projectPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -417,7 +490,8 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
];
|
];
|
||||||
|
|
||||||
let allPassed = true;
|
let allPassed = true;
|
||||||
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
|
const results: Array<{ check: string; passed: boolean; output?: string }> =
|
||||||
|
[];
|
||||||
|
|
||||||
for (const check of verificationChecks) {
|
for (const check of verificationChecks) {
|
||||||
try {
|
try {
|
||||||
@@ -425,7 +499,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
});
|
});
|
||||||
results.push({ check: check.name, passed: true, output: stdout || stderr });
|
results.push({
|
||||||
|
check: check.name,
|
||||||
|
passed: true,
|
||||||
|
output: stdout || stderr,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
allPassed = false;
|
allPassed = false;
|
||||||
results.push({
|
results.push({
|
||||||
@@ -442,7 +520,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
passes: allPassed,
|
passes: allPassed,
|
||||||
message: allPassed
|
message: allPassed
|
||||||
? "All verification checks passed"
|
? "All verification checks passed"
|
||||||
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
|
: `Verification failed: ${
|
||||||
|
results.find((r) => !r.passed)?.check || "Unknown"
|
||||||
|
}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return allPassed;
|
return allPassed;
|
||||||
@@ -451,8 +531,16 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
/**
|
/**
|
||||||
* Commit feature changes
|
* Commit feature changes
|
||||||
*/
|
*/
|
||||||
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
|
async commitFeature(
|
||||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const worktreePath = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"worktrees",
|
||||||
|
featureId
|
||||||
|
);
|
||||||
let workDir = projectPath;
|
let workDir = projectPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -464,7 +552,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check for changes
|
// Check for changes
|
||||||
const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir });
|
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
if (!status.trim()) {
|
if (!status.trim()) {
|
||||||
return null; // No changes
|
return null; // No changes
|
||||||
}
|
}
|
||||||
@@ -472,7 +562,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
// Load feature for commit message
|
// Load feature for commit message
|
||||||
const feature = await this.loadFeature(projectPath, featureId);
|
const feature = await this.loadFeature(projectPath, featureId);
|
||||||
const commitMessage = feature
|
const commitMessage = feature
|
||||||
? `feat: ${feature.title}\n\nImplemented by Automaker auto-mode`
|
? `feat: ${this.extractTitleFromDescription(
|
||||||
|
feature.description
|
||||||
|
)}\n\nImplemented by Automaker auto-mode`
|
||||||
: `feat: Feature ${featureId}`;
|
: `feat: Feature ${featureId}`;
|
||||||
|
|
||||||
// Stage and commit
|
// Stage and commit
|
||||||
@@ -482,7 +574,9 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get commit hash
|
// Get commit hash
|
||||||
const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir });
|
const { stdout: hash } = await execAsync("git rev-parse HEAD", {
|
||||||
|
cwd: workDir,
|
||||||
|
});
|
||||||
|
|
||||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||||
featureId,
|
featureId,
|
||||||
@@ -500,7 +594,10 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
/**
|
/**
|
||||||
* Check if context exists for a feature
|
* Check if context exists for a feature
|
||||||
*/
|
*/
|
||||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
async contextExists(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<boolean> {
|
||||||
const contextPath = path.join(
|
const contextPath = path.join(
|
||||||
projectPath,
|
projectPath,
|
||||||
".automaker",
|
".automaker",
|
||||||
@@ -527,7 +624,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||||
featureId: analysisFeatureId,
|
featureId: analysisFeatureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" },
|
feature: {
|
||||||
|
id: analysisFeatureId,
|
||||||
|
title: "Project Analysis",
|
||||||
|
description: "Analyzing project structure",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const prompt = `Analyze this project and provide a summary of:
|
const prompt = `Analyze this project and provide a summary of:
|
||||||
@@ -570,7 +671,11 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save analysis
|
// Save analysis
|
||||||
const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md");
|
const analysisPath = path.join(
|
||||||
|
projectPath,
|
||||||
|
".automaker",
|
||||||
|
"project-analysis.md"
|
||||||
|
);
|
||||||
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
|
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
|
||||||
await fs.writeFile(analysisPath, analysisResult);
|
await fs.writeFile(analysisPath, analysisResult);
|
||||||
|
|
||||||
@@ -606,6 +711,23 @@ Format your response as a structured markdown document.`;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed info about all running agents
|
||||||
|
*/
|
||||||
|
getRunningAgents(): Array<{
|
||||||
|
featureId: string;
|
||||||
|
projectPath: string;
|
||||||
|
projectName: string;
|
||||||
|
isAutoMode: boolean;
|
||||||
|
}> {
|
||||||
|
return Array.from(this.runningFeatures.values()).map((rf) => ({
|
||||||
|
featureId: rf.featureId,
|
||||||
|
projectPath: rf.projectPath,
|
||||||
|
projectName: path.basename(rf.projectPath),
|
||||||
|
isAutoMode: rf.isAutoMode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Private helpers
|
// Private helpers
|
||||||
|
|
||||||
private async setupWorktree(
|
private async setupWorktree(
|
||||||
@@ -647,7 +769,10 @@ Format your response as a structured markdown document.`;
|
|||||||
return worktreePath;
|
return worktreePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
|
private async loadFeature(
|
||||||
|
projectPath: string,
|
||||||
|
featureId: string
|
||||||
|
): Promise<Feature | null> {
|
||||||
const featurePath = path.join(
|
const featurePath = path.join(
|
||||||
projectPath,
|
projectPath,
|
||||||
".automaker",
|
".automaker",
|
||||||
@@ -682,6 +807,14 @@ Format your response as a structured markdown document.`;
|
|||||||
const feature = JSON.parse(data);
|
const feature = JSON.parse(data);
|
||||||
feature.status = status;
|
feature.status = status;
|
||||||
feature.updatedAt = new Date().toISOString();
|
feature.updatedAt = new Date().toISOString();
|
||||||
|
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||||
|
// Badge will show for 2 minutes after this timestamp
|
||||||
|
if (status === "waiting_approval") {
|
||||||
|
feature.justFinishedAt = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
// Clear the timestamp when moving to other statuses
|
||||||
|
feature.justFinishedAt = undefined;
|
||||||
|
}
|
||||||
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Feature file may not exist
|
// Feature file may not exist
|
||||||
@@ -697,7 +830,11 @@ Format your response as a structured markdown document.`;
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const featurePath = path.join(featuresDir, entry.name, "feature.json");
|
const featurePath = path.join(
|
||||||
|
featuresDir,
|
||||||
|
entry.name,
|
||||||
|
"feature.json"
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const data = await fs.readFile(featurePath, "utf-8");
|
const data = await fs.readFile(featurePath, "utf-8");
|
||||||
const feature = JSON.parse(data);
|
const feature = JSON.parse(data);
|
||||||
@@ -717,21 +854,59 @@ Format your response as a structured markdown document.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a title from feature description (first line or truncated)
|
||||||
|
*/
|
||||||
|
private extractTitleFromDescription(description: string): string {
|
||||||
|
if (!description || !description.trim()) {
|
||||||
|
return "Untitled Feature";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first line, or first 60 characters if no newline
|
||||||
|
const firstLine = description.split("\n")[0].trim();
|
||||||
|
if (firstLine.length <= 60) {
|
||||||
|
return firstLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to 60 characters and add ellipsis
|
||||||
|
return firstLine.substring(0, 57) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract image paths from feature's imagePaths array
|
||||||
|
* Handles both string paths and objects with path property
|
||||||
|
*/
|
||||||
|
private extractImagePaths(
|
||||||
|
imagePaths:
|
||||||
|
| Array<string | { path: string; [key: string]: unknown }>
|
||||||
|
| undefined,
|
||||||
|
projectPath: string
|
||||||
|
): string[] {
|
||||||
|
if (!imagePaths || imagePaths.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return imagePaths
|
||||||
|
.map((imgPath) => {
|
||||||
|
const pathStr = typeof imgPath === "string" ? imgPath : imgPath.path;
|
||||||
|
// Resolve relative paths to absolute paths
|
||||||
|
return path.isAbsolute(pathStr)
|
||||||
|
? pathStr
|
||||||
|
: path.join(projectPath, pathStr);
|
||||||
|
})
|
||||||
|
.filter((p) => p); // Filter out any empty paths
|
||||||
|
}
|
||||||
|
|
||||||
private buildFeaturePrompt(feature: Feature): string {
|
private buildFeaturePrompt(feature: Feature): string {
|
||||||
|
const title = this.extractTitleFromDescription(feature.description);
|
||||||
|
|
||||||
let prompt = `## Feature Implementation Task
|
let prompt = `## Feature Implementation Task
|
||||||
|
|
||||||
**Feature ID:** ${feature.id}
|
**Feature ID:** ${feature.id}
|
||||||
**Title:** ${feature.title}
|
**Title:** ${title}
|
||||||
**Description:** ${feature.description}
|
**Description:** ${feature.description}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (feature.spec) {
|
|
||||||
prompt += `
|
|
||||||
**Specification:**
|
|
||||||
${feature.spec}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt += `
|
prompt += `
|
||||||
## Instructions
|
## Instructions
|
||||||
|
|
||||||
@@ -758,14 +933,7 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
model: "claude-opus-4-5-20251101",
|
model: "claude-opus-4-5-20251101",
|
||||||
maxTurns: 50,
|
maxTurns: 50,
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: [
|
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
|
||||||
"Read",
|
|
||||||
"Write",
|
|
||||||
"Edit",
|
|
||||||
"Glob",
|
|
||||||
"Grep",
|
|
||||||
"Bash",
|
|
||||||
],
|
|
||||||
permissionMode: "acceptEdits",
|
permissionMode: "acceptEdits",
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -778,12 +946,20 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
let finalPrompt = prompt;
|
let finalPrompt = prompt;
|
||||||
|
|
||||||
if (imagePaths && imagePaths.length > 0) {
|
if (imagePaths && imagePaths.length > 0) {
|
||||||
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`;
|
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths
|
||||||
|
.map((p) => `- ${p}`)
|
||||||
|
.join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = query({ prompt: finalPrompt, options });
|
const stream = query({ prompt: finalPrompt, options });
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
|
const outputPath = path.join(
|
||||||
|
workDir,
|
||||||
|
".automaker",
|
||||||
|
"features",
|
||||||
|
featureId,
|
||||||
|
"agent-output.md"
|
||||||
|
);
|
||||||
|
|
||||||
for await (const msg of stream) {
|
for await (const msg of stream) {
|
||||||
if (msg.type === "assistant" && msg.message.content) {
|
if (msg.type === "assistant" && msg.message.content) {
|
||||||
@@ -792,12 +968,14 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
responseText = block.text;
|
responseText = block.text;
|
||||||
|
|
||||||
// Check for authentication errors in the response
|
// Check for authentication errors in the response
|
||||||
if (block.text.includes("Invalid API key") ||
|
if (
|
||||||
block.text.includes("authentication_failed") ||
|
block.text.includes("Invalid API key") ||
|
||||||
block.text.includes("Fix external API key")) {
|
block.text.includes("authentication_failed") ||
|
||||||
|
block.text.includes("Fix external API key")
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
|
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,18 +991,21 @@ When done, summarize what you implemented and any notes for the developer.`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") {
|
} else if (
|
||||||
|
msg.type === "assistant" &&
|
||||||
|
(msg as { error?: string }).error === "authentication_failed"
|
||||||
|
) {
|
||||||
// Handle authentication error from the SDK
|
// Handle authentication error from the SDK
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
||||||
);
|
);
|
||||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||||
// Check if result indicates an error
|
// Check if result indicates an error
|
||||||
if (msg.is_error && msg.result?.includes("Invalid API key")) {
|
if (msg.is_error && msg.result?.includes("Invalid API key")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Authentication failed: Invalid or expired API key. " +
|
"Authentication failed: Invalid or expired API key. " +
|
||||||
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
responseText = msg.result || responseText;
|
responseText = msg.result || responseText;
|
||||||
|
|||||||
Reference in New Issue
Block a user