Compare commits

..

46 Commits

Author SHA1 Message Date
Web Dev Cody
e2c238f4f8 Merge pull request #64 from AutoMaker-Org/new-license
New license
2025-12-13 16:01:04 -05:00
Cody Seibert
ae02b30aba fix: clarify internal use and governing law in licensing documentation
- Updated the LICENSE file to specify that modifications for internal use are allowed within the organization, whether commercial or non-profit.
- Revised the governing law clause to explicitly state that the agreement is governed by the laws of the State of Tennessee, USA.
- Adjusted the README to reflect these clarifications regarding internal use and modification rights.
2025-12-13 15:34:46 -05:00
Cody Seibert
ed65b096e3 feat: update licensing terms and disclaimer in documentation
- Revised the LICENSE file to establish a comprehensive Automaker License Agreement, replacing the previous dual licensing structure.
- Enhanced the README to reflect the new licensing terms, clarifying allowed uses and restrictions, including the "No Monetization of the Tool" rule.
- Updated the DISCLAIMER.md to include a more detailed warning about the risks associated with using AI-generated code and the limitations of liability.
- Removed the obsolete LICENSE-AGPL-3.0 and LICENSE-COMMERCIAL files to streamline licensing documentation.
2025-12-13 15:25:24 -05:00
Shirone
1db24ab887 Merge pull request #63 from AutoMaker-Org/wiki
feat: add Wiki view and sidebar link
2025-12-13 19:45:49 +01:00
SuperComboGamer
e27e0b2343 feat: add Wiki view and sidebar link
- Introduced a new Wiki view component to the application.
- Updated the sidebar to include a button for navigating to the Wiki view.
- Modified the app store to support the new "wiki" view mode.
2025-12-13 13:23:13 -05:00
Ben
54311a887c Merge pull request #59 from AutoMaker-Org/feat/integrated-terminal
feat: add integrated terminal with tab system and theme support
2025-12-13 11:40:22 -06:00
trueheads
89216c01e5 fixes for windows, but maybe breaking linux 2025-12-13 11:34:59 -06:00
Cody Seibert
9e9cffde6b feat: implement dual licensing structure and update license documentation
- Introduced a dual licensing model for the project, allowing users to choose between the GNU Affero General Public License v3.0 (AGPL-3.0) and a Commercial License.
- Added detailed licensing information in the LICENSE and README files, clarifying the terms and conditions for both licenses.
- Created a new LICENSE-AGPL-3.0 file to include the full text of the AGPL-3.0 license.
- Established a LICENSE-COMMERCIAL file outlining the terms for commercial use and the process for obtaining a Business License.
- Updated the README to reflect the new licensing structure and emphasize the need for a Business License for commercial use.
2025-12-13 10:17:26 -05:00
SuperComboGamer
66fe3392ad commit 2025-12-13 02:04:14 -05:00
SuperComboGamer
3a553c892d docs: fix terminal documentation
- Terminal is NOT password protected by default
- Add TERMINAL_PASSWORD to .env to enable protection
- Add TERMINAL_ENABLED=false to disable terminal completely

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 02:04:05 -05:00
SuperComboGamer
ca506a208e docs: add terminal documentation
Explains terminal features including:
- Password protection and how to disable it
- Keyboard shortcuts (Alt+D, Alt+S, Alt+W)
- Theming, font size, scrollback
- Architecture overview
- Troubleshooting tips

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 02:00:52 -05:00
SuperComboGamer
cbca6fa6e4 fix: change split-down shortcut to Alt+S to avoid system conflict
- Change split-down from Alt+Shift+D to Alt+S (Alt+Shift is Windows
  keyboard layout switch shortcut)
- Use event.code for keyboard-layout-independent key detection
- Add red theme to dark theme scrollbar selectors
- Add red-themed scrollbar styling with dark red colors
- Tone down white/bright colors in red terminal theme

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:54:04 -05:00
SuperComboGamer
951010b64d fix: add missing red terminal theme and fix split panel type
- Add red terminal theme with dark red-accented color scheme
- Add size property to split type in TerminalPanelContent to support
  nested splits with size tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:47:49 -05:00
SuperComboGamer
08221c6660 fix: move terminal creation debounce to view level
The per-panel debounce didn't work because each new terminal has
its own fresh ref. Move debounce to createTerminal function with:
- 500ms cooldown between creations
- isCreating flag to prevent concurrent requests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:44:24 -05:00
SuperComboGamer
ffd8752cde feat: add debounce to terminal shortcuts and show in keyboard layout
- Add 300ms cooldown to prevent rapid terminal creation when holding keys
- Merge DEFAULT_KEYBOARD_SHORTCUTS with user shortcuts so terminal
  shortcuts (Alt+D, Alt+Shift+D, Alt+W) show in keyboard layout
- Fix keyboard map to handle undefined shortcuts from old persisted state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:42:40 -05:00
SuperComboGamer
deae01712a fix: intercept terminal shortcuts at xterm level
When the terminal is focused, xterm captures keyboard events before
they reach the window. Use attachCustomKeyEventHandler to intercept
Alt+D, Alt+Shift+D, and Alt+W directly at the xterm level.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:39:05 -05:00
SuperComboGamer
14d1562903 fix: handle undefined shortcuts in parseShortcut and formatShortcut
Add guards to handle undefined/null shortcuts for users with
old persisted state missing the new terminal shortcuts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:35:18 -05:00
SuperComboGamer
8c100230ab fix: add safety checks for undefined shortcuts in keyboard map
Handle cases where users have old persisted state that doesn't
include the new terminal shortcuts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:34:27 -05:00
SuperComboGamer
8eb374d77c fix: use Alt-based shortcuts to avoid browser conflicts
- Split right: Alt+D
- Split down: Alt+Shift+D
- Close terminal: Alt+W

Alt modifier avoids conflicts with both terminal signals and browser shortcuts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:32:36 -05:00
SuperComboGamer
998ad354d2 fix: change terminal shortcuts to avoid conflicts with shell signals
- Split right: Cmd+Shift+D / Ctrl+Shift+D (was Cmd+D which conflicts with EOF)
- Split down: Cmd+Shift+E / Ctrl+Shift+E
- Close: Cmd+Shift+W / Ctrl+Shift+W (was Cmd+W which conflicts with delete word)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:31:02 -05:00
SuperComboGamer
a2bd1b593b fix: handle undefined shortcuts for users with persisted state
Users with existing persisted state won't have the new terminal
shortcuts, so guard against undefined values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:29:39 -05:00
SuperComboGamer
2ebb650609 feat: add terminal keyboard shortcuts with cross-platform support
- Add splitTerminalRight, splitTerminalDown, closeTerminal to KeyboardShortcuts
- Wire up shortcuts in terminal view (Cmd+D, Cmd+Shift+D, Cmd+W on Mac)
- Auto-detect platform and use Ctrl instead of Cmd on Linux/Windows

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:28:58 -05:00
SuperComboGamer
11ddcfaf90 fix: throttle terminal output to prevent system lockup under heavy load
- Batch terminal output at ~60fps max to prevent overwhelming WebSocket
- Reduce scrollback buffer from 100KB to 50KB per terminal
- Clean up flush timeouts on session kill/cleanup
- Should fix lockups when running npm run dev with high output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:21:52 -05:00
SuperComboGamer
be4a0b292c fix: split terminal inside current panel instead of at root
When clicking split on a terminal, the new terminal is now added
as a sibling of that specific terminal rather than at the root
of the layout tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:07:33 -05:00
SuperComboGamer
18494547bc fix: address code review feedback
- Display actual shell name instead of hardcoded "bash"
- Fix type assertion by making findFirstTerminal accept null

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:05:12 -05:00
Cody Seibert
26074f9390 feat: improve URL accessibility checks and download handling
- Enhanced the URL accessibility check function to handle multiple redirect types and provide detailed feedback on accessibility status, including content type validation.
- Updated the download function to follow redirects correctly and ensure proper error handling, improving the reliability of downloading source archives from GitHub.
- Adjusted the main function to utilize the final URLs after redirects for downloading, ensuring accurate resource retrieval.
2025-12-13 01:03:26 -05:00
SuperComboGamer
272905b884 fix: add terminal keyboard shortcut to KeyboardShortcuts interface
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:02:38 -05:00
Cody Seibert
0ad2de90ee feat: implement URL accessibility check with exponential backoff
- Added a new function to check the accessibility of URLs with retries and exponential backoff, improving the reliability of downloading source archives from GitHub.
- Updated the main function to wait for the source archives to be accessible before proceeding with the download, enhancing error handling and user feedback.
2025-12-13 01:01:35 -05:00
SuperComboGamer
21cbdba530 fix: add missing Terminal icon import in sidebar
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 01:00:01 -05:00
SuperComboGamer
04ccd6f81c feat: add integrated terminal with tab system and theme support
- Add terminal view with draggable split panels and multi-tab support
- Implement terminal WebSocket server with password protection
- Add per-terminal font size that persists when moving between tabs
- Support all 12 app themes with matching terminal colors
- Add keyboard shortcut (Ctrl+`) to toggle terminal view
- Include scrollback buffer for session history on reconnect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 00:57:28 -05:00
Cody Seibert
af04e69dc7 chore: specify shell for version extraction in release workflow
- Updated the release workflow to explicitly set the shell to bash for the version extraction steps, ensuring consistent execution across environments.
2025-12-13 00:51:23 -05:00
Cody Seibert
935316cb51 testing releases 2025-12-13 00:46:24 -05:00
Web Dev Cody
e608f46a49 Merge pull request #52 from AutoMaker-Org/new-project-from-template
feat: Add new project from template feature and UI enhancements
2025-12-13 00:32:56 -05:00
Cody Seibert
8de4056417 fix: update release URL in marketing pages
- Changed the default release URL from 'https://releases.automaker.dev/releases.json' to 'https://releases.automaker.app/releases.json' in both index.html and releases.html files to ensure correct resource loading.
2025-12-13 00:29:03 -05:00
Cody Seibert
9196a1afb4 feat: enhance board background settings and introduce animated borders
- Added default background settings to streamline background management across components.
- Implemented animated border styles for in-progress cards to improve visual feedback.
- Refactored BoardBackgroundModal and BoardView components to utilize the new default settings, ensuring consistent background behavior.
- Updated KanbanCard to support animated borders, enhancing the user experience during task progress.
- Improved Sidebar component by optimizing the fetching of running agents count with a more efficient use of hooks.
2025-12-13 00:25:16 -05:00
Cody Seibert
eaef95c4a3 chore: clean up .gitignore by removing redundant node_modules entry
- Removed duplicate entry for node_modules from the .gitignore file to streamline ignored files and improve clarity.
2025-12-12 23:56:33 -05:00
Cody Seibert
3dd10aa8c7 feat: add project management actions to WelcomeView
- Introduced `addProject` and `setCurrentProject` actions to the WelcomeView component for enhanced project management capabilities.
- Updated the component's state management to support these new actions, improving user experience in project handling.
2025-12-12 23:45:36 -05:00
Cody Seibert
104f478f89 feat: enhance background image handling with cache-busting
- Added a cache-busting query parameter to the background image URL to ensure the browser reloads the image when updated.
- Updated the AppState to include an optional imageVersion property for managing image updates.
- Modified the BoardBackgroundModal and BoardView components to utilize the new imageVersion for dynamic image loading.
2025-12-12 23:09:51 -05:00
Cody Seibert
b32af0c86b feat: implement upsert project functionality in sidebar and welcome view
- Refactored project handling in Sidebar and WelcomeView components to use a new `upsertAndSetCurrentProject` action for creating or updating projects.
- Enhanced theme preservation logic during project creation and updates by integrating theme management directly into the store action.
- Cleaned up redundant code related to project existence checks and state updates, improving maintainability and readability.
2025-12-12 23:06:22 -05:00
Cody Seibert
c991d5f2f7 feat: add video demo section to marketing page
- Introduced a new video demo section to showcase features with an embedded video player.
- Styled the video container for responsive design and improved aesthetics.
- Added media queries for better display on smaller screens.
2025-12-12 22:51:39 -05:00
Cody Seibert
b3a4fd2be1 feat: introduce marketing mode and update sidebar display
- Added a new configuration flag `IS_MARKETING` to toggle marketing mode.
- Updated the sidebar component to conditionally display the marketing URL when in marketing mode.
- Refactored event type naming for consistency in the sidebar logic.
- Cleaned up formatting in the HttpApiClient for improved readability.
2025-12-12 22:42:43 -05:00
Cody Seibert
28328d7d1e feat: add red theme and board background modal
- Introduced a new red theme with custom color variables for a bold aesthetic.
- Updated the theme management to include the new red theme option.
- Added a BoardBackgroundModal component for managing board background settings, including image uploads and opacity controls.
- Enhanced KanbanCard and KanbanColumn components to support new background settings such as opacity and border visibility.
- Updated API client to handle saving and deleting board backgrounds.
- Refactored theme application logic to accommodate the new preview theme functionality.
2025-12-12 22:05:16 -05:00
Cody Seibert
346c38d6da Merge branch 'main' into new-project-from-template 2025-12-12 20:51:23 -05:00
Cody Seibert
ca4809ca06 various fixes 2025-12-12 20:51:01 -05:00
Cody Seibert
5544031164 Merge branch 'main' into new-project-from-template 2025-12-12 18:31:09 -05:00
Cody Seibert
be4aadb632 adding new project from template 2025-12-12 17:14:31 -05:00
47 changed files with 7639 additions and 914 deletions

View File

@@ -1,10 +1,16 @@
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
const fs = require('fs');
const path = require('path');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
} = require("@aws-sdk/client-s3");
const fs = require("fs");
const path = require("path");
const https = require("https");
const { pipeline } = require("stream/promises");
const s3Client = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
@@ -14,19 +20,22 @@ const s3Client = new S3Client({
const BUCKET = process.env.R2_BUCKET_NAME;
const PUBLIC_URL = process.env.R2_PUBLIC_URL;
const VERSION = process.env.RELEASE_VERSION;
const RELEASE_TAG = process.env.RELEASE_TAG || `v${VERSION}`;
const GITHUB_REPO = process.env.GITHUB_REPOSITORY;
async function fetchExistingReleases() {
try {
const response = await s3Client.send(new GetObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
}));
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: "releases.json",
})
);
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (error) {
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
console.log('No existing releases.json found, creating new one');
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
console.log("No existing releases.json found, creating new one");
return { latestVersion: null, releases: [] };
}
throw error;
@@ -37,12 +46,14 @@ async function uploadFile(localPath, r2Key, contentType) {
const fileBuffer = fs.readFileSync(localPath);
const stats = fs.statSync(localPath);
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: r2Key,
Body: fileBuffer,
ContentType: contentType,
}));
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: r2Key,
Body: fileBuffer,
ContentType: contentType,
})
);
console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`);
return stats.size;
@@ -51,44 +62,270 @@ async function uploadFile(localPath, r2Key, contentType) {
function findArtifacts(dir, pattern) {
if (!fs.existsSync(dir)) return [];
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 checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await new Promise((resolve, reject) => {
const request = https.get(url, { timeout: 10000 }, (response) => {
const statusCode = response.statusCode;
// Follow redirects
if (
statusCode === 302 ||
statusCode === 301 ||
statusCode === 307 ||
statusCode === 308
) {
const redirectUrl = response.headers.location;
response.destroy();
if (!redirectUrl) {
resolve({
accessible: false,
statusCode,
error: "Redirect without location header",
});
return;
}
// Follow the redirect URL
return https
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
const redirectStatus = redirectResponse.statusCode;
const contentType =
redirectResponse.headers["content-type"] || "";
// Check if it's actually a file (zip/tar.gz) and not HTML
const isFile =
contentType.includes("application/zip") ||
contentType.includes("application/gzip") ||
contentType.includes("application/x-gzip") ||
contentType.includes("application/x-tar") ||
redirectUrl.includes(".zip") ||
redirectUrl.includes(".tar.gz");
const isGood =
redirectStatus >= 200 && redirectStatus < 300 && isFile;
redirectResponse.destroy();
resolve({
accessible: isGood,
statusCode: redirectStatus,
finalUrl: redirectUrl,
contentType,
});
})
.on("error", (error) => {
resolve({
accessible: false,
statusCode,
error: error.message,
});
})
.on("timeout", function () {
this.destroy();
resolve({
accessible: false,
statusCode,
error: "Timeout following redirect",
});
});
}
// Check if status is good (200-299 range) and it's actually a file
const contentType = response.headers["content-type"] || "";
const isFile =
contentType.includes("application/zip") ||
contentType.includes("application/gzip") ||
contentType.includes("application/x-gzip") ||
contentType.includes("application/x-tar") ||
url.includes(".zip") ||
url.includes(".tar.gz");
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
response.destroy();
resolve({ accessible: isGood, statusCode, contentType });
});
request.on("error", (error) => {
resolve({
accessible: false,
statusCode: null,
error: error.message,
});
});
request.on("timeout", () => {
request.destroy();
resolve({
accessible: false,
statusCode: null,
error: "Request timeout",
});
});
});
if (result.accessible) {
if (attempt > 0) {
console.log(
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
);
} else {
console.log(
`✓ URL ${url} is accessible (status: ${result.statusCode})`
);
}
return result.finalUrl || url; // Return the final URL (after redirects) if available
} else {
const errorMsg = result.error ? ` - ${result.error}` : "";
const statusMsg = result.statusCode
? ` (status: ${result.statusCode})`
: "";
const contentTypeMsg = result.contentType
? ` [content-type: ${result.contentType}]`
: "";
console.log(
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
);
}
} catch (error) {
console.log(`✗ URL ${url} check failed: ${error.message}`);
}
if (attempt < maxRetries - 1) {
const delay = initialDelay * Math.pow(2, attempt);
console.log(
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error(`URL ${url} is not accessible after ${maxRetries} attempts`);
}
async function downloadFromGitHub(url, outputPath) {
return new Promise((resolve, reject) => {
const request = https.get(url, { timeout: 30000 }, (response) => {
const statusCode = response.statusCode;
// Follow redirects (all redirect types)
if (
statusCode === 301 ||
statusCode === 302 ||
statusCode === 307 ||
statusCode === 308
) {
const redirectUrl = response.headers.location;
response.destroy();
if (!redirectUrl) {
reject(new Error(`Redirect without location header for ${url}`));
return;
}
// Resolve relative redirects
const finalRedirectUrl = redirectUrl.startsWith("http")
? redirectUrl
: new URL(redirectUrl, url).href;
console.log(` Following redirect: ${finalRedirectUrl}`);
return downloadFromGitHub(finalRedirectUrl, outputPath)
.then(resolve)
.catch(reject);
}
if (statusCode !== 200) {
response.destroy();
reject(
new Error(
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
)
);
return;
}
const fileStream = fs.createWriteStream(outputPath);
response.pipe(fileStream);
fileStream.on("finish", () => {
fileStream.close();
resolve();
});
fileStream.on("error", (error) => {
response.destroy();
reject(error);
});
});
request.on("error", reject);
request.on("timeout", () => {
request.destroy();
reject(new Error(`Request timeout for ${url}`));
});
});
}
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(`Waiting for source archives to be available on GitHub...`);
console.log(` ZIP: ${githubZipUrl}`);
console.log(` TAR.GZ: ${githubTarGzUrl}`);
// Wait for archives to be accessible with exponential backoff
// This returns the final URL after following redirects
const finalZipUrl = await checkUrlAccessible(githubZipUrl);
const finalTarGzUrl = await checkUrlAccessible(githubTarGzUrl);
console.log(`Downloading source archives from GitHub...`);
await downloadFromGitHub(finalZipUrl, sourceZipPath);
await downloadFromGitHub(finalTarGzUrl, sourceTarGzPath);
console.log(`Downloaded source archives successfully`);
// Find all artifacts
const artifacts = {
windows: findArtifacts(
path.join(artifactsDir, 'windows-builds'),
/\.exe$/
),
macos: findArtifacts(
path.join(artifactsDir, 'macos-builds'),
/-x64\.dmg$/
),
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
macosArm: findArtifacts(
path.join(artifactsDir, 'macos-builds'),
path.join(artifactsDir, "macos-builds"),
/-arm64\.dmg$/
),
linux: findArtifacts(
path.join(artifactsDir, 'linux-builds'),
path.join(artifactsDir, "linux-builds"),
/\.AppImage$/
),
sourceZip: [sourceZipPath],
sourceTarGz: [sourceTarGzPath],
};
console.log('Found artifacts:');
console.log("Found artifacts:");
for (const [platform, files] of Object.entries(artifacts)) {
console.log(` ${platform}: ${files.length > 0 ? files.map(f => path.basename(f)).join(', ') : 'none'}`);
console.log(
` ${platform}: ${
files.length > 0
? files.map((f) => path.basename(f)).join(", ")
: "none"
}`
);
}
// Upload each artifact to R2
const assets = {};
const contentTypes = {
windows: 'application/x-msdownload',
macos: 'application/x-apple-diskimage',
macosArm: 'application/x-apple-diskimage',
linux: 'application/x-executable',
windows: "application/x-msdownload",
macos: "application/x-apple-diskimage",
macosArm: "application/x-apple-diskimage",
linux: "application/x-executable",
sourceZip: "application/zip",
sourceTarGz: "application/gzip",
};
for (const [platform, files] of Object.entries(artifacts)) {
@@ -107,7 +344,12 @@ async function main() {
url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`,
filename,
size,
arch: platform === 'macosArm' ? 'arm64' : 'x64',
arch:
platform === "macosArm"
? "arm64"
: platform === "sourceZip" || platform === "sourceTarGz"
? "source"
: "x64",
};
}
@@ -118,31 +360,35 @@ async function main() {
version: VERSION,
date: new Date().toISOString(),
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
releasesData.releases = releasesData.releases.filter(r => r.version !== VERSION);
releasesData.releases = releasesData.releases.filter(
(r) => r.version !== VERSION
);
// Prepend new release
releasesData.releases.unshift(newRelease);
releasesData.latestVersion = VERSION;
// Upload updated releases.json
await s3Client.send(new PutObjectCommand({
Bucket: BUCKET,
Key: 'releases.json',
Body: JSON.stringify(releasesData, null, 2),
ContentType: 'application/json',
CacheControl: 'public, max-age=60',
}));
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: "releases.json",
Body: JSON.stringify(releasesData, null, 2),
ContentType: "application/json",
CacheControl: "public, max-age=60",
})
);
console.log('Successfully updated releases.json');
console.log("Successfully updated releases.json");
console.log(`Latest version: ${VERSION}`);
console.log(`Total releases: ${releasesData.releases.length}`);
}
main().catch(err => {
console.error('Failed to upload to R2:', err);
main().catch((err) => {
console.error("Failed to upload to R2:", err);
process.exit(1);
});

View File

@@ -48,6 +48,21 @@ jobs:
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
run: npm install
- name: Extract and set version
id: version
shell: bash
run: |
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION_TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "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)
if: matrix.os == 'macos-latest'
env:
@@ -127,13 +142,25 @@ jobs:
- name: Install AWS SDK
run: npm install @aws-sdk/client-s3
- name: Extract version
id: version
shell: bash
run: |
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${VERSION_TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
- name: Upload to R2 and update releases.json
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
RELEASE_VERSION: ${{ github.event.inputs.version || github.ref_name }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: node .github/scripts/upload-to-r2.js

1
.gitignore vendored
View File

@@ -7,7 +7,6 @@ node_modules/
# Build outputs
dist/
.next/
node_modules
.automaker/images/
.automaker/
/.automaker/*

View File

@@ -19,9 +19,11 @@ While we have made efforts to review this codebase for security vulnerabilities
## Recommendations
### 1. Review the Code First
Before running Automaker, we strongly recommend reviewing the source code yourself to understand what operations it performs and ensure you are comfortable with its behavior.
### 2. Use Sandboxing (Highly Recommended)
**We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Instead, consider:
- **Docker**: Run Automaker in a Docker container to isolate it from your host system
@@ -29,20 +31,25 @@ Before running Automaker, we strongly recommend reviewing the source code yourse
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
### 3. Limit Access
If you must run locally:
- Create a dedicated user account with limited permissions
- Only grant access to specific project directories
- Avoid running with administrator/root privileges
- Keep sensitive files and credentials outside of project directories
### 4. Monitor Activity
- Review the agent's actions in the output logs
- Pay attention to file modifications and command executions
- Stop the agent immediately if you notice unexpected behavior
## No Warranty
## No Warranty & Limitation of Liability
This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
This software is provided "as is", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, including but not limited to hardware damage, data loss, financial loss, or business interruption, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
## Acknowledgment

236
LICENSE
View File

@@ -1,208 +1,116 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
AUTOMAKER LICENSE AGREEMENT
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
This License Agreement ("Agreement") is entered into between you ("Licensee") and the copyright holders of Automaker ("Licensor"). By using, copying, modifying, downloading, cloning, or distributing the Software (as defined below), you agree to be bound by the terms of this Agreement.
Preamble
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
1. DEFINITIONS
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
"Software" means the Automaker software, including all source code, object code, documentation, and related materials.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
"Generated Files" means files created by the Software during normal operation to store internal state, configuration, or working data, including but not limited to app_spec.txt, feature.json, and similar files generated by the Software. Generated Files are not considered part of the Software for the purposes of this license and are not subject to the restrictions herein.
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
"Derivative Work" means any work that is based on, derived from, or incorporates the Software or any substantial portion of it, including but not limited to modifications, forks, adaptations, translations, or any altered version of the Software.
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
"Monetization" means any activity that generates revenue, income, or commercial benefit from the Software itself or any Derivative Work, including but not limited to:
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
- Reselling, redistributing, or sublicensing the Software, any Derivative Work, or any substantial portion thereof
- Including the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
- Offering the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
- Hosting the Software or any Derivative Work as a service (whether free or paid) for use by others, including cloud hosting, Software-as-a-Service (SaaS), or any other form of hosted access for third parties
- Extracting, reselling, redistributing, or sublicensing any prompts, context, or other instructional content bundled within the Software
- Creating, distributing, or selling modified versions, forks, or Derivative Works of the Software
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
Monetization does NOT include:
The precise terms and conditions for copying, distribution and modification follow.
- Using the Software internally within your organization, regardless of whether your organization is for-profit
- Using the Software to build products or services that generate revenue, as long as you are not reselling or redistributing the Software itself
- Using the Software to provide services for which fees are charged, as long as the Software itself is not being resold or redistributed
- Hosting the Software anywhere for personal use by a single developer, as long as the Software is not made accessible to others
TERMS AND CONDITIONS 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Core Contributors" means the following individuals who are granted perpetual, royalty-free licenses:
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
- Cody Seibert (webdevcody)
- SuperComboGamer (SCG)
- Kacper Lachowicz (Shironex, Shirone)
- Ben Scott (trueheads)
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
2. GRANT OF LICENSE
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable license to use, copy, modify, and distribute the Software, provided that:
A "covered work" means either the unmodified Program or a work based on the Program.
a) Licensee may freely clone, install, and use the Software locally or within an organization for the purpose of building, developing, and maintaining other products, software, or services. There are no restrictions on the products you build _using_ the Software.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
b) Licensee may run the Software on personal or organizational infrastructure for internal use.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
3. RESTRICTIONS
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
Licensee may NOT:
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
- Engage in any Monetization of the Software or any Derivative Work without explicit written permission from all Core Contributors
- Resell, redistribute, or sublicense the Software, any Derivative Work, or any substantial portion thereof
- Create, distribute, or sell modified versions, forks, or Derivative Works of the Software for any commercial purpose
- Include the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
- Offer the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
- Extract, resell, redistribute, or sublicense any prompts, context, or other instructional content bundled within the Software
- Host the Software or any Derivative Work as a service (whether free or paid) for use by others (except Core Contributors)
- Remove or alter any copyright notices or license terms
- Use the Software in any manner that violates applicable laws or regulations
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
Licensee MAY:
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
- Use the Software internally within their organization (commercial or non-profit)
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
- Modify the Software for internal use within their organization (commercial or non-profit)
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
4. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
The Corresponding Source for a work in source code form is that same work.
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
a) **Full Ownership Transfer & Rights Grant:** You hereby assign to the Core Contributors all right, title, and interest in and to your Contributions, including all copyrights, patents, and other intellectual property rights. If such assignment is not effective under applicable law, you grant the Core Contributors an unrestricted, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, irrevocable, sublicensable, and transferable license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute, perform, display, and otherwise exploit your Contributions in any manner they see fit, including for any commercial purpose or Monetization.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
b) **No Take-Backs:** You understand and agree that this grant of rights is irrevocable ("no take-backs"). You cannot revoke, rescind, or terminate this grant of rights once your Contribution has been submitted.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with respect to attribution of authorship or integrity of materials regarding your Contributions that you may have under any applicable law.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
5. TERMINATION
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
6. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
b) **USE AT YOUR OWN RISK:** YOU AGREE THAT YOUR USE OF THE SOFTWARE IS SOLELY AT YOUR OWN RISK. THE CORE CONTRIBUTORS AND LICENSOR DO NOT GUARANTEE THAT THE SOFTWARE OR ANY CODE GENERATED BY IT WILL BE SAFE, BUG-FREE, OR FUNCTIONAL.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
c) **NO WARRANTY:** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, INCLUDING BUT NOT LIMITED TO:
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
- DAMAGE TO HARDWARE OR COMPUTER SYSTEMS
- DATA LOSS OR CORRUPTION
- GENERATION OF BAD, VULNERABLE, OR MALICIOUS CODE
- FINANCIAL LOSSES
- BUSINESS INTERRUPTION
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
7. CONTACT
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
- Agentic Jumpstart Discord: https://discord.gg/JUDWZDN3VT
- Website: https://automaker.app
- Email: automakerapp@gmail.com
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
8. GOVERNING LAW
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
By using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions.
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
---
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <https://www.gnu.org/licenses/>.
Copyright (c) 2025 Automaker Core Contributors

View File

@@ -170,4 +170,27 @@ To learn more about Next.js, take a look at the following resources:
## License
See [LICENSE](../LICENSE) for details.
This project is licensed under the **Automaker License Agreement**. See [LICENSE](LICENSE) for the full text.
**Summary of Terms:**
- **Allowed:**
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
- **Restricted (The "No Monetization of the Tool" Rule):**
- **No Resale:** You cannot resell Automaker itself.
- **No SaaS:** You cannot host Automaker as a service for others.
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
- **Liability:**
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
- **Contributing:**
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).
**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.

View File

@@ -7,6 +7,7 @@
const path = require("path");
const { spawn } = require("child_process");
const fs = require("fs");
// Load environment variables from .env file
require("dotenv").config({ path: path.join(__dirname, "../.env") });
@@ -30,10 +31,39 @@ function getIconPath() {
async function startServer() {
const isDev = !app.isPackaged;
// Server entry point
const serverPath = isDev
? path.join(__dirname, "../../server/dist/index.js")
: path.join(process.resourcesPath, "server", "index.js");
// Server entry point - use tsx in dev, compiled version in production
let command, args, serverPath;
if (isDev) {
// In development, use tsx to run TypeScript directly
// Use the node executable that's running Electron
command = process.execPath; // This is the path to node.exe
serverPath = path.join(__dirname, "../../server/src/index.ts");
// Find tsx CLI - check server node_modules first, then root
const serverNodeModules = path.join(__dirname, "../../server/node_modules/tsx");
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
let tsxCliPath;
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
} else {
// Last resort: try require.resolve
try {
tsxCliPath = require.resolve("tsx/cli.mjs", { paths: [path.join(__dirname, "../../server")] });
} catch {
throw new Error("Could not find tsx. Please run 'npm install' in the server directory.");
}
}
args = [tsxCliPath, "watch", serverPath];
} else {
// In production, use compiled JavaScript
command = "node";
serverPath = path.join(process.resourcesPath, "server", "index.js");
args = [serverPath];
}
// Set environment variables for server
const env = {
@@ -44,7 +74,7 @@ async function startServer() {
console.log("[Electron] Starting backend server...");
serverProcess = spawn("node", [serverPath], {
serverProcess = spawn(command, args, {
env,
stdio: ["ignore", "pipe", "pipe"],
});

View File

@@ -42,6 +42,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -52,6 +55,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"

View File

@@ -12,6 +12,7 @@
@custom-variant catppuccin (&:is(.catppuccin *));
@custom-variant onedark (&:is(.onedark *));
@custom-variant synthwave (&:is(.synthwave *));
@custom-variant red (&:is(.red *));
@theme inline {
--color-background: var(--background);
@@ -1072,6 +1073,75 @@
--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 {
* {
@apply border-border outline-ring/50;
@@ -1107,12 +1177,12 @@
}
/* Custom scrollbar for dark themes */
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave) ::-webkit-scrollbar {
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave) ::-webkit-scrollbar-track {
:is(.dark, .retro, .dracula, .nord, .monokai, .tokyonight, .solarized, .gruvbox, .catppuccin, .onedark, .synthwave, .red) ::-webkit-scrollbar-track {
background: var(--muted);
}
@@ -1134,6 +1204,20 @@
background: var(--background);
}
/* Red theme scrollbar */
.red ::-webkit-scrollbar-thumb {
background: oklch(0.35 0.15 25);
border-radius: 4px;
}
.red ::-webkit-scrollbar-thumb:hover {
background: oklch(0.45 0.18 25);
}
.red ::-webkit-scrollbar-track {
background: oklch(0.15 0.05 25);
}
/* Always visible scrollbar for file diffs and code blocks */
.scrollbar-visible {
overflow-y: auto !important;
@@ -1327,6 +1411,39 @@
.text-running-indicator {
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 */

View File

@@ -12,10 +12,16 @@ import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { SetupView } from "@/components/views/setup-view";
import { RunningAgentsView } from "@/components/views/running-agents-view";
import { TerminalView } from "@/components/views/terminal-view";
import { WikiView } from "@/components/views/wiki-view";
import { useAppStore } from "@/store/app-store";
import { useSetupStore } from "@/store/setup-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context";
import {
FileBrowserProvider,
useFileBrowser,
setGlobalFileBrowser,
} from "@/contexts/file-browser-context";
function HomeContent() {
const {
@@ -24,6 +30,8 @@ function HomeContent() {
setIpcConnected,
theme,
currentProject,
previewTheme,
getEffectiveTheme,
} = useAppStore();
const { isFirstRun, setupComplete } = useSetupStore();
const [isMounted, setIsMounted] = useState(false);
@@ -72,9 +80,9 @@ function HomeContent() {
};
}, [handleStreamerPanelShortcut]);
// Compute the effective theme: project theme takes priority over global theme
// This is reactive because it depends on currentProject and theme from the store
const effectiveTheme = currentProject?.theme || theme;
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
const effectiveTheme = getEffectiveTheme();
// Prevent hydration issues
useEffect(() => {
@@ -122,7 +130,7 @@ function HomeContent() {
testConnection();
}, [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(() => {
const root = document.documentElement;
root.classList.remove(
@@ -137,7 +145,8 @@ function HomeContent() {
"gruvbox",
"catppuccin",
"onedark",
"synthwave"
"synthwave",
"red"
);
if (effectiveTheme === "dark") {
@@ -162,6 +171,8 @@ function HomeContent() {
root.classList.add("onedark");
} else if (effectiveTheme === "synthwave") {
root.classList.add("synthwave");
} else if (effectiveTheme === "red") {
root.classList.add("red");
} else if (effectiveTheme === "light") {
root.classList.add("light");
} else if (effectiveTheme === "system") {
@@ -173,7 +184,7 @@ function HomeContent() {
root.classList.add("light");
}
}
}, [effectiveTheme]);
}, [effectiveTheme, previewTheme, currentProject, theme]);
const renderView = () => {
switch (currentView) {
@@ -197,6 +208,10 @@ function HomeContent() {
return <ProfilesView />;
case "running-agents":
return <RunningAgentsView />;
case "terminal":
return <TerminalView />;
case "wiki":
return <WikiView />;
default:
return <WelcomeView />;
}

View 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>
);
}

View File

@@ -2,8 +2,9 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
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 { IS_MARKETING } from "@/config/app-config";
import {
FolderOpen,
Plus,
@@ -26,22 +27,14 @@ import {
UserCircle,
MoreVertical,
Palette,
Moon,
Sun,
Terminal,
Ghost,
Snowflake,
Flame,
Sparkles as TokyoNightIcon,
Eclipse,
Trees,
Cat,
Atom,
Radio,
Monitor,
Search,
Bug,
Activity,
Recycle,
Sparkles,
Loader2,
Terminal,
} from "lucide-react";
import {
DropdownMenu,
@@ -70,16 +63,22 @@ import {
useKeyboardShortcutsConfig,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI, Project, TrashedProject } from "@/lib/electron";
import {
getElectronAPI,
Project,
TrashedProject,
RunningAgent,
} from "@/lib/electron";
import {
initializeProject,
hasAppSpec,
hasAutomakerDir,
} from "@/lib/project-init";
import { toast } from "sonner";
import { Sparkles, Loader2 } from "lucide-react";
import { themeOptions } from "@/config/theme-options";
import { Checkbox } from "@/components/ui/checkbox";
import type { SpecRegenerationEvent } from "@/types/electron";
import { DeleteProjectDialog } from "@/components/views/settings-view/components/delete-project-dialog";
import {
DndContext,
DragEndEvent,
@@ -173,21 +172,14 @@ function SortableProjectItem({
);
}
// Theme options for project theme selector
// Theme options for project theme selector - derived from the shared config
const PROJECT_THEME_OPTIONS = [
{ value: "", label: "Use Global", icon: Monitor },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "light", label: "Light", icon: Sun },
{ value: "retro", label: "Retro", icon: Terminal },
{ value: "dracula", label: "Dracula", icon: Ghost },
{ 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 },
...themeOptions.map((opt) => ({
value: opt.value,
label: opt.label,
icon: opt.Icon,
})),
] as const;
export function Sidebar() {
@@ -198,7 +190,7 @@ export function Sidebar() {
currentView,
sidebarOpen,
projectHistory,
addProject,
upsertAndSetCurrentProject,
setCurrentProject,
setCurrentView,
toggleSidebar,
@@ -211,7 +203,9 @@ export function Sidebar() {
clearProjectHistory,
setProjectTheme,
setTheme,
setPreviewTheme,
theme: globalTheme,
moveProjectToTrash,
} = useAppStore();
// Get customizable keyboard shortcuts
@@ -225,6 +219,12 @@ export function Sidebar() {
const [activeTrashId, setActiveTrashId] = useState<string | null>(null);
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
const [showSetupDialog, setShowSetupDialog] = useState(false);
const [setupProjectPath, setSetupProjectPath] = useState("");
@@ -334,6 +334,49 @@ export function Sidebar() {
};
}, [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
const handleCreateInitialSpec = useCallback(async () => {
if (!setupProjectPath || !projectOverview.trim()) return;
@@ -414,38 +457,14 @@ export function Sidebar() {
return;
}
// Check if project already exists (by path) to preserve theme and other settings
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 (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);
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -479,7 +498,12 @@ export function Sidebar() {
});
}
}
}, [projects, trashedProjects, addProject, setCurrentProject, currentProject, globalTheme]);
}, [
trashedProjects,
upsertAndSetCurrentProject,
currentProject,
globalTheme,
]);
const handleRestoreProject = useCallback(
(projectId: string) => {
@@ -534,14 +558,14 @@ export function Sidebar() {
}
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;
setIsEmptyingTrash(true);
try {
emptyTrash();
toast.success("Trash cleared");
toast.success("Recycle bin cleared");
setShowTrashDialog(false);
} finally {
setIsEmptyingTrash(false);
@@ -587,6 +611,12 @@ export function Sidebar() {
icon: UserCircle,
shortcut: shortcuts.profiles,
},
{
id: "terminal",
label: "Terminal",
icon: Terminal,
shortcut: shortcuts.terminal,
},
],
},
];
@@ -761,7 +791,9 @@ export function Sidebar() {
<div
className={cn(
"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
@@ -785,14 +817,24 @@ export function Sidebar() {
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>
</div>
{/* Bug Report Button */}
<button
onClick={() => {
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"
title="Report Bug / Feature Request"
@@ -830,10 +872,10 @@ export function Sidebar() {
<button
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"
title="Trash"
title="Recycle Bin"
data-testid="trash-button"
>
<Trash2 className="size-4 shrink-0" />
<Recycle className="size-4 shrink-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">
{trashedProjects.length > 9 ? "9+" : trashedProjects.length}
@@ -934,7 +976,14 @@ export function Sidebar() {
{/* Project Options Menu - theme and history */}
{currentProject && (
<DropdownMenu>
<DropdownMenu
onOpenChange={(open) => {
// Clear preview theme when the menu closes
if (!open) {
setPreviewTheme(null);
}
}}
>
<DropdownMenuTrigger asChild>
<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"
@@ -957,8 +1006,12 @@ export function Sidebar() {
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className="w-48"
className="w-56"
data-testid="project-theme-menu"
onPointerLeave={() => {
// Clear preview theme when leaving the dropdown
setPreviewTheme(null);
}}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Select theme for this project
@@ -968,9 +1021,14 @@ export function Sidebar() {
value={currentProject.theme || ""}
onValueChange={(value) => {
if (currentProject) {
// Clear preview theme when a theme is selected
setPreviewTheme(null);
// If selecting an actual theme (not "Use Global"), also update global
if (value !== "") {
setTheme(value as any);
} else {
// Restore to global theme when "Use Global" is selected
setTheme(globalTheme);
}
setProjectTheme(
currentProject.id,
@@ -981,22 +1039,54 @@ export function Sidebar() {
>
{PROJECT_THEME_OPTIONS.map((option) => {
const Icon = option.icon;
const themeValue =
option.value === "" ? globalTheme : option.value;
return (
<DropdownMenuRadioItem
<div
key={option.value}
value={option.value}
data-testid={`project-theme-${
option.value || "global"
}`}
onPointerEnter={() => {
// Preview the theme on hover
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" />
<span>{option.label}</span>
{option.value === "" && (
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={option.value}
data-testid={`project-theme-${
option.value || "global"
}`}
onFocus={() => {
// Preview the theme on keyboard navigation
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>
@@ -1039,6 +1129,17 @@ export function Sidebar() {
</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>
</DropdownMenu>
)}
@@ -1146,6 +1247,46 @@ export function Sidebar() {
<div className="border-t border-sidebar-border bg-sidebar-accent/10 shrink-0">
{/* Course Promo Badge */}
<CoursePromoBadge sidebarOpen={sidebarOpen} />
{/* Wiki Link */}
<div className="p-2 pb-0">
<button
onClick={() => setCurrentView("wiki")}
className={cn(
"group flex items-center w-full px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all titlebar-no-drag",
isActiveRoute("wiki")
? "bg-sidebar-accent/50 text-foreground border border-sidebar-border"
: "text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50",
sidebarOpen ? "justify-start" : "justify-center"
)}
title={!sidebarOpen ? "Wiki" : undefined}
data-testid="wiki-link"
>
{isActiveRoute("wiki") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<BookOpen
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("wiki")
? "text-brand-500"
: "group-hover:text-brand-400"
)}
/>
<span
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
sidebarOpen ? "hidden lg:block" : "hidden"
)}
>
Wiki
</span>
{!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">
Wiki
</span>
)}
</button>
</div>
{/* Running Agents Link */}
<div className="p-2 pb-0">
<button
@@ -1163,14 +1304,25 @@ export function Sidebar() {
{isActiveRoute("running-agents") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "text-brand-500"
: "group-hover:text-brand-400"
<div className="relative">
<Activity
className={cn(
"w-4 h-4 shrink-0 transition-colors",
isActiveRoute("running-agents")
? "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
className={cn(
"ml-2.5 font-medium text-sm flex-1 text-left",
@@ -1179,6 +1331,18 @@ export function Sidebar() {
>
Running Agents
</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 && (
<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
@@ -1242,7 +1406,7 @@ export function Sidebar() {
<Dialog open={showTrashDialog} onOpenChange={setShowTrashDialog}>
<DialogContent className="bg-popover border-border max-w-2xl">
<DialogHeader>
<DialogTitle>Trash</DialogTitle>
<DialogTitle>Recycle Bin</DialogTitle>
<DialogDescription className="text-muted-foreground">
Restore projects to the sidebar or delete their folders using your
system Trash.
@@ -1250,7 +1414,9 @@ export function Sidebar() {
</DialogHeader>
{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">
{trashedProjects.map((project) => (
@@ -1318,7 +1484,7 @@ export function Sidebar() {
disabled={isEmptyingTrash}
data-testid="empty-trash"
>
{isEmptyingTrash ? "Clearing..." : "Empty Trash"}
{isEmptyingTrash ? "Clearing..." : "Empty Recycle Bin"}
</Button>
)}
</DialogFooter>
@@ -1421,6 +1587,14 @@ export function Sidebar() {
</button>
</div>
)}
{/* Delete Project Confirmation Dialog */}
<DeleteProjectDialog
open={showDeleteProjectDialog}
onOpenChange={setShowDeleteProjectDialog}
project={currentProject}
onConfirm={moveProjectToTrash}
/>
</aside>
);
}

View 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>
);
}

View File

@@ -90,6 +90,7 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
context: "Context",
settings: "Settings",
profiles: "AI Profiles",
terminal: "Terminal",
toggleSidebar: "Toggle Sidebar",
addFeature: "Add Feature",
addContextFile: "Add Context File",
@@ -100,6 +101,9 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
cyclePrevProject: "Prev Project",
cycleNextProject: "Next Project",
addProfile: "Add Profile",
splitTerminalRight: "Split Right",
splitTerminalDown: "Split Down",
closeTerminal: "Close Terminal",
};
// Categorize shortcuts for color coding
@@ -110,6 +114,7 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
context: "navigation",
settings: "navigation",
profiles: "navigation",
terminal: "navigation",
toggleSidebar: "ui",
addFeature: "action",
addContextFile: "action",
@@ -120,6 +125,9 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" |
cyclePrevProject: "action",
cycleNextProject: "action",
addProfile: "action",
splitTerminalRight: "action",
splitTerminalDown: "action",
closeTerminal: "action",
};
// Category colors
@@ -153,11 +161,18 @@ interface KeyboardMapProps {
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
const { keyboardShortcuts } = useAppStore();
// Merge with defaults to ensure new shortcuts are always shown
const mergedShortcuts = React.useMemo(() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}), [keyboardShortcuts]);
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
const keyToShortcuts = React.useMemo(() => {
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
(Object.entries(mergedShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
([shortcutName, shortcutStr]) => {
if (!shortcutStr) return; // Skip undefined shortcuts
const parsed = parseShortcut(shortcutStr);
const normalizedKey = parsed.key.toUpperCase();
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
@@ -168,7 +183,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
}
);
return map;
}, [keyboardShortcuts]);
}, [mergedShortcuts]);
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
const normalizedKey = keyDef.key.toUpperCase();
@@ -177,7 +192,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
const isBound = shortcuts.length > 0;
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
const isModified = shortcuts.some(
(s) => keyboardShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
(s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
);
// Get category for coloring (use first shortcut's category if multiple)
@@ -223,7 +238,7 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
>
{isBound && shortcuts.length > 0
? (shortcuts.length === 1
? SHORTCUT_LABELS[shortcuts[0]].split(" ")[0]
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
: `${shortcuts.length}x`)
: "\u00A0" // Non-breaking space to maintain height
}
@@ -242,21 +257,23 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
{shortcuts.map((shortcut) => {
const shortcutStr = keyboardShortcuts[shortcut];
const shortcutStr = mergedShortcuts[shortcut];
const displayShortcut = formatShortcut(shortcutStr, true);
return (
<div key={shortcut} className="flex items-center gap-2">
<span
className={cn(
"w-2 h-2 rounded-full",
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
: "bg-muted-foreground"
)}
/>
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
{displayShortcut}
</kbd>
{keyboardShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
{mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
<span className="text-xs text-yellow-400">(custom)</span>
)}
</div>
@@ -343,6 +360,12 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
// Merge with defaults to ensure new shortcuts are always shown
const mergedShortcuts = React.useMemo(() => ({
...DEFAULT_KEYBOARD_SHORTCUTS,
...keyboardShortcuts,
}), [keyboardShortcuts]);
const groupedShortcuts = React.useMemo(() => {
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
navigation: [],
@@ -354,14 +377,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
([shortcut, category]) => {
groups[category].push({
key: shortcut,
label: SHORTCUT_LABELS[shortcut],
value: keyboardShortcuts[shortcut],
label: SHORTCUT_LABELS[shortcut] ?? shortcut,
value: mergedShortcuts[shortcut],
});
}
);
return groups;
}, [keyboardShortcuts]);
}, [mergedShortcuts]);
// Build the full shortcut string from key + modifiers
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
@@ -375,14 +398,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
// Check for conflicts with other shortcuts
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
const conflict = Object.entries(keyboardShortcuts).find(
([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase()
const conflict = Object.entries(mergedShortcuts).find(
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
);
return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null;
}, [keyboardShortcuts]);
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
}, [mergedShortcuts]);
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
const currentValue = keyboardShortcuts[key];
const currentValue = mergedShortcuts[key];
const parsed = parseShortcut(currentValue);
setEditingShortcut(key);
setKeyValue(parsed.key);
@@ -485,7 +508,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
</h4>
<div className="grid grid-cols-2 gap-2">
{shortcuts.map(({ key, label, value }) => {
const isModified = keyboardShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
const isEditing = editingShortcut === key;
return (

View File

@@ -24,6 +24,7 @@ import {
AgentModel,
ThinkingLevel,
AIProfile,
defaultBackgroundSettings,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn, modelSupportsThinking } from "@/lib/utils";
@@ -58,6 +59,7 @@ import { KanbanColumn } from "./kanban-column";
import { KanbanCard } from "./kanban-card";
import { AgentOutputModal } from "./agent-output-modal";
import { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
import { BoardBackgroundModal } from "@/components/dialogs/board-background-modal";
import {
Plus,
RefreshCw,
@@ -86,6 +88,7 @@ import {
Square,
Maximize2,
Shuffle,
ImageIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Slider } from "@/components/ui/slider";
@@ -206,6 +209,7 @@ export function BoardView() {
aiProfiles,
kanbanCardDetailLevel,
setKanbanCardDetailLevel,
boardBackgroundByProject,
} = useAppStore();
const shortcuts = useKeyboardShortcutsConfig();
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
@@ -230,6 +234,8 @@ export function BoardView() {
);
const [showDeleteAllVerifiedDialog, setShowDeleteAllVerifiedDialog] =
useState(false);
const [showBoardBackgroundModal, setShowBoardBackgroundModal] =
useState(false);
const [persistedCategories, setPersistedCategories] = useState<string[]>([]);
const [showFollowUpDialog, setShowFollowUpDialog] = useState(false);
const [followUpFeature, setFollowUpFeature] = useState<Feature | null>(null);
@@ -400,7 +406,8 @@ export function BoardView() {
const currentPath = currentProject.path;
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)
const cachedFeatures = useAppStore.getState().features;
@@ -556,7 +563,8 @@ export function BoardView() {
const unsubscribe = api.autoMode.onEvent((event) => {
// Use event's projectPath or projectId if available, otherwise use current 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") {
// Reload features when a feature is completed
@@ -585,15 +593,16 @@ export function BoardView() {
loadFeatures();
// Check for authentication errors and show a more helpful message
const isAuthError = event.errorType === "authentication" ||
(event.error && (
event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")
));
const isAuthError =
event.errorType === "authentication" ||
(event.error &&
(event.error.includes("Authentication failed") ||
event.error.includes("Invalid API key")));
if (isAuthError) {
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,
});
} else {
@@ -867,7 +876,11 @@ export function BoardView() {
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === "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", {
description: `Manually verified: ${draggedFeature.description.slice(
0,
@@ -877,7 +890,11 @@ export function BoardView() {
} else if (targetStatus === "backlog") {
// Allow moving waiting_approval cards back to 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", {
description: `Moved to Backlog: ${draggedFeature.description.slice(
0,
@@ -1198,7 +1215,11 @@ export function BoardView() {
description: feature.description,
});
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", {
description: `Marked as verified: ${feature.description.slice(0, 50)}${
feature.description.length > 50 ? "..." : ""
@@ -1264,9 +1285,11 @@ export function BoardView() {
}
// Move feature back to in_progress before sending follow-up
// Clear justFinishedAt timestamp since user is now interacting with it
const updates = {
status: "in_progress" as const,
startedAt: new Date().toISOString(),
justFinishedAt: undefined,
};
updateFeature(featureId, updates);
persistFeatureUpdate(featureId, updates);
@@ -1626,7 +1649,7 @@ export function BoardView() {
return;
}
const featuresToStart = backlogFeatures.slice(0, availableSlots);
const featuresToStart = backlogFeatures.slice(0, 1);
for (const feature of featuresToStart) {
// Update the feature status with startedAt timestamp
@@ -1835,202 +1858,282 @@ export function BoardView() {
)}
</div>
{/* Kanban Card Detail Level Toggle */}
{/* Board Background & Detail Level Controls */}
{isMounted && (
<TooltipProvider>
<div
className="flex items-center rounded-lg bg-secondary border border-border ml-4"
data-testid="kanban-detail-toggle"
>
<div className="flex items-center gap-2 ml-4">
{/* Board Background Button */}
<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"
<Button
variant="outline"
size="sm"
onClick={() => setShowBoardBackgroundModal(true)}
className="h-8 px-2"
data-testid="board-background-button"
>
<Minimize2 className="w-4 h-4" />
</button>
<ImageIcon 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>
<p>Board Background Settings</p>
</TooltipContent>
</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>
</TooltipProvider>
)}
</div>
{/* Kanban Columns */}
<div className="flex-1 overflow-x-auto px-4 pb-4">
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full min-w-max">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
color={column.color}
count={columnFeatures.length}
headerAction={
column.id === "verified" && columnFeatures.length > 0 ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => setShowDeleteAllVerifiedDialog(true)}
data-testid="delete-all-verified-button"
>
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
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
{(() => {
// Get background settings for current project
const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) ||
defaultBackgroundSettings;
// Build background image style if image exists
const backgroundImageStyle = backgroundSettings.imagePath
? {
backgroundImage: `url(${
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"
}/api/fs/image?path=${encodeURIComponent(
backgroundSettings.imagePath
)}&projectPath=${encodeURIComponent(
currentProject?.path || ""
)}${
backgroundSettings.imageVersion
? `&v=${backgroundSettings.imageVersion}`
: ""
})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}
: {};
return (
<div
className="flex-1 overflow-x-auto px-4 pb-4 relative"
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex gap-4 h-full min-w-max">
{COLUMNS.map((column) => {
const columnFeatures = getColumnFeatures(column.id);
return (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
color={column.color}
count={columnFeatures.length}
opacity={backgroundSettings.columnOpacity}
showBorder={backgroundSettings.columnBorderEnabled}
hideScrollbar={backgroundSettings.hideScrollbar}
headerAction={
column.id === "verified" &&
columnFeatures.length > 0 ? (
<Button
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"
className="h-6 px-2 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() =>
setShowDeleteAllVerifiedDialog(true)
}
data-testid="delete-all-verified-button"
>
<FastForward className="w-3 h-3 mr-1" />
Pull Top
</HotkeyButton>
)}
</div>
) : undefined
}
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey = index === 9 ? "0" : String(index + 1);
<Trash2 className="w-3 h-3 mr-1" />
Delete All
</Button>
) : column.id === "backlog" ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 relative"
onClick={() => setShowSuggestionsDialog(true)}
title="Feature Suggestions"
data-testid="feature-suggestions-button"
>
<Lightbulb className="w-3.5 h-3.5" />
{suggestionsCount > 0 && (
<span
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
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
onForceStop={() => handleForceStopFeature(feature)}
onManualVerify={() => handleManualVerify(feature)}
onMoveBackToInProgress={() =>
handleMoveBackToInProgress(feature)
>
<SortableContext
items={columnFeatures.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{columnFeatures.map((feature, index) => {
// Calculate shortcut key for in-progress cards (first 10 get 1-9, 0)
let shortcutKey: string | undefined;
if (column.id === "in_progress" && index < 10) {
shortcutKey =
index === 9 ? "0" : String(index + 1);
}
onFollowUp={() => handleOpenFollowUp(feature)}
onCommit={() => handleCommitFeature(feature)}
onRevert={() => handleRevertFeature(feature)}
onMerge={() => handleMergeFeature(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(
feature.id
)}
shortcutKey={shortcutKey}
/>
);
})}
</SortableContext>
</KanbanColumn>
);
})}
</div>
return (
<KanbanCard
key={feature.id}
feature={feature}
onEdit={() => setEditingFeature(feature)}
onDelete={() => handleDeleteFeature(feature.id)}
onViewOutput={() => handleViewOutput(feature)}
onVerify={() => handleVerifyFeature(feature)}
onResume={() => handleResumeFeature(feature)}
onForceStop={() =>
handleForceStopFeature(feature)
}
onManualVerify={() =>
handleManualVerify(feature)
}
onMoveBackToInProgress={() =>
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>
{activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
<CardHeader className="p-3">
<CardTitle className="text-sm">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
<DragOverlay>
{activeFeature && (
<Card className="w-72 opacity-90 rotate-3 shadow-xl">
<CardHeader className="p-3">
<CardTitle className="text-sm">
{activeFeature.description}
</CardTitle>
<CardDescription className="text-xs">
{activeFeature.category}
</CardDescription>
</CardHeader>
</Card>
)}
</DragOverlay>
</DndContext>
</div>
);
})()}
</div>
{/* Board Background Modal */}
<BoardBackgroundModal
open={showBoardBackgroundModal}
onOpenChange={setShowBoardBackgroundModal}
/>
{/* Add Feature Dialog */}
<Dialog
open={showAddDialog}

View File

@@ -18,6 +18,7 @@ import {
import { cn } from "@/lib/utils";
import { getElectronAPI } from "@/lib/electron";
import { Markdown } from "@/components/ui/markdown";
import { useFileBrowser } from "@/contexts/file-browser-context";
interface InterviewMessage {
id: string;
@@ -65,6 +66,7 @@ const INTERVIEW_QUESTIONS = [
export function InterviewView() {
const { setCurrentView, addProject, setCurrentProject, setAppSpec } =
useAppStore();
const { openFileBrowser } = useFileBrowser();
const [input, setInput] = useState("");
const [messages, setMessages] = useState<InterviewMessage[]>([]);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@@ -286,11 +288,13 @@ export function InterviewView() {
};
const handleSelectDirectory = async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
const selectedPath = await openFileBrowser({
title: "Select Base Directory",
description: "Choose the parent directory where your new project will be created",
});
if (!result.canceled && result.filePaths[0]) {
setProjectPath(result.filePaths[0]);
if (selectedPath) {
setProjectPath(selectedPath);
}
};

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, memo } from "react";
import { useState, useEffect, useMemo, memo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
@@ -28,7 +28,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Feature, useAppStore } from "@/store/app-store";
import { Feature, useAppStore, ThinkingLevel } from "@/store/app-store";
import {
GripVertical,
Edit,
@@ -56,6 +56,7 @@ import {
GitMerge,
ChevronDown,
ChevronUp,
Brain,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -73,6 +74,21 @@ import {
TooltipTrigger,
} 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 {
feature: Feature;
onEdit: () => void;
@@ -94,6 +110,14 @@ interface KanbanCardProps {
contextContent?: string;
/** Feature summary from agent completion */
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({
@@ -115,12 +139,17 @@ export const KanbanCard = memo(function KanbanCard({
shortcutKey,
contextContent,
summary,
opacity = 100,
glassmorphism = true,
cardBorderEnabled = true,
cardBorderOpacity = 100,
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(() => Date.now());
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
@@ -132,6 +161,43 @@ export const KanbanCard = memo(function KanbanCard({
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
useEffect(() => {
const loadContext = async () => {
@@ -168,11 +234,11 @@ export const KanbanCard = memo(function KanbanCard({
} else {
// Fallback to direct file read for backward compatibility
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) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
if (result.success && result.content) {
const info = parseAgentContext(result.content);
setAgentInfo(info);
}
}
} catch {
@@ -225,17 +291,48 @@ export const KanbanCard = memo(function KanbanCard({
const style = {
transform: CSS.Transform.toString(transform),
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
ref={setNodeRef}
style={style}
style={isCurrentAutoTask ? style : borderStyle}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content select-none",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
"cursor-grab active:cursor-grabbing transition-all relative kanban-card-content select-none",
// Apply border class when border is enabled and opacity is 100%
// When opacity is not 100%, we use inline styles for border color
// 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 &&
!isCurrentAutoTask &&
"border-red-500 border-2 shadow-red-500/30 shadow-lg",
@@ -246,6 +343,16 @@ export const KanbanCard = memo(function KanbanCard({
{...attributes}
{...(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 */}
{feature.skipTests && !feature.error && (
<div
@@ -276,6 +383,21 @@ export const KanbanCard = memo(function KanbanCard({
<span>Errored</span>
</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 */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
@@ -285,19 +407,23 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"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",
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
// Position below other badges if present, otherwise use normal position
feature.error || feature.skipTests || isJustFinished
? "top-8 left-2"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<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>
</TooltipTrigger>
<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>
</Tooltip>
</TooltipProvider>
@@ -306,14 +432,19 @@ export const KanbanCard = memo(function KanbanCard({
className={cn(
"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
(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
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
hasWorktree &&
(feature.skipTests || feature.error || isJustFinished) &&
"pt-14"
)}
>
{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">
<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 && (
<CountUpTimer
startedAt={feature.startedAt}
@@ -437,7 +568,9 @@ export const KanbanCard = memo(function KanbanCard({
) : (
<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>
))}
{feature.steps.length > 3 && (
@@ -448,6 +581,28 @@ export const KanbanCard = memo(function KanbanCard({
</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 */}
{/* Detailed mode: Show all agent info */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
@@ -509,7 +664,8 @@ export const KanbanCard = memo(function KanbanCard({
todo.status === "completed" &&
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-foreground-secondary"
todo.status === "pending" &&
"text-foreground-secondary"
)}
>
{todo.content}
@@ -822,9 +978,13 @@ export const KanbanCard = memo(function KanbanCard({
<Sparkles className="w-5 h-5 text-green-400" />
Implementation Summary
</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
? `${displayText.slice(0, 100)}...`
: displayText;
@@ -860,10 +1020,15 @@ export const KanbanCard = memo(function KanbanCard({
Revert Changes
</DialogTitle>
<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 && (
<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 className="block mt-2 text-red-400 font-medium">
@@ -895,4 +1060,11 @@ export const KanbanCard = memo(function KanbanCard({
</Dialog>
</Card>
);
// Wrap with animated border when in progress
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper">{cardElement}</div>;
}
return cardElement;
});

View File

@@ -12,6 +12,9 @@ interface KanbanColumnProps {
count: number;
children: 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({
@@ -21,6 +24,9 @@ export const KanbanColumn = memo(function KanbanColumn({
count,
children,
headerAction,
opacity = 100,
showBorder = true,
hideScrollbar = false,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
@@ -28,13 +34,27 @@ export const KanbanColumn = memo(function KanbanColumn({
<div
ref={setNodeRef}
className={cn(
"flex flex-col h-full rounded-lg bg-card backdrop-blur-sm border border-border transition-colors w-72",
isOver && "bg-accent"
"relative flex flex-col h-full rounded-lg transition-colors w-72",
showBorder && "border border-border"
)}
data-testid={`kanban-column-${id}`}
>
{/* Column Header */}
<div className="flex items-center gap-2 p-3 border-b border-border">
{/* Background layer with opacity - only this layer is affected by opacity */}
<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)} />
<h3 className="font-medium text-sm flex-1">{title}</h3>
{headerAction}
@@ -43,8 +63,14 @@ export const KanbanColumn = memo(function KanbanColumn({
</span>
</div>
{/* Column Content */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{/* Column Content - positioned above the background */}
<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}
</div>
</div>

View File

@@ -29,7 +29,8 @@ export type Theme =
| "gruvbox"
| "catppuccin"
| "onedark"
| "synthwave";
| "synthwave"
| "red";
export type KanbanDetailLevel = "minimal" | "standard" | "detailed";

View File

@@ -0,0 +1,697 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Terminal as TerminalIcon,
Plus,
Lock,
Unlock,
SplitSquareHorizontal,
SplitSquareVertical,
Loader2,
AlertCircle,
RefreshCw,
X,
SquarePlus,
} from "lucide-react";
import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store";
import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Panel,
PanelGroup,
PanelResizeHandle,
} from "react-resizable-panels";
import { TerminalPanel } from "./terminal-view/terminal-panel";
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
DragOverlay,
useDroppable,
} from "@dnd-kit/core";
import { cn } from "@/lib/utils";
interface TerminalStatus {
enabled: boolean;
passwordRequired: boolean;
platform: {
platform: string;
isWSL: boolean;
defaultShell: string;
arch: string;
};
}
// Tab component with drop target support
function TerminalTabButton({
tab,
isActive,
onClick,
onClose,
isDropTarget,
}: {
tab: TerminalTab;
isActive: boolean;
onClick: () => void;
onClose: () => void;
isDropTarget: boolean;
}) {
const { setNodeRef, isOver } = useDroppable({
id: `tab-${tab.id}`,
data: { type: "tab", tabId: tab.id },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors",
isActive
? "bg-background border-brand-500 text-foreground"
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
isOver && isDropTarget && "ring-2 ring-green-500"
)}
onClick={onClick}
>
<TerminalIcon className="h-3 w-3" />
<span className="max-w-24 truncate">{tab.name}</span>
<button
className="ml-1 p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// New tab drop zone
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: "new-tab-zone",
data: { type: "new-tab" },
});
return (
<div
ref={setNodeRef}
className={cn(
"flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all",
isOver && isDropTarget
? "border-green-500 bg-green-500/10 text-green-500"
: "border-transparent text-muted-foreground hover:border-border"
)}
>
<SquarePlus className="h-4 w-4" />
</div>
);
}
export function TerminalView() {
const {
terminalState,
setTerminalUnlocked,
addTerminalToLayout,
removeTerminalFromLayout,
setActiveTerminalSession,
swapTerminals,
currentProject,
addTerminalTab,
removeTerminalTab,
setActiveTerminalTab,
moveTerminalToTab,
setTerminalPanelFontSize,
} = useAppStore();
const [status, setStatus] = useState<TerminalStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [password, setPassword] = useState("");
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const lastCreateTimeRef = useRef<number>(0);
const isCreatingRef = useRef<boolean>(false);
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Get active tab
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
// DnD sensors with activation constraint to avoid accidental drags
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(event.active.id as string);
}, []);
// Handle drag over - track which tab we're hovering
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
if (over?.data?.current?.type === "tab") {
setDragOverTabId(over.data.current.tabId);
} else if (over?.data?.current?.type === "new-tab") {
setDragOverTabId("new");
} else {
setDragOverTabId(null);
}
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
setDragOverTabId(null);
if (!over) return;
const activeId = active.id as string;
const overData = over.data?.current;
// If dropped on a tab, move terminal to that tab
if (overData?.type === "tab") {
moveTerminalToTab(activeId, overData.tabId);
return;
}
// If dropped on new tab zone, create new tab with this terminal
if (overData?.type === "new-tab") {
moveTerminalToTab(activeId, "new");
return;
}
// Otherwise, swap terminals within current tab
if (active.id !== over.id) {
swapTerminals(activeId, over.id as string);
}
}, [swapTerminals, moveTerminalToTab]);
// Fetch terminal status
const fetchStatus = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${serverUrl}/api/terminal/status`);
const data = await response.json();
if (data.success) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
}
} else {
setError(data.error || "Failed to get terminal status");
}
} catch (err) {
setError("Failed to connect to server");
console.error("[Terminal] Status fetch error:", err);
} finally {
setLoading(false);
}
}, [serverUrl, setTerminalUnlocked]);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Handle password authentication
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setAuthLoading(true);
setAuthError(null);
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
const data = await response.json();
if (data.success) {
setTerminalUnlocked(true, data.data.token);
setPassword("");
} else {
setAuthError(data.error || "Authentication failed");
}
} catch (err) {
setAuthError("Failed to authenticate");
console.error("[Terminal] Auth error:", err);
} finally {
setAuthLoading(false);
}
};
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
// Debounce: prevent rapid terminal creation
const now = Date.now();
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
console.log("[Terminal] Debounced terminal creation");
return;
}
lastCreateTimeRef.current = now;
isCreatingRef.current = true;
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
});
const data = await response.json();
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
} else {
console.error("[Terminal] Failed to create session:", data.error);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
} finally {
isCreatingRef.current = false;
}
};
// Create terminal in new tab
const createTerminalInNewTab = async () => {
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: "POST",
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
});
const data = await response.json();
if (data.success) {
// Add to the newly created tab
const { addTerminalToTab } = useAppStore.getState();
addTerminalToTab(data.data.id, tabId);
}
} catch (err) {
console.error("[Terminal] Create session error:", err);
}
};
// Kill a terminal session
const killTerminal = async (sessionId: string) => {
try {
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers["X-Terminal-Token"] = terminalState.authToken;
}
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: "DELETE",
headers,
});
removeTerminalFromLayout(sessionId);
} catch (err) {
console.error("[Terminal] Kill session error:", err);
}
};
// Get keyboard shortcuts config
const shortcuts = useKeyboardShortcutsConfig();
// Handle terminal-specific keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle shortcuts when terminal is unlocked and has an active session
if (!terminalState.isUnlocked || !terminalState.activeSessionId) return;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
// Parse shortcut string to check for match
const matchesShortcut = (shortcutStr: string | undefined) => {
if (!shortcutStr) return false;
const parts = shortcutStr.toLowerCase().split('+');
const key = parts[parts.length - 1];
const needsCmd = parts.includes('cmd');
const needsShift = parts.includes('shift');
const needsAlt = parts.includes('alt');
// Check modifiers
const cmdMatches = needsCmd ? cmdOrCtrl : !cmdOrCtrl;
const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey;
const altMatches = needsAlt ? e.altKey : !e.altKey;
return (
e.key.toLowerCase() === key &&
cmdMatches &&
shiftMatches &&
altMatches
);
};
// Split terminal right (Cmd+D / Ctrl+D)
if (matchesShortcut(shortcuts.splitTerminalRight)) {
e.preventDefault();
createTerminal("horizontal", terminalState.activeSessionId);
return;
}
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
if (matchesShortcut(shortcuts.splitTerminalDown)) {
e.preventDefault();
createTerminal("vertical", terminalState.activeSessionId);
return;
}
// Close terminal (Cmd+W / Ctrl+W)
if (matchesShortcut(shortcuts.closeTerminal)) {
e.preventDefault();
killTerminal(terminalState.activeSessionId);
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]);
// Get a stable key for a panel
const getPanelKey = (panel: TerminalPanelContent): string => {
if (panel.type === "terminal") {
return panel.sessionId;
}
return `split-${panel.direction}-${panel.panels.map(getPanelKey).join("-")}`;
};
// Render panel content recursively
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
if (content.type === "terminal") {
// Use per-terminal fontSize or fall back to default
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
return (
<TerminalPanel
key={content.sessionId}
sessionId={content.sessionId}
authToken={terminalState.authToken}
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
isDragging={activeDragId === content.sessionId}
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
fontSize={terminalFontSize}
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
/>
);
}
const isHorizontal = content.direction === "horizontal";
const defaultSizePerPanel = 100 / content.panels.length;
return (
<PanelGroup direction={content.direction}>
{content.panels.map((panel, index) => {
const panelSize = panel.type === "terminal" && panel.size
? panel.size
: defaultSizePerPanel;
return (
<React.Fragment key={getPanelKey(panel)}>
{index > 0 && (
<PanelResizeHandle
className={
isHorizontal
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
: "h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
}
/>
)}
<Panel defaultSize={panelSize} minSize={15}>
{renderPanelContent(panel)}
</Panel>
</React.Fragment>
);
})}
</PanelGroup>
);
};
// Loading state
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-12 w-12 text-destructive" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Unavailable</h2>
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
<Button variant="outline" onClick={fetchStatus}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
);
}
// Disabled state
if (!status?.enabled) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<TerminalIcon className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
<p className="text-muted-foreground max-w-md">
Terminal access has been disabled. Set <code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your server .env file to enable it.
</p>
</div>
);
}
// Password gate
if (status.passwordRequired && !terminalState.isUnlocked) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<Lock className="h-12 w-12 text-muted-foreground" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal Protected</h2>
<p className="text-muted-foreground max-w-md mb-6">
Terminal access requires authentication. Enter the password to unlock.
</p>
<form onSubmit={handleAuth} className="w-full max-w-xs space-y-4">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={authLoading}
autoFocus
/>
{authError && (
<p className="text-sm text-destructive">{authError}</p>
)}
<Button type="submit" className="w-full" disabled={authLoading || !password}>
{authLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Unlock className="h-4 w-4 mr-2" />
)}
Unlock Terminal
</Button>
</form>
{status.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// No terminals yet - show welcome screen
if (terminalState.tabs.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-brand-500/10 mb-4">
<TerminalIcon className="h-12 w-12 text-brand-500" />
</div>
<h2 className="text-lg font-medium mb-2">Terminal</h2>
<p className="text-muted-foreground max-w-md mb-6">
Create a new terminal session to start executing commands.
{currentProject && (
<span className="block mt-2 text-sm">
Working directory: <code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
</span>
)}
</p>
<Button onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
{status?.platform && (
<p className="text-xs text-muted-foreground mt-6">
Platform: {status.platform.platform}
{status.platform.isWSL && " (WSL)"}
{" | "}Shell: {status.platform.defaultShell}
</p>
)}
</div>
);
}
// Terminal view with tabs
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center bg-card border-b border-border px-2">
{/* Tabs */}
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
{terminalState.tabs.map((tab) => (
<TerminalTabButton
key={tab.id}
tab={tab}
isActive={tab.id === terminalState.activeTabId}
onClick={() => setActiveTerminalTab(tab.id)}
onClose={() => removeTerminalTab(tab.id)}
isDropTarget={activeDragId !== null}
/>
))}
{/* New tab drop zone (visible when dragging) */}
{activeDragId && (
<NewTabDropZone isDropTarget={true} />
)}
{/* New tab button */}
<button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* Toolbar buttons */}
<div className="flex items-center gap-1 pl-2 border-l border-border">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("horizontal")}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal("vertical")}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
</Button>
</div>
</div>
{/* Active tab content */}
<div className="flex-1 overflow-hidden bg-background">
{activeTab?.layout ? (
renderPanelContent(activeTab.layout)
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<p className="text-muted-foreground mb-4">This tab is empty</p>
<Button
variant="outline"
size="sm"
onClick={() => createTerminal()}
>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
</div>
)}
</div>
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null} zIndex={1000}>
{activeDragId ? (
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
<span className="text-sm font-medium text-foreground whitespace-nowrap">
{dragOverTabId === "new"
? "New tab"
: dragOverTabId
? "Move to tab"
: "Terminal"}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,624 @@
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import {
X,
SplitSquareHorizontal,
SplitSquareVertical,
GripHorizontal,
Terminal,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useDraggable, useDroppable } from "@dnd-kit/core";
import { useAppStore } from "@/store/app-store";
import { getTerminalTheme } from "@/config/terminal-themes";
// Font size constraints
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32;
const DEFAULT_FONT_SIZE = 14;
interface TerminalPanelProps {
sessionId: string;
authToken: string | null;
isActive: boolean;
onFocus: () => void;
onClose: () => void;
onSplitHorizontal: () => void;
onSplitVertical: () => void;
isDragging?: boolean;
isDropTarget?: boolean;
fontSize: number;
onFontSizeChange: (size: number) => void;
}
// Type for xterm Terminal - we'll use any since we're dynamically importing
type XTerminal = InstanceType<typeof import("@xterm/xterm").Terminal>;
type XFitAddon = InstanceType<typeof import("@xterm/addon-fit").FitAddon>;
export function TerminalPanel({
sessionId,
authToken,
isActive,
onFocus,
onClose,
onSplitHorizontal,
onSplitVertical,
isDragging = false,
isDropTarget = false,
fontSize,
onFontSizeChange,
}: TerminalPanelProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<XFitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastShortcutTimeRef = useRef<number>(0);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const [shellName, setShellName] = useState("shell");
// Get effective theme from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
// Use refs for callbacks and values to avoid effect re-runs
const onFocusRef = useRef(onFocus);
onFocusRef.current = onFocus;
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const onSplitHorizontalRef = useRef(onSplitHorizontal);
onSplitHorizontalRef.current = onSplitHorizontal;
const onSplitVerticalRef = useRef(onSplitVertical);
onSplitVerticalRef.current = onSplitVertical;
const fontSizeRef = useRef(fontSize);
fontSizeRef.current = fontSize;
const themeRef = useRef(effectiveTheme);
themeRef.current = effectiveTheme;
// Zoom functions - use the prop callback
const zoomIn = useCallback(() => {
onFontSizeChange(Math.min(fontSize + 1, MAX_FONT_SIZE));
}, [fontSize, onFontSizeChange]);
const zoomOut = useCallback(() => {
onFontSizeChange(Math.max(fontSize - 1, MIN_FONT_SIZE));
}, [fontSize, onFontSizeChange]);
const resetZoom = useCallback(() => {
onFontSizeChange(DEFAULT_FONT_SIZE);
}, [onFontSizeChange]);
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
const wsUrl = serverUrl.replace(/^http/, "ws");
// Draggable - only the drag handle triggers drag
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef: setDragRef,
} = useDraggable({
id: sessionId,
});
// Droppable - the entire panel is a drop target
const {
setNodeRef: setDropRef,
isOver,
} = useDroppable({
id: sessionId,
});
// Initialize terminal - dynamically import xterm to avoid SSR issues
useEffect(() => {
if (!terminalRef.current) return;
let mounted = true;
const initTerminal = async () => {
// Dynamically import xterm modules
const [
{ Terminal },
{ FitAddon },
{ WebglAddon },
] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-webgl"),
]);
// Also import CSS
await import("@xterm/xterm/css/xterm.css");
if (!mounted || !terminalRef.current) return;
// Get terminal theme matching the app theme
const terminalTheme = getTerminalTheme(themeRef.current);
// Create terminal instance with the current global font size and theme
const terminal = new Terminal({
cursorBlink: true,
cursorStyle: "block",
fontSize: fontSizeRef.current,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
theme: terminalTheme,
allowProposedApi: true,
});
// Create fit addon
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
// Open terminal
terminal.open(terminalRef.current);
// Try to load WebGL addon for better performance
try {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
webglAddon.dispose();
});
terminal.loadAddon(webglAddon);
} catch {
console.warn("[Terminal] WebGL addon not available, falling back to canvas");
}
// Fit terminal to container
setTimeout(() => {
fitAddon.fit();
}, 0);
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
setIsTerminalReady(true);
// Handle focus - use ref to avoid re-running effect
terminal.onData(() => {
onFocusRef.current();
});
// Custom key handler to intercept terminal shortcuts
// Return false to prevent xterm from handling the key
const SHORTCUT_COOLDOWN_MS = 300; // Prevent rapid firing
terminal.attachCustomKeyEventHandler((event) => {
// Only intercept keydown events
if (event.type !== 'keydown') return true;
// Check cooldown to prevent rapid terminal creation
const now = Date.now();
const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS;
// Use event.code for keyboard-layout-independent key detection
const code = event.code;
// Alt+D - Split right
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyD') {
event.preventDefault();
if (canTrigger) {
lastShortcutTimeRef.current = now;
onSplitHorizontalRef.current();
}
return false;
}
// Alt+S - Split down
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyS') {
event.preventDefault();
if (canTrigger) {
lastShortcutTimeRef.current = now;
onSplitVerticalRef.current();
}
return false;
}
// Alt+W - Close terminal
if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && code === 'KeyW') {
event.preventDefault();
if (canTrigger) {
lastShortcutTimeRef.current = now;
onCloseRef.current();
}
return false;
}
// Let xterm handle all other keys
return true;
});
};
initTerminal();
// Cleanup
return () => {
mounted = false;
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setIsTerminalReady(false);
};
}, []); // No dependencies - only run once on mount
// Connect WebSocket - wait for terminal to be ready
useEffect(() => {
if (!isTerminalReady || !sessionId) return;
const terminal = xtermRef.current;
if (!terminal) return;
const connect = () => {
// Build WebSocket URL with token
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`;
}
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
console.log(`[Terminal] WebSocket connected for session ${sessionId}`);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "data":
terminal.write(msg.data);
break;
case "scrollback":
// Replay scrollback buffer (previous terminal output)
if (msg.data) {
terminal.write(msg.data);
}
break;
case "connected":
console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`);
if (msg.shell) {
// Extract shell name from path (e.g., "/bin/bash" -> "bash")
const name = msg.shell.split("/").pop() || msg.shell;
setShellName(name);
}
break;
case "exit":
terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`);
break;
case "pong":
// Heartbeat response
break;
}
} catch (err) {
console.error("[Terminal] Message parse error:", err);
}
};
ws.onclose = (event) => {
console.log(`[Terminal] WebSocket closed for session ${sessionId}:`, event.code, event.reason);
wsRef.current = null;
// Don't reconnect if closed normally or auth failed
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
return;
}
// Attempt reconnect after a delay
reconnectTimeoutRef.current = setTimeout(() => {
if (xtermRef.current) {
console.log(`[Terminal] Attempting reconnect for session ${sessionId}`);
connect();
}
}, 2000);
};
ws.onerror = (error) => {
console.error(`[Terminal] WebSocket error for session ${sessionId}:`, error);
};
};
connect();
// Handle terminal input
const dataHandler = terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "input", data }));
}
});
// Cleanup
return () => {
dataHandler.dispose();
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [sessionId, authToken, wsUrl, isTerminalReady]);
// Handle resize
const handleResize = useCallback(() => {
if (fitAddonRef.current && xtermRef.current) {
fitAddonRef.current.fit();
const { cols, rows } = xtermRef.current;
// Send resize to server
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
}, []);
// Resize observer
useEffect(() => {
const container = terminalRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(container);
// Also handle window resize
window.addEventListener("resize", handleResize);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
};
}, [handleResize]);
// Focus terminal when becoming active
useEffect(() => {
if (isActive && xtermRef.current) {
xtermRef.current.focus();
}
}, [isActive]);
// Update terminal font size when it changes
useEffect(() => {
if (xtermRef.current && isTerminalReady) {
xtermRef.current.options.fontSize = fontSize;
// Refit after font size change
if (fitAddonRef.current) {
fitAddonRef.current.fit();
// Notify server of new dimensions
const { cols, rows } = xtermRef.current;
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "resize", cols, rows }));
}
}
}
}, [fontSize, isTerminalReady]);
// Update terminal theme when app theme changes
useEffect(() => {
if (xtermRef.current && isTerminalReady) {
const terminalTheme = getTerminalTheme(effectiveTheme);
xtermRef.current.options.theme = terminalTheme;
}
}, [effectiveTheme, isTerminalReady]);
// Handle keyboard shortcuts for zoom (Ctrl+Plus, Ctrl+Minus, Ctrl+0)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle if Ctrl (or Cmd on Mac) is pressed
if (!e.ctrlKey && !e.metaKey) return;
// Ctrl/Cmd + Plus or Ctrl/Cmd + = (for keyboards without numpad)
if (e.key === "+" || e.key === "=") {
e.preventDefault();
e.stopPropagation();
zoomIn();
return;
}
// Ctrl/Cmd + Minus
if (e.key === "-") {
e.preventDefault();
e.stopPropagation();
zoomOut();
return;
}
// Ctrl/Cmd + 0 to reset
if (e.key === "0") {
e.preventDefault();
e.stopPropagation();
resetZoom();
return;
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [zoomIn, zoomOut, resetZoom]);
// Handle mouse wheel zoom (Ctrl+Wheel)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
// Only zoom if Ctrl (or Cmd on Mac) is pressed
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
e.stopPropagation();
if (e.deltaY < 0) {
// Scroll up = zoom in
zoomIn();
} else if (e.deltaY > 0) {
// Scroll down = zoom out
zoomOut();
}
};
// Use passive: false to allow preventDefault
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, [zoomIn, zoomOut]);
// Combine refs for the container
const setRefs = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node;
setDropRef(node);
}, [setDropRef]);
// Get current terminal theme for xterm styling
const currentTerminalTheme = getTerminalTheme(effectiveTheme);
return (
<div
ref={setRefs}
className={cn(
"flex flex-col h-full relative",
isActive && "ring-1 ring-brand-500 ring-inset",
// Visual feedback when dragging this terminal
isDragging && "opacity-50",
// Visual feedback when hovering over as drop target
isOver && isDropTarget && "ring-2 ring-green-500 ring-inset"
)}
onClick={onFocus}
tabIndex={0}
data-terminal-container="true"
>
{/* Drop indicator overlay */}
{isOver && isDropTarget && (
<div className="absolute inset-0 bg-green-500/10 z-10 pointer-events-none flex items-center justify-center">
<div className="px-3 py-2 bg-green-500/90 rounded-md text-white text-sm font-medium">
Drop to swap
</div>
</div>
)}
{/* Header bar with drag handle - uses app theme CSS variables */}
<div className="flex items-center h-7 px-1 shrink-0 bg-card border-b border-border">
{/* Drag handle */}
<button
ref={setDragRef}
{...dragAttributes}
{...dragListeners}
className={cn(
"p-1 rounded cursor-grab active:cursor-grabbing mr-1 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent",
isDragging && "cursor-grabbing"
)}
title="Drag to swap terminals"
>
<GripHorizontal className="h-3 w-3" />
</button>
{/* Terminal icon and label */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<Terminal className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="text-xs truncate text-foreground">
{shellName}
</span>
{/* Font size indicator - only show when not default */}
{fontSize !== DEFAULT_FONT_SIZE && (
<button
onClick={(e) => {
e.stopPropagation();
resetZoom();
}}
className="text-[10px] px-1 rounded transition-colors text-muted-foreground hover:text-foreground hover:bg-accent"
title="Click to reset zoom (Ctrl+0)"
>
{fontSize}px
</button>
)}
</div>
{/* Zoom and action buttons */}
<div className="flex items-center gap-0.5">
{/* Zoom controls */}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
zoomOut();
}}
title="Zoom Out (Ctrl+-)"
disabled={fontSize <= MIN_FONT_SIZE}
>
<ZoomOut className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
zoomIn();
}}
title="Zoom In (Ctrl++)"
disabled={fontSize >= MAX_FONT_SIZE}
>
<ZoomIn className="h-3 w-3" />
</Button>
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Split/close buttons */}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSplitHorizontal();
}}
title="Split Right (Cmd+D)"
>
<SplitSquareHorizontal className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onSplitVertical();
}}
title="Split Down (Cmd+Shift+D)"
>
<SplitSquareVertical className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title="Close Terminal (Cmd+W)"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* Terminal container - uses terminal theme */}
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ backgroundColor: currentTerminalTheme.background }}
/>
</div>
);
}

View File

@@ -2,9 +2,6 @@
import { useState, useCallback } from "react";
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 {
Dialog,
DialogContent,
@@ -13,14 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAppStore } from "@/store/app-store";
import { useAppStore, type ThemeMode } from "@/store/app-store";
import { getElectronAPI, type Project } from "@/lib/electron";
import { initializeProject } from "@/lib/project-init";
import {
@@ -41,14 +31,22 @@ import {
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { WorkspacePickerModal } from "@/components/workspace-picker-modal";
import { NewProjectModal } from "@/components/new-project-modal";
import { getHttpApiClient } from "@/lib/http-api-client";
import type { StarterTemplate } from "@/lib/templates";
export function WelcomeView() {
const { projects, addProject, setCurrentProject, setCurrentView } =
useAppStore();
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const [newProjectPath, setNewProjectPath] = useState("");
const {
projects,
trashedProjects,
currentProject,
upsertAndSetCurrentProject,
addProject,
setCurrentProject,
setCurrentView,
theme: globalTheme,
} = useAppStore();
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isOpening, setIsOpening] = useState(false);
const [showInitDialog, setShowInitDialog] = useState(false);
@@ -108,35 +106,14 @@ export function WelcomeView() {
return;
}
// Check if project already exists (by path) to preserve theme and other settings
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 (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);
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
const project = upsertAndSetCurrentProject(path, name, effectiveTheme);
// Show initialization dialog if files were created
if (initResult.createdFiles && initResult.createdFiles.length > 0) {
@@ -171,7 +148,13 @@ export function WelcomeView() {
setIsOpening(false);
}
},
[projects, addProject, setCurrentProject, analyzeProject]
[
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
analyzeProject,
]
);
const handleOpenProject = useCallback(async () => {
@@ -191,7 +174,8 @@ export function WelcomeView() {
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
// 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);
}
}
@@ -203,7 +187,8 @@ export function WelcomeView() {
if (!result.canceled && 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);
}
}
@@ -231,31 +216,24 @@ export function WelcomeView() {
);
const handleNewProject = () => {
setNewProjectName("");
setNewProjectPath("");
setShowNewProjectDialog(true);
setShowNewProjectModal(true);
};
const handleInteractiveMode = () => {
setCurrentView("interview");
};
const handleSelectDirectory = async () => {
const api = getElectronAPI();
const result = await api.openDirectory();
if (!result.canceled && result.filePaths[0]) {
setNewProjectPath(result.filePaths[0]);
}
};
const handleCreateProject = async () => {
if (!newProjectName || !newProjectPath) return;
/**
* Create a blank project with just .automaker directory structure
*/
const handleCreateBlankProject = async (
projectName: string,
parentDir: string
) => {
setIsCreating(true);
try {
const api = getElectronAPI();
const projectPath = `${newProjectPath}/${newProjectName}`;
const projectPath = `${parentDir}/${projectName}`;
// Create project directory
await api.mkdir(projectPath);
@@ -274,7 +252,7 @@ export function WelcomeView() {
await api.writeFile(
`${projectPath}/.automaker/app_spec.txt`,
`<project_specification>
<project_name>${newProjectName}</project_name>
<project_name>${projectName}</project_name>
<overview>
Describe your project here. This file will be analyzed by an AI agent
@@ -297,24 +275,24 @@ export function WelcomeView() {
const project = {
id: `project-${Date.now()}`,
name: newProjectName,
name: projectName,
path: projectPath,
lastOpened: new Date().toISOString(),
};
addProject(project);
setCurrentProject(project);
setShowNewProjectDialog(false);
setShowNewProjectModal(false);
toast.success("Project created", {
description: `Created ${newProjectName} with .automaker directory`,
description: `Created ${projectName} with .automaker directory`,
});
// Set init status to show the dialog
setInitStatus({
isNewProject: true,
createdFiles: initResult.createdFiles || [],
projectName: newProjectName,
projectName: projectName,
projectPath: projectPath,
});
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]
.sort((a, b) => {
const dateA = a.lastOpened ? new Date(a.lastOpened).getTime() : 0;
@@ -508,82 +690,15 @@ export function WelcomeView() {
</div>
</div>
{/* New Project Dialog */}
<Dialog
open={showNewProjectDialog}
onOpenChange={setShowNewProjectDialog}
>
<DialogContent
className="bg-card border-border"
data-testid="new-project-dialog"
>
<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>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreating}
/>
{/* Project Initialization Dialog */}
<Dialog open={showInitDialog} onOpenChange={setShowInitDialog}>

View File

@@ -0,0 +1,479 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
ChevronDown,
ChevronRight,
Rocket,
Layers,
Sparkles,
GitBranch,
FolderTree,
Component,
Settings,
PlayCircle,
Bot,
LayoutGrid,
FileText,
Terminal,
Palette,
Keyboard,
Cpu,
Zap,
Image,
TestTube,
Brain,
Users,
} from "lucide-react";
interface WikiSection {
id: string;
title: string;
icon: React.ElementType;
content: React.ReactNode;
}
function CollapsibleSection({
section,
isOpen,
onToggle,
}: {
section: WikiSection;
isOpen: boolean;
onToggle: () => void;
}) {
const Icon = section.icon;
return (
<div className="border border-border rounded-lg overflow-hidden bg-card/50 backdrop-blur-sm">
<button
onClick={onToggle}
className="w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-brand-500/10 text-brand-500">
<Icon className="w-4 h-4" />
</div>
<span className="flex-1 font-medium text-foreground">{section.title}</span>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{isOpen && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<div className="pt-4 text-sm text-muted-foreground leading-relaxed">
{section.content}
</div>
</div>
)}
</div>
);
}
function CodeBlock({ children, title }: { children: string; title?: string }) {
return (
<div className="my-3 rounded-lg overflow-hidden border border-border">
{title && (
<div className="px-3 py-1.5 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground">
{title}
</div>
)}
<pre className="p-3 bg-muted/30 overflow-x-auto text-xs font-mono text-foreground">
{children}
</pre>
</div>
);
}
function FeatureList({ items }: { items: { icon: React.ElementType; title: string; description: string }[] }) {
return (
<div className="grid gap-3 mt-3">
{items.map((item, index) => {
const ItemIcon = item.icon;
return (
<div key={index} className="flex items-start gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-center w-6 h-6 rounded bg-brand-500/10 text-brand-500 shrink-0 mt-0.5">
<ItemIcon className="w-3.5 h-3.5" />
</div>
<div>
<div className="font-medium text-foreground text-sm">{item.title}</div>
<div className="text-xs text-muted-foreground mt-0.5">{item.description}</div>
</div>
</div>
);
})}
</div>
);
}
export function WikiView() {
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["overview"]));
const toggleSection = (id: string) => {
setOpenSections((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
const expandAll = () => {
setOpenSections(new Set(sections.map((s) => s.id)));
};
const collapseAll = () => {
setOpenSections(new Set());
};
const sections: WikiSection[] = [
{
id: "overview",
title: "Project Overview",
icon: Rocket,
content: (
<div className="space-y-3">
<p>
<strong className="text-foreground">Automaker</strong> is an autonomous AI development studio that helps developers build software faster using AI agents.
</p>
<p>
At its core, Automaker provides a visual Kanban board to manage features. When you're ready, AI agents automatically implement those features in your codebase, complete with git worktree isolation for safe parallel development.
</p>
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
<p className="text-brand-400 text-sm">
Think of it as having a team of AI developers that can work on multiple features simultaneously while you focus on the bigger picture.
</p>
</div>
</div>
),
},
{
id: "architecture",
title: "Architecture",
icon: Layers,
content: (
<div className="space-y-3">
<p>Automaker is built as a monorepo with two main applications:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>
<strong className="text-foreground">apps/app</strong> - Next.js + Electron frontend for the desktop application
</li>
<li>
<strong className="text-foreground">apps/server</strong> - Express backend handling API requests and agent orchestration
</li>
</ul>
<div className="mt-4 space-y-2">
<p className="font-medium text-foreground">Key Technologies:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Electron wraps Next.js for cross-platform desktop support</li>
<li>Real-time communication via WebSocket for live agent updates</li>
<li>State management with Zustand for reactive UI updates</li>
<li>Claude Agent SDK for AI capabilities</li>
</ul>
</div>
</div>
),
},
{
id: "features",
title: "Key Features",
icon: Sparkles,
content: (
<div>
<FeatureList
items={[
{
icon: LayoutGrid,
title: "Kanban Board",
description: "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.",
},
{
icon: Bot,
title: "AI Agent Integration",
description: "Powered by Claude via the Agent SDK with full file, bash, and git access.",
},
{
icon: Cpu,
title: "Multi-Model Support",
description: "Claude Haiku/Sonnet/Opus + OpenAI Codex models. Choose the right model for each task.",
},
{
icon: Brain,
title: "Extended Thinking",
description: "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.",
},
{
icon: Zap,
title: "Real-time Streaming",
description: "Watch AI agents work in real-time with live output streaming.",
},
{
icon: GitBranch,
title: "Git Worktree Isolation",
description: "Each feature runs in its own git worktree for safe parallel development.",
},
{
icon: Users,
title: "AI Profiles",
description: "Pre-configured model + thinking level combinations for different task types.",
},
{
icon: Terminal,
title: "Integrated Terminal",
description: "Built-in terminal with tab support and split panes.",
},
{
icon: Keyboard,
title: "Keyboard Shortcuts",
description: "Fully customizable shortcuts for power users.",
},
{
icon: Palette,
title: "14 Themes",
description: "From light to dark, retro to synthwave - pick your style.",
},
{
icon: Image,
title: "Image Support",
description: "Attach images to features for visual context.",
},
{
icon: TestTube,
title: "Test Integration",
description: "Automatic test running and TDD support for quality assurance.",
},
]}
/>
</div>
),
},
{
id: "data-flow",
title: "How It Works (Data Flow)",
icon: GitBranch,
content: (
<div className="space-y-3">
<p>Here's what happens when you use Automaker to implement a feature:</p>
<ol className="list-decimal list-inside space-y-3 ml-2 mt-4">
<li className="text-foreground">
<strong>Create Feature</strong>
<p className="text-muted-foreground ml-5 mt-1">Add a new feature card to the Kanban board with description and steps</p>
</li>
<li className="text-foreground">
<strong>Feature Saved</strong>
<p className="text-muted-foreground ml-5 mt-1">Feature saved to <code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/features/&#123;id&#125;/feature.json</code></p>
</li>
<li className="text-foreground">
<strong>Start Work</strong>
<p className="text-muted-foreground ml-5 mt-1">Drag to "In Progress" or enable auto mode to start implementation</p>
</li>
<li className="text-foreground">
<strong>Git Worktree Created</strong>
<p className="text-muted-foreground ml-5 mt-1">Backend AutoModeService creates isolated git worktree (if enabled)</p>
</li>
<li className="text-foreground">
<strong>Agent Executes</strong>
<p className="text-muted-foreground ml-5 mt-1">Claude Agent SDK runs with file/bash/git tool access</p>
</li>
<li className="text-foreground">
<strong>Progress Streamed</strong>
<p className="text-muted-foreground ml-5 mt-1">Real-time updates via WebSocket as agent works</p>
</li>
<li className="text-foreground">
<strong>Completion</strong>
<p className="text-muted-foreground ml-5 mt-1">On success, feature moves to "waiting_approval" for your review</p>
</li>
<li className="text-foreground">
<strong>Verify</strong>
<p className="text-muted-foreground ml-5 mt-1">Review changes and move to "verified" when satisfied</p>
</li>
</ol>
</div>
),
},
{
id: "structure",
title: "Project Structure",
icon: FolderTree,
content: (
<div>
<p className="mb-3">The Automaker codebase is organized as follows:</p>
<CodeBlock title="Directory Structure">
{`/automaker/
├── apps/
│ ├── app/ # Frontend (Next.js + Electron)
│ │ ├── electron/ # Electron main process
│ │ └── src/
│ │ ├── app/ # Next.js App Router pages
│ │ ├── components/ # React components
│ │ ├── store/ # Zustand state management
│ │ ├── hooks/ # Custom React hooks
│ │ └── lib/ # Utilities and helpers
│ └── server/ # Backend (Express)
│ └── src/
│ ├── routes/ # API endpoints
│ └── services/ # Business logic (AutoModeService, etc.)
├── docs/ # Documentation
└── package.json # Workspace root`}
</CodeBlock>
</div>
),
},
{
id: "components",
title: "Key Components",
icon: Component,
content: (
<div className="space-y-3">
<p>The main UI components that make up Automaker:</p>
<div className="grid gap-2 mt-4">
{[
{ file: "sidebar.tsx", desc: "Main navigation with project picker and view switching" },
{ file: "board-view.tsx", desc: "Kanban board with drag-and-drop cards" },
{ file: "agent-view.tsx", desc: "AI chat interface for conversational development" },
{ file: "spec-view.tsx", desc: "Project specification editor" },
{ file: "context-view.tsx", desc: "Context file manager for AI context" },
{ file: "terminal-view.tsx", desc: "Integrated terminal with splits and tabs" },
{ file: "profiles-view.tsx", desc: "AI profile management (model + thinking presets)" },
{ file: "app-store.ts", desc: "Central Zustand state management" },
].map((item) => (
<div key={item.file} className="flex items-center gap-3 p-2 rounded bg-muted/30 border border-border/50">
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">{item.file}</code>
<span className="text-xs text-muted-foreground">{item.desc}</span>
</div>
))}
</div>
</div>
),
},
{
id: "configuration",
title: "Configuration",
icon: Settings,
content: (
<div className="space-y-3">
<p>Automaker stores project configuration in the <code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/</code> directory:</p>
<div className="grid gap-2 mt-4">
{[
{ file: "app_spec.txt", desc: "Project specification describing your app for AI context" },
{ file: "context/", desc: "Additional context files (docs, examples) for AI" },
{ file: "features/", desc: "Feature definitions with descriptions and steps" },
].map((item) => (
<div key={item.file} className="flex items-center gap-3 p-2 rounded bg-muted/30 border border-border/50">
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">{item.file}</code>
<span className="text-xs text-muted-foreground">{item.desc}</span>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-sm text-foreground font-medium mb-2">Tip: App Spec Best Practices</p>
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
<li>Include your tech stack and key dependencies</li>
<li>Describe the project structure and conventions</li>
<li>List any important patterns or architectural decisions</li>
<li>Note testing requirements and coding standards</li>
</ul>
</div>
</div>
),
},
{
id: "getting-started",
title: "Getting Started",
icon: PlayCircle,
content: (
<div className="space-y-3">
<p>Follow these steps to start building with Automaker:</p>
<ol className="list-decimal list-inside space-y-4 ml-2 mt-4">
<li className="text-foreground">
<strong>Create or Open a Project</strong>
<p className="text-muted-foreground ml-5 mt-1">Use the sidebar to create a new project or open an existing folder</p>
</li>
<li className="text-foreground">
<strong>Write an App Spec</strong>
<p className="text-muted-foreground ml-5 mt-1">Go to Spec Editor and describe your project. This helps AI understand your codebase.</p>
</li>
<li className="text-foreground">
<strong>Add Context (Optional)</strong>
<p className="text-muted-foreground ml-5 mt-1">Add relevant documentation or examples to the Context view for better AI results</p>
</li>
<li className="text-foreground">
<strong>Create Features</strong>
<p className="text-muted-foreground ml-5 mt-1">Add feature cards to your Kanban board with clear descriptions and implementation steps</p>
</li>
<li className="text-foreground">
<strong>Configure AI Profile</strong>
<p className="text-muted-foreground ml-5 mt-1">Choose an AI profile or customize model/thinking settings per feature</p>
</li>
<li className="text-foreground">
<strong>Start Implementation</strong>
<p className="text-muted-foreground ml-5 mt-1">Drag features to "In Progress" or enable auto mode to let AI work</p>
</li>
<li className="text-foreground">
<strong>Review and Verify</strong>
<p className="text-muted-foreground ml-5 mt-1">Check completed features, review changes, and mark as verified</p>
</li>
</ol>
<div className="mt-6 p-4 rounded-lg bg-brand-500/10 border border-brand-500/20">
<p className="text-brand-400 text-sm font-medium mb-2">Pro Tips:</p>
<ul className="list-disc list-inside space-y-1 text-xs text-brand-400/80">
<li>Use keyboard shortcuts for faster navigation (press <code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)</li>
<li>Enable git worktree isolation for parallel feature development</li>
<li>Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work</li>
<li>Keep your app spec up to date as your project evolves</li>
</ul>
</div>
</div>
),
},
];
return (
<div className="flex-1 flex flex-col overflow-hidden bg-background">
{/* Header */}
<div className="border-b border-border bg-card/30 backdrop-blur-sm px-6 py-4 shrink-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-foreground">Wiki</h1>
<p className="text-sm text-muted-foreground mt-1">
Learn how Automaker works and how to use it effectively
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={expandAll}
className="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
>
Expand All
</button>
<button
onClick={collapseAll}
className="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
>
Collapse All
</button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-6 space-y-3">
{sections.map((section) => (
<CollapsibleSection
key={section.id}
section={section}
isOpen={openSections.has(section.id)}
onToggle={() => toggleSection(section.id)}
/>
))}
</div>
</div>
</div>
);
}

View 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";

View File

@@ -0,0 +1,393 @@
/**
* Terminal themes that match the app themes
* Each theme provides colors for xterm.js terminal emulator
*/
import type { ThemeMode } from "@/store/app-store";
export interface TerminalTheme {
background: string;
foreground: string;
cursor: string;
cursorAccent: string;
selectionBackground: string;
selectionForeground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
// Dark theme (default)
const darkTheme: TerminalTheme = {
background: "#0a0a0a",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
cursorAccent: "#0a0a0a",
selectionBackground: "#264f78",
black: "#1e1e1e",
red: "#f44747",
green: "#6a9955",
yellow: "#dcdcaa",
blue: "#569cd6",
magenta: "#c586c0",
cyan: "#4ec9b0",
white: "#d4d4d4",
brightBlack: "#808080",
brightRed: "#f44747",
brightGreen: "#6a9955",
brightYellow: "#dcdcaa",
brightBlue: "#569cd6",
brightMagenta: "#c586c0",
brightCyan: "#4ec9b0",
brightWhite: "#ffffff",
};
// Light theme
const lightTheme: TerminalTheme = {
background: "#ffffff",
foreground: "#383a42",
cursor: "#383a42",
cursorAccent: "#ffffff",
selectionBackground: "#add6ff",
black: "#383a42",
red: "#e45649",
green: "#50a14f",
yellow: "#c18401",
blue: "#4078f2",
magenta: "#a626a4",
cyan: "#0184bc",
white: "#fafafa",
brightBlack: "#4f525e",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
};
// Retro / Cyberpunk theme - neon green on black
const retroTheme: TerminalTheme = {
background: "#000000",
foreground: "#39ff14",
cursor: "#39ff14",
cursorAccent: "#000000",
selectionBackground: "#39ff14",
selectionForeground: "#000000",
black: "#000000",
red: "#ff0055",
green: "#39ff14",
yellow: "#ffff00",
blue: "#00ffff",
magenta: "#ff00ff",
cyan: "#00ffff",
white: "#39ff14",
brightBlack: "#555555",
brightRed: "#ff5555",
brightGreen: "#55ff55",
brightYellow: "#ffff55",
brightBlue: "#55ffff",
brightMagenta: "#ff55ff",
brightCyan: "#55ffff",
brightWhite: "#ffffff",
};
// Dracula theme
const draculaTheme: TerminalTheme = {
background: "#282a36",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#282a36",
selectionBackground: "#44475a",
black: "#21222c",
red: "#ff5555",
green: "#50fa7b",
yellow: "#f1fa8c",
blue: "#bd93f9",
magenta: "#ff79c6",
cyan: "#8be9fd",
white: "#f8f8f2",
brightBlack: "#6272a4",
brightRed: "#ff6e6e",
brightGreen: "#69ff94",
brightYellow: "#ffffa5",
brightBlue: "#d6acff",
brightMagenta: "#ff92df",
brightCyan: "#a4ffff",
brightWhite: "#ffffff",
};
// Nord theme
const nordTheme: TerminalTheme = {
background: "#2e3440",
foreground: "#d8dee9",
cursor: "#d8dee9",
cursorAccent: "#2e3440",
selectionBackground: "#434c5e",
black: "#3b4252",
red: "#bf616a",
green: "#a3be8c",
yellow: "#ebcb8b",
blue: "#81a1c1",
magenta: "#b48ead",
cyan: "#88c0d0",
white: "#e5e9f0",
brightBlack: "#4c566a",
brightRed: "#bf616a",
brightGreen: "#a3be8c",
brightYellow: "#ebcb8b",
brightBlue: "#81a1c1",
brightMagenta: "#b48ead",
brightCyan: "#8fbcbb",
brightWhite: "#eceff4",
};
// Monokai theme
const monokaiTheme: TerminalTheme = {
background: "#272822",
foreground: "#f8f8f2",
cursor: "#f8f8f2",
cursorAccent: "#272822",
selectionBackground: "#49483e",
black: "#272822",
red: "#f92672",
green: "#a6e22e",
yellow: "#f4bf75",
blue: "#66d9ef",
magenta: "#ae81ff",
cyan: "#a1efe4",
white: "#f8f8f2",
brightBlack: "#75715e",
brightRed: "#f92672",
brightGreen: "#a6e22e",
brightYellow: "#f4bf75",
brightBlue: "#66d9ef",
brightMagenta: "#ae81ff",
brightCyan: "#a1efe4",
brightWhite: "#f9f8f5",
};
// Tokyo Night theme
const tokyonightTheme: TerminalTheme = {
background: "#1a1b26",
foreground: "#a9b1d6",
cursor: "#c0caf5",
cursorAccent: "#1a1b26",
selectionBackground: "#33467c",
black: "#15161e",
red: "#f7768e",
green: "#9ece6a",
yellow: "#e0af68",
blue: "#7aa2f7",
magenta: "#bb9af7",
cyan: "#7dcfff",
white: "#a9b1d6",
brightBlack: "#414868",
brightRed: "#f7768e",
brightGreen: "#9ece6a",
brightYellow: "#e0af68",
brightBlue: "#7aa2f7",
brightMagenta: "#bb9af7",
brightCyan: "#7dcfff",
brightWhite: "#c0caf5",
};
// Solarized Dark theme
const solarizedTheme: TerminalTheme = {
background: "#002b36",
foreground: "#839496",
cursor: "#839496",
cursorAccent: "#002b36",
selectionBackground: "#073642",
black: "#073642",
red: "#dc322f",
green: "#859900",
yellow: "#b58900",
blue: "#268bd2",
magenta: "#d33682",
cyan: "#2aa198",
white: "#eee8d5",
brightBlack: "#002b36",
brightRed: "#cb4b16",
brightGreen: "#586e75",
brightYellow: "#657b83",
brightBlue: "#839496",
brightMagenta: "#6c71c4",
brightCyan: "#93a1a1",
brightWhite: "#fdf6e3",
};
// Gruvbox Dark theme
const gruvboxTheme: TerminalTheme = {
background: "#282828",
foreground: "#ebdbb2",
cursor: "#ebdbb2",
cursorAccent: "#282828",
selectionBackground: "#504945",
black: "#282828",
red: "#cc241d",
green: "#98971a",
yellow: "#d79921",
blue: "#458588",
magenta: "#b16286",
cyan: "#689d6a",
white: "#a89984",
brightBlack: "#928374",
brightRed: "#fb4934",
brightGreen: "#b8bb26",
brightYellow: "#fabd2f",
brightBlue: "#83a598",
brightMagenta: "#d3869b",
brightCyan: "#8ec07c",
brightWhite: "#ebdbb2",
};
// Catppuccin Mocha theme
const catppuccinTheme: TerminalTheme = {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
cursorAccent: "#1e1e2e",
selectionBackground: "#45475a",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#cba6f7",
cyan: "#94e2d5",
white: "#bac2de",
brightBlack: "#585b70",
brightRed: "#f38ba8",
brightGreen: "#a6e3a1",
brightYellow: "#f9e2af",
brightBlue: "#89b4fa",
brightMagenta: "#cba6f7",
brightCyan: "#94e2d5",
brightWhite: "#a6adc8",
};
// One Dark theme
const onedarkTheme: TerminalTheme = {
background: "#282c34",
foreground: "#abb2bf",
cursor: "#528bff",
cursorAccent: "#282c34",
selectionBackground: "#3e4451",
black: "#282c34",
red: "#e06c75",
green: "#98c379",
yellow: "#e5c07b",
blue: "#61afef",
magenta: "#c678dd",
cyan: "#56b6c2",
white: "#abb2bf",
brightBlack: "#5c6370",
brightRed: "#e06c75",
brightGreen: "#98c379",
brightYellow: "#e5c07b",
brightBlue: "#61afef",
brightMagenta: "#c678dd",
brightCyan: "#56b6c2",
brightWhite: "#ffffff",
};
// Synthwave '84 theme
const synthwaveTheme: TerminalTheme = {
background: "#262335",
foreground: "#ffffff",
cursor: "#ff7edb",
cursorAccent: "#262335",
selectionBackground: "#463465",
black: "#262335",
red: "#fe4450",
green: "#72f1b8",
yellow: "#fede5d",
blue: "#03edf9",
magenta: "#ff7edb",
cyan: "#03edf9",
white: "#ffffff",
brightBlack: "#614d85",
brightRed: "#fe4450",
brightGreen: "#72f1b8",
brightYellow: "#f97e72",
brightBlue: "#03edf9",
brightMagenta: "#ff7edb",
brightCyan: "#03edf9",
brightWhite: "#ffffff",
};
// Red theme - Dark theme with red accents
const redTheme: TerminalTheme = {
background: "#1a0a0a",
foreground: "#c8b0b0",
cursor: "#ff4444",
cursorAccent: "#1a0a0a",
selectionBackground: "#5a2020",
black: "#2a1010",
red: "#ff4444",
green: "#6a9a6a",
yellow: "#ccaa55",
blue: "#6688aa",
magenta: "#aa5588",
cyan: "#558888",
white: "#b0a0a0",
brightBlack: "#6a4040",
brightRed: "#ff6666",
brightGreen: "#88bb88",
brightYellow: "#ddbb66",
brightBlue: "#88aacc",
brightMagenta: "#cc77aa",
brightCyan: "#77aaaa",
brightWhite: "#d0c0c0",
};
// Theme mapping
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
light: lightTheme,
dark: darkTheme,
system: darkTheme, // Will be resolved at runtime
retro: retroTheme,
dracula: draculaTheme,
nord: nordTheme,
monokai: monokaiTheme,
tokyonight: tokyonightTheme,
solarized: solarizedTheme,
gruvbox: gruvboxTheme,
catppuccin: catppuccinTheme,
onedark: onedarkTheme,
synthwave: synthwaveTheme,
red: redTheme,
};
/**
* Get terminal theme for the given app theme
* For "system" theme, it checks the user's system preference
*/
export function getTerminalTheme(theme: ThemeMode): TerminalTheme {
if (theme === "system") {
// Check system preference
if (typeof window !== "undefined") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? darkTheme : lightTheme;
}
return darkTheme; // Default to dark for SSR
}
return terminalThemes[theme] || darkTheme;
}
export default terminalThemes;

View File

@@ -5,6 +5,7 @@ import {
Eclipse,
Flame,
Ghost,
Heart,
Moon,
Radio,
Snowflake,
@@ -85,4 +86,10 @@ export const themeOptions: ReadonlyArray<ThemeOption> = [
Icon: Radio,
testId: "synthwave-mode-button",
},
{
value: "red",
label: "Red",
Icon: Heart,
testId: "red-mode-button",
},
];

View File

@@ -3,8 +3,13 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog";
interface FileBrowserOptions {
title?: string;
description?: string;
}
interface FileBrowserContextValue {
openFileBrowser: () => Promise<string | null>;
openFileBrowser: (options?: FileBrowserOptions) => Promise<string | null>;
}
const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
@@ -12,9 +17,11 @@ const FileBrowserContext = createContext<FileBrowserContextValue | null>(null);
export function FileBrowserProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
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) => {
setDialogOptions(options || {});
setIsOpen(true);
setResolver(() => resolve);
});
@@ -26,6 +33,7 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
setResolver(null);
}
setIsOpen(false);
setDialogOptions({});
}, [resolver]);
const handleOpenChange = useCallback((open: boolean) => {
@@ -34,6 +42,9 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
setResolver(null);
}
setIsOpen(open);
if (!open) {
setDialogOptions({});
}
}, [resolver]);
return (
@@ -43,6 +54,8 @@ export function FileBrowserProvider({ children }: { children: ReactNode }) {
open={isOpen}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
title={dialogOptions.title}
description={dialogOptions.description}
/>
</FileBrowserContext.Provider>
);
@@ -57,12 +70,15 @@ export function useFileBrowser() {
}
// 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;
}
export function getGlobalFileBrowser() {
return globalFileBrowserFn;
}
// Export the options type for consumers
export type { FileBrowserOptions };

View File

@@ -34,6 +34,18 @@ function isInputFocused(): boolean {
return true;
}
// Check if focus is inside an xterm terminal (they use a hidden textarea)
const xtermContainer = activeElement.closest(".xterm");
if (xtermContainer) {
return true;
}
// Also check if any parent has data-terminal-container attribute
const terminalContainer = activeElement.closest("[data-terminal-container]");
if (terminalContainer) {
return true;
}
// Check for autocomplete/typeahead dropdowns being open
const autocompleteList = document.querySelector(
'[data-testid="category-autocomplete-list"]'

View File

@@ -33,7 +33,6 @@ import type {
} from "@/types/electron";
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
// Server URL - configurable via environment variable
const getServerUrl = (): string => {
if (typeof window !== "undefined") {
@@ -43,7 +42,6 @@ const getServerUrl = (): string => {
return "http://localhost:3008";
};
// Get API key from environment variable
const getApiKey = (): string | null => {
if (typeof window !== "undefined") {
@@ -76,7 +74,10 @@ export class HttpApiClient implements ElectronAPI {
}
private connectWebSocket(): void {
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
return;
}
@@ -103,7 +104,10 @@ export class HttpApiClient implements ElectronAPI {
callbacks.forEach((cb) => cb(data.payload));
}
} 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)) {
this.eventCallbacks.set(type, new Set());
}
@@ -196,7 +203,9 @@ export class HttpApiClient implements ElectronAPI {
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
window.open(url, "_blank", "noopener,noreferrer");
return { success: true };
@@ -301,7 +310,9 @@ export class HttpApiClient implements ElectronAPI {
async getPath(name: string): Promise<string> {
// Server provides data directory
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 `/data/${name}`;
@@ -313,7 +324,32 @@ export class HttpApiClient implements ElectronAPI {
mimeType: string,
projectPath?: string
): 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
@@ -444,14 +480,19 @@ export class HttpApiClient implements ElectronAPI {
output?: string;
}> => this.post("/api/setup/auth-claude"),
authCodex: (apiKey?: string): Promise<{
authCodex: (
apiKey?: string
): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
error?: string;
}> => this.post("/api/setup/auth-codex", { apiKey }),
storeApiKey: (provider: string, apiKey: string): Promise<{
storeApiKey: (
provider: string,
apiKey: string
): Promise<{
success: boolean;
error?: string;
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
@@ -463,7 +504,9 @@ export class HttpApiClient implements ElectronAPI {
hasGoogleKey: boolean;
}> => this.get("/api/setup/api-keys"),
configureCodexMcp: (projectPath: string): Promise<{
configureCodexMcp: (
projectPath: string
): Promise<{
success: boolean;
configPath?: string;
error?: string;
@@ -496,8 +539,11 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/features/get", { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post("/api/features/create", { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post("/api/features/update", { projectPath, featureId, updates }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
) => this.post("/api/features/update", { projectPath, featureId, updates }),
delete: (projectPath: string, featureId: string) =>
this.post("/api/features/delete", { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>
@@ -514,8 +560,16 @@ export class HttpApiClient implements ElectronAPI {
this.post("/api/auto-mode/stop-feature", { featureId }),
status: (projectPath?: string) =>
this.post("/api/auto-mode/status", { projectPath }),
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
runFeature: (
projectPath: string,
featureId: string,
useWorktrees?: boolean
) =>
this.post("/api/auto-mode/run-feature", {
projectPath,
featureId,
useWorktrees,
}),
verifyFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
resumeFeature: (projectPath: string, featureId: string) =>
@@ -539,7 +593,10 @@ export class HttpApiClient implements ElectronAPI {
commitFeature: (projectPath: string, featureId: string) =>
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
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) =>
this.post("/api/worktree/diffs", { projectPath, featureId }),
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
@@ -576,20 +637,30 @@ export class HttpApiClient implements ElectronAPI {
stop: () => this.post("/api/suggestions/stop"),
status: () => this.get("/api/suggestions/status"),
onEvent: (callback: (event: SuggestionsEvent) => void) => {
return this.subscribeToEvent("suggestions:event", callback as EventCallback);
return this.subscribeToEvent(
"suggestions:event",
callback as EventCallback
);
},
};
// Spec Regeneration API
specRegeneration: SpecRegenerationAPI = {
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
create: (
projectPath: string,
projectOverview: string,
generateFeatures?: boolean
) =>
this.post("/api/spec-regeneration/create", {
projectPath,
projectOverview,
generateFeatures,
}),
generate: (projectPath: string, projectDefinition: string) =>
this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
this.post("/api/spec-regeneration/generate", {
projectPath,
projectDefinition,
}),
generateFeatures: (projectPath: string) =>
this.post("/api/spec-regeneration/generate-features", { projectPath }),
stop: () => this.post("/api/spec-regeneration/stop"),
@@ -636,7 +707,10 @@ export class HttpApiClient implements ElectronAPI {
// Agent API
agent = {
start: (sessionId: string, workingDirectory?: string): Promise<{
start: (
sessionId: string,
workingDirectory?: string
): Promise<{
success: boolean;
messages?: Message[];
error?: string;
@@ -648,9 +722,16 @@ export class HttpApiClient implements ElectronAPI {
workingDirectory?: string,
imagePaths?: 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;
messages?: Message[];
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 = {
list: (includeArchived?: boolean): Promise<{
list: (
includeArchived?: boolean
): Promise<{
success: boolean;
sessions?: SessionListItem[];
error?: string;
@@ -700,13 +798,19 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{ success: boolean; error?: string }> =>
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`, {}),
unarchive: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
unarchive: (
sessionId: string
): Promise<{ success: boolean; error?: string }> =>
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}`),
};
}

View 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);
}

File diff suppressed because it is too large Load Diff

9
apps/app/src/types/css.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module "*.css" {
const content: { [className: string]: string };
export default content;
}
declare module "@xterm/xterm/css/xterm.css" {
const content: unknown;
export default content;
}

View File

@@ -357,6 +357,50 @@
.download-subtitle a:hover {
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>
</head>
<body>
@@ -382,6 +426,15 @@
<a href="https://github.com/AutoMaker-Org/automaker#getting-started" class="btn btn-secondary" target="_blank">Get Started</a>
</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;">
<p class="download-label">Download for your platform:</p>
<div class="download-buttons">
@@ -492,7 +545,7 @@
<script>
(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() {
try {

View File

@@ -321,7 +321,7 @@
<script>
(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 = {
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>',

View File

@@ -43,3 +43,14 @@ OPENAI_API_KEY=
# Google API key (for future Gemini support)
GOOGLE_API_KEY=
# ============================================
# OPTIONAL - Terminal Access
# ============================================
# Enable/disable terminal access (default: true)
TERMINAL_ENABLED=true
# Password to protect terminal access (leave empty for no password)
# If set, users must enter this password before accessing terminal
TERMINAL_PASSWORD=

View File

@@ -16,6 +16,7 @@
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
},
"devDependencies": {

View File

@@ -29,8 +29,12 @@ import { createModelsRoutes } from "./routes/models.js";
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { createWorkspaceRoutes } from "./routes/workspace.js";
import { createTemplatesRoutes } from "./routes/templates.js";
import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired } from "./routes/terminal.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
import { AutoModeService } from "./services/auto-mode-service.js";
import { getTerminalService } from "./services/terminal-service.js";
// Load environment variables
dotenv.config();
@@ -86,6 +90,7 @@ const events: EventEmitter = createEventEmitter();
// Create services
const agentService = new AgentService(DATA_DIR, events);
const featureLoader = new FeatureLoader();
const autoModeService = new AutoModeService(events);
// Initialize services
(async () => {
@@ -103,22 +108,44 @@ app.use("/api/fs", createFsRoutes(events));
app.use("/api/agent", createAgentRoutes(agentService, events));
app.use("/api/sessions", createSessionsRoutes(agentService));
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/git", createGitRoutes());
app.use("/api/setup", createSetupRoutes());
app.use("/api/suggestions", createSuggestionsRoutes(events));
app.use("/api/models", createModelsRoutes());
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/templates", createTemplatesRoutes());
app.use("/api/terminal", createTerminalRoutes());
// Create HTTP server
const server = createServer(app);
// WebSocket server for streaming events
const wss = new WebSocketServer({ server, path: "/api/events" });
// WebSocket servers using noServer mode for proper multi-path support
const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
// Handle HTTP upgrade requests manually to route to correct WebSocket server
server.on("upgrade", (request, socket, head) => {
const { pathname } = new URL(request.url || "", `http://${request.headers.host}`);
if (pathname === "/api/events") {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
} else if (pathname === "/api/terminal/ws") {
terminalWss.handleUpgrade(request, socket, head, (ws) => {
terminalWss.emit("connection", ws, request);
});
} else {
socket.destroy();
}
});
// Events WebSocket connection handler
wss.on("connection", (ws: WebSocket) => {
console.log("[WebSocket] Client connected");
@@ -140,15 +167,153 @@ wss.on("connection", (ws: WebSocket) => {
});
});
// Track WebSocket connections per session
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
// Terminal WebSocket connection handler
terminalWss.on("connection", (ws: WebSocket, req: import("http").IncomingMessage) => {
// Parse URL to get session ID and token
const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId");
const token = url.searchParams.get("token");
console.log(`[Terminal WS] Connection attempt for session: ${sessionId}`);
// Check if terminal is enabled
if (!isTerminalEnabled()) {
console.log("[Terminal WS] Terminal is disabled");
ws.close(4003, "Terminal access is disabled");
return;
}
// Validate token if password is required
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
console.log("[Terminal WS] Invalid or missing token");
ws.close(4001, "Authentication required");
return;
}
if (!sessionId) {
console.log("[Terminal WS] No session ID provided");
ws.close(4002, "Session ID required");
return;
}
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
console.log(`[Terminal WS] Session ${sessionId} not found`);
ws.close(4004, "Session not found");
return;
}
console.log(`[Terminal WS] Client connected to session ${sessionId}`);
// Track this connection
if (!terminalConnections.has(sessionId)) {
terminalConnections.set(sessionId, new Set());
}
terminalConnections.get(sessionId)!.add(ws);
// Subscribe to terminal data
const unsubscribeData = terminalService.onData((sid, data) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "data", data }));
}
});
// Subscribe to terminal exit
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "exit", exitCode }));
ws.close(1000, "Session ended");
}
});
// Handle incoming messages
ws.on("message", (message) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "input":
// Write user input to terminal
terminalService.write(sessionId, msg.data);
break;
case "resize":
// Resize terminal
if (msg.cols && msg.rows) {
terminalService.resize(sessionId, msg.cols, msg.rows);
}
break;
case "ping":
// Respond to ping
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
console.warn(`[Terminal WS] Unknown message type: ${msg.type}`);
}
} catch (error) {
console.error("[Terminal WS] Error processing message:", error);
}
});
ws.on("close", () => {
console.log(`[Terminal WS] Client disconnected from session ${sessionId}`);
unsubscribeData();
unsubscribeExit();
// Remove from connections tracking
const connections = terminalConnections.get(sessionId);
if (connections) {
connections.delete(ws);
if (connections.size === 0) {
terminalConnections.delete(sessionId);
}
}
});
ws.on("error", (error) => {
console.error(`[Terminal WS] Error on session ${sessionId}:`, error);
unsubscribeData();
unsubscribeExit();
});
// Send initial connection success
ws.send(JSON.stringify({
type: "connected",
sessionId,
shell: session.shell,
cwd: session.cwd,
}));
// Send scrollback buffer to replay previous output
const scrollback = terminalService.getScrollback(sessionId);
if (scrollback && scrollback.length > 0) {
ws.send(JSON.stringify({
type: "scrollback",
data: scrollback,
}));
}
});
// Start server
server.listen(PORT, () => {
const terminalStatus = isTerminalEnabled()
? (isTerminalPasswordRequired() ? "enabled (password protected)" : "enabled")
: "disabled";
console.log(`
╔═══════════════════════════════════════════════════════╗
║ Automaker Backend Server ║
╠═══════════════════════════════════════════════════════╣
║ HTTP API: http://localhost:${PORT}
║ WebSocket: ws://localhost:${PORT}/api/events ║
║ Terminal: ws://localhost:${PORT}/api/terminal/ws ║
║ Health: http://localhost:${PORT}/api/health ║
║ Terminal: ${terminalStatus.padEnd(37)}
╚═══════════════════════════════════════════════════════╝
`);
});
@@ -156,6 +321,7 @@ server.listen(PORT, () => {
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down...");
terminalService.cleanup();
server.close(() => {
console.log("Server closed");
process.exit(0);
@@ -164,6 +330,7 @@ process.on("SIGTERM", () => {
process.on("SIGINT", () => {
console.log("SIGINT received, shutting down...");
terminalService.cleanup();
server.close(() => {
console.log("Server closed");
process.exit(0);

View File

@@ -5,12 +5,10 @@
*/
import { Router, type Request, type Response } from "express";
import type { EventEmitter } from "../lib/events.js";
import { AutoModeService } from "../services/auto-mode-service.js";
import type { AutoModeService } from "../services/auto-mode-service.js";
export function createAutoModeRoutes(events: EventEmitter): Router {
export function createAutoModeRoutes(autoModeService: AutoModeService): Router {
const router = Router();
const autoModeService = new AutoModeService(events);
// Start auto mode loop
router.post("/start", async (req: Request, res: Response) => {

View File

@@ -7,7 +7,6 @@ import { Router, type Request, type Response } from "express";
import fs from "fs/promises";
import os from "os";
import path from "path";
import os from "os";
import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js";
import type { EventEmitter } from "../lib/events.js";
@@ -500,6 +499,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
// SECURITY: Restricted to home directory, allowed paths, and drive roots on Windows
router.post("/browse", async (req: Request, res: Response) => {

View File

@@ -3,32 +3,22 @@
*/
import { Router, type Request, type Response } from "express";
import path from "path";
import type { AutoModeService } from "../services/auto-mode-service.js";
interface RunningAgent {
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 {
export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router {
const router = Router();
// Get all running agents
router.get("/", async (_req: Request, res: Response) => {
try {
const runningAgents = Array.from(runningAgentsMap.values());
const runningAgents = autoModeService.getRunningAgents();
const status = autoModeService.getStatus();
res.json({
success: true,
runningAgents,
totalCount: runningAgents.length,
autoLoopRunning,
autoLoopRunning: status.autoLoopRunning,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
@@ -38,33 +28,3 @@ export function createRunningAgentsRoutes(): 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);
}

View File

@@ -355,7 +355,7 @@ Format your response as markdown. Be specific and actionable.`;
} else if (msg.type === "result" && (msg as any).subtype === "success") {
console.log("[SpecRegeneration] Received success result");
responseText = (msg as any).result || responseText;
} else if (msg.type === "error") {
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
}
@@ -505,7 +505,7 @@ Generate 5-15 features that build on each other logically.`;
} else if (msg.type === "result" && (msg as any).subtype === "success") {
console.log("[SpecRegeneration] Received success result for features");
responseText = (msg as any).result || responseText;
} else if (msg.type === "error") {
} else if ((msg as { type: string }).type === "error") {
console.error("[SpecRegeneration] ❌ Received error message from feature stream:");
console.error("[SpecRegeneration] Error message:", JSON.stringify(msg, null, 2));
}

View 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;
}

View File

@@ -0,0 +1,312 @@
/**
* Terminal routes with password protection
*
* Provides REST API for terminal session management and authentication.
* WebSocket connections for real-time I/O are handled separately in index.ts.
*/
import { Router, Request, Response, NextFunction } from "express";
import { getTerminalService } from "../services/terminal-service.js";
// Read env variables lazily to ensure dotenv has loaded them
function getTerminalPassword(): string | undefined {
return process.env.TERMINAL_PASSWORD;
}
function getTerminalEnabledConfig(): boolean {
return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default
}
// In-memory session tokens (would use Redis in production)
const validTokens: Map<string, { createdAt: Date; expiresAt: Date }> = new Map();
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Generate a secure random token
*/
function generateToken(): string {
return `term-${Date.now()}-${Math.random().toString(36).substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`;
}
/**
* Clean up expired tokens
*/
function cleanupExpiredTokens(): void {
const now = new Date();
validTokens.forEach((data, token) => {
if (data.expiresAt < now) {
validTokens.delete(token);
}
});
}
// Clean up expired tokens every 5 minutes
setInterval(cleanupExpiredTokens, 5 * 60 * 1000);
/**
* Validate a terminal session token
*/
export function validateTerminalToken(token: string | undefined): boolean {
if (!token) return false;
const tokenData = validTokens.get(token);
if (!tokenData) return false;
if (tokenData.expiresAt < new Date()) {
validTokens.delete(token);
return false;
}
return true;
}
/**
* Check if terminal requires password
*/
export function isTerminalPasswordRequired(): boolean {
return !!getTerminalPassword();
}
/**
* Check if terminal is enabled
*/
export function isTerminalEnabled(): boolean {
return getTerminalEnabledConfig();
}
/**
* Terminal authentication middleware
* Checks for valid session token if password is configured
*/
export function terminalAuthMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
// Check if terminal is enabled
if (!getTerminalEnabledConfig()) {
res.status(403).json({
success: false,
error: "Terminal access is disabled",
});
return;
}
// If no password configured, allow all requests
if (!getTerminalPassword()) {
next();
return;
}
// Check for session token
const token =
(req.headers["x-terminal-token"] as string) ||
(req.query.token as string);
if (!validateTerminalToken(token)) {
res.status(401).json({
success: false,
error: "Terminal authentication required",
passwordRequired: true,
});
return;
}
next();
}
export function createTerminalRoutes(): Router {
const router = Router();
const terminalService = getTerminalService();
/**
* GET /api/terminal/status
* Get terminal status (enabled, password required, platform info)
*/
router.get("/status", (_req, res) => {
res.json({
success: true,
data: {
enabled: getTerminalEnabledConfig(),
passwordRequired: !!getTerminalPassword(),
platform: terminalService.getPlatformInfo(),
},
});
});
/**
* POST /api/terminal/auth
* Authenticate with password to get a session token
*/
router.post("/auth", (req, res) => {
if (!getTerminalEnabledConfig()) {
res.status(403).json({
success: false,
error: "Terminal access is disabled",
});
return;
}
const terminalPassword = getTerminalPassword();
// If no password required, return immediate success
if (!terminalPassword) {
res.json({
success: true,
data: {
authenticated: true,
passwordRequired: false,
},
});
return;
}
const { password } = req.body;
if (!password || password !== terminalPassword) {
res.status(401).json({
success: false,
error: "Invalid password",
});
return;
}
// Generate session token
const token = generateToken();
const now = new Date();
validTokens.set(token, {
createdAt: now,
expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS),
});
res.json({
success: true,
data: {
authenticated: true,
token,
expiresIn: TOKEN_EXPIRY_MS,
},
});
});
/**
* POST /api/terminal/logout
* Invalidate a session token
*/
router.post("/logout", (req, res) => {
const token =
(req.headers["x-terminal-token"] as string) ||
req.body.token;
if (token) {
validTokens.delete(token);
}
res.json({
success: true,
});
});
// Apply terminal auth middleware to all routes below
router.use(terminalAuthMiddleware);
/**
* GET /api/terminal/sessions
* List all active terminal sessions
*/
router.get("/sessions", (_req, res) => {
const sessions = terminalService.getAllSessions();
res.json({
success: true,
data: sessions,
});
});
/**
* POST /api/terminal/sessions
* Create a new terminal session
*/
router.post("/sessions", (req, res) => {
try {
const { cwd, cols, rows, shell } = req.body;
const session = terminalService.createSession({
cwd,
cols: cols || 80,
rows: rows || 24,
shell,
});
res.json({
success: true,
data: {
id: session.id,
cwd: session.cwd,
shell: session.shell,
createdAt: session.createdAt,
},
});
} catch (error) {
console.error("[Terminal] Error creating session:", error);
res.status(500).json({
success: false,
error: "Failed to create terminal session",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
/**
* DELETE /api/terminal/sessions/:id
* Kill a terminal session
*/
router.delete("/sessions/:id", (req, res) => {
const { id } = req.params;
const killed = terminalService.killSession(id);
if (!killed) {
res.status(404).json({
success: false,
error: "Session not found",
});
return;
}
res.json({
success: true,
});
});
/**
* POST /api/terminal/sessions/:id/resize
* Resize a terminal session
*/
router.post("/sessions/:id/resize", (req, res) => {
const { id } = req.params;
const { cols, rows } = req.body;
if (!cols || !rows) {
res.status(400).json({
success: false,
error: "cols and rows are required",
});
return;
}
const resized = terminalService.resize(id, cols, rows);
if (!resized) {
res.status(404).json({
success: false,
error: "Session not found",
});
return;
}
res.json({
success: true,
});
});
return router;
}

View File

@@ -9,7 +9,11 @@
* - 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 { promisify } from "util";
import path from "path";
@@ -20,11 +24,13 @@ const execAsync = promisify(exec);
interface Feature {
id: string;
title: string;
category: string;
description: string;
steps?: string[];
status: string;
priority?: number;
spec?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
[key: string]: unknown; // Allow additional fields
}
interface RunningFeature {
@@ -85,7 +91,11 @@ export class AutoModeService {
}
private async runAutoLoop(): Promise<void> {
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
while (
this.autoLoopRunning &&
this.autoLoopAbortController &&
!this.autoLoopAbortController.signal.aborted
) {
try {
// Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
@@ -94,7 +104,9 @@ export class AutoModeService {
}
// Load pending features
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
const pendingFeatures = await this.loadPendingFeatures(
this.config!.projectPath
);
if (pendingFeatures.length === 0) {
this.emitAutoModeEvent("auto_mode_complete", {
@@ -105,7 +117,9 @@ export class AutoModeService {
}
// 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) {
// Start feature execution in background
@@ -164,7 +178,11 @@ export class AutoModeService {
// Setup worktree if enabled
if (useWorktrees) {
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
worktreePath = await this.setupWorktree(
projectPath,
featureId,
branchName
);
}
const workDir = worktreePath || projectPath;
@@ -183,7 +201,11 @@ export class AutoModeService {
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
projectPath,
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
feature: {
id: featureId,
title: "Loading...",
description: "Feature is starting",
},
});
try {
@@ -199,20 +221,38 @@ export class AutoModeService {
// Build the prompt
const prompt = this.buildFeaturePrompt(feature);
// Run the agent
await this.runAgent(workDir, featureId, prompt, abortController);
// Extract image paths from feature
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
await this.updateFeatureStatus(projectPath, featureId, "waiting_approval");
await this.updateFeatureStatus(
projectPath,
featureId,
"waiting_approval"
);
this.emitAutoModeEvent("auto_mode_feature_complete", {
featureId,
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,
});
} 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", {
featureId,
passes: false,
@@ -221,9 +261,10 @@ export class AutoModeService {
});
} else {
const errorMessage = (error as Error).message || "Unknown error";
const isAuthError = errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
const isAuthError =
errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "backlog");
@@ -280,7 +321,12 @@ export class AutoModeService {
if (hasContext) {
// Load previous context and continue
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
@@ -303,7 +349,12 @@ export class AutoModeService {
const abortController = new AbortController();
// Check if worktree exists
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
@@ -366,14 +417,28 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId,
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 {
await this.runAgent(workDir, featureId, fullPrompt, abortController, imagePaths);
await this.runAgent(
workDir,
featureId,
fullPrompt,
abortController,
imagePaths
);
// 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", {
featureId,
@@ -397,8 +462,16 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Verify a feature's implementation
*/
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
async verifyFeature(
projectPath: string,
featureId: string
): Promise<boolean> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
@@ -417,7 +490,8 @@ Address the follow-up instructions above. Review the previous work and make the
];
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) {
try {
@@ -425,7 +499,11 @@ Address the follow-up instructions above. Review the previous work and make the
cwd: workDir,
timeout: 120000,
});
results.push({ check: check.name, passed: true, output: stdout || stderr });
results.push({
check: check.name,
passed: true,
output: stdout || stderr,
});
} catch (error) {
allPassed = false;
results.push({
@@ -442,7 +520,9 @@ Address the follow-up instructions above. Review the previous work and make the
passes: allPassed,
message: allPassed
? "All verification checks passed"
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
: `Verification failed: ${
results.find((r) => !r.passed)?.check || "Unknown"
}`,
});
return allPassed;
@@ -451,8 +531,16 @@ Address the follow-up instructions above. Review the previous work and make the
/**
* Commit feature changes
*/
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
async commitFeature(
projectPath: string,
featureId: string
): Promise<string | null> {
const worktreePath = path.join(
projectPath,
".automaker",
"worktrees",
featureId
);
let workDir = projectPath;
try {
@@ -464,7 +552,9 @@ Address the follow-up instructions above. Review the previous work and make the
try {
// 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()) {
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
const feature = await this.loadFeature(projectPath, featureId);
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}`;
// Stage and commit
@@ -482,7 +574,9 @@ Address the follow-up instructions above. Review the previous work and make the
});
// 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", {
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
*/
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
async contextExists(
projectPath: string,
featureId: string
): Promise<boolean> {
const contextPath = path.join(
projectPath,
".automaker",
@@ -527,7 +624,11 @@ Address the follow-up instructions above. Review the previous work and make the
this.emitAutoModeEvent("auto_mode_feature_start", {
featureId: analysisFeatureId,
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:
@@ -570,7 +671,11 @@ Format your response as a structured markdown document.`;
}
// 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.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 async setupWorktree(
@@ -647,7 +769,10 @@ Format your response as a structured markdown document.`;
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(
projectPath,
".automaker",
@@ -682,6 +807,14 @@ Format your response as a structured markdown document.`;
const feature = JSON.parse(data);
feature.status = status;
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));
} catch {
// Feature file may not exist
@@ -697,7 +830,11 @@ Format your response as a structured markdown document.`;
for (const entry of entries) {
if (entry.isDirectory()) {
const featurePath = path.join(featuresDir, entry.name, "feature.json");
const featurePath = path.join(
featuresDir,
entry.name,
"feature.json"
);
try {
const data = await fs.readFile(featurePath, "utf-8");
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 {
const title = this.extractTitleFromDescription(feature.description);
let prompt = `## Feature Implementation Task
**Feature ID:** ${feature.id}
**Title:** ${feature.title}
**Title:** ${title}
**Description:** ${feature.description}
`;
if (feature.spec) {
prompt += `
**Specification:**
${feature.spec}
`;
}
prompt += `
## Instructions
@@ -758,14 +933,7 @@ When done, summarize what you implemented and any notes for the developer.`;
model: "claude-opus-4-5-20251101",
maxTurns: 50,
cwd: workDir,
allowedTools: [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"Bash",
],
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash"],
permissionMode: "acceptEdits",
sandbox: {
enabled: true,
@@ -778,12 +946,20 @@ When done, summarize what you implemented and any notes for the developer.`;
let finalPrompt = prompt;
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 });
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) {
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;
// Check for authentication errors in the response
if (block.text.includes("Invalid API key") ||
block.text.includes("authentication_failed") ||
block.text.includes("Fix external API key")) {
if (
block.text.includes("Invalid API key") ||
block.text.includes("authentication_failed") ||
block.text.includes("Fix external API key")
) {
throw new Error(
"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
throw new Error(
"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") {
// Check if result indicates an error
if (msg.is_error && msg.result?.includes("Invalid API key")) {
throw new Error(
"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;

View File

@@ -0,0 +1,401 @@
/**
* Terminal Service
*
* Manages PTY (pseudo-terminal) sessions using node-pty.
* Supports cross-platform shell detection including WSL.
*/
import * as pty from "node-pty";
import { EventEmitter } from "events";
import * as os from "os";
import * as fs from "fs";
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 16; // ~60fps max update rate
const OUTPUT_BATCH_SIZE = 8192; // Max bytes to send per batch
export interface TerminalSession {
id: string;
pty: pty.IPty;
cwd: string;
createdAt: Date;
shell: string;
scrollbackBuffer: string; // Store recent output for replay on reconnect
outputBuffer: string; // Pending output to be flushed
flushTimeout: NodeJS.Timeout | null; // Throttle timer
}
export interface TerminalOptions {
cwd?: string;
shell?: string;
cols?: number;
rows?: number;
env?: Record<string, string>;
}
type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void;
export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set();
/**
* Detect the best shell for the current platform
*/
detectShell(): { shell: string; args: string[] } {
const platform = os.platform();
// Check if running in WSL
if (platform === "linux" && this.isWSL()) {
// In WSL, prefer the user's configured shell or bash
const userShell = process.env.SHELL || "/bin/bash";
if (fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] };
}
return { shell: "/bin/bash", args: ["--login"] };
}
switch (platform) {
case "win32": {
// Windows: prefer PowerShell, fall back to cmd
const pwsh = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
const pwshCore = "C:\\Program Files\\PowerShell\\7\\pwsh.exe";
if (fs.existsSync(pwshCore)) {
return { shell: pwshCore, args: [] };
}
if (fs.existsSync(pwsh)) {
return { shell: pwsh, args: [] };
}
return { shell: "cmd.exe", args: [] };
}
case "darwin": {
// macOS: prefer user's shell, then zsh, then bash
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] };
}
if (fs.existsSync("/bin/zsh")) {
return { shell: "/bin/zsh", args: ["--login"] };
}
return { shell: "/bin/bash", args: ["--login"] };
}
case "linux":
default: {
// Linux: prefer user's shell, then bash, then sh
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) {
return { shell: userShell, args: ["--login"] };
}
if (fs.existsSync("/bin/bash")) {
return { shell: "/bin/bash", args: ["--login"] };
}
return { shell: "/bin/sh", args: [] };
}
}
}
/**
* Detect if running inside WSL (Windows Subsystem for Linux)
*/
isWSL(): boolean {
try {
// Check /proc/version for Microsoft/WSL indicators
if (fs.existsSync("/proc/version")) {
const version = fs.readFileSync("/proc/version", "utf-8").toLowerCase();
return version.includes("microsoft") || version.includes("wsl");
}
// Check for WSL environment variable
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
return true;
}
} catch {
// Ignore errors
}
return false;
}
/**
* Get platform info for the client
*/
getPlatformInfo(): {
platform: string;
isWSL: boolean;
defaultShell: string;
arch: string;
} {
const { shell } = this.detectShell();
return {
platform: os.platform(),
isWSL: this.isWSL(),
defaultShell: shell,
arch: os.arch(),
};
}
/**
* Validate and resolve a working directory path
*/
private resolveWorkingDirectory(requestedCwd?: string): string {
const homeDir = os.homedir();
// If no cwd requested, use home
if (!requestedCwd) {
return homeDir;
}
// Clean up the path
let cwd = requestedCwd.trim();
// Fix double slashes at start (but not for Windows UNC paths)
if (cwd.startsWith("//") && !cwd.startsWith("//wsl")) {
cwd = cwd.slice(1);
}
// Check if path exists and is a directory
try {
const stat = fs.statSync(cwd);
if (stat.isDirectory()) {
return cwd;
}
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
console.warn(`[Terminal] Working directory does not exist: ${cwd}, falling back to home`);
return homeDir;
}
}
/**
* Create a new terminal session
*/
createSession(options: TerminalOptions = {}): TerminalSession {
const id = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const { shell: detectedShell, args: shellArgs } = this.detectShell();
const shell = options.shell || detectedShell;
// Validate and resolve working directory
const cwd = this.resolveWorkingDirectory(options.cwd);
// Build environment with some useful defaults
const env: Record<string, string> = {
...process.env,
TERM: "xterm-256color",
COLORTERM: "truecolor",
TERM_PROGRAM: "automaker-terminal",
...options.env,
};
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
cols: options.cols || 80,
rows: options.rows || 24,
cwd,
env,
});
const session: TerminalSession = {
id,
pty: ptyProcess,
cwd,
createdAt: new Date(),
shell,
scrollbackBuffer: "",
outputBuffer: "",
flushTimeout: null,
};
this.sessions.set(id, session);
// Flush buffered output to clients (throttled)
const flushOutput = () => {
if (session.outputBuffer.length === 0) return;
// Send in batches if buffer is large
let dataToSend = session.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else {
session.outputBuffer = "";
session.flushTimeout = null;
}
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit("data", id, dataToSend);
};
// Forward data events with throttling
ptyProcess.onData((data) => {
// Append to scrollback buffer
session.scrollbackBuffer += data;
// Trim if too large (keep the most recent data)
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
// Buffer output for throttled delivery
session.outputBuffer += data;
// Schedule flush if not already scheduled
if (!session.flushTimeout) {
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
}
});
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit("exit", id, exitCode);
});
console.log(`[Terminal] Session ${id} created successfully`);
return session;
}
/**
* Write data to a terminal session
*/
write(sessionId: string, data: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found`);
return false;
}
session.pty.write(data);
return true;
}
/**
* Resize a terminal session
*/
resize(sessionId: string, cols: number, rows: number): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
return false;
}
try {
session.pty.resize(cols, rows);
return true;
} catch (error) {
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
return false;
}
}
/**
* Kill a terminal session
*/
killSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
session.pty.kill();
this.sessions.delete(sessionId);
console.log(`[Terminal] Session ${sessionId} killed`);
return true;
} catch (error) {
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
return false;
}
}
/**
* Get a session by ID
*/
getSession(sessionId: string): TerminalSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Get scrollback buffer for a session (for replay on reconnect)
*/
getScrollback(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.scrollbackBuffer || null;
}
/**
* Get all active sessions
*/
getAllSessions(): Array<{
id: string;
cwd: string;
createdAt: Date;
shell: string;
}> {
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
cwd: s.cwd,
createdAt: s.createdAt,
shell: s.shell,
}));
}
/**
* Subscribe to data events
*/
onData(callback: DataCallback): () => void {
this.dataCallbacks.add(callback);
return () => this.dataCallbacks.delete(callback);
}
/**
* Subscribe to exit events
*/
onExit(callback: ExitCallback): () => void {
this.exitCallbacks.add(callback);
return () => this.exitCallbacks.delete(callback);
}
/**
* Clean up all sessions
*/
cleanup(): void {
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
}
session.pty.kill();
} catch {
// Ignore errors during cleanup
}
this.sessions.delete(id);
});
}
}
// Singleton instance
let terminalService: TerminalService | null = null;
export function getTerminalService(): TerminalService {
if (!terminalService) {
terminalService = new TerminalService();
}
return terminalService;
}

93
docs/terminal.md Normal file
View File

@@ -0,0 +1,93 @@
# Terminal
The integrated terminal provides a full-featured terminal emulator within Automaker, powered by xterm.js.
## Configuration
Configure the terminal via environment variables in `apps/server/.env`:
### Disable Terminal Completely
```
TERMINAL_ENABLED=false
```
Set to `false` to completely disable the terminal feature.
### Password Protection
```
TERMINAL_PASSWORD=yourpassword
```
By default, the terminal is **not password protected**. Add this variable to require a password.
When password protection is enabled:
- Enter the password in **Settings > Terminal** to unlock
- The terminal remains unlocked for the session
- You can toggle password requirement on/off in settings after unlocking
## Keyboard Shortcuts
When the terminal is focused, the following shortcuts are available:
| Shortcut | Action |
|----------|--------|
| `Alt+D` | Split terminal right (horizontal split) |
| `Alt+S` | Split terminal down (vertical split) |
| `Alt+W` | Close current terminal |
Global shortcut (works anywhere in the app):
| Shortcut | Action |
|----------|--------|
| `Cmd+`` (Mac) / `Ctrl+`` (Windows/Linux) | Toggle terminal view |
## Features
### Multiple Terminals
- Create multiple terminal tabs using the `+` button
- Split terminals horizontally or vertically within a tab
- Drag terminals to rearrange them
### Theming
The terminal automatically matches your app theme. Supported themes include:
- Light / Dark / System
- Retro, Dracula, Nord, Monokai
- Tokyo Night, Solarized, Gruvbox
- Catppuccin, One Dark, Synthwave, Red
### Font Size
- Use the zoom controls (`+`/`-` buttons) in each terminal panel
- Or use `Cmd/Ctrl + Scroll` to zoom
### Scrollback
- The terminal maintains a scrollback buffer of recent output
- Scroll up to view previous output
- Output is preserved when reconnecting
## Architecture
The terminal uses a client-server architecture:
1. **Frontend** (`apps/app`): xterm.js terminal emulator with WebGL rendering
2. **Backend** (`apps/server`): node-pty for PTY (pseudo-terminal) sessions
Communication happens over WebSocket for real-time bidirectional data flow.
### Shell Detection
The server automatically detects the best shell:
- **WSL**: User's shell or `/bin/bash`
- **macOS**: User's shell, zsh, or bash
- **Linux**: User's shell, bash, or sh
- **Windows**: PowerShell 7, PowerShell, or cmd.exe
## Troubleshooting
### Terminal not connecting
1. Ensure the server is running (`npm run dev:server`)
2. Check that port 3008 is available
3. Verify the terminal is unlocked
### Slow performance with heavy output
The terminal throttles output at ~60fps to prevent UI lockup. Very fast output (like `cat` on large files) will be batched.
### Shortcuts not working
- Ensure the terminal is focused (click inside it)
- Some system shortcuts may conflict (especially Alt+Shift combinations on Windows)

55
package-lock.json generated
View File

@@ -30,6 +30,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -40,6 +43,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^3.0.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.9"
@@ -10256,6 +10260,7 @@
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"node-pty": "1.1.0-beta41",
"ws": "^8.18.0"
},
"devDependencies": {
@@ -11948,6 +11953,30 @@
"@types/node": "*"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-webgl": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/@zeit/schemas": {
"version": "2.36.0",
"resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz",
@@ -13517,6 +13546,22 @@
}
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0-beta41",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta41.tgz",
"integrity": "sha512-OUT29KMnzh1IS0b2YcUwVz56D4iAXDsl2PtIKP3zHMljiUBq2WcaHEFfhzQfgkhWs2SExcXvfdlBPANDVU9SnQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -13765,6 +13810,16 @@
"react": "^19.2.0"
}
},
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/registry-auth-token": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz",