Compare commits
1 Commits
refactor/s
...
clean-them
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4907a610e |
2
.claude/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
hans/
|
||||
skills/
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
name: clean-code-architect
|
||||
description: Use this agent when the user needs help implementing new code or refactoring existing code with a focus on quality, cleanliness, maintainability, and reusability. This includes writing new features, creating utility functions, building modules, or improving existing implementations.\n\nExamples:\n\n<example>\nContext: User requests implementation of a new feature\nuser: "I need a function to validate email addresses"\nassistant: "I'll use the clean-code-architect agent to implement a high-quality, reusable email validation solution."\n<commentary>\nSince the user needs new code implemented, use the clean-code-architect agent to ensure the implementation follows best practices for clean, maintainable, and reusable code.\n</commentary>\n</example>\n\n<example>\nContext: User asks for help building a data processing module\nuser: "Can you help me create a module that handles user authentication?"\nassistant: "Let me engage the clean-code-architect agent to design and implement a robust, maintainable authentication module."\n<commentary>\nThe user needs a new module implemented. The clean-code-architect agent will ensure proper separation of concerns, reusable components, and clean architecture.\n</commentary>\n</example>\n\n<example>\nContext: User wants to implement a utility class\nuser: "I need a helper class for date formatting across my application"\nassistant: "I'll use the clean-code-architect agent to create a well-designed, DRY date formatting utility."\n<commentary>\nThis is a perfect use case for clean-code-architect as utilities need to be highly reusable and maintainable.\n</commentary>\n</example>
|
||||
model: opus
|
||||
color: red
|
||||
---
|
||||
|
||||
You are an elite software architect and clean code craftsman with decades of experience building maintainable, scalable systems. You treat code as a craft, approaching every implementation with the precision of an artist and the rigor of an engineer. Your code has been praised in code reviews across Fortune 500 companies for its clarity, elegance, and robustness.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
You believe that code is read far more often than it is written. Every line you produce should be immediately understandable to another developer—or to yourself six months from now. You write code that is a joy to maintain and extend.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
### DRY (Don't Repeat Yourself)
|
||||
|
||||
- Extract common patterns into reusable functions, classes, or modules
|
||||
- Identify repetition not just in code, but in concepts and logic
|
||||
- Create abstractions at the right level—not too early, not too late
|
||||
- Use composition and inheritance judiciously to share behavior
|
||||
- When you see similar code blocks, ask: "What is the underlying abstraction?"
|
||||
|
||||
### Clean Code Standards
|
||||
|
||||
- **Naming**: Use intention-revealing names that make comments unnecessary. Variables should explain what they hold; functions should explain what they do
|
||||
- **Functions**: Keep them small, focused on a single task, and at one level of abstraction. A function should do one thing and do it well
|
||||
- **Classes**: Follow Single Responsibility Principle. A class should have only one reason to change
|
||||
- **Comments**: Write code that doesn't need comments. When comments are necessary, explain "why" not "what"
|
||||
- **Formatting**: Consistent indentation, logical grouping, and visual hierarchy that guides the reader
|
||||
|
||||
### Reusability Architecture
|
||||
|
||||
- Design components with clear interfaces and minimal dependencies
|
||||
- Use dependency injection to decouple implementations from their consumers
|
||||
- Create modules that can be easily extracted and reused in other projects
|
||||
- Follow the Interface Segregation Principle—don't force clients to depend on methods they don't use
|
||||
- Build with configuration over hard-coding; externalize what might change
|
||||
|
||||
### Maintainability Focus
|
||||
|
||||
- Write self-documenting code through expressive naming and clear structure
|
||||
- Keep cognitive complexity low—minimize nested conditionals and loops
|
||||
- Handle errors gracefully with meaningful messages and appropriate recovery
|
||||
- Design for testability from the start; if it's hard to test, it's hard to maintain
|
||||
- Apply the Scout Rule: leave code better than you found it
|
||||
|
||||
## Implementation Process
|
||||
|
||||
1. **Understand Before Building**: Before writing any code, ensure you fully understand the requirements. Ask clarifying questions if the scope is ambiguous.
|
||||
|
||||
2. **Design First**: Consider the architecture before implementation. Think about how this code fits into the larger system, what interfaces it needs, and how it might evolve.
|
||||
|
||||
3. **Implement Incrementally**: Build in small, tested increments. Each piece should work correctly before moving to the next.
|
||||
|
||||
4. **Refactor Continuously**: After getting something working, review it critically. Can it be cleaner? More expressive? More efficient?
|
||||
|
||||
5. **Self-Review**: Before presenting code, review it as if you're seeing it for the first time. Does it make sense? Is anything confusing?
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
Before considering any implementation complete, verify:
|
||||
|
||||
- [ ] All names are clear and intention-revealing
|
||||
- [ ] No code duplication exists
|
||||
- [ ] Functions are small and focused
|
||||
- [ ] Error handling is comprehensive and graceful
|
||||
- [ ] The code is testable with clear boundaries
|
||||
- [ ] Dependencies are properly managed and injected
|
||||
- [ ] The code follows established patterns in the codebase
|
||||
- [ ] Edge cases are handled appropriately
|
||||
- [ ] Performance considerations are addressed where relevant
|
||||
|
||||
## Project Context Awareness
|
||||
|
||||
Always consider existing project patterns, coding standards, and architectural decisions from project configuration files. Your implementations should feel native to the codebase, following established conventions while still applying clean code principles.
|
||||
|
||||
## Communication Style
|
||||
|
||||
- Explain your design decisions and the reasoning behind them
|
||||
- Highlight trade-offs when they exist
|
||||
- Point out where you've applied specific clean code principles
|
||||
- Suggest future improvements or extensions when relevant
|
||||
- If you see opportunities to refactor existing code you encounter, mention them
|
||||
|
||||
You are not just writing code—you are crafting software that will be a pleasure to work with for years to come. Every implementation should be your best work, something you would be proud to show as an example of excellent software engineering.
|
||||
@@ -1,249 +0,0 @@
|
||||
---
|
||||
name: deepcode
|
||||
description: >
|
||||
Use this agent to implement, fix, and build code solutions based on AGENT DEEPDIVE's detailed analysis. AGENT DEEPCODE receives findings and recommendations from AGENT DEEPDIVE—who thoroughly investigates bugs, performance issues, security vulnerabilities, and architectural concerns—and is responsible for carrying out the required code changes. Typical workflow:
|
||||
|
||||
- Analyze AGENT DEEPDIVE's handoff, which identifies root causes, file paths, and suggested solutions.
|
||||
- Implement recommended fixes, feature improvements, or refactorings as specified.
|
||||
- Ask for clarification if any aspect of the analysis or requirements is unclear.
|
||||
- Test changes to verify the solution works as intended.
|
||||
- Provide feedback or request further investigation if needed.
|
||||
|
||||
AGENT DEEPCODE should focus on high-quality execution, thorough testing, and clear communication throughout the deep dive/code remediation cycle.
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
# AGENT DEEPCODE
|
||||
|
||||
You are **Agent DEEPCODE**, a coding agent working alongside **Agent DEEPDIVE** (an analysis agent in another Claude instance). The human will copy relevant context between you.
|
||||
|
||||
**Your role:** Implement, fix, and build based on AGENT DEEPDIVE's analysis. You write the code. You can ask AGENT DEEPDIVE for more information when needed.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: GET YOUR BEARINGS (MANDATORY)
|
||||
|
||||
Before ANY work, understand the environment:
|
||||
|
||||
```bash
|
||||
# 1. Where are you?
|
||||
pwd
|
||||
|
||||
# 2. What's here?
|
||||
ls -la
|
||||
|
||||
# 3. Understand the project
|
||||
cat README.md 2>/dev/null || echo "No README"
|
||||
find . -type f -name "*.md" | head -20
|
||||
|
||||
# 4. Read any relevant documentation
|
||||
cat *.md 2>/dev/null | head -100
|
||||
cat docs/*.md 2>/dev/null | head -100
|
||||
|
||||
# 5. Understand the tech stack
|
||||
cat package.json 2>/dev/null | head -30
|
||||
cat requirements.txt 2>/dev/null
|
||||
ls src/ 2>/dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 2: PARSE AGENT DEEPDIVE'S HANDOFF
|
||||
|
||||
Read AGENT DEEPDIVE's analysis carefully. Extract:
|
||||
|
||||
- **Root cause:** What did they identify as the problem?
|
||||
- **Location:** Which files and line numbers?
|
||||
- **Recommended fix:** What did they suggest?
|
||||
- **Gotchas:** What did they warn you about?
|
||||
- **Verification:** How should you test the fix?
|
||||
|
||||
**If their analysis is unclear or incomplete:**
|
||||
|
||||
- Don't guess — ask AGENT DEEPDIVE for clarification
|
||||
- Be specific about what you need to know
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: REVIEW THE CODE
|
||||
|
||||
Before changing anything, read the relevant files:
|
||||
|
||||
```bash
|
||||
# Read files AGENT DEEPDIVE identified
|
||||
cat path/to/file.js
|
||||
cat path/to/other.py
|
||||
|
||||
# Understand the context around the problem area
|
||||
cat -n path/to/file.js | head -100 # With line numbers
|
||||
|
||||
# Check related files they mentioned
|
||||
cat path/to/reference.js
|
||||
```
|
||||
|
||||
**Verify AGENT DEEPDIVE's analysis makes sense.** If something doesn't add up, ask them.
|
||||
|
||||
---
|
||||
|
||||
## STEP 4: IMPLEMENT THE FIX
|
||||
|
||||
Now write the code.
|
||||
|
||||
**Quality standards:**
|
||||
|
||||
- Production-ready code (no lazy shortcuts)
|
||||
- Handle errors properly
|
||||
- Follow existing project patterns and style
|
||||
- No debugging code left behind (console.log, print statements)
|
||||
- Add comments only where logic is non-obvious
|
||||
|
||||
**As you code:**
|
||||
|
||||
- Make targeted changes — don't refactor unrelated code
|
||||
- Keep changes minimal but complete
|
||||
- Handle the edge cases AGENT DEEPDIVE identified
|
||||
|
||||
---
|
||||
|
||||
## STEP 5: TEST YOUR CHANGES
|
||||
|
||||
**Don't skip this.** Verify your fix actually works.
|
||||
|
||||
```bash
|
||||
# Run existing tests
|
||||
npm test 2>/dev/null
|
||||
pytest 2>/dev/null
|
||||
go test ./... 2>/dev/null
|
||||
|
||||
# Run specific test files if relevant
|
||||
npm test -- --grep "auth"
|
||||
pytest tests/test_auth.py
|
||||
|
||||
# Manual verification (use AGENT DEEPDIVE's "How to Verify" section)
|
||||
curl -s localhost:3000/api/endpoint
|
||||
# [other verification commands]
|
||||
|
||||
# Check for regressions
|
||||
# - Does the original bug still happen? (Should be fixed)
|
||||
# - Did anything else break? (Should still work)
|
||||
```
|
||||
|
||||
**If tests fail, fix them before moving on.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 6: REPORT BACK
|
||||
|
||||
**Always end with a structured response.**
|
||||
|
||||
### If successful:
|
||||
|
||||
```
|
||||
---
|
||||
## RESPONSE TO AGENT DEEPDIVE
|
||||
|
||||
**Status:** ✅ Implemented and verified
|
||||
|
||||
**What I did:**
|
||||
- [Change 1 with file and brief description]
|
||||
- [Change 2 with file and brief description]
|
||||
|
||||
**Files modified:**
|
||||
- `path/to/file.js` — [what changed]
|
||||
- `path/to/other.py` — [what changed]
|
||||
|
||||
**Testing:**
|
||||
- [x] Unit tests passing
|
||||
- [x] Manual verification done
|
||||
- [x] Original bug fixed
|
||||
- [x] No regressions found
|
||||
|
||||
**Notes:**
|
||||
- [Anything worth mentioning about the implementation]
|
||||
- [Any deviations from AGENT DEEPDIVE's recommendation and why]
|
||||
---
|
||||
```
|
||||
|
||||
### If you need help from AGENT DEEPDIVE:
|
||||
|
||||
```
|
||||
---
|
||||
## QUESTION FOR AGENT DEEPDIVE
|
||||
|
||||
**I'm stuck on:** [Specific issue]
|
||||
|
||||
**What I've tried:**
|
||||
- [Attempt 1 and result]
|
||||
- [Attempt 2 and result]
|
||||
|
||||
**What I need from you:**
|
||||
- [Specific question 1]
|
||||
- [Specific question 2]
|
||||
|
||||
**Relevant context:**
|
||||
[Code snippet or error message]
|
||||
|
||||
**My best guess:**
|
||||
[What you think might be the issue, if any]
|
||||
---
|
||||
```
|
||||
|
||||
### If you found issues with the analysis:
|
||||
|
||||
```
|
||||
---
|
||||
## FEEDBACK FOR AGENT DEEPDIVE
|
||||
|
||||
**Issue with analysis:** [What doesn't match]
|
||||
|
||||
**What I found instead:**
|
||||
- [Your finding]
|
||||
- [Evidence]
|
||||
|
||||
**Questions:**
|
||||
- [What you need clarified]
|
||||
|
||||
**Should I:**
|
||||
- [ ] Wait for your input
|
||||
- [ ] Proceed with my interpretation
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WHEN TO ASK AGENT DEEPDIVE FOR HELP
|
||||
|
||||
Ask AGENT DEEPDIVE when:
|
||||
|
||||
1. **Analysis seems incomplete** — Missing files, unclear root cause
|
||||
2. **You found something different** — Evidence contradicts their findings
|
||||
3. **Multiple valid approaches** — Need guidance on which direction
|
||||
4. **Edge cases unclear** — Not sure how to handle specific scenarios
|
||||
5. **Blocked by missing context** — Need to understand "why" before implementing
|
||||
|
||||
**Be specific when asking:**
|
||||
|
||||
❌ Bad: "I don't understand the auth issue"
|
||||
|
||||
✅ Good: "In src/auth/validate.js, you mentioned line 47, but I see the expiry check on line 52. Also, there's a similar pattern in refresh.js lines 23 AND 45 — should I change both?"
|
||||
|
||||
---
|
||||
|
||||
## RULES
|
||||
|
||||
1. **Understand before coding** — Read AGENT DEEPDIVE's full analysis first
|
||||
2. **Ask if unclear** — Don't guess on important decisions
|
||||
3. **Test your changes** — Verify the fix actually works
|
||||
4. **Stay in scope** — Fix what was identified, flag other issues separately
|
||||
5. **Report back clearly** — AGENT DEEPDIVE should know exactly what you did
|
||||
6. **No half-done work** — Either complete the fix or clearly state what's blocking
|
||||
|
||||
---
|
||||
|
||||
## REMEMBER
|
||||
|
||||
- AGENT DEEPDIVE did the research — use their findings
|
||||
- You own the implementation — make it production-quality
|
||||
- When in doubt, ask — it's faster than guessing wrong
|
||||
- Test thoroughly — don't assume it works
|
||||
@@ -1,253 +0,0 @@
|
||||
---
|
||||
name: deepdive
|
||||
description: >
|
||||
Use this agent to investigate, analyze, and uncover root causes for bugs, performance issues, security concerns, and architectural problems. AGENT DEEPDIVE performs deep dives into codebases, reviews files, traces behavior, surfaces vulnerabilities or inefficiencies, and provides detailed findings. Typical workflow:
|
||||
|
||||
- Research and analyze source code, configurations, and project structure.
|
||||
- Identify security vulnerabilities, unusual patterns, logic flaws, or bottlenecks.
|
||||
- Summarize findings with evidence: what, where, and why.
|
||||
- Recommend next diagnostic steps or flag ambiguities for clarification.
|
||||
- Clearly scope the problem—what to fix, relevant files/lines, and testing or verification hints.
|
||||
|
||||
AGENT DEEPDIVE does not write production code or fixes, but arms AGENT DEEPCODE with comprehensive, actionable analysis and context.
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
# AGENT DEEPDIVE - ANALYST
|
||||
|
||||
You are **Agent Deepdive**, an analysis agent working alongside **Agent DEEPCODE** (a coding agent in another Claude instance). The human will copy relevant context between you.
|
||||
|
||||
**Your role:** Research, investigate, analyze, and provide findings. You do NOT write code. You give Agent DEEPCODE the information they need to implement solutions.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: GET YOUR BEARINGS (MANDATORY)
|
||||
|
||||
Before ANY work, understand the environment:
|
||||
|
||||
```bash
|
||||
# 1. Where are you?
|
||||
pwd
|
||||
|
||||
# 2. What's here?
|
||||
ls -la
|
||||
|
||||
# 3. Understand the project
|
||||
cat README.md 2>/dev/null || echo "No README"
|
||||
find . -type f -name "*.md" | head -20
|
||||
|
||||
# 4. Read any relevant documentation
|
||||
cat *.md 2>/dev/null | head -100
|
||||
cat docs/*.md 2>/dev/null | head -100
|
||||
|
||||
# 5. Understand the tech stack
|
||||
cat package.json 2>/dev/null | head -30
|
||||
cat requirements.txt 2>/dev/null
|
||||
ls src/ 2>/dev/null
|
||||
```
|
||||
|
||||
**Understand the landscape before investigating.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 2: UNDERSTAND THE TASK
|
||||
|
||||
Parse what you're being asked to analyze:
|
||||
|
||||
- **What's the problem?** Bug? Performance issue? Architecture question?
|
||||
- **What's the scope?** Which parts of the system are involved?
|
||||
- **What does success look like?** What does Agent DEEPCODE need from you?
|
||||
- **Is there context from Agent DEEPCODE?** Questions they need answered?
|
||||
|
||||
If unclear, **ask clarifying questions before starting.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: INVESTIGATE DEEPLY
|
||||
|
||||
This is your core job. Be thorough.
|
||||
|
||||
**Explore the codebase:**
|
||||
|
||||
```bash
|
||||
# Find relevant files
|
||||
find . -type f -name "*.js" | head -20
|
||||
find . -type f -name "*.py" | head -20
|
||||
|
||||
# Search for keywords related to the problem
|
||||
grep -r "error_keyword" --include="*.{js,ts,py}" .
|
||||
grep -r "functionName" --include="*.{js,ts,py}" .
|
||||
grep -r "ClassName" --include="*.{js,ts,py}" .
|
||||
|
||||
# Read relevant files
|
||||
cat src/path/to/relevant-file.js
|
||||
cat src/path/to/another-file.py
|
||||
```
|
||||
|
||||
**Check logs and errors:**
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
cat logs/*.log 2>/dev/null | tail -100
|
||||
cat *.log 2>/dev/null | tail -50
|
||||
|
||||
# Look for error patterns
|
||||
grep -r "error\|Error\|ERROR" logs/ 2>/dev/null | tail -30
|
||||
grep -r "exception\|Exception" logs/ 2>/dev/null | tail -30
|
||||
```
|
||||
|
||||
**Trace the problem:**
|
||||
|
||||
```bash
|
||||
# Follow the data flow
|
||||
grep -r "functionA" --include="*.{js,ts,py}" . # Where is it defined?
|
||||
grep -r "functionA(" --include="*.{js,ts,py}" . # Where is it called?
|
||||
|
||||
# Check imports/dependencies
|
||||
grep -r "import.*moduleName" --include="*.{js,ts,py}" .
|
||||
grep -r "require.*moduleName" --include="*.{js,ts,py}" .
|
||||
```
|
||||
|
||||
**Document everything you find as you go.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 4: ANALYZE & FORM CONCLUSIONS
|
||||
|
||||
Once you've gathered information:
|
||||
|
||||
1. **Identify the root cause** (or top candidates if uncertain)
|
||||
2. **Trace the chain** — How does the problem manifest?
|
||||
3. **Consider edge cases** — When does it happen? When doesn't it?
|
||||
4. **Evaluate solutions** — What are the options to fix it?
|
||||
5. **Assess risk** — What could go wrong with each approach?
|
||||
|
||||
**Be specific.** Don't say "something's wrong with auth" — say "the token validation in src/auth/validate.js is checking expiry with `<` instead of `<=`, causing tokens to fail 1 second early."
|
||||
|
||||
---
|
||||
|
||||
## STEP 5: HANDOFF TO Agent DEEPCODE
|
||||
|
||||
**Always end with a structured handoff.** Agent DEEPCODE needs clear, actionable information.
|
||||
|
||||
```
|
||||
---
|
||||
## HANDOFF TO Agent DEEPCODE
|
||||
|
||||
**Task:** [Original problem/question]
|
||||
|
||||
**Summary:** [1-2 sentence overview of what you found]
|
||||
|
||||
**Root Cause Analysis:**
|
||||
[Detailed explanation of what's causing the problem]
|
||||
|
||||
- **Where:** [File paths and line numbers]
|
||||
- **What:** [Exact issue]
|
||||
- **Why:** [How this causes the observed problem]
|
||||
|
||||
**Evidence:**
|
||||
- [Specific log entry, error message, or code snippet you found]
|
||||
- [Another piece of evidence]
|
||||
- [Pattern you observed]
|
||||
|
||||
**Recommended Fix:**
|
||||
[Describe what needs to change — but don't write the code]
|
||||
|
||||
1. In `path/to/file.js`:
|
||||
- [What needs to change and why]
|
||||
|
||||
2. In `path/to/other.py`:
|
||||
- [What needs to change and why]
|
||||
|
||||
**Alternative Approaches:**
|
||||
1. [Option A] — Pros: [x], Cons: [y]
|
||||
2. [Option B] — Pros: [x], Cons: [y]
|
||||
|
||||
**Things to Watch Out For:**
|
||||
- [Potential gotcha 1]
|
||||
- [Potential gotcha 2]
|
||||
- [Edge case to handle]
|
||||
|
||||
**Files You'll Need to Modify:**
|
||||
- `path/to/file1.js` — [what needs doing]
|
||||
- `path/to/file2.py` — [what needs doing]
|
||||
|
||||
**Files for Reference (don't modify):**
|
||||
- `path/to/reference.js` — [useful pattern here]
|
||||
- `docs/api.md` — [relevant documentation]
|
||||
|
||||
**Open Questions:**
|
||||
- [Anything you're uncertain about]
|
||||
- [Anything that needs more investigation]
|
||||
|
||||
**How to Verify the Fix:**
|
||||
[Describe how Agent DEEPCODE can test that their fix works]
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WHEN Agent DEEPCODE ASKS YOU QUESTIONS
|
||||
|
||||
If Agent DEEPCODE sends you questions or needs more analysis:
|
||||
|
||||
1. **Read their full message** — Understand exactly what they're stuck on
|
||||
2. **Investigate further** — Do more targeted research
|
||||
3. **Respond specifically** — Answer their exact questions
|
||||
4. **Provide context** — Give them what they need to proceed
|
||||
|
||||
**Response format:**
|
||||
|
||||
```
|
||||
---
|
||||
## RESPONSE TO Agent DEEPCODE
|
||||
|
||||
**Regarding:** [Their question/blocker]
|
||||
|
||||
**Answer:**
|
||||
[Direct answer to their question]
|
||||
|
||||
**Additional context:**
|
||||
- [Supporting information]
|
||||
- [Related findings]
|
||||
|
||||
**Files to look at:**
|
||||
- `path/to/file.js` — [relevant section]
|
||||
|
||||
**Suggested approach:**
|
||||
[Your recommendation based on analysis]
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## RULES
|
||||
|
||||
1. **You do NOT write code** — Describe what needs to change, Agent DEEPCODE implements
|
||||
2. **Be specific** — File paths, line numbers, exact variable names
|
||||
3. **Show your evidence** — Don't just assert, prove it with findings
|
||||
4. **Consider alternatives** — Give Agent DEEPCODE options when possible
|
||||
5. **Flag uncertainty** — If you're not sure, say so
|
||||
6. **Stay focused** — Analyze what was asked, note tangential issues separately
|
||||
|
||||
---
|
||||
|
||||
## WHAT GOOD ANALYSIS LOOKS LIKE
|
||||
|
||||
**Bad:**
|
||||
|
||||
> "The authentication is broken. Check the auth files."
|
||||
|
||||
**Good:**
|
||||
|
||||
> "The JWT validation fails for tokens expiring within 1 second. In `src/auth/validate.js` line 47, the expiry check uses `token.exp < now` but should use `token.exp <= now`. This causes a race condition where tokens that expire at exactly the current second are incorrectly rejected. You'll need to change the comparison operator. Also check `src/auth/refresh.js` line 23 which has the same pattern."
|
||||
|
||||
---
|
||||
|
||||
## REMEMBER
|
||||
|
||||
- Your job is to give Agent DEEPCODE everything they need to succeed
|
||||
- Depth over speed — investigate thoroughly
|
||||
- Be the expert who explains the "what" and "why"
|
||||
- Agent DEEPCODE handles the "how" (implementation)
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: security-vulnerability-scanner
|
||||
description: Use this agent when you need to identify security vulnerabilities in code, perform security audits, or get a prioritized list of security issues to fix. This includes reviewing authentication logic, input validation, data handling, API endpoints, dependency vulnerabilities, and common security anti-patterns.\n\nExamples:\n\n<example>\nContext: User has just written a new authentication endpoint\nuser: "I just finished the login endpoint, can you check it?"\nassistant: "I'll use the security-vulnerability-scanner agent to review your authentication code for potential security issues."\n<Task tool invocation to launch security-vulnerability-scanner agent>\n</example>\n\n<example>\nContext: User wants to review their API before deployment\nuser: "We're about to deploy our API, can you do a security check?"\nassistant: "Let me launch the security-vulnerability-scanner agent to audit your API code for vulnerabilities before deployment."\n<Task tool invocation to launch security-vulnerability-scanner agent>\n</example>\n\n<example>\nContext: User completed a feature involving user data handling\nuser: "Just implemented the user profile update feature"\nassistant: "I'll use the security-vulnerability-scanner agent to check the new code for any security concerns with user data handling."\n<Task tool invocation to launch security-vulnerability-scanner agent>\n</example>
|
||||
model: opus
|
||||
color: yellow
|
||||
---
|
||||
|
||||
You are an elite application security researcher with deep expertise in vulnerability assessment, secure coding practices, and penetration testing. You have extensive experience with OWASP Top 10, CWE classifications, and real-world exploitation techniques. Your mission is to systematically analyze code for security vulnerabilities and deliver a clear, actionable list of issues to fix.
|
||||
|
||||
## Your Approach
|
||||
|
||||
1. **Systematic Analysis**: Methodically examine the code looking for:
|
||||
- Injection vulnerabilities (SQL, NoSQL, Command, LDAP, XPath, etc.)
|
||||
- Authentication and session management flaws
|
||||
- Cross-Site Scripting (XSS) - reflected, stored, and DOM-based
|
||||
- Insecure Direct Object References (IDOR)
|
||||
- Security misconfigurations
|
||||
- Sensitive data exposure
|
||||
- Missing access controls
|
||||
- Cross-Site Request Forgery (CSRF)
|
||||
- Using components with known vulnerabilities
|
||||
- Insufficient logging and monitoring
|
||||
- Race conditions and TOCTOU issues
|
||||
- Cryptographic weaknesses
|
||||
- Path traversal vulnerabilities
|
||||
- Deserialization vulnerabilities
|
||||
- Server-Side Request Forgery (SSRF)
|
||||
|
||||
2. **Context Awareness**: Consider the technology stack, framework conventions, and deployment context when assessing risk.
|
||||
|
||||
3. **Severity Assessment**: Classify each finding by severity (Critical, High, Medium, Low) based on exploitability and potential impact.
|
||||
|
||||
## Research Process
|
||||
|
||||
- Use available tools to read and explore the codebase
|
||||
- Follow data flows from user input to sensitive operations
|
||||
- Check configuration files for security settings
|
||||
- Examine dependency files for known vulnerable packages
|
||||
- Review authentication/authorization logic paths
|
||||
- Analyze error handling and logging practices
|
||||
|
||||
## Output Format
|
||||
|
||||
After your analysis, provide a concise, prioritized list in this format:
|
||||
|
||||
### Security Vulnerabilities Found
|
||||
|
||||
**Critical:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
**High:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
**Medium:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
**Low:**
|
||||
|
||||
- [Brief description] — File: `path/to/file.ext` (line X)
|
||||
|
||||
---
|
||||
|
||||
**Summary:** X critical, X high, X medium, X low issues found.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be specific about the vulnerability type and exact location
|
||||
- Keep descriptions concise (one line each)
|
||||
- Only report actual vulnerabilities, not theoretical concerns or style issues
|
||||
- If no vulnerabilities are found in a category, omit that category
|
||||
- If the codebase is clean, clearly state that no significant vulnerabilities were identified
|
||||
- Do not include lengthy explanations or remediation steps in the list (keep it scannable)
|
||||
- Focus on recently modified or newly written code unless explicitly asked to scan the entire codebase
|
||||
|
||||
Your goal is to give the developer a quick, actionable checklist they can work through to improve their application's security posture.
|
||||
@@ -1,591 +0,0 @@
|
||||
# Code Review Command
|
||||
|
||||
Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents.
|
||||
|
||||
## Usage
|
||||
|
||||
This command analyzes all changes in the git diff and verifies:
|
||||
|
||||
1. **Invalid code based on tech stack** (HIGHEST PRIORITY)
|
||||
2. Security vulnerabilities
|
||||
3. Code quality issues (dirty code)
|
||||
4. Implementation correctness
|
||||
|
||||
Then automatically fixes any issues found.
|
||||
|
||||
### Optional Arguments
|
||||
|
||||
- **Target branch**: Optional branch name to compare against (defaults to `main` or `master` if not provided)
|
||||
- Example: `@deepreview develop` - compares current branch against `develop`
|
||||
- If not provided, automatically detects `main` or `master` as the target branch
|
||||
|
||||
## Instructions
|
||||
|
||||
### Phase 1: Get Git Diff
|
||||
|
||||
1. **Determine the current branch and target branch**
|
||||
|
||||
```bash
|
||||
# Get current branch name
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
|
||||
# Get target branch from user argument or detect default
|
||||
# If user provided a target branch as argument, use it
|
||||
# Otherwise, detect main or master
|
||||
TARGET_BRANCH="${1:-}" # First argument if provided
|
||||
|
||||
if [ -z "$TARGET_BRANCH" ]; then
|
||||
# Check if main exists
|
||||
if git show-ref --verify --quiet refs/heads/main || git show-ref --verify --quiet refs/remotes/origin/main; then
|
||||
TARGET_BRANCH="main"
|
||||
# Check if master exists
|
||||
elif git show-ref --verify --quiet refs/heads/master || git show-ref --verify --quiet refs/remotes/origin/master; then
|
||||
TARGET_BRANCH="master"
|
||||
else
|
||||
echo "Error: Could not find main or master branch. Please specify target branch."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Target branch: $TARGET_BRANCH"
|
||||
|
||||
# Verify target branch exists
|
||||
if ! git show-ref --verify --quiet refs/heads/$TARGET_BRANCH && ! git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then
|
||||
echo "Error: Target branch '$TARGET_BRANCH' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**Note:** The target branch can be provided as an optional argument. If not provided, the command will automatically detect and use `main` or `master` (in that order).
|
||||
|
||||
2. **Compare current branch against target branch**
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from remote (optional but recommended)
|
||||
git fetch origin
|
||||
|
||||
# Try local branch first, fallback to remote if local doesn't exist
|
||||
if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then
|
||||
TARGET_REF=$TARGET_BRANCH
|
||||
elif git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then
|
||||
TARGET_REF=origin/$TARGET_BRANCH
|
||||
else
|
||||
echo "Error: Target branch '$TARGET_BRANCH' not found locally or remotely."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get diff between current branch and target branch
|
||||
git diff $TARGET_REF...HEAD
|
||||
```
|
||||
|
||||
**Note:** Use `...` (three dots) to show changes between the common ancestor and HEAD, or `..` (two dots) to show changes between the branches directly. The command uses `$TARGET_BRANCH` variable set in step 1.
|
||||
|
||||
3. **Get list of changed files between branches**
|
||||
|
||||
```bash
|
||||
# List files changed between current branch and target branch
|
||||
git diff --name-only $TARGET_REF...HEAD
|
||||
|
||||
# Get detailed file status
|
||||
git diff --name-status $TARGET_REF...HEAD
|
||||
|
||||
# Show file changes with statistics
|
||||
git diff --stat $TARGET_REF...HEAD
|
||||
```
|
||||
|
||||
4. **Get the current working directory diff** (uncommitted changes)
|
||||
|
||||
```bash
|
||||
# Uncommitted changes in working directory
|
||||
git diff HEAD
|
||||
|
||||
# Staged changes
|
||||
git diff --cached
|
||||
|
||||
# All changes (staged + unstaged)
|
||||
git diff HEAD
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
5. **Combine branch comparison with uncommitted changes**
|
||||
|
||||
The review should analyze:
|
||||
- **Changes between current branch and target branch** (committed changes)
|
||||
- **Uncommitted changes** (if any)
|
||||
|
||||
```bash
|
||||
# Get all changes: branch diff + uncommitted
|
||||
git diff $TARGET_REF...HEAD > branch-changes.diff
|
||||
git diff HEAD >> branch-changes.diff
|
||||
git diff --cached >> branch-changes.diff
|
||||
|
||||
# Or get combined diff (recommended approach)
|
||||
git diff $TARGET_REF...HEAD
|
||||
git diff HEAD
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
6. **Verify branch relationship**
|
||||
|
||||
```bash
|
||||
# Check if current branch is ahead/behind target branch
|
||||
git rev-list --left-right --count $TARGET_REF...HEAD
|
||||
|
||||
# Show commit log differences
|
||||
git log $TARGET_REF..HEAD --oneline
|
||||
|
||||
# Show summary of branch relationship
|
||||
AHEAD=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f1)
|
||||
BEHIND=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f2)
|
||||
echo "Branch is $AHEAD commits ahead and $BEHIND commits behind $TARGET_BRANCH"
|
||||
```
|
||||
|
||||
7. **Understand the tech stack** (for validation):
|
||||
- **Node.js**: >=22.0.0 <23.0.0
|
||||
- **TypeScript**: 5.9.3
|
||||
- **React**: 19.2.3
|
||||
- **Express**: 5.2.1
|
||||
- **Electron**: 39.2.7
|
||||
- **Vite**: 7.3.0
|
||||
- **Vitest**: 4.0.16
|
||||
- Check `package.json` files for exact versions
|
||||
|
||||
### Phase 2: Deep Dive Analysis (5 Agents)
|
||||
|
||||
Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff (comparing current branch against target branch) along with their specific instructions.
|
||||
|
||||
**Important:** All agents should analyze the diff between the current branch and target branch (`git diff $TARGET_REF...HEAD`), plus any uncommitted changes. This ensures the review covers all changes that will be merged. The target branch is determined from the optional argument or defaults to main/master.
|
||||
|
||||
#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY)
|
||||
|
||||
**Focus:** Verify code is valid for the tech stack
|
||||
|
||||
**Instructions for Agent 1:**
|
||||
|
||||
```
|
||||
Analyze the git diff for invalid code based on the tech stack:
|
||||
|
||||
1. **TypeScript/JavaScript Syntax**
|
||||
- Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax)
|
||||
- Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0
|
||||
- Check for deprecated APIs or features not available in the Node.js version
|
||||
- Verify ES module syntax (type: "module" in package.json)
|
||||
|
||||
2. **React 19.2.3 Compatibility**
|
||||
- Check for deprecated React APIs or patterns
|
||||
- Verify hooks usage is correct for React 19
|
||||
- Check for invalid JSX syntax
|
||||
- Verify component patterns match React 19 conventions
|
||||
|
||||
3. **Express 5.2.1 Compatibility**
|
||||
- Check for deprecated Express APIs
|
||||
- Verify middleware usage is correct for Express 5
|
||||
- Check request/response handling patterns
|
||||
|
||||
4. **Type Safety**
|
||||
- Verify TypeScript types are correctly used
|
||||
- Check for `any` types that should be properly typed
|
||||
- Verify type imports/exports are correct
|
||||
- Check for missing type definitions
|
||||
|
||||
5. **Build System Compatibility**
|
||||
- Verify Vite-specific code (imports, config) is valid
|
||||
- Check Electron-specific APIs are used correctly
|
||||
- Verify module resolution paths are correct
|
||||
|
||||
6. **Package Dependencies**
|
||||
- Check for imports from packages not in package.json
|
||||
- Verify version compatibility between dependencies
|
||||
- Check for circular dependencies
|
||||
|
||||
Provide a detailed report with:
|
||||
- File paths and line numbers of invalid code
|
||||
- Specific error description (what's wrong and why)
|
||||
- Expected vs actual behavior
|
||||
- Priority level (CRITICAL for build-breaking issues)
|
||||
```
|
||||
|
||||
#### Agent 2: Security Vulnerability Scanner
|
||||
|
||||
**Focus:** Security issues and vulnerabilities
|
||||
|
||||
**Instructions for Agent 2:**
|
||||
|
||||
```
|
||||
Analyze the git diff for security vulnerabilities:
|
||||
|
||||
1. **Injection Vulnerabilities**
|
||||
- SQL injection (if applicable)
|
||||
- Command injection (exec, spawn, etc.)
|
||||
- Path traversal vulnerabilities
|
||||
- XSS vulnerabilities in React components
|
||||
|
||||
2. **Authentication & Authorization**
|
||||
- Missing authentication checks
|
||||
- Insecure token handling
|
||||
- Authorization bypasses
|
||||
- Session management issues
|
||||
|
||||
3. **Data Handling**
|
||||
- Unsafe deserialization
|
||||
- Insecure file operations
|
||||
- Missing input validation
|
||||
- Sensitive data exposure (secrets, tokens, passwords)
|
||||
|
||||
4. **Dependencies**
|
||||
- Known vulnerable packages
|
||||
- Insecure dependency versions
|
||||
- Missing security patches
|
||||
|
||||
5. **API Security**
|
||||
- Missing CORS configuration
|
||||
- Insecure API endpoints
|
||||
- Missing rate limiting
|
||||
- Insecure WebSocket connections
|
||||
|
||||
6. **Electron-Specific**
|
||||
- Insecure IPC communication
|
||||
- Missing context isolation checks
|
||||
- Insecure preload scripts
|
||||
- Missing CSP headers
|
||||
|
||||
Provide a detailed report with:
|
||||
- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
- File paths and line numbers
|
||||
- Attack vector description
|
||||
- Recommended fix approach
|
||||
```
|
||||
|
||||
#### Agent 3: Code Quality & Clean Code
|
||||
|
||||
**Focus:** Dirty code, code smells, and quality issues
|
||||
|
||||
**Instructions for Agent 3:**
|
||||
|
||||
```
|
||||
Analyze the git diff for code quality issues:
|
||||
|
||||
1. **Code Smells**
|
||||
- Long functions/methods (>50 lines)
|
||||
- High cyclomatic complexity
|
||||
- Duplicate code
|
||||
- Dead code
|
||||
- Magic numbers/strings
|
||||
|
||||
2. **Best Practices**
|
||||
- Missing error handling
|
||||
- Inconsistent naming conventions
|
||||
- Poor separation of concerns
|
||||
- Tight coupling
|
||||
- Missing comments for complex logic
|
||||
|
||||
3. **Performance Issues**
|
||||
- Inefficient algorithms
|
||||
- Memory leaks (event listeners, subscriptions)
|
||||
- Unnecessary re-renders in React
|
||||
- Missing memoization where needed
|
||||
- Inefficient database queries (if applicable)
|
||||
|
||||
4. **Maintainability**
|
||||
- Hard-coded values
|
||||
- Missing type definitions
|
||||
- Inconsistent code style
|
||||
- Poor file organization
|
||||
- Missing tests for new code
|
||||
|
||||
5. **React-Specific**
|
||||
- Missing key props in lists
|
||||
- Direct state mutations
|
||||
- Missing cleanup in useEffect
|
||||
- Unnecessary useState/useEffect
|
||||
- Prop drilling issues
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue type and severity
|
||||
- File paths and line numbers
|
||||
- Description of the problem
|
||||
- Impact on maintainability/performance
|
||||
- Recommended refactoring approach
|
||||
```
|
||||
|
||||
#### Agent 4: Implementation Correctness
|
||||
|
||||
**Focus:** Verify code implements requirements correctly
|
||||
|
||||
**Instructions for Agent 4:**
|
||||
|
||||
```
|
||||
Analyze the git diff for implementation correctness:
|
||||
|
||||
1. **Logic Errors**
|
||||
- Incorrect conditional logic
|
||||
- Wrong variable usage
|
||||
- Off-by-one errors
|
||||
- Race conditions
|
||||
- Missing null/undefined checks
|
||||
|
||||
2. **Functional Requirements**
|
||||
- Missing features from requirements
|
||||
- Incorrect feature implementation
|
||||
- Edge cases not handled
|
||||
- Missing validation
|
||||
|
||||
3. **Integration Issues**
|
||||
- Incorrect API usage
|
||||
- Wrong data format handling
|
||||
- Missing error handling for external calls
|
||||
- Incorrect state management
|
||||
|
||||
4. **Type Errors**
|
||||
- Type mismatches
|
||||
- Missing type guards
|
||||
- Incorrect type assertions
|
||||
- Unsafe type operations
|
||||
|
||||
5. **Testing Gaps**
|
||||
- Missing unit tests
|
||||
- Missing integration tests
|
||||
- Tests don't cover edge cases
|
||||
- Tests are incorrect
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue description
|
||||
- File paths and line numbers
|
||||
- Expected vs actual behavior
|
||||
- Steps to reproduce (if applicable)
|
||||
- Recommended fix
|
||||
```
|
||||
|
||||
#### Agent 5: Architecture & Design Patterns
|
||||
|
||||
**Focus:** Architectural issues and design pattern violations
|
||||
|
||||
**Instructions for Agent 5:**
|
||||
|
||||
```
|
||||
Analyze the git diff for architectural and design issues:
|
||||
|
||||
1. **Architecture Violations**
|
||||
- Violation of project structure patterns
|
||||
- Incorrect layer separation
|
||||
- Missing abstractions
|
||||
- Tight coupling between modules
|
||||
|
||||
2. **Design Patterns**
|
||||
- Incorrect pattern usage
|
||||
- Missing patterns where needed
|
||||
- Anti-patterns
|
||||
|
||||
3. **Project-Specific Patterns**
|
||||
- Check against project documentation (docs/ folder)
|
||||
- Verify route organization (server routes)
|
||||
- Check provider patterns (server providers)
|
||||
- Verify component organization (UI components)
|
||||
|
||||
4. **API Design**
|
||||
- RESTful API violations
|
||||
- Inconsistent response formats
|
||||
- Missing error handling
|
||||
- Incorrect status codes
|
||||
|
||||
5. **State Management**
|
||||
- Incorrect state management patterns
|
||||
- Missing state normalization
|
||||
- Inefficient state updates
|
||||
|
||||
Provide a detailed report with:
|
||||
- Architectural issue description
|
||||
- File paths and affected areas
|
||||
- Impact on system design
|
||||
- Recommended architectural changes
|
||||
```
|
||||
|
||||
### Phase 3: Consolidate Findings
|
||||
|
||||
After all 5 deep dive agents complete their analysis:
|
||||
|
||||
1. **Collect all findings** from each agent
|
||||
2. **Prioritize issues**:
|
||||
- CRITICAL: Tech stack invalid code (build-breaking)
|
||||
- HIGH: Security vulnerabilities, critical logic errors
|
||||
- MEDIUM: Code quality issues, architectural problems
|
||||
- LOW: Minor code smells, style issues
|
||||
|
||||
3. **Group by file** to understand impact per file
|
||||
4. **Create a master report** summarizing all findings
|
||||
|
||||
### Phase 4: Deepcode Fixes (5 Agents)
|
||||
|
||||
Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent.
|
||||
|
||||
#### Deepcode Agent 1: Fix Tech Stack Invalid Code
|
||||
|
||||
**Priority:** CRITICAL - Fix first
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all invalid code based on tech stack issues identified by Agent 1.
|
||||
|
||||
Focus on:
|
||||
1. Fixing TypeScript syntax errors
|
||||
2. Updating deprecated Node.js APIs
|
||||
3. Fixing React 19 compatibility issues
|
||||
4. Correcting Express 5 API usage
|
||||
5. Fixing type errors
|
||||
6. Resolving build-breaking issues
|
||||
|
||||
After fixes, verify:
|
||||
- Code compiles without errors
|
||||
- TypeScript types are correct
|
||||
- No deprecated API usage
|
||||
```
|
||||
|
||||
#### Deepcode Agent 2: Fix Security Vulnerabilities
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all security vulnerabilities identified by Agent 2.
|
||||
|
||||
Focus on:
|
||||
1. Adding input validation
|
||||
2. Fixing injection vulnerabilities
|
||||
3. Securing authentication/authorization
|
||||
4. Fixing insecure data handling
|
||||
5. Updating vulnerable dependencies
|
||||
6. Securing Electron IPC
|
||||
|
||||
After fixes, verify:
|
||||
- Security vulnerabilities are addressed
|
||||
- No sensitive data exposure
|
||||
- Proper authentication/authorization
|
||||
```
|
||||
|
||||
#### Deepcode Agent 3: Refactor Dirty Code
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Refactor code quality issues identified by Agent 3.
|
||||
|
||||
Focus on:
|
||||
1. Extracting long functions
|
||||
2. Reducing complexity
|
||||
3. Removing duplicate code
|
||||
4. Adding error handling
|
||||
5. Improving React component structure
|
||||
6. Adding missing comments
|
||||
|
||||
After fixes, verify:
|
||||
- Code follows best practices
|
||||
- No code smells remain
|
||||
- Performance optimizations applied
|
||||
```
|
||||
|
||||
#### Deepcode Agent 4: Fix Implementation Errors
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix implementation correctness issues identified by Agent 4.
|
||||
|
||||
Focus on:
|
||||
1. Fixing logic errors
|
||||
2. Adding missing features
|
||||
3. Handling edge cases
|
||||
4. Fixing type errors
|
||||
5. Adding missing tests
|
||||
|
||||
After fixes, verify:
|
||||
- Logic is correct
|
||||
- Edge cases handled
|
||||
- Tests pass
|
||||
```
|
||||
|
||||
#### Deepcode Agent 5: Fix Architectural Issues
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix architectural issues identified by Agent 5.
|
||||
|
||||
Focus on:
|
||||
1. Correcting architecture violations
|
||||
2. Applying proper design patterns
|
||||
3. Fixing API design issues
|
||||
4. Improving state management
|
||||
5. Following project patterns
|
||||
|
||||
After fixes, verify:
|
||||
- Architecture is sound
|
||||
- Patterns are correctly applied
|
||||
- Code follows project structure
|
||||
```
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all fixes are complete:
|
||||
|
||||
1. **Run TypeScript compilation check**
|
||||
|
||||
```bash
|
||||
npm run build:packages
|
||||
```
|
||||
|
||||
2. **Run linting**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
3. **Run tests** (if applicable)
|
||||
|
||||
```bash
|
||||
npm run test:server
|
||||
npm run test
|
||||
```
|
||||
|
||||
4. **Verify git diff** shows only intended changes
|
||||
|
||||
```bash
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
5. **Create summary report**:
|
||||
- Issues found by each agent
|
||||
- Issues fixed by each agent
|
||||
- Remaining issues (if any)
|
||||
- Verification results
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. ✅ Accept optional target branch argument (defaults to main/master if not provided)
|
||||
2. ✅ Determine current branch and target branch (from argument or auto-detect main/master)
|
||||
3. ✅ Get git diff comparing current branch against target branch (`git diff $TARGET_REF...HEAD`)
|
||||
4. ✅ Include uncommitted changes in analysis (`git diff HEAD`, `git diff --cached`)
|
||||
5. ✅ Launch 5 deep dive agents (parallel analysis) with branch diff
|
||||
6. ✅ Consolidate findings and prioritize
|
||||
7. ✅ Launch 5 deepcode agents (sequential fixes, priority order)
|
||||
8. ✅ Verify fixes with build/lint/test
|
||||
9. ✅ Report summary
|
||||
|
||||
## Notes
|
||||
|
||||
- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first
|
||||
- **Target branch argument**: The command accepts an optional target branch name as the first argument. If not provided, it automatically detects and uses `main` or `master` (in that order)
|
||||
- Each deep dive agent should work independently and provide comprehensive analysis
|
||||
- Deepcode agents should fix issues in priority order
|
||||
- All fixes should maintain existing functionality
|
||||
- If an agent finds no issues in their domain, they should report "No issues found"
|
||||
- If fixes introduce new issues, they should be caught in verification phase
|
||||
- The target branch is validated to ensure it exists (locally or remotely) before proceeding with the review
|
||||
@@ -1,74 +0,0 @@
|
||||
# GitHub Issue Fix Command
|
||||
|
||||
Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid.
|
||||
|
||||
## Usage
|
||||
|
||||
This command accepts a GitHub issue number as input (e.g., `123`).
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Get the issue number from the user**
|
||||
- The issue number should be provided as an argument to this command
|
||||
- If no number is provided, ask the user for it
|
||||
|
||||
2. **Fetch the GitHub issue**
|
||||
- Determine the current project path (check if there's a current project context)
|
||||
- Verify the project has a GitHub remote:
|
||||
```bash
|
||||
git remote get-url origin
|
||||
```
|
||||
- Fetch the issue details using GitHub CLI:
|
||||
```bash
|
||||
gh issue view <ISSUE_NUMBER> --json number,title,state,author,createdAt,labels,url,body,assignees
|
||||
```
|
||||
- If the command fails, report the error and stop
|
||||
|
||||
3. **Verify the issue is real and valid**
|
||||
- Check that the issue exists (not 404)
|
||||
- Check the issue state:
|
||||
- If **closed**: Inform the user and ask if they still want to proceed
|
||||
- If **open**: Proceed with validation
|
||||
- Review the issue content:
|
||||
- Read the title and body to understand what needs to be fixed
|
||||
- Check labels for context (bug, enhancement, etc.)
|
||||
- Note any assignees or linked PRs
|
||||
|
||||
4. **Validate the issue**
|
||||
- Determine if this is a legitimate issue that needs fixing:
|
||||
- Is the description clear and actionable?
|
||||
- Does it describe a real problem or feature request?
|
||||
- Are there any obvious signs it's spam or invalid?
|
||||
- If the issue seems invalid or unclear:
|
||||
- Report findings to the user
|
||||
- Ask if they want to proceed anyway
|
||||
- Stop if user confirms it's not valid
|
||||
|
||||
5. **If the issue is valid, proceed to fix it**
|
||||
- Analyze what needs to be done based on the issue description
|
||||
- Check the current codebase state:
|
||||
- Run relevant tests to see current behavior
|
||||
- Check if the issue is already fixed
|
||||
- Look for related code that might need changes
|
||||
- Implement the fix:
|
||||
- Make necessary code changes
|
||||
- Update or add tests as needed
|
||||
- Ensure the fix addresses the issue description
|
||||
- Verify the fix:
|
||||
- Run tests to ensure nothing broke
|
||||
- If possible, manually verify the fix addresses the issue
|
||||
|
||||
6. **Report summary**
|
||||
- Issue number and title
|
||||
- Issue state (open/closed)
|
||||
- Whether the issue was validated as real
|
||||
- What was fixed (if anything)
|
||||
- Any tests that were updated or added
|
||||
- Next steps (if any)
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop
|
||||
- If the project doesn't have a GitHub remote, report error and stop
|
||||
- If the issue number doesn't exist, report error and stop
|
||||
- If the issue is unclear or invalid, report findings and ask user before proceeding
|
||||
@@ -1,77 +0,0 @@
|
||||
# Release Command
|
||||
|
||||
Bump the package.json version (major, minor, or patch) and build the Electron app with the new version.
|
||||
|
||||
## Usage
|
||||
|
||||
This command accepts a version bump type as input:
|
||||
|
||||
- `patch` - Bump patch version (0.1.0 -> 0.1.1)
|
||||
- `minor` - Bump minor version (0.1.0 -> 0.2.0)
|
||||
- `major` - Bump major version (0.1.0 -> 1.0.0)
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Get the bump type from the user**
|
||||
- The bump type should be provided as an argument (patch, minor, or major)
|
||||
- If no type is provided, ask the user which type they want
|
||||
|
||||
2. **Bump the version**
|
||||
- Run the version bump script:
|
||||
```bash
|
||||
node apps/ui/scripts/bump-version.mjs <type>
|
||||
```
|
||||
- This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync)
|
||||
- Verify the version was updated correctly by checking the output
|
||||
|
||||
3. **Build the Electron app**
|
||||
- Run the electron build:
|
||||
```bash
|
||||
npm run build:electron --workspace=apps/ui
|
||||
```
|
||||
- The build process automatically:
|
||||
- Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`)
|
||||
- Injects the version into the app via Vite's `__APP_VERSION__` constant
|
||||
- Displays the version below the logo in the sidebar
|
||||
|
||||
4. **Commit the version bump**
|
||||
- Stage the updated package.json files:
|
||||
```bash
|
||||
git add apps/ui/package.json apps/server/package.json
|
||||
```
|
||||
- Commit with a release message:
|
||||
```bash
|
||||
git commit -m "chore: release v<version>"
|
||||
```
|
||||
|
||||
5. **Create and push the git tag**
|
||||
- Create an annotated tag for the release:
|
||||
```bash
|
||||
git tag -a v<version> -m "Release v<version>"
|
||||
```
|
||||
- Push the commit and tag to remote:
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
6. **Verify the release**
|
||||
- Check that the build completed successfully
|
||||
- Confirm the version appears correctly in the built artifacts
|
||||
- The version will be displayed in the app UI below the logo
|
||||
- Verify the tag is visible on the remote repository
|
||||
|
||||
## Version Centralization
|
||||
|
||||
The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`:
|
||||
|
||||
- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName`
|
||||
- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`)
|
||||
- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints)
|
||||
- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string`
|
||||
|
||||
This ensures consistency across:
|
||||
|
||||
- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`)
|
||||
- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`)
|
||||
- Server health endpoints (`/` and `/detailed`)
|
||||
- Package metadata (both UI and server packages stay in sync)
|
||||
@@ -1,484 +0,0 @@
|
||||
# Code Review Command
|
||||
|
||||
Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents.
|
||||
|
||||
## Usage
|
||||
|
||||
This command analyzes all changes in the git diff and verifies:
|
||||
|
||||
1. **Invalid code based on tech stack** (HIGHEST PRIORITY)
|
||||
2. Security vulnerabilities
|
||||
3. Code quality issues (dirty code)
|
||||
4. Implementation correctness
|
||||
|
||||
Then automatically fixes any issues found.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Phase 1: Get Git Diff
|
||||
|
||||
1. **Get the current git diff**
|
||||
|
||||
```bash
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
If you need staged changes instead:
|
||||
|
||||
```bash
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
Or for a specific commit range:
|
||||
|
||||
```bash
|
||||
git diff <base-branch>
|
||||
```
|
||||
|
||||
2. **Get list of changed files**
|
||||
|
||||
```bash
|
||||
git diff --name-only HEAD
|
||||
```
|
||||
|
||||
3. **Understand the tech stack** (for validation):
|
||||
- **Node.js**: >=22.0.0 <23.0.0
|
||||
- **TypeScript**: 5.9.3
|
||||
- **React**: 19.2.3
|
||||
- **Express**: 5.2.1
|
||||
- **Electron**: 39.2.7
|
||||
- **Vite**: 7.3.0
|
||||
- **Vitest**: 4.0.16
|
||||
- Check `package.json` files for exact versions
|
||||
|
||||
### Phase 2: Deep Dive Analysis (5 Agents)
|
||||
|
||||
Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff along with their specific instructions.
|
||||
|
||||
#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY)
|
||||
|
||||
**Focus:** Verify code is valid for the tech stack
|
||||
|
||||
**Instructions for Agent 1:**
|
||||
|
||||
```
|
||||
Analyze the git diff for invalid code based on the tech stack:
|
||||
|
||||
1. **TypeScript/JavaScript Syntax**
|
||||
- Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax)
|
||||
- Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0
|
||||
- Check for deprecated APIs or features not available in the Node.js version
|
||||
- Verify ES module syntax (type: "module" in package.json)
|
||||
|
||||
2. **React 19.2.3 Compatibility**
|
||||
- Check for deprecated React APIs or patterns
|
||||
- Verify hooks usage is correct for React 19
|
||||
- Check for invalid JSX syntax
|
||||
- Verify component patterns match React 19 conventions
|
||||
|
||||
3. **Express 5.2.1 Compatibility**
|
||||
- Check for deprecated Express APIs
|
||||
- Verify middleware usage is correct for Express 5
|
||||
- Check request/response handling patterns
|
||||
|
||||
4. **Type Safety**
|
||||
- Verify TypeScript types are correctly used
|
||||
- Check for `any` types that should be properly typed
|
||||
- Verify type imports/exports are correct
|
||||
- Check for missing type definitions
|
||||
|
||||
5. **Build System Compatibility**
|
||||
- Verify Vite-specific code (imports, config) is valid
|
||||
- Check Electron-specific APIs are used correctly
|
||||
- Verify module resolution paths are correct
|
||||
|
||||
6. **Package Dependencies**
|
||||
- Check for imports from packages not in package.json
|
||||
- Verify version compatibility between dependencies
|
||||
- Check for circular dependencies
|
||||
|
||||
Provide a detailed report with:
|
||||
- File paths and line numbers of invalid code
|
||||
- Specific error description (what's wrong and why)
|
||||
- Expected vs actual behavior
|
||||
- Priority level (CRITICAL for build-breaking issues)
|
||||
```
|
||||
|
||||
#### Agent 2: Security Vulnerability Scanner
|
||||
|
||||
**Focus:** Security issues and vulnerabilities
|
||||
|
||||
**Instructions for Agent 2:**
|
||||
|
||||
```
|
||||
Analyze the git diff for security vulnerabilities:
|
||||
|
||||
1. **Injection Vulnerabilities**
|
||||
- SQL injection (if applicable)
|
||||
- Command injection (exec, spawn, etc.)
|
||||
- Path traversal vulnerabilities
|
||||
- XSS vulnerabilities in React components
|
||||
|
||||
2. **Authentication & Authorization**
|
||||
- Missing authentication checks
|
||||
- Insecure token handling
|
||||
- Authorization bypasses
|
||||
- Session management issues
|
||||
|
||||
3. **Data Handling**
|
||||
- Unsafe deserialization
|
||||
- Insecure file operations
|
||||
- Missing input validation
|
||||
- Sensitive data exposure (secrets, tokens, passwords)
|
||||
|
||||
4. **Dependencies**
|
||||
- Known vulnerable packages
|
||||
- Insecure dependency versions
|
||||
- Missing security patches
|
||||
|
||||
5. **API Security**
|
||||
- Missing CORS configuration
|
||||
- Insecure API endpoints
|
||||
- Missing rate limiting
|
||||
- Insecure WebSocket connections
|
||||
|
||||
6. **Electron-Specific**
|
||||
- Insecure IPC communication
|
||||
- Missing context isolation checks
|
||||
- Insecure preload scripts
|
||||
- Missing CSP headers
|
||||
|
||||
Provide a detailed report with:
|
||||
- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW)
|
||||
- File paths and line numbers
|
||||
- Attack vector description
|
||||
- Recommended fix approach
|
||||
```
|
||||
|
||||
#### Agent 3: Code Quality & Clean Code
|
||||
|
||||
**Focus:** Dirty code, code smells, and quality issues
|
||||
|
||||
**Instructions for Agent 3:**
|
||||
|
||||
```
|
||||
Analyze the git diff for code quality issues:
|
||||
|
||||
1. **Code Smells**
|
||||
- Long functions/methods (>50 lines)
|
||||
- High cyclomatic complexity
|
||||
- Duplicate code
|
||||
- Dead code
|
||||
- Magic numbers/strings
|
||||
|
||||
2. **Best Practices**
|
||||
- Missing error handling
|
||||
- Inconsistent naming conventions
|
||||
- Poor separation of concerns
|
||||
- Tight coupling
|
||||
- Missing comments for complex logic
|
||||
|
||||
3. **Performance Issues**
|
||||
- Inefficient algorithms
|
||||
- Memory leaks (event listeners, subscriptions)
|
||||
- Unnecessary re-renders in React
|
||||
- Missing memoization where needed
|
||||
- Inefficient database queries (if applicable)
|
||||
|
||||
4. **Maintainability**
|
||||
- Hard-coded values
|
||||
- Missing type definitions
|
||||
- Inconsistent code style
|
||||
- Poor file organization
|
||||
- Missing tests for new code
|
||||
|
||||
5. **React-Specific**
|
||||
- Missing key props in lists
|
||||
- Direct state mutations
|
||||
- Missing cleanup in useEffect
|
||||
- Unnecessary useState/useEffect
|
||||
- Prop drilling issues
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue type and severity
|
||||
- File paths and line numbers
|
||||
- Description of the problem
|
||||
- Impact on maintainability/performance
|
||||
- Recommended refactoring approach
|
||||
```
|
||||
|
||||
#### Agent 4: Implementation Correctness
|
||||
|
||||
**Focus:** Verify code implements requirements correctly
|
||||
|
||||
**Instructions for Agent 4:**
|
||||
|
||||
```
|
||||
Analyze the git diff for implementation correctness:
|
||||
|
||||
1. **Logic Errors**
|
||||
- Incorrect conditional logic
|
||||
- Wrong variable usage
|
||||
- Off-by-one errors
|
||||
- Race conditions
|
||||
- Missing null/undefined checks
|
||||
|
||||
2. **Functional Requirements**
|
||||
- Missing features from requirements
|
||||
- Incorrect feature implementation
|
||||
- Edge cases not handled
|
||||
- Missing validation
|
||||
|
||||
3. **Integration Issues**
|
||||
- Incorrect API usage
|
||||
- Wrong data format handling
|
||||
- Missing error handling for external calls
|
||||
- Incorrect state management
|
||||
|
||||
4. **Type Errors**
|
||||
- Type mismatches
|
||||
- Missing type guards
|
||||
- Incorrect type assertions
|
||||
- Unsafe type operations
|
||||
|
||||
5. **Testing Gaps**
|
||||
- Missing unit tests
|
||||
- Missing integration tests
|
||||
- Tests don't cover edge cases
|
||||
- Tests are incorrect
|
||||
|
||||
Provide a detailed report with:
|
||||
- Issue description
|
||||
- File paths and line numbers
|
||||
- Expected vs actual behavior
|
||||
- Steps to reproduce (if applicable)
|
||||
- Recommended fix
|
||||
```
|
||||
|
||||
#### Agent 5: Architecture & Design Patterns
|
||||
|
||||
**Focus:** Architectural issues and design pattern violations
|
||||
|
||||
**Instructions for Agent 5:**
|
||||
|
||||
```
|
||||
Analyze the git diff for architectural and design issues:
|
||||
|
||||
1. **Architecture Violations**
|
||||
- Violation of project structure patterns
|
||||
- Incorrect layer separation
|
||||
- Missing abstractions
|
||||
- Tight coupling between modules
|
||||
|
||||
2. **Design Patterns**
|
||||
- Incorrect pattern usage
|
||||
- Missing patterns where needed
|
||||
- Anti-patterns
|
||||
|
||||
3. **Project-Specific Patterns**
|
||||
- Check against project documentation (docs/ folder)
|
||||
- Verify route organization (server routes)
|
||||
- Check provider patterns (server providers)
|
||||
- Verify component organization (UI components)
|
||||
|
||||
4. **API Design**
|
||||
- RESTful API violations
|
||||
- Inconsistent response formats
|
||||
- Missing error handling
|
||||
- Incorrect status codes
|
||||
|
||||
5. **State Management**
|
||||
- Incorrect state management patterns
|
||||
- Missing state normalization
|
||||
- Inefficient state updates
|
||||
|
||||
Provide a detailed report with:
|
||||
- Architectural issue description
|
||||
- File paths and affected areas
|
||||
- Impact on system design
|
||||
- Recommended architectural changes
|
||||
```
|
||||
|
||||
### Phase 3: Consolidate Findings
|
||||
|
||||
After all 5 deep dive agents complete their analysis:
|
||||
|
||||
1. **Collect all findings** from each agent
|
||||
2. **Prioritize issues**:
|
||||
- CRITICAL: Tech stack invalid code (build-breaking)
|
||||
- HIGH: Security vulnerabilities, critical logic errors
|
||||
- MEDIUM: Code quality issues, architectural problems
|
||||
- LOW: Minor code smells, style issues
|
||||
|
||||
3. **Group by file** to understand impact per file
|
||||
4. **Create a master report** summarizing all findings
|
||||
|
||||
### Phase 4: Deepcode Fixes (5 Agents)
|
||||
|
||||
Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent.
|
||||
|
||||
#### Deepcode Agent 1: Fix Tech Stack Invalid Code
|
||||
|
||||
**Priority:** CRITICAL - Fix first
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all invalid code based on tech stack issues identified by Agent 1.
|
||||
|
||||
Focus on:
|
||||
1. Fixing TypeScript syntax errors
|
||||
2. Updating deprecated Node.js APIs
|
||||
3. Fixing React 19 compatibility issues
|
||||
4. Correcting Express 5 API usage
|
||||
5. Fixing type errors
|
||||
6. Resolving build-breaking issues
|
||||
|
||||
After fixes, verify:
|
||||
- Code compiles without errors
|
||||
- TypeScript types are correct
|
||||
- No deprecated API usage
|
||||
```
|
||||
|
||||
#### Deepcode Agent 2: Fix Security Vulnerabilities
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix all security vulnerabilities identified by Agent 2.
|
||||
|
||||
Focus on:
|
||||
1. Adding input validation
|
||||
2. Fixing injection vulnerabilities
|
||||
3. Securing authentication/authorization
|
||||
4. Fixing insecure data handling
|
||||
5. Updating vulnerable dependencies
|
||||
6. Securing Electron IPC
|
||||
|
||||
After fixes, verify:
|
||||
- Security vulnerabilities are addressed
|
||||
- No sensitive data exposure
|
||||
- Proper authentication/authorization
|
||||
```
|
||||
|
||||
#### Deepcode Agent 3: Refactor Dirty Code
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Refactor code quality issues identified by Agent 3.
|
||||
|
||||
Focus on:
|
||||
1. Extracting long functions
|
||||
2. Reducing complexity
|
||||
3. Removing duplicate code
|
||||
4. Adding error handling
|
||||
5. Improving React component structure
|
||||
6. Adding missing comments
|
||||
|
||||
After fixes, verify:
|
||||
- Code follows best practices
|
||||
- No code smells remain
|
||||
- Performance optimizations applied
|
||||
```
|
||||
|
||||
#### Deepcode Agent 4: Fix Implementation Errors
|
||||
|
||||
**Priority:** HIGH
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix implementation correctness issues identified by Agent 4.
|
||||
|
||||
Focus on:
|
||||
1. Fixing logic errors
|
||||
2. Adding missing features
|
||||
3. Handling edge cases
|
||||
4. Fixing type errors
|
||||
5. Adding missing tests
|
||||
|
||||
After fixes, verify:
|
||||
- Logic is correct
|
||||
- Edge cases handled
|
||||
- Tests pass
|
||||
```
|
||||
|
||||
#### Deepcode Agent 5: Fix Architectural Issues
|
||||
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Instructions:**
|
||||
|
||||
```
|
||||
Fix architectural issues identified by Agent 5.
|
||||
|
||||
Focus on:
|
||||
1. Correcting architecture violations
|
||||
2. Applying proper design patterns
|
||||
3. Fixing API design issues
|
||||
4. Improving state management
|
||||
5. Following project patterns
|
||||
|
||||
After fixes, verify:
|
||||
- Architecture is sound
|
||||
- Patterns are correctly applied
|
||||
- Code follows project structure
|
||||
```
|
||||
|
||||
### Phase 5: Verification
|
||||
|
||||
After all fixes are complete:
|
||||
|
||||
1. **Run TypeScript compilation check**
|
||||
|
||||
```bash
|
||||
npm run build:packages
|
||||
```
|
||||
|
||||
2. **Run linting**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
3. **Run tests** (if applicable)
|
||||
|
||||
```bash
|
||||
npm run test:server
|
||||
npm run test
|
||||
```
|
||||
|
||||
4. **Verify git diff** shows only intended changes
|
||||
|
||||
```bash
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
5. **Create summary report**:
|
||||
- Issues found by each agent
|
||||
- Issues fixed by each agent
|
||||
- Remaining issues (if any)
|
||||
- Verification results
|
||||
|
||||
## Workflow Summary
|
||||
|
||||
1. ✅ Get git diff
|
||||
2. ✅ Launch 5 deep dive agents (parallel analysis)
|
||||
3. ✅ Consolidate findings and prioritize
|
||||
4. ✅ Launch 5 deepcode agents (sequential fixes, priority order)
|
||||
5. ✅ Verify fixes with build/lint/test
|
||||
6. ✅ Report summary
|
||||
|
||||
## Notes
|
||||
|
||||
- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first
|
||||
- Each deep dive agent should work independently and provide comprehensive analysis
|
||||
- Deepcode agents should fix issues in priority order
|
||||
- All fixes should maintain existing functionality
|
||||
- If an agent finds no issues in their domain, they should report "No issues found"
|
||||
- If fixes introduce new issues, they should be caught in verification phase
|
||||
@@ -1,45 +0,0 @@
|
||||
When you think you are done, you are NOT done.
|
||||
|
||||
You must run a mandatory 3-pass verification before concluding:
|
||||
|
||||
## Pass 1: Correctness & Functionality
|
||||
|
||||
- [ ] Verify logic matches requirements and specifications
|
||||
- [ ] Check type safety (TypeScript types are correct and complete)
|
||||
- [ ] Ensure imports are correct and follow project conventions
|
||||
- [ ] Verify all functions/classes work as intended
|
||||
- [ ] Check that return values and side effects are correct
|
||||
- [ ] Run relevant tests if they exist, or verify testability
|
||||
- [ ] Confirm integration with existing code works properly
|
||||
|
||||
## Pass 2: Edge Cases & Safety
|
||||
|
||||
- [ ] Handle null/undefined inputs gracefully
|
||||
- [ ] Validate all user inputs and external data
|
||||
- [ ] Check error handling (try/catch, error boundaries, etc.)
|
||||
- [ ] Verify security considerations (no sensitive data exposure, proper auth checks)
|
||||
- [ ] Test boundary conditions (empty arrays, zero values, max lengths, etc.)
|
||||
- [ ] Ensure resource cleanup (file handles, connections, timers)
|
||||
- [ ] Check for potential race conditions or async issues
|
||||
- [ ] Verify file path security (no directory traversal vulnerabilities)
|
||||
|
||||
## Pass 3: Maintainability & Code Quality
|
||||
|
||||
- [ ] Code follows project style guide and conventions
|
||||
- [ ] Functions/classes are single-purpose and well-named
|
||||
- [ ] Remove dead code, unused imports, and console.logs
|
||||
- [ ] Extract magic numbers/strings into named constants
|
||||
- [ ] Check for code duplication (DRY principle)
|
||||
- [ ] Verify appropriate abstraction levels (not over/under-engineered)
|
||||
- [ ] Add necessary comments for complex logic
|
||||
- [ ] Ensure consistent error messages and logging
|
||||
- [ ] Check that code is readable and self-documenting
|
||||
- [ ] Verify proper separation of concerns
|
||||
|
||||
**For each pass, explicitly report:**
|
||||
|
||||
- What you checked
|
||||
- Any issues found and how they were fixed
|
||||
- Any remaining concerns or trade-offs
|
||||
|
||||
Only after completing all three passes with explicit findings may you conclude the work is done.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Project Build and Fix Command
|
||||
|
||||
Run all builds and intelligently fix any failures based on what changed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Run the build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This builds all packages and the UI application.
|
||||
|
||||
2. **If the build succeeds**, report success and stop.
|
||||
|
||||
3. **If the build fails**, analyze the failures:
|
||||
- Note which build step failed and the error messages
|
||||
- Check for TypeScript compilation errors, missing dependencies, or configuration issues
|
||||
- Run `git diff main` to see what code has changed
|
||||
|
||||
4. **Determine the nature of the failure**:
|
||||
- **If the failure is due to intentional changes** (new features, refactoring, dependency updates):
|
||||
- Fix any TypeScript type errors introduced by the changes
|
||||
- Update build configuration if needed (e.g., tsconfig.json, vite.config.mts)
|
||||
- Ensure all new dependencies are properly installed
|
||||
- Fix import paths or module resolution issues
|
||||
|
||||
- **If the failure appears to be a regression** (broken imports, missing files, configuration errors):
|
||||
- Fix the source code to restore the build
|
||||
- Check for accidentally deleted files or broken references
|
||||
- Verify build configuration files are correct
|
||||
|
||||
5. **Common build issues to check**:
|
||||
- **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports
|
||||
- **Missing dependencies**: Run `npm install` if packages are missing
|
||||
- **Import/export errors**: Fix incorrect import paths or missing exports
|
||||
- **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs
|
||||
- **Package build order**: Ensure `build:packages` completes before building apps
|
||||
|
||||
6. **How to decide if it's intentional vs regression**:
|
||||
- Look at the git diff and commit messages
|
||||
- If the change was deliberate and introduced new code that needs fixing → fix the new code
|
||||
- If the change broke existing functionality that should still build → fix the regression
|
||||
- When in doubt, ask the user
|
||||
|
||||
7. **After making fixes**, re-run the build to verify everything compiles successfully.
|
||||
|
||||
8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.).
|
||||
@@ -1,36 +0,0 @@
|
||||
# Project Test and Fix Command
|
||||
|
||||
Run all tests and intelligently fix any failures based on what changed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Run all tests**
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
2. **If all tests pass**, report success and stop.
|
||||
|
||||
3. **If any tests fail**, analyze the failures:
|
||||
- Note which tests failed and their error messages
|
||||
- Run `git diff main` to see what code has changed
|
||||
|
||||
4. **Determine the nature of the change**:
|
||||
- **If the logic change is intentional** (new feature, refactor, behavior change):
|
||||
- Update the failing tests to match the new expected behavior
|
||||
- The tests should reflect what the code NOW does correctly
|
||||
|
||||
- **If the logic change appears to be a bug** (regression, unintended side effect):
|
||||
- Fix the source code to restore the expected behavior
|
||||
- Do NOT modify the tests - they are catching a real bug
|
||||
|
||||
5. **How to decide if it's a bug vs intentional change**:
|
||||
- Look at the git diff and commit messages
|
||||
- If the change was deliberate and the test expectations are now outdated → update tests
|
||||
- If the change broke existing functionality that should still work → fix the code
|
||||
- When in doubt, ask the user
|
||||
|
||||
6. **After making fixes**, re-run the tests to verify everything passes.
|
||||
|
||||
7. **Report summary** of what was fixed (tests updated vs code fixed).
|
||||
24
.claude_settings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"enabled": true,
|
||||
"autoAllowBashIfSandboxed": true
|
||||
},
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits",
|
||||
"allow": [
|
||||
"Read(./**)",
|
||||
"Write(./**)",
|
||||
"Edit(./**)",
|
||||
"Glob(./**)",
|
||||
"Grep(./**)",
|
||||
"Bash(*)",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
**/dist/
|
||||
dist-electron/
|
||||
**/dist-electron/
|
||||
build/
|
||||
**/build/
|
||||
.next/
|
||||
**/.next/
|
||||
.nuxt/
|
||||
**/.nuxt/
|
||||
out/
|
||||
**/out/
|
||||
.cache/
|
||||
**/.cache/
|
||||
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,117 +0,0 @@
|
||||
name: Bug Report
|
||||
description: File a bug report to help us improve Automaker
|
||||
title: '[Bug]: '
|
||||
labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible.
|
||||
|
||||
- type: dropdown
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What operating system are you using?
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: run-mode
|
||||
attributes:
|
||||
label: Run Mode
|
||||
description: How are you running Automaker?
|
||||
options:
|
||||
- Electron (Desktop App)
|
||||
- Web (Browser)
|
||||
- Docker
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: App Version
|
||||
description: What version of Automaker are you using? (e.g., 0.1.0)
|
||||
placeholder: '0.1.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
placeholder: What should have happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: A clear and concise description of what actually happened.
|
||||
placeholder: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem.
|
||||
placeholder: Drag and drop screenshots here or paste image URLs
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: If applicable, paste relevant logs or error messages.
|
||||
placeholder: Paste logs here...
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
placeholder: Any additional information that might be helpful...
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues to ensure this bug hasn't been reported already
|
||||
required: true
|
||||
- label: I have provided all required information above
|
||||
required: true
|
||||
108
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,108 +0,0 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for Automaker
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request.
|
||||
|
||||
- type: dropdown
|
||||
id: feature-area
|
||||
attributes:
|
||||
label: Feature Area
|
||||
description: Which area of Automaker does this feature relate to?
|
||||
options:
|
||||
- UI/UX (User Interface)
|
||||
- Agent/AI
|
||||
- Kanban Board
|
||||
- Git/Worktree Management
|
||||
- Project Management
|
||||
- Settings/Configuration
|
||||
- Documentation
|
||||
- Performance
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority
|
||||
description: How important is this feature to your workflow?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow
|
||||
- Critical for my use case
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: Is your feature request related to a problem? Please describe the problem you're trying to solve.
|
||||
placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the solution you'd like to see implemented.
|
||||
placeholder: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Describe any alternative solutions or workarounds you've considered.
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: use-cases
|
||||
attributes:
|
||||
label: Use Cases
|
||||
description: Describe specific scenarios where this feature would be useful.
|
||||
placeholder: |
|
||||
1. When working on...
|
||||
2. As a user who needs to...
|
||||
3. In situations where...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mockups
|
||||
attributes:
|
||||
label: Mockups/Screenshots
|
||||
description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request.
|
||||
placeholder: Drag and drop images here or paste image URLs
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, references, or examples about the feature request here.
|
||||
placeholder: Any additional information that might be helpful...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Checklist
|
||||
options:
|
||||
- label: I have searched existing issues to ensure this feature hasn't been requested already
|
||||
required: true
|
||||
- label: I have provided a clear description of the problem and proposed solution
|
||||
required: true
|
||||
72
.github/actions/setup-project/action.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: 'Setup Project'
|
||||
description: 'Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules'
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '22'
|
||||
check-lockfile:
|
||||
description: 'Run lockfile lint check for SSH URLs'
|
||||
required: false
|
||||
default: 'false'
|
||||
rebuild-node-pty-path:
|
||||
description: 'Working directory for node-pty rebuild (empty = root)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
shell: bash
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
|
||||
# Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms
|
||||
run: npm install --ignore-scripts --force
|
||||
|
||||
- name: Install Linux native bindings
|
||||
shell: bash
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force --ignore-scripts \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Build shared packages
|
||||
shell: bash
|
||||
# Build shared packages (types, utils, platform, etc.) before apps can use them
|
||||
run: npm run build:packages
|
||||
|
||||
- name: Rebuild native modules (root)
|
||||
if: inputs.rebuild-node-pty-path == ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules for Electron
|
||||
run: npm rebuild node-pty
|
||||
|
||||
- name: Rebuild native modules (workspace)
|
||||
if: inputs.rebuild-node-pty-path != ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules needed for server
|
||||
run: npm rebuild node-pty
|
||||
working-directory: ${{ inputs.rebuild-node-pty-path }}
|
||||
179
.github/scripts/upload-to-r2.js
vendored
@@ -1,11 +1,15 @@
|
||||
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
} = require("@aws-sdk/client-s3");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const { pipeline } = require("stream/promises");
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: 'auto',
|
||||
region: "auto",
|
||||
endpoint: process.env.R2_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
@@ -24,14 +28,14 @@ async function fetchExistingReleases() {
|
||||
const response = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'releases.json',
|
||||
Key: "releases.json",
|
||||
})
|
||||
);
|
||||
const body = await response.Body.transformToString();
|
||||
return JSON.parse(body);
|
||||
} catch (error) {
|
||||
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
|
||||
console.log('No existing releases.json found, creating new one');
|
||||
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
|
||||
console.log("No existing releases.json found, creating new one");
|
||||
return { latestVersion: null, releases: [] };
|
||||
}
|
||||
throw error;
|
||||
@@ -81,7 +85,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: 'Redirect without location header',
|
||||
error: "Redirect without location header",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -89,16 +93,18 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
return https
|
||||
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
|
||||
const redirectStatus = redirectResponse.statusCode;
|
||||
const contentType = redirectResponse.headers['content-type'] || '';
|
||||
const contentType =
|
||||
redirectResponse.headers["content-type"] || "";
|
||||
// Check if it's actually a file (zip/tar.gz) and not HTML
|
||||
const isFile =
|
||||
contentType.includes('application/zip') ||
|
||||
contentType.includes('application/gzip') ||
|
||||
contentType.includes('application/x-gzip') ||
|
||||
contentType.includes('application/x-tar') ||
|
||||
redirectUrl.includes('.zip') ||
|
||||
redirectUrl.includes('.tar.gz');
|
||||
const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
|
||||
contentType.includes("application/zip") ||
|
||||
contentType.includes("application/gzip") ||
|
||||
contentType.includes("application/x-gzip") ||
|
||||
contentType.includes("application/x-tar") ||
|
||||
redirectUrl.includes(".zip") ||
|
||||
redirectUrl.includes(".tar.gz");
|
||||
const isGood =
|
||||
redirectStatus >= 200 && redirectStatus < 300 && isFile;
|
||||
redirectResponse.destroy();
|
||||
resolve({
|
||||
accessible: isGood,
|
||||
@@ -107,38 +113,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
contentType,
|
||||
});
|
||||
})
|
||||
.on('error', (error) => {
|
||||
.on("error", (error) => {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.on('timeout', function () {
|
||||
.on("timeout", function () {
|
||||
this.destroy();
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: 'Timeout following redirect',
|
||||
error: "Timeout following redirect",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if status is good (200-299 range) and it's actually a file
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
const contentType = response.headers["content-type"] || "";
|
||||
const isFile =
|
||||
contentType.includes('application/zip') ||
|
||||
contentType.includes('application/gzip') ||
|
||||
contentType.includes('application/x-gzip') ||
|
||||
contentType.includes('application/x-tar') ||
|
||||
url.includes('.zip') ||
|
||||
url.includes('.tar.gz');
|
||||
contentType.includes("application/zip") ||
|
||||
contentType.includes("application/gzip") ||
|
||||
contentType.includes("application/x-gzip") ||
|
||||
contentType.includes("application/x-tar") ||
|
||||
url.includes(".zip") ||
|
||||
url.includes(".tar.gz");
|
||||
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
|
||||
response.destroy();
|
||||
resolve({ accessible: isGood, statusCode, contentType });
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
request.on("error", (error) => {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode: null,
|
||||
@@ -146,12 +152,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
});
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.on("timeout", () => {
|
||||
request.destroy();
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode: null,
|
||||
error: 'Request timeout',
|
||||
error: "Request timeout",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -162,14 +168,22 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
|
||||
);
|
||||
} else {
|
||||
console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
|
||||
console.log(
|
||||
`✓ URL ${url} is accessible (status: ${result.statusCode})`
|
||||
);
|
||||
}
|
||||
return result.finalUrl || url; // Return the final URL (after redirects) if available
|
||||
} else {
|
||||
const errorMsg = result.error ? ` - ${result.error}` : '';
|
||||
const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
|
||||
const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
|
||||
console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
|
||||
const errorMsg = result.error ? ` - ${result.error}` : "";
|
||||
const statusMsg = result.statusCode
|
||||
? ` (status: ${result.statusCode})`
|
||||
: "";
|
||||
const contentTypeMsg = result.contentType
|
||||
? ` [content-type: ${result.contentType}]`
|
||||
: "";
|
||||
console.log(
|
||||
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`✗ URL ${url} check failed: ${error.message}`);
|
||||
@@ -177,7 +191,9 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = initialDelay * Math.pow(2, attempt);
|
||||
console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
|
||||
console.log(
|
||||
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
@@ -191,7 +207,12 @@ async function downloadFromGitHub(url, outputPath) {
|
||||
const statusCode = response.statusCode;
|
||||
|
||||
// Follow redirects (all redirect types)
|
||||
if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
|
||||
if (
|
||||
statusCode === 301 ||
|
||||
statusCode === 302 ||
|
||||
statusCode === 307 ||
|
||||
statusCode === 308
|
||||
) {
|
||||
const redirectUrl = response.headers.location;
|
||||
response.destroy();
|
||||
if (!redirectUrl) {
|
||||
@@ -199,33 +220,39 @@ async function downloadFromGitHub(url, outputPath) {
|
||||
return;
|
||||
}
|
||||
// Resolve relative redirects
|
||||
const finalRedirectUrl = redirectUrl.startsWith('http')
|
||||
const finalRedirectUrl = redirectUrl.startsWith("http")
|
||||
? redirectUrl
|
||||
: new URL(redirectUrl, url).href;
|
||||
console.log(` Following redirect: ${finalRedirectUrl}`);
|
||||
return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
|
||||
return downloadFromGitHub(finalRedirectUrl, outputPath)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}
|
||||
|
||||
if (statusCode !== 200) {
|
||||
response.destroy();
|
||||
reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(outputPath);
|
||||
response.pipe(fileStream);
|
||||
fileStream.on('finish', () => {
|
||||
fileStream.on("finish", () => {
|
||||
fileStream.close();
|
||||
resolve();
|
||||
});
|
||||
fileStream.on('error', (error) => {
|
||||
fileStream.on("error", (error) => {
|
||||
response.destroy();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
request.on('timeout', () => {
|
||||
request.on("error", reject);
|
||||
request.on("timeout", () => {
|
||||
request.destroy();
|
||||
reject(new Error(`Request timeout for ${url}`));
|
||||
});
|
||||
@@ -233,8 +260,8 @@ async function downloadFromGitHub(url, outputPath) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifactsDir = 'artifacts';
|
||||
const tempDir = path.join(artifactsDir, 'temp');
|
||||
const artifactsDir = "artifacts";
|
||||
const tempDir = path.join(artifactsDir, "temp");
|
||||
|
||||
// Create temp directory for downloaded GitHub archives
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
@@ -265,30 +292,40 @@ async function main() {
|
||||
|
||||
// Find all artifacts
|
||||
const artifacts = {
|
||||
windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
|
||||
macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
|
||||
macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
|
||||
linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
|
||||
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
|
||||
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
|
||||
macosArm: findArtifacts(
|
||||
path.join(artifactsDir, "macos-builds"),
|
||||
/-arm64\.dmg$/
|
||||
),
|
||||
linux: findArtifacts(
|
||||
path.join(artifactsDir, "linux-builds"),
|
||||
/\.AppImage$/
|
||||
),
|
||||
sourceZip: [sourceZipPath],
|
||||
sourceTarGz: [sourceTarGzPath],
|
||||
};
|
||||
|
||||
console.log('Found artifacts:');
|
||||
console.log("Found artifacts:");
|
||||
for (const [platform, files] of Object.entries(artifacts)) {
|
||||
console.log(
|
||||
` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
|
||||
` ${platform}: ${
|
||||
files.length > 0
|
||||
? files.map((f) => path.basename(f)).join(", ")
|
||||
: "none"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// Upload each artifact to R2
|
||||
const assets = {};
|
||||
const contentTypes = {
|
||||
windows: 'application/x-msdownload',
|
||||
macos: 'application/x-apple-diskimage',
|
||||
macosArm: 'application/x-apple-diskimage',
|
||||
linux: 'application/x-executable',
|
||||
sourceZip: 'application/zip',
|
||||
sourceTarGz: 'application/gzip',
|
||||
windows: "application/x-msdownload",
|
||||
macos: "application/x-apple-diskimage",
|
||||
macosArm: "application/x-apple-diskimage",
|
||||
linux: "application/x-executable",
|
||||
sourceZip: "application/zip",
|
||||
sourceTarGz: "application/gzip",
|
||||
};
|
||||
|
||||
for (const [platform, files] of Object.entries(artifacts)) {
|
||||
@@ -308,11 +345,11 @@ async function main() {
|
||||
filename,
|
||||
size,
|
||||
arch:
|
||||
platform === 'macosArm'
|
||||
? 'arm64'
|
||||
: platform === 'sourceZip' || platform === 'sourceTarGz'
|
||||
? 'source'
|
||||
: 'x64',
|
||||
platform === "macosArm"
|
||||
? "arm64"
|
||||
: platform === "sourceZip" || platform === "sourceTarGz"
|
||||
? "source"
|
||||
: "x64",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -327,7 +364,9 @@ async function main() {
|
||||
};
|
||||
|
||||
// Remove existing entry for this version if re-running
|
||||
releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
|
||||
releasesData.releases = releasesData.releases.filter(
|
||||
(r) => r.version !== VERSION
|
||||
);
|
||||
|
||||
// Prepend new release
|
||||
releasesData.releases.unshift(newRelease);
|
||||
@@ -337,19 +376,19 @@ async function main() {
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'releases.json',
|
||||
Key: "releases.json",
|
||||
Body: JSON.stringify(releasesData, null, 2),
|
||||
ContentType: 'application/json',
|
||||
CacheControl: 'public, max-age=60',
|
||||
ContentType: "application/json",
|
||||
CacheControl: "public, max-age=60",
|
||||
})
|
||||
);
|
||||
|
||||
console.log('Successfully updated releases.json');
|
||||
console.log("Successfully updated releases.json");
|
||||
console.log(`Latest version: ${VERSION}`);
|
||||
console.log(`Total releases: ${releasesData.releases.length}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to upload to R2:', err);
|
||||
console.error("Failed to upload to R2:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
49
.github/workflows/claude.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
160
.github/workflows/e2e-tests.yml
vendored
@@ -3,7 +3,7 @@ name: E2E Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- "*"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -18,161 +18,79 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
rebuild-node-pty-path: 'apps/server'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: apps/ui
|
||||
working-directory: apps/app
|
||||
|
||||
- name: Build server
|
||||
run: npm run build --workspace=apps/server
|
||||
|
||||
- name: Set up Git user
|
||||
run: |
|
||||
git config --global user.name "GitHub CI"
|
||||
git config --global user.email "ci@example.com"
|
||||
|
||||
- name: Start backend server
|
||||
run: |
|
||||
echo "Starting backend server..."
|
||||
# Start server in background and save PID
|
||||
npm run start --workspace=apps/server > backend.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||
|
||||
run: npm run start --workspace=apps/server &
|
||||
env:
|
||||
PORT: 3008
|
||||
NODE_ENV: test
|
||||
# Use a deterministic API key so Playwright can log in reliably
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
# Reduce log noise in CI
|
||||
AUTOMAKER_HIDE_API_KEY: 'true'
|
||||
# Avoid real API calls during CI
|
||||
AUTOMAKER_MOCK_AGENT: 'true'
|
||||
# Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true'
|
||||
|
||||
- name: Wait for backend server
|
||||
run: |
|
||||
echo "Waiting for backend server to be ready..."
|
||||
|
||||
# Check if server process is running
|
||||
if [ -z "$SERVER_PID" ]; then
|
||||
echo "ERROR: Server PID not found in environment"
|
||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if process is actually running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process $SERVER_PID is not running!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "=== Recent system logs ==="
|
||||
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for health endpoint
|
||||
for i in {1..60}; do
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
echo "Backend server is ready!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "Health check response:"
|
||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if server process is still running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process died during wait!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting... ($i/60)"
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "ERROR: Backend server failed to start within 60 seconds!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "=== Process status ==="
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||
echo ""
|
||||
echo "=== Health endpoint test ==="
|
||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||
|
||||
# Kill the server process if it's still hanging
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Killing stuck server process..."
|
||||
kill -9 $SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Backend server failed to start!"
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
# Playwright automatically starts the Vite frontend via webServer config
|
||||
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/ui
|
||||
# Playwright automatically starts the Next.js frontend via webServer config
|
||||
# (see apps/app/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/app
|
||||
env:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
|
||||
- name: Print backend logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== E2E Tests Failed - Backend Logs ==="
|
||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||
echo ""
|
||||
echo "=== Process status at failure ==="
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
|
||||
NEXT_PUBLIC_SKIP_SETUP: "true"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/ui/playwright-report/
|
||||
path: apps/app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results (screenshots, traces, videos)
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
apps/ui/test-results/
|
||||
path: apps/app/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Cleanup - Kill backend server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
echo "Cleaning up backend server (PID: $SERVER_PID)..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
kill -9 $SERVER_PID 2>/dev/null || true
|
||||
echo "Backend server cleanup complete"
|
||||
fi
|
||||
|
||||
31
.github/workflows/format-check.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Format Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts --force
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
35
.github/workflows/pr-check.yml
vendored
@@ -3,7 +3,7 @@ name: PR Build Check
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- "*"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -17,10 +17,33 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Run build:electron (dir only - faster CI)
|
||||
run: npm run build:electron:dir
|
||||
- name: Check for SSH URLs in lockfile
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Run build:electron
|
||||
run: npm run build:electron
|
||||
|
||||
257
.github/workflows/release.yml
vendored
@@ -1,131 +1,180 @@
|
||||
name: Release Build
|
||||
name: Build and Release Electron App
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*" # Triggers on version tags like v1.0.0
|
||||
workflow_dispatch: # Allows manual triggering
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to release (e.g., v1.0.0)"
|
||||
required: true
|
||||
default: "v0.1.0"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-and-release:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
include:
|
||||
- os: macos-latest
|
||||
name: macOS
|
||||
artifact-name: macos-builds
|
||||
- os: windows-latest
|
||||
name: Windows
|
||||
artifact-name: windows-builds
|
||||
- os: ubuntu-latest
|
||||
name: Linux
|
||||
artifact-name: linux-builds
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Only needed on Linux - macOS and Windows get their bindings automatically
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Extract and set version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
# Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3")
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: ${VERSION}"
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
# Update the app's package.json version
|
||||
cd apps/app
|
||||
npm version $VERSION --no-git-tag-version
|
||||
cd ../..
|
||||
echo "Updated apps/app/package.json to version $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
run: |
|
||||
node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Install RPM build tools (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Build Electron app (macOS)
|
||||
- name: Build Electron App (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:mac --workspace=apps/ui
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --mac --x64 --arm64
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
- name: Build Electron App (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:win --workspace=apps/ui
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --win --x64
|
||||
|
||||
- name: Build Electron app (Linux)
|
||||
- name: Build Electron App (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:linux --workspace=apps/ui
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --linux --x64
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: macos-builds
|
||||
path: |
|
||||
apps/ui/release/*.dmg
|
||||
apps/ui/release/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: |
|
||||
apps/ui/release/*.AppImage
|
||||
apps/ui/release/*.deb
|
||||
apps/ui/release/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.release.draft == false
|
||||
|
||||
steps:
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-builds
|
||||
path: artifacts/macos-builds
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: artifacts/windows-builds
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: artifacts/linux-builds
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
fail_on_unmatched_files: true
|
||||
tag_name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
files: |
|
||||
artifacts/macos-builds/*.dmg
|
||||
artifacts/macos-builds/*.zip
|
||||
artifacts/windows-builds/*.exe
|
||||
artifacts/linux-builds/*.AppImage
|
||||
artifacts/linux-builds/*.deb
|
||||
artifacts/linux-builds/*.rpm
|
||||
apps/app/dist/*.exe
|
||||
apps/app/dist/*.dmg
|
||||
apps/app/dist/*.AppImage
|
||||
apps/app/dist/*.zip
|
||||
apps/app/dist/*.deb
|
||||
apps/app/dist/*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload macOS artifacts for R2
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.dmg
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Windows artifacts for R2
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Linux artifacts for R2
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.AppImage
|
||||
retention-days: 1
|
||||
|
||||
upload-to-r2:
|
||||
needs: build-and-release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Install AWS SDK
|
||||
run: npm install @aws-sdk/client-s3
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
|
||||
- name: Upload to R2 and update releases.json
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node .github/scripts/upload-to-r2.js
|
||||
|
||||
30
.github/workflows/security-audit.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
schedule:
|
||||
# Run weekly on Mondays at 9 AM UTC
|
||||
- cron: '0 9 * * 1'
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=critical
|
||||
continue-on-error: false
|
||||
32
.github/workflows/test.yml
vendored
@@ -3,7 +3,7 @@ name: Test Suite
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
- "*"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -17,16 +17,30 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
rebuild-node-pty-path: 'apps/server'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Run package tests
|
||||
run: npm run test:packages
|
||||
env:
|
||||
NODE_ENV: test
|
||||
- name: Configure Git for HTTPS
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
run: npm install
|
||||
|
||||
- name: Install Linux native bindings
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Run server tests with coverage
|
||||
run: npm run test:server:coverage
|
||||
|
||||
17
.gitignore
vendored
@@ -73,25 +73,8 @@ blob-report/
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# Codex config (contains API keys)
|
||||
.codex/config.toml
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
docker-compose.override.yml
|
||||
.claude/docker-compose.override.yml
|
||||
.claude/hans/
|
||||
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Fork-specific workflow files (should never be committed)
|
||||
# API key files
|
||||
data/.api-key
|
||||
data/credentials.json
|
||||
data/
|
||||
.codex/
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Try to load nvm if available (optional - works without it too)
|
||||
if [ -z "$NVM_DIR" ]; then
|
||||
# Check for Herd's nvm first (macOS with Herd)
|
||||
if [ -s "$HOME/Library/Application Support/Herd/config/nvm/nvm.sh" ]; then
|
||||
export NVM_DIR="$HOME/Library/Application Support/Herd/config/nvm"
|
||||
# Then check standard nvm location
|
||||
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Source nvm if found (silently skip if not available)
|
||||
[ -n "$NVM_DIR" ] && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 2>/dev/null
|
||||
|
||||
# Load node version from .nvmrc if using nvm (silently skip if nvm not available or fails)
|
||||
if [ -f .nvmrc ] && command -v nvm >/dev/null 2>&1; then
|
||||
# Check if Unix nvm was sourced (it's a shell function with NVM_DIR set)
|
||||
if [ -n "$NVM_DIR" ] && type nvm 2>/dev/null | grep -q "function"; then
|
||||
# Unix nvm: reads .nvmrc automatically
|
||||
nvm use >/dev/null 2>&1 || true
|
||||
else
|
||||
# nvm-windows: needs explicit version from .nvmrc
|
||||
NODE_VERSION=$(cat .nvmrc | tr -d '[:space:]')
|
||||
if [ -n "$NODE_VERSION" ]; then
|
||||
nvm use "$NODE_VERSION" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure common system paths are in PATH (for systems without nvm)
|
||||
# This helps find node/npm installed via Homebrew, system packages, etc.
|
||||
if [ -n "$WINDIR" ]; then
|
||||
export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs"
|
||||
export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs"
|
||||
else
|
||||
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
|
||||
fi
|
||||
|
||||
# Run lint-staged - works with or without nvm
|
||||
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
npx lint-staged
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
npm exec -- lint-staged
|
||||
else
|
||||
echo "Error: Neither npx nor npm found in PATH."
|
||||
echo "Please ensure Node.js is installed (via nvm, Homebrew, system package manager, etc.)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,41 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.turbo/
|
||||
release/
|
||||
|
||||
# Automaker
|
||||
.automaker/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
routeTree.gen.ts
|
||||
apps/ui/src/routeTree.gen.ts
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
coverage/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# IDE/Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Electron
|
||||
dist-electron/
|
||||
server-bundle/
|
||||
10
.prettierrc
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
176
CLAUDE.md
@@ -1,176 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev # Interactive launcher (choose web or electron)
|
||||
npm run dev:web # Web browser mode (localhost:3007)
|
||||
npm run dev:electron # Desktop app mode
|
||||
npm run dev:electron:debug # Desktop with DevTools open
|
||||
|
||||
# Building
|
||||
npm run build # Build web application
|
||||
npm run build:packages # Build all shared packages (required before other builds)
|
||||
npm run build:electron # Build desktop app for current platform
|
||||
npm run build:server # Build server only
|
||||
|
||||
# Testing
|
||||
npm run test # E2E tests (Playwright, headless)
|
||||
npm run test:headed # E2E tests with browser visible
|
||||
npm run test:server # Server unit tests (Vitest)
|
||||
npm run test:packages # All shared package tests
|
||||
npm run test:all # All tests (packages + server)
|
||||
|
||||
# Single test file
|
||||
npm run test:server -- tests/unit/specific.test.ts
|
||||
|
||||
# Linting and formatting
|
||||
npm run lint # ESLint
|
||||
npm run format # Prettier write
|
||||
npm run format:check # Prettier check
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend (port 3007)
|
||||
│ └── server/ # Express + WebSocket backend (port 3008)
|
||||
└── libs/ # Shared packages (@automaker/*)
|
||||
├── types/ # Core TypeScript definitions (no dependencies)
|
||||
├── utils/ # Logging, errors, image processing, context loading
|
||||
├── prompts/ # AI prompt templates
|
||||
├── platform/ # Path management, security, process spawning
|
||||
├── model-resolver/ # Claude model alias resolution
|
||||
├── dependency-resolver/ # Feature dependency ordering
|
||||
└── git-utils/ # Git operations & worktree management
|
||||
```
|
||||
|
||||
### Package Dependency Chain
|
||||
|
||||
Packages can only depend on packages above them:
|
||||
|
||||
```
|
||||
@automaker/types (no dependencies)
|
||||
↓
|
||||
@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver
|
||||
↓
|
||||
@automaker/git-utils
|
||||
↓
|
||||
@automaker/server, @automaker/ui
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4
|
||||
- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty
|
||||
- **Testing**: Playwright (E2E), Vitest (unit)
|
||||
|
||||
### Server Architecture
|
||||
|
||||
The server (`apps/server/src/`) follows a modular pattern:
|
||||
|
||||
- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.)
|
||||
- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService)
|
||||
- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK)
|
||||
- `lib/` - Utilities (events, auth, worktree metadata)
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
The UI (`apps/ui/src/`) uses:
|
||||
|
||||
- `routes/` - TanStack Router file-based routing
|
||||
- `components/views/` - Main view components (board, settings, terminal, etc.)
|
||||
- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts)
|
||||
- `hooks/` - Custom React hooks
|
||||
- `lib/` - Utilities and API client
|
||||
|
||||
## Data Storage
|
||||
|
||||
### Per-Project Data (`.automaker/`)
|
||||
|
||||
```
|
||||
.automaker/
|
||||
├── features/ # Feature JSON files and images
|
||||
│ └── {featureId}/
|
||||
│ ├── feature.json
|
||||
│ ├── agent-output.md
|
||||
│ └── images/
|
||||
├── context/ # Context files for AI agents (CLAUDE.md, etc.)
|
||||
├── settings.json # Project-specific settings
|
||||
├── spec.md # Project specification
|
||||
└── analysis.json # Project structure analysis
|
||||
```
|
||||
|
||||
### Global Data (`DATA_DIR`, default `./data`)
|
||||
|
||||
```
|
||||
data/
|
||||
├── settings.json # Global settings, profiles, shortcuts
|
||||
├── credentials.json # API keys
|
||||
├── sessions-metadata.json # Chat session metadata
|
||||
└── agent-sessions/ # Conversation histories
|
||||
```
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Always import from shared packages, never from old paths:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import type { Feature, ExecuteOptions } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
import { getEnhancementPrompt } from '@automaker/prompts';
|
||||
import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { resolveDependencies } from '@automaker/dependency-resolver';
|
||||
import { getGitRepositoryDiffs } from '@automaker/git-utils';
|
||||
|
||||
// ❌ Never import from old paths
|
||||
import { Feature } from '../services/feature-loader'; // Wrong
|
||||
import { createLogger } from '../lib/logger'; // Wrong
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Event-Driven Architecture
|
||||
|
||||
All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`.
|
||||
|
||||
### Git Worktree Isolation
|
||||
|
||||
Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution.
|
||||
|
||||
### Context Files
|
||||
|
||||
Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`.
|
||||
|
||||
### Model Resolution
|
||||
|
||||
Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases:
|
||||
|
||||
- `haiku` → `claude-haiku-4-5`
|
||||
- `sonnet` → `claude-sonnet-4-20250514`
|
||||
- `opus` → `claude-opus-4-5-20251101`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth)
|
||||
- `HOST` - Host to bind server to (default: 0.0.0.0)
|
||||
- `HOSTNAME` - Hostname for user-facing URLs (default: localhost)
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production)
|
||||
- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost)
|
||||
740
CONTRIBUTING.md
@@ -1,740 +0,0 @@
|
||||
# Contributing to Automaker
|
||||
|
||||
Thank you for your interest in contributing to Automaker! We're excited to have you join our community of developers building the future of autonomous AI development.
|
||||
|
||||
Automaker is an autonomous AI development studio that provides a Kanban-based workflow where AI agents implement features in isolated git worktrees. Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your contributions help make this project better for everyone.
|
||||
|
||||
This guide will help you get started with contributing to Automaker. Please take a moment to read through these guidelines to ensure a smooth contribution process.
|
||||
|
||||
## Contribution License Agreement
|
||||
|
||||
**Important:** By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials to the Automaker project, you agree to assign all right, title, and interest in and to your contributions, including all copyrights, patents, and other intellectual property rights, to the Core Contributors of Automaker. This assignment is irrevocable and includes the right to use, modify, distribute, and monetize your contributions in any manner.
|
||||
|
||||
**You understand and agree that you will have no right to receive any royalties, compensation, or other financial benefits from any revenue, income, or commercial use generated from your contributed code or any derivative works thereof.** All contributions are made without expectation of payment or financial return.
|
||||
|
||||
For complete details on contribution terms and rights assignment, please review [Section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT) of the LICENSE](LICENSE#5-contributions-and-rights-assignment).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contributing to Automaker](#contributing-to-automaker)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Fork and Clone](#fork-and-clone)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Branching Strategy (RC Branches)](#branching-strategy-rc-branches)
|
||||
- [Branch Naming Convention](#branch-naming-convention)
|
||||
- [Commit Message Format](#commit-message-format)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [1. Prepare Your Changes](#1-prepare-your-changes)
|
||||
- [2. Run Pre-submission Checks](#2-run-pre-submission-checks)
|
||||
- [3. Push Your Changes](#3-push-your-changes)
|
||||
- [4. Open a Pull Request](#4-open-a-pull-request)
|
||||
- [PR Requirements Checklist](#pr-requirements-checklist)
|
||||
- [Review Process](#review-process)
|
||||
- [What to Expect](#what-to-expect)
|
||||
- [Review Focus Areas](#review-focus-areas)
|
||||
- [Responding to Feedback](#responding-to-feedback)
|
||||
- [Approval Criteria](#approval-criteria)
|
||||
- [Getting Help](#getting-help)
|
||||
- [Code Style Guidelines](#code-style-guidelines)
|
||||
- [Testing Requirements](#testing-requirements)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Test Frameworks](#test-frameworks)
|
||||
- [End-to-End Tests (Playwright)](#end-to-end-tests-playwright)
|
||||
- [Unit Tests (Vitest)](#unit-tests-vitest)
|
||||
- [Writing Tests](#writing-tests)
|
||||
- [When to Write Tests](#when-to-write-tests)
|
||||
- [CI/CD Pipeline](#cicd-pipeline)
|
||||
- [CI Checks](#ci-checks)
|
||||
- [CI Testing Environment](#ci-testing-environment)
|
||||
- [Viewing CI Results](#viewing-ci-results)
|
||||
- [Common CI Failures](#common-ci-failures)
|
||||
- [Coverage Requirements](#coverage-requirements)
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Bug Reports](#bug-reports)
|
||||
- [Before Reporting](#before-reporting)
|
||||
- [Bug Report Template](#bug-report-template)
|
||||
- [Feature Requests](#feature-requests)
|
||||
- [Before Requesting](#before-requesting)
|
||||
- [Feature Request Template](#feature-request-template)
|
||||
- [Security Issues](#security-issues)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before contributing to Automaker, ensure you have the following installed on your system:
|
||||
|
||||
- **Node.js 18+** (tested with Node.js 22)
|
||||
- Download from [nodejs.org](https://nodejs.org/)
|
||||
- Verify installation: `node --version`
|
||||
- **npm** (comes with Node.js)
|
||||
- Verify installation: `npm --version`
|
||||
- **Git** for version control
|
||||
- Verify installation: `git --version`
|
||||
- **Claude Code CLI** or **Anthropic API Key** (for AI agent functionality)
|
||||
- Required to run the AI development features
|
||||
|
||||
**Optional but recommended:**
|
||||
|
||||
- A code editor with TypeScript support (VS Code recommended)
|
||||
- GitHub CLI (`gh`) for easier PR management
|
||||
|
||||
### Fork and Clone
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
- Navigate to [https://github.com/AutoMaker-Org/automaker](https://github.com/AutoMaker-Org/automaker)
|
||||
- Click the "Fork" button in the top-right corner
|
||||
- This creates your own copy of the repository
|
||||
|
||||
2. **Clone your fork locally**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/automaker.git
|
||||
cd automaker
|
||||
```
|
||||
|
||||
3. **Add the upstream remote** to keep your fork in sync
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/AutoMaker-Org/automaker.git
|
||||
```
|
||||
|
||||
4. **Verify remotes**
|
||||
```bash
|
||||
git remote -v
|
||||
# Should show:
|
||||
# origin https://github.com/YOUR_USERNAME/automaker.git (fetch)
|
||||
# origin https://github.com/YOUR_USERNAME/automaker.git (push)
|
||||
# upstream https://github.com/AutoMaker-Org/automaker.git (fetch)
|
||||
# upstream https://github.com/AutoMaker-Org/automaker.git (push)
|
||||
```
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Build shared packages** (required before running the app)
|
||||
|
||||
```bash
|
||||
npm run build:packages
|
||||
```
|
||||
|
||||
3. **Start the development server**
|
||||
```bash
|
||||
npm run dev # Interactive launcher - choose mode
|
||||
npm run dev:web # Browser mode (web interface)
|
||||
npm run dev:electron # Desktop app mode
|
||||
```
|
||||
|
||||
**Common development commands:**
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------ | -------------------------------- |
|
||||
| `npm run dev` | Interactive development launcher |
|
||||
| `npm run dev:web` | Start in browser mode |
|
||||
| `npm run dev:electron` | Start desktop app |
|
||||
| `npm run build` | Build all packages and apps |
|
||||
| `npm run build:packages` | Build shared packages only |
|
||||
| `npm run lint` | Run ESLint checks |
|
||||
| `npm run format` | Format code with Prettier |
|
||||
| `npm run format:check` | Check formatting without changes |
|
||||
| `npm run test` | Run E2E tests (Playwright) |
|
||||
| `npm run test:server` | Run server unit tests |
|
||||
| `npm run test:packages` | Run package tests |
|
||||
| `npm run test:all` | Run all tests |
|
||||
|
||||
### Project Structure
|
||||
|
||||
Automaker is organized as an npm workspace monorepo:
|
||||
|
||||
```
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend
|
||||
│ └── server/ # Express + WebSocket backend
|
||||
├── libs/
|
||||
│ ├── @automaker/types/ # Shared TypeScript types
|
||||
│ ├── @automaker/utils/ # Utility functions
|
||||
│ ├── @automaker/prompts/ # AI prompt templates
|
||||
│ ├── @automaker/platform/ # Platform abstractions
|
||||
│ ├── @automaker/model-resolver/ # AI model resolution
|
||||
│ ├── @automaker/dependency-resolver/ # Dependency management
|
||||
│ └── @automaker/git-utils/ # Git operations
|
||||
├── docs/ # Documentation
|
||||
└── package.json # Root package configuration
|
||||
```
|
||||
|
||||
**Key conventions:**
|
||||
|
||||
- Always import from `@automaker/*` shared packages, never use relative paths to `libs/`
|
||||
- Frontend code lives in `apps/ui/`
|
||||
- Backend code lives in `apps/server/`
|
||||
- Shared logic should be in the appropriate `libs/` package
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged.
|
||||
|
||||
### Branching Strategy (RC Branches)
|
||||
|
||||
Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs
|
||||
2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main`
|
||||
3. **Main branch is for releases only** - The `main` branch contains only released, stable code
|
||||
|
||||
**Before creating a PR:**
|
||||
|
||||
1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git branch -r | grep rc
|
||||
```
|
||||
|
||||
2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`:
|
||||
|
||||
```bash
|
||||
# Find the latest RC branch (e.g., v0.11.0rc)
|
||||
git checkout upstream/v0.11.0rc
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main`
|
||||
|
||||
**Example workflow:**
|
||||
|
||||
```bash
|
||||
# 1. Fetch latest changes
|
||||
git fetch upstream
|
||||
|
||||
# 2. Check for RC branches
|
||||
git branch -r | grep rc
|
||||
# Output: upstream/v0.11.0rc
|
||||
|
||||
# 3. Create your branch from the RC
|
||||
git checkout -b feature/add-dark-mode upstream/v0.11.0rc
|
||||
|
||||
# 4. Make your changes and commit
|
||||
git commit -m "feat: Add dark mode support"
|
||||
|
||||
# 5. Push to your fork
|
||||
git push origin feature/add-dark-mode
|
||||
|
||||
# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main
|
||||
```
|
||||
|
||||
**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch.
|
||||
|
||||
### Branch Naming Convention
|
||||
|
||||
We use a consistent branch naming pattern to keep our repository organized:
|
||||
|
||||
```
|
||||
<type>/<description>
|
||||
```
|
||||
|
||||
**Branch types:**
|
||||
|
||||
| Type | Purpose | Example |
|
||||
| ---------- | ------------------------ | --------------------------------- |
|
||||
| `feature` | New functionality | `feature/add-user-authentication` |
|
||||
| `fix` | Bug fixes | `fix/resolve-memory-leak` |
|
||||
| `docs` | Documentation changes | `docs/update-contributing-guide` |
|
||||
| `refactor` | Code restructuring | `refactor/simplify-api-handlers` |
|
||||
| `test` | Adding or updating tests | `test/add-utils-unit-tests` |
|
||||
| `chore` | Maintenance tasks | `chore/update-dependencies` |
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Use lowercase letters and hyphens (no underscores or spaces)
|
||||
- Keep descriptions short but descriptive
|
||||
- Include issue number when applicable: `feature/123-add-login`
|
||||
|
||||
```bash
|
||||
# Create and checkout a new feature branch
|
||||
git checkout -b feature/add-dark-mode
|
||||
|
||||
# Create a fix branch with issue reference
|
||||
git checkout -b fix/456-resolve-login-error
|
||||
```
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
We follow the **Conventional Commits** style for clear, readable commit history:
|
||||
|
||||
```
|
||||
<type>: <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
**Commit types:**
|
||||
|
||||
| Type | Purpose |
|
||||
| ---------- | --------------------------- |
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | Formatting (no code change) |
|
||||
| `refactor` | Code restructuring |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Maintenance tasks |
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Use **imperative mood** ("Add feature" not "Added feature")
|
||||
- Keep first line under **72 characters**
|
||||
- Capitalize the first letter after the type prefix
|
||||
- No period at the end of the subject line
|
||||
- Add a blank line before the body for detailed explanations
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Simple commit
|
||||
git commit -m "feat: Add user authentication flow"
|
||||
|
||||
# Commit with body for more context
|
||||
git commit -m "fix: Resolve memory leak in WebSocket handler
|
||||
|
||||
The connection cleanup was not being called when clients
|
||||
disconnected unexpectedly. Added proper cleanup in the
|
||||
error handler to prevent memory accumulation."
|
||||
|
||||
# Documentation update
|
||||
git commit -m "docs: Update API documentation"
|
||||
|
||||
# Refactoring
|
||||
git commit -m "refactor: Simplify state management logic"
|
||||
```
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
Follow these steps to submit your contribution:
|
||||
|
||||
#### 1. Prepare Your Changes
|
||||
|
||||
Ensure you've synced with the latest upstream changes from the RC branch:
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Rebase your branch on the current RC branch (if needed)
|
||||
git rebase upstream/v0.11.0rc # Use the current RC branch name
|
||||
```
|
||||
|
||||
#### 2. Run Pre-submission Checks
|
||||
|
||||
Before opening your PR, verify everything passes locally:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:all
|
||||
|
||||
# Check formatting
|
||||
npm run format:check
|
||||
|
||||
# Run linter
|
||||
npm run lint
|
||||
|
||||
# Build to verify no compile errors
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### 3. Push Your Changes
|
||||
|
||||
```bash
|
||||
# Push your branch to your fork
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
#### 4. Open a Pull Request
|
||||
|
||||
1. Go to your fork on GitHub
|
||||
2. Click "Compare & pull request" for your branch
|
||||
3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main`
|
||||
4. Fill out the PR template completely
|
||||
|
||||
#### PR Requirements Checklist
|
||||
|
||||
Your PR should include:
|
||||
|
||||
- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches)
|
||||
- [ ] **Clear title** describing the change (use conventional commit format)
|
||||
- [ ] **Description** explaining what changed and why
|
||||
- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456`
|
||||
- [ ] **All CI checks passing** (format, lint, build, tests)
|
||||
- [ ] **No merge conflicts** with the RC branch
|
||||
- [ ] **Tests included** for new functionality
|
||||
- [ ] **Documentation updated** if adding/changing public APIs
|
||||
|
||||
**Example PR Description:**
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
This PR adds dark mode support to the Automaker UI.
|
||||
|
||||
- Implements theme toggle in settings panel
|
||||
- Adds CSS custom properties for theme colors
|
||||
- Persists theme preference to localStorage
|
||||
|
||||
## Related Issue
|
||||
|
||||
Closes #123
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] Tested toggle functionality in Chrome and Firefox
|
||||
- [x] Verified theme persists across page reloads
|
||||
- [x] Checked accessibility contrast ratios
|
||||
|
||||
## Screenshots
|
||||
|
||||
[Include before/after screenshots for UI changes]
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
All contributions go through code review to maintain quality:
|
||||
|
||||
#### What to Expect
|
||||
|
||||
1. **CI Checks Run First** - Automated checks (format, lint, build, tests) must pass before review
|
||||
2. **Maintainer Review** - The project maintainers will review your PR and decide whether to merge it
|
||||
3. **Feedback & Discussion** - The reviewer may ask questions or request changes
|
||||
4. **Iteration** - Make requested changes and push updates to the same branch
|
||||
5. **Approval & Merge** - Once approved and checks pass, your PR will be merged
|
||||
|
||||
#### Review Focus Areas
|
||||
|
||||
The reviewer checks for:
|
||||
|
||||
- **Correctness** - Does the code work as intended?
|
||||
- **Clean Code** - Does it follow our [code style guidelines](#code-style-guidelines)?
|
||||
- **Test Coverage** - Are new features properly tested?
|
||||
- **Documentation** - Are public APIs documented?
|
||||
- **Breaking Changes** - Are any breaking changes discussed first?
|
||||
|
||||
#### Responding to Feedback
|
||||
|
||||
- Respond to **all** review comments, even if just to acknowledge
|
||||
- Ask questions if feedback is unclear
|
||||
- Push additional commits to address feedback (don't force-push during review)
|
||||
- Mark conversations as resolved once addressed
|
||||
|
||||
#### Approval Criteria
|
||||
|
||||
Your PR is ready to merge when:
|
||||
|
||||
- ✅ All CI checks pass
|
||||
- ✅ The maintainer has approved the changes
|
||||
- ✅ All review comments are addressed
|
||||
- ✅ No unresolved merge conflicts
|
||||
|
||||
#### Getting Help
|
||||
|
||||
If your PR seems stuck:
|
||||
|
||||
- Comment asking for status update (mention @webdevcody if needed)
|
||||
- Reach out on [Discord](https://discord.gg/jjem7aEDKU)
|
||||
- Make sure all checks are passing and you've responded to all feedback
|
||||
|
||||
---
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
Automaker uses automated tooling to enforce code style. Run `npm run format` to format code and `npm run lint` to check for issues. Pre-commit hooks automatically format staged files before committing.
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
Testing helps prevent regressions. Automaker uses **Playwright** for end-to-end testing and **Vitest** for unit tests.
|
||||
|
||||
### Running Tests
|
||||
|
||||
Use these commands to run tests locally:
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------ | ------------------------------------- |
|
||||
| `npm run test` | Run E2E tests (Playwright) |
|
||||
| `npm run test:server` | Run server unit tests (Vitest) |
|
||||
| `npm run test:packages` | Run shared package tests |
|
||||
| `npm run test:all` | Run all tests |
|
||||
| `npm run test:server:coverage` | Run server tests with coverage report |
|
||||
|
||||
**Before submitting a PR**, always run the full test suite:
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
### Test Frameworks
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
|
||||
E2E tests verify the entire application works correctly from a user's perspective.
|
||||
|
||||
- **Framework:** [Playwright](https://playwright.dev/)
|
||||
- **Location:** `e2e/` directory
|
||||
- **Test ports:** UI on port 3007, Server on port 3008
|
||||
|
||||
**Running E2E tests:**
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
npm run test
|
||||
|
||||
# Run with headed browser (useful for debugging)
|
||||
npx playwright test --headed
|
||||
|
||||
# Run a specific test file
|
||||
npm test --workspace=@automaker/ui -- tests/example.spec.ts
|
||||
```
|
||||
|
||||
**E2E Test Guidelines:**
|
||||
|
||||
- Write tests from a user's perspective
|
||||
- Use descriptive test names that explain the scenario
|
||||
- Clean up test data after each test
|
||||
- Use appropriate timeouts for async operations
|
||||
- Prefer `locator` over direct selectors for resilience
|
||||
|
||||
#### Unit Tests (Vitest)
|
||||
|
||||
Unit tests verify individual functions and modules work correctly in isolation.
|
||||
|
||||
- **Framework:** [Vitest](https://vitest.dev/)
|
||||
- **Location:** In the `tests/` directory within each package (e.g., `apps/server/tests/`)
|
||||
|
||||
**Running unit tests:**
|
||||
|
||||
```bash
|
||||
# Run all server unit tests
|
||||
npm run test:server
|
||||
|
||||
# Run with coverage report
|
||||
npm run test:server:coverage
|
||||
|
||||
# Run package tests
|
||||
npm run test:packages
|
||||
|
||||
# Run in watch mode during development
|
||||
npx vitest --watch
|
||||
```
|
||||
|
||||
**Unit Test Guidelines:**
|
||||
|
||||
- Keep tests small and focused on one behavior
|
||||
- Use descriptive test names: `it('should return null when user is not found')`
|
||||
- Follow the AAA pattern: Arrange, Act, Assert
|
||||
- Mock external dependencies to isolate the unit under test
|
||||
- Aim for meaningful coverage, not just line coverage
|
||||
|
||||
### Writing Tests
|
||||
|
||||
#### When to Write Tests
|
||||
|
||||
- **New features:** All new features should include tests
|
||||
- **Bug fixes:** Add a test that reproduces the bug before fixing
|
||||
- **Refactoring:** Ensure existing tests pass after refactoring
|
||||
- **Public APIs:** All public APIs must have test coverage
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
Automaker uses **GitHub Actions** for continuous integration. Every pull request triggers automated checks.
|
||||
|
||||
#### CI Checks
|
||||
|
||||
The following checks must pass before your PR can be merged:
|
||||
|
||||
| Check | Description |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| **Format** | Verifies code is formatted with Prettier |
|
||||
| **Build** | Ensures the project compiles without errors |
|
||||
| **Package Tests** | Runs tests for shared `@automaker/*` packages |
|
||||
| **Server Tests** | Runs server unit tests with coverage |
|
||||
|
||||
#### CI Testing Environment
|
||||
|
||||
For CI environments, Automaker supports a mock agent mode:
|
||||
|
||||
```bash
|
||||
# Enable mock agent mode for CI testing
|
||||
AUTOMAKER_MOCK_AGENT=true npm run test
|
||||
```
|
||||
|
||||
This allows tests to run without requiring a real Claude API connection.
|
||||
|
||||
#### Viewing CI Results
|
||||
|
||||
1. Go to your PR on GitHub
|
||||
2. Scroll to the "Checks" section at the bottom
|
||||
3. Click on any failed check to see detailed logs
|
||||
4. Fix issues locally and push updates
|
||||
|
||||
#### Common CI Failures
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| Format check failed | Run `npm run format` locally |
|
||||
| Build failed | Run `npm run build` and fix TypeScript errors |
|
||||
| Tests failed | Run `npm run test:all` locally to reproduce |
|
||||
| Coverage decreased | Add tests for new code paths |
|
||||
|
||||
### Coverage Requirements
|
||||
|
||||
While we don't enforce strict coverage percentages, we expect:
|
||||
|
||||
- **New features:** Should include comprehensive tests
|
||||
- **Bug fixes:** Should include a regression test
|
||||
- **Critical paths:** Must have test coverage (authentication, data persistence, etc.)
|
||||
|
||||
To view coverage reports locally:
|
||||
|
||||
```bash
|
||||
npm run test:server:coverage
|
||||
```
|
||||
|
||||
This generates an HTML report you can open in your browser to see which lines are covered.
|
||||
|
||||
---
|
||||
|
||||
## Issue Reporting
|
||||
|
||||
Found a bug or have an idea for a new feature? We'd love to hear from you! This section explains how to report issues effectively.
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When reporting a bug, please provide as much information as possible to help us understand and reproduce the issue.
|
||||
|
||||
#### Before Reporting
|
||||
|
||||
1. **Search existing issues** - Check if the bug has already been reported
|
||||
2. **Try the latest version** - Make sure you're running the latest version of Automaker
|
||||
3. **Reproduce the issue** - Verify you can consistently reproduce the bug
|
||||
|
||||
#### Bug Report Template
|
||||
|
||||
When creating a bug report, include:
|
||||
|
||||
- **Title:** A clear, descriptive title summarizing the issue
|
||||
- **Environment:**
|
||||
- Operating System and version
|
||||
- Node.js version (`node --version`)
|
||||
- Automaker version or commit hash
|
||||
- **Steps to Reproduce:** Numbered list of steps to reproduce the bug
|
||||
- **Expected Behavior:** What you expected to happen
|
||||
- **Actual Behavior:** What actually happened
|
||||
- **Logs/Screenshots:** Any relevant error messages, console output, or screenshots
|
||||
|
||||
**Example Bug Report:**
|
||||
|
||||
```markdown
|
||||
## Bug: WebSocket connection drops after 5 minutes of inactivity
|
||||
|
||||
### Environment
|
||||
|
||||
- OS: Windows 11
|
||||
- Node.js: 22.11.0
|
||||
- Automaker: commit abc1234
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Start the application with `npm run dev:web`
|
||||
2. Open the Kanban board
|
||||
3. Leave the browser tab open for 5+ minutes without interaction
|
||||
4. Try to move a card
|
||||
|
||||
### Expected Behavior
|
||||
|
||||
The card should move to the new column.
|
||||
|
||||
### Actual Behavior
|
||||
|
||||
The UI shows "Connection lost" and the card doesn't move.
|
||||
|
||||
### Logs
|
||||
|
||||
[WebSocket] Connection closed: 1006
|
||||
```
|
||||
|
||||
### Feature Requests
|
||||
|
||||
We welcome ideas for improving Automaker! Here's how to submit a feature request:
|
||||
|
||||
#### Before Requesting
|
||||
|
||||
1. **Check existing issues** - Your idea may already be proposed or in development
|
||||
2. **Consider scope** - Think about whether the feature fits Automaker's mission as an autonomous AI development studio
|
||||
|
||||
#### Feature Request Template
|
||||
|
||||
A good feature request includes:
|
||||
|
||||
- **Title:** A brief, descriptive title
|
||||
- **Problem Statement:** What problem does this feature solve?
|
||||
- **Proposed Solution:** How do you envision this working?
|
||||
- **Alternatives Considered:** What other approaches did you consider?
|
||||
- **Additional Context:** Mockups, examples, or references that help explain your idea
|
||||
|
||||
**Example Feature Request:**
|
||||
|
||||
```markdown
|
||||
## Feature: Dark Mode Support
|
||||
|
||||
### Problem Statement
|
||||
|
||||
Working late at night, the bright UI causes eye strain and doesn't match
|
||||
my system's dark theme preference.
|
||||
|
||||
### Proposed Solution
|
||||
|
||||
Add a theme toggle in the settings panel that allows switching between
|
||||
light and dark modes. Ideally, it should also detect system preference.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
- Browser extension to force dark mode (doesn't work well with custom styling)
|
||||
- Custom CSS override (breaks with updates)
|
||||
|
||||
### Additional Context
|
||||
|
||||
Similar to how VS Code handles themes - a dropdown in settings with
|
||||
immediate preview.
|
||||
```
|
||||
|
||||
### Security Issues
|
||||
|
||||
**Important:** If you discover a security vulnerability, please do NOT open a public issue. Instead:
|
||||
|
||||
1. Join our [Discord server](https://discord.gg/jjem7aEDKU) and send a direct message to the user `@webdevcody`
|
||||
2. Include detailed steps to reproduce
|
||||
3. Allow time for us to address the issue before public disclosure
|
||||
|
||||
We take security seriously and appreciate responsible disclosure.
|
||||
|
||||
---
|
||||
|
||||
For license and contribution terms, see the [LICENSE](LICENSE) file in the repository root and the [README.md](README.md#license) for more details.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Automaker!
|
||||
@@ -1,253 +0,0 @@
|
||||
# Development Workflow
|
||||
|
||||
This document defines the standard workflow for keeping a branch in sync with the upstream
|
||||
release candidate (RC) and for shipping feature work. It is paired with `check-sync.sh`.
|
||||
|
||||
## Quick Decision Rule
|
||||
|
||||
1. Ask the user to select a workflow:
|
||||
- **Sync Workflow** → you are maintaining the current RC branch with fixes/improvements
|
||||
and will push the same fixes to both origin and upstream RC when you have local
|
||||
commits to publish.
|
||||
- **PR Workflow** → you are starting new feature work on a new branch; upstream updates
|
||||
happen via PR only.
|
||||
2. After the user selects, run:
|
||||
```bash
|
||||
./check-sync.sh
|
||||
```
|
||||
3. Use the status output to confirm alignment. If it reports **diverged**, default to
|
||||
merging `upstream/<TARGET_RC>` into the current branch and preserving local commits.
|
||||
For Sync Workflow, when the working tree is clean and you are behind upstream RC,
|
||||
proceed with the fetch + merge without asking for additional confirmation.
|
||||
|
||||
## Target RC Resolution
|
||||
|
||||
The target RC is resolved dynamically so the workflow stays current as the RC changes.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. Latest `upstream/v*rc` branch (auto-detected)
|
||||
2. `upstream/HEAD` (fallback)
|
||||
3. If neither is available, you must pass `--rc <branch>`
|
||||
|
||||
Override for a single run:
|
||||
|
||||
```bash
|
||||
./check-sync.sh --rc <rc-branch>
|
||||
```
|
||||
|
||||
## Pre-Flight Checklist
|
||||
|
||||
1. Confirm a clean working tree:
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
2. Confirm the current branch:
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
3. Ensure remotes exist (origin + upstream):
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
## Sync Workflow (Upstream Sync)
|
||||
|
||||
Use this flow when you are updating the current branch with fixes or improvements and
|
||||
intend to keep origin and upstream RC in lockstep.
|
||||
|
||||
1. **Check sync status**
|
||||
```bash
|
||||
./check-sync.sh
|
||||
```
|
||||
2. **Update from upstream RC before editing (no pulls)**
|
||||
- **Behind upstream RC** → fetch and merge RC into your branch:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git merge upstream/<TARGET_RC> --no-edit
|
||||
```
|
||||
When the working tree is clean and the user selected Sync Workflow, proceed without
|
||||
an extra confirmation prompt.
|
||||
- **Diverged** → stop and resolve manually.
|
||||
3. **Resolve conflicts if needed**
|
||||
- Handle conflicts intelligently: preserve upstream behavior and your local intent.
|
||||
4. **Make changes and commit (if you are delivering fixes)**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "type: description"
|
||||
```
|
||||
5. **Build to verify**
|
||||
```bash
|
||||
npm run build:packages
|
||||
npm run build
|
||||
```
|
||||
6. **Push after a successful merge to keep remotes aligned**
|
||||
- If you only merged upstream RC changes, push **origin only** to sync your fork:
|
||||
```bash
|
||||
git push origin <branch>
|
||||
```
|
||||
- If you have local fixes to publish, push **origin + upstream**:
|
||||
```bash
|
||||
git push origin <branch>
|
||||
git push upstream <branch>:<TARGET_RC>
|
||||
```
|
||||
- Always ask the user which push to perform.
|
||||
- Origin (origin-only sync):
|
||||
```bash
|
||||
git push origin <branch>
|
||||
```
|
||||
- Upstream RC (publish the same fixes when you have local commits):
|
||||
```bash
|
||||
git push upstream <branch>:<TARGET_RC>
|
||||
```
|
||||
7. **Re-check sync**
|
||||
```bash
|
||||
./check-sync.sh
|
||||
```
|
||||
|
||||
## PR Workflow (Feature Work)
|
||||
|
||||
Use this flow only for new feature work on a new branch. Do not push to upstream RC.
|
||||
|
||||
1. **Create or switch to a feature branch**
|
||||
```bash
|
||||
git checkout -b <branch>
|
||||
```
|
||||
2. **Make changes and commit**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "type: description"
|
||||
```
|
||||
3. **Merge upstream RC before shipping**
|
||||
```bash
|
||||
git merge upstream/<TARGET_RC> --no-edit
|
||||
```
|
||||
4. **Build and/or test**
|
||||
```bash
|
||||
npm run build:packages
|
||||
npm run build
|
||||
```
|
||||
5. **Push to origin**
|
||||
```bash
|
||||
git push -u origin <branch>
|
||||
```
|
||||
6. **Create or update the PR**
|
||||
- Use `gh pr create` or the GitHub UI.
|
||||
7. **Review and follow-up**
|
||||
|
||||
- Apply feedback, commit changes, and push again.
|
||||
- Re-run `./check-sync.sh` if additional upstream sync is needed.
|
||||
|
||||
## Conflict Resolution Checklist
|
||||
|
||||
1. Identify which changes are from upstream vs. local.
|
||||
2. Preserve both behaviors where possible; avoid dropping either side.
|
||||
3. Prefer minimal, safe integrations over refactors.
|
||||
4. Re-run build commands after resolving conflicts.
|
||||
5. Re-run `./check-sync.sh` to confirm status.
|
||||
|
||||
## Build/Test Matrix
|
||||
|
||||
- **Sync Workflow**: `npm run build:packages` and `npm run build`.
|
||||
- **PR Workflow**: `npm run build:packages` and `npm run build` (plus relevant tests).
|
||||
|
||||
## Post-Sync Verification
|
||||
|
||||
1. `git status` should be clean.
|
||||
2. `./check-sync.sh` should show expected alignment.
|
||||
3. Verify recent commits with:
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
## check-sync.sh Usage
|
||||
|
||||
- Uses dynamic Target RC resolution (see above).
|
||||
- Override target RC:
|
||||
```bash
|
||||
./check-sync.sh --rc <rc-branch>
|
||||
```
|
||||
- Optional preview limit:
|
||||
```bash
|
||||
./check-sync.sh --preview 10
|
||||
```
|
||||
- The script prints sync status for both origin and upstream and previews recent commits
|
||||
when you are behind.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
Stop and ask for guidance if any of the following are true:
|
||||
|
||||
- The working tree is dirty and you are about to merge or push.
|
||||
- `./check-sync.sh` reports **diverged** during PR Workflow, or a merge cannot be completed.
|
||||
- The script cannot resolve a target RC and requests `--rc`.
|
||||
- A build fails after sync or conflict resolution.
|
||||
|
||||
## AI Agent Guardrails
|
||||
|
||||
- Always run `./check-sync.sh` before merges or pushes.
|
||||
- Always ask for explicit user approval before any push command.
|
||||
- Do not ask for additional confirmation before a Sync Workflow fetch + merge when the
|
||||
working tree is clean and the user has already selected the Sync Workflow.
|
||||
- Choose Sync vs PR workflow based on intent (RC maintenance vs new feature work), not
|
||||
on the script's workflow hint.
|
||||
- Only use force push when the user explicitly requests a history rewrite.
|
||||
- Ask for explicit approval before dependency installs, branch deletion, or destructive operations.
|
||||
- When resolving merge conflicts, preserve both upstream changes and local intent where possible.
|
||||
- Do not create or switch to new branches unless the user explicitly requests it.
|
||||
|
||||
## AI Agent Decision Guidance
|
||||
|
||||
Agents should provide concrete, task-specific suggestions instead of repeatedly asking
|
||||
open-ended questions. Use the user's stated goal and the `./check-sync.sh` status to
|
||||
propose a default path plus one or two alternatives, and only ask for confirmation when
|
||||
an action requires explicit approval.
|
||||
|
||||
Default behavior:
|
||||
|
||||
- If the intent is RC maintenance, recommend the Sync Workflow and proceed with
|
||||
safe preparation steps (status checks, previews). If the branch is behind upstream RC,
|
||||
fetch and merge without additional confirmation when the working tree is clean, then
|
||||
push to origin to keep the fork aligned. Push upstream only when there are local fixes
|
||||
to publish.
|
||||
- If the intent is new feature work, recommend the PR Workflow and proceed with safe
|
||||
preparation steps (status checks, identifying scope). Ask for approval before merges,
|
||||
pushes, or dependency installs.
|
||||
- If `./check-sync.sh` reports **diverged** during Sync Workflow, merge
|
||||
`upstream/<TARGET_RC>` into the current branch and preserve local commits.
|
||||
- If `./check-sync.sh` reports **diverged** during PR Workflow, stop and ask for guidance
|
||||
with a short explanation of the divergence and the minimal options to resolve it.
|
||||
If the user's intent is RC maintenance, prefer the Sync Workflow regardless of the
|
||||
script hint. When the intent is new feature work, use the PR Workflow and avoid upstream
|
||||
RC pushes.
|
||||
|
||||
Suggestion format (keep it short):
|
||||
|
||||
- **Recommended**: one sentence with the default path and why it fits the task.
|
||||
- **Alternatives**: one or two options with the tradeoff or prerequisite.
|
||||
- **Approval points**: mention any upcoming actions that need explicit approval (exclude sync
|
||||
workflow pushes and merges).
|
||||
|
||||
## Failure Modes and How to Avoid Them
|
||||
|
||||
Sync Workflow:
|
||||
|
||||
- Wrong RC target: verify the auto-detected RC in `./check-sync.sh` output before merging.
|
||||
- Diverged from upstream RC: stop and resolve manually before any merge or push.
|
||||
- Dirty working tree: commit or stash before syncing to avoid accidental merges.
|
||||
- Missing remotes: ensure both `origin` and `upstream` are configured before syncing.
|
||||
- Build breaks after sync: run `npm run build:packages` and `npm run build` before pushing.
|
||||
|
||||
PR Workflow:
|
||||
|
||||
- Branch not synced to current RC: re-run `./check-sync.sh` and merge RC before shipping.
|
||||
- Pushing the wrong branch: confirm `git branch --show-current` before pushing.
|
||||
- Unreviewed changes: always commit and push to origin before opening or updating a PR.
|
||||
- Skipped tests/builds: run the build commands before declaring the PR ready.
|
||||
|
||||
## Notes
|
||||
|
||||
- Avoid merging with uncommitted changes; commit or stash first.
|
||||
- Prefer merge over rebase for PR branches; rebases rewrite history and often require a force push,
|
||||
which should only be done with an explicit user request.
|
||||
- Use clear, conventional commit messages and split unrelated changes into separate commits.
|
||||
@@ -30,26 +30,6 @@ Before running Automaker, we strongly recommend reviewing the source code yourse
|
||||
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
|
||||
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
|
||||
|
||||
#### Running in Isolated Docker Container
|
||||
|
||||
For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
|
||||
|
||||
```bash
|
||||
# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
|
||||
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
|
||||
|
||||
# On Windows PowerShell, use instead:
|
||||
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
|
||||
|
||||
# 2. Build and run isolated container
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Access the UI at http://localhost:3007
|
||||
# API at http://localhost:3008/api/health
|
||||
```
|
||||
|
||||
The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
|
||||
|
||||
### 3. Limit Access
|
||||
|
||||
If you must run locally:
|
||||
|
||||
226
Dockerfile
@@ -1,226 +0,0 @@
|
||||
# Automaker Multi-Stage Dockerfile
|
||||
# Single Dockerfile for both server and UI builds
|
||||
# Usage:
|
||||
# docker build --target server -t automaker-server .
|
||||
# docker build --target ui -t automaker-ui .
|
||||
# Or use docker-compose which selects targets automatically
|
||||
|
||||
# =============================================================================
|
||||
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
||||
# =============================================================================
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Copy all libs package.json files (centralized - add new libs here)
|
||||
COPY libs/types/package*.json ./libs/types/
|
||||
COPY libs/utils/package*.json ./libs/utils/
|
||||
COPY libs/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
|
||||
# Copy scripts (needed by npm workspace)
|
||||
COPY scripts ./scripts
|
||||
|
||||
# =============================================================================
|
||||
# SERVER BUILD STAGE
|
||||
# =============================================================================
|
||||
FROM base AS server-builder
|
||||
|
||||
# Copy server-specific package.json
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
|
||||
# Install dependencies (--ignore-scripts to skip husky/prepare, then rebuild native modules)
|
||||
RUN npm ci --ignore-scripts && npm rebuild node-pty
|
||||
|
||||
# Copy all source files
|
||||
COPY libs ./libs
|
||||
COPY apps/server ./apps/server
|
||||
|
||||
# Build packages in dependency order, then build server
|
||||
RUN npm run build:packages && npm run build --workspace=apps/server
|
||||
|
||||
# =============================================================================
|
||||
# SERVER PRODUCTION STAGE
|
||||
# =============================================================================
|
||||
FROM node:22-slim AS server
|
||||
|
||||
# Build argument for tracking which commit this image was built from
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||
|
||||
# Build arguments for user ID matching (allows matching host user for mounted volumes)
|
||||
# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ...
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
||||
# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git curl bash gosu ca-certificates openssh-client \
|
||||
# Playwright/Chromium dependencies
|
||||
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
||||
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
|
||||
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
|
||||
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
x86_64) GH_ARCH="amd64" ;; \
|
||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac \
|
||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
||||
&& tar -xzf gh.tar.gz \
|
||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude CLI globally (available to all users via npm global bin)
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create non-root user with home directory BEFORE installing Cursor CLI
|
||||
# Uses UID/GID build args to match host user for mounted volume permissions
|
||||
# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
|
||||
RUN groupadd -o -g ${GID} automaker && \
|
||||
useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||
mkdir -p /home/automaker/.local/bin && \
|
||||
mkdir -p /home/automaker/.cursor && \
|
||||
chown -R automaker:automaker /home/automaker && \
|
||||
chmod 700 /home/automaker/.cursor
|
||||
|
||||
# Install Cursor CLI as the automaker user
|
||||
# Set HOME explicitly and install to /home/automaker/.local/bin/
|
||||
USER automaker
|
||||
ENV HOME=/home/automaker
|
||||
RUN curl https://cursor.com/install -fsS | bash && \
|
||||
echo "=== Checking Cursor CLI installation ===" && \
|
||||
ls -la /home/automaker/.local/bin/ && \
|
||||
echo "=== PATH is: $PATH ===" && \
|
||||
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
|
||||
|
||||
# Install OpenCode CLI (for multi-provider AI model access)
|
||||
RUN curl -fsSL https://opencode.ai/install | bash && \
|
||||
echo "=== Checking OpenCode CLI installation ===" && \
|
||||
ls -la /home/automaker/.local/bin/ && \
|
||||
(which opencode && opencode --version) || echo "opencode installed (may need auth setup)"
|
||||
USER root
|
||||
|
||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
||||
RUN mkdir -p /etc/profile.d && \
|
||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
||||
chmod +x /etc/profile.d/cursor-cli.sh
|
||||
|
||||
# Add to automaker's .bashrc for bash interactive shells
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
||||
chown automaker:automaker /home/automaker/.bashrc
|
||||
|
||||
# Also add to root's .bashrc since docker exec defaults to root
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy root package.json (needed for workspace resolution)
|
||||
COPY --from=server-builder /app/package*.json ./
|
||||
|
||||
# Copy built libs (workspace packages are symlinked in node_modules)
|
||||
COPY --from=server-builder /app/libs ./libs
|
||||
|
||||
# Copy built server
|
||||
COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
|
||||
|
||||
# Copy node_modules (includes symlinks to libs)
|
||||
COPY --from=server-builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data and projects directories
|
||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
||||
|
||||
# Configure git for mounted volumes and authentication
|
||||
# Use --system so it's not overwritten by mounted user .gitconfig
|
||||
RUN git config --system --add safe.directory '*' && \
|
||||
# Use gh as credential helper (works with GH_TOKEN env var)
|
||||
git config --system credential.helper '!gh auth git-credential'
|
||||
|
||||
# Copy entrypoint script for fixing permissions on mounted volumes
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Note: We stay as root here so entrypoint can fix permissions
|
||||
# The entrypoint script will switch to automaker user before running the command
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
ENV HOME=/home/automaker
|
||||
# Add user's local bin to PATH for cursor-agent
|
||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
|
||||
# Health check (using curl since it's already installed, more reliable than busybox wget)
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Use entrypoint to fix permissions before starting
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Start server
|
||||
CMD ["node", "apps/server/dist/index.js"]
|
||||
|
||||
# =============================================================================
|
||||
# UI BUILD STAGE
|
||||
# =============================================================================
|
||||
FROM base AS ui-builder
|
||||
|
||||
# Copy UI-specific package.json
|
||||
COPY apps/ui/package*.json ./apps/ui/
|
||||
|
||||
# Install dependencies (--ignore-scripts to skip husky and build:packages in prepare script)
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Copy all source files
|
||||
COPY libs ./libs
|
||||
COPY apps/ui ./apps/ui
|
||||
|
||||
# Build packages in dependency order, then build UI
|
||||
# VITE_SERVER_URL tells the UI where to find the API server
|
||||
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com
|
||||
ARG VITE_SERVER_URL=http://localhost:3008
|
||||
ENV VITE_SKIP_ELECTRON=true
|
||||
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||
RUN npm run build:packages && npm run build --workspace=apps/ui
|
||||
|
||||
# =============================================================================
|
||||
# UI PRODUCTION STAGE
|
||||
# =============================================================================
|
||||
FROM nginx:alpine AS ui
|
||||
|
||||
# Build argument for tracking which commit this image was built from
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||
|
||||
# Copy built files
|
||||
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config for SPA routing
|
||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,94 +0,0 @@
|
||||
# Automaker Development Dockerfile
|
||||
# For development with live reload via volume mounting
|
||||
# Source code is NOT copied - it's mounted as a volume
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
# Install build dependencies for native modules (node-pty) and runtime tools
|
||||
# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
git curl bash gosu ca-certificates openssh-client \
|
||||
# Playwright/Chromium dependencies
|
||||
libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \
|
||||
libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \
|
||||
libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
||||
libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \
|
||||
libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \
|
||||
xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
x86_64) GH_ARCH="amd64" ;; \
|
||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac \
|
||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
||||
&& tar -xzf gh.tar.gz \
|
||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude CLI globally
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Build arguments for user ID matching (allows matching host user for mounted volumes)
|
||||
# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g)
|
||||
ARG UID=1001
|
||||
ARG GID=1001
|
||||
|
||||
# Create non-root user with configurable UID/GID
|
||||
# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group)
|
||||
RUN groupadd -o -g ${GID} automaker && \
|
||||
useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||
mkdir -p /home/automaker/.local/bin && \
|
||||
mkdir -p /home/automaker/.cursor && \
|
||||
chown -R automaker:automaker /home/automaker && \
|
||||
chmod 700 /home/automaker/.cursor
|
||||
|
||||
# Install Cursor CLI as automaker user
|
||||
USER automaker
|
||||
ENV HOME=/home/automaker
|
||||
RUN curl https://cursor.com/install -fsS | bash || true
|
||||
USER root
|
||||
|
||||
# Add PATH to profile for Cursor CLI
|
||||
RUN mkdir -p /etc/profile.d && \
|
||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
||||
chmod +x /etc/profile.d/cursor-cli.sh
|
||||
|
||||
# Add to user bashrc files
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
||||
chown automaker:automaker /home/automaker/.bashrc
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create directories with proper permissions
|
||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
||||
|
||||
# Configure git for mounted volumes
|
||||
RUN git config --system --add safe.directory '*' && \
|
||||
git config --system credential.helper '!gh auth git-credential'
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
ENV HOME=/home/automaker
|
||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
||||
|
||||
# Expose both dev ports
|
||||
EXPOSE 3007 3008
|
||||
|
||||
# Use entrypoint for permission handling
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Default command - will be overridden by docker-compose
|
||||
CMD ["npm", "run", "dev:web"]
|
||||
562
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="apps/ui/public/readme_logo.svg" alt="Automaker Logo" height="80" />
|
||||
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||
</p>
|
||||
|
||||
> **[!TIP]**
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
|
||||
>
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker).
|
||||
|
||||
# Automaker
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||
- [The Workflow](#the-workflow)
|
||||
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
|
||||
- [Powered by Claude Code](#powered-by-claude-code)
|
||||
- [Why This Matters](#why-this-matters)
|
||||
- [Security Disclaimer](#security-disclaimer)
|
||||
- [Community & Support](#community--support)
|
||||
@@ -28,37 +28,22 @@
|
||||
- [Quick Start](#quick-start)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Development Mode](#development-mode)
|
||||
- [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users)
|
||||
- [Electron Desktop App (Recommended)](#electron-desktop-app-recommended)
|
||||
- [Web Browser Mode](#web-browser-mode)
|
||||
- [Building for Production](#building-for-production)
|
||||
- [Running Production Build](#running-production-build)
|
||||
- [Testing](#testing)
|
||||
- [Linting](#linting)
|
||||
- [Environment Configuration](#environment-configuration)
|
||||
- [Authentication Setup](#authentication-setup)
|
||||
- [Authentication Options](#authentication-options)
|
||||
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
||||
- [Features](#features)
|
||||
- [Core Workflow](#core-workflow)
|
||||
- [AI & Planning](#ai--planning)
|
||||
- [Project Management](#project-management)
|
||||
- [Collaboration & Review](#collaboration--review)
|
||||
- [Developer Tools](#developer-tools)
|
||||
- [Advanced Features](#advanced-features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Frontend](#frontend)
|
||||
- [Backend](#backend)
|
||||
- [Testing & Quality](#testing--quality)
|
||||
- [Shared Libraries](#shared-libraries)
|
||||
- [Available Views](#available-views)
|
||||
- [Architecture](#architecture)
|
||||
- [Monorepo Structure](#monorepo-structure)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Key Architectural Patterns](#key-architectural-patterns)
|
||||
- [Security & Isolation](#security--isolation)
|
||||
- [Data Storage](#data-storage)
|
||||
- [Learn More](#learn-more)
|
||||
- [License](#license)
|
||||
|
||||
</details>
|
||||
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution.
|
||||
Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Code automatically implement them.
|
||||
|
||||

|
||||
|
||||
@@ -74,27 +59,43 @@ Traditional development tools help you write code. Automaker helps you **orchest
|
||||
4. **Review & Verify** - Review the changes, run tests, and approve when ready
|
||||
5. **Ship Faster** - Build entire applications in days, not weeks
|
||||
|
||||
### Powered by Claude Agent SDK
|
||||
### Powered by Claude Code
|
||||
|
||||
Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention.
|
||||
Automaker leverages the [Claude Agent SDK](https://docs.anthropic.com/en/docs/claude-code) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe.
|
||||
|
||||
### Why This Matters
|
||||
|
||||
The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic.
|
||||
|
||||
---
|
||||
|
||||
> **[!CAUTION]**
|
||||
>
|
||||
> ## Security Disclaimer
|
||||
>
|
||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||
>
|
||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
|
||||
## Community & Support
|
||||
|
||||
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||
|
||||
In the Discord, you can:
|
||||
|
||||
- 💬 Discuss agentic coding patterns and best practices
|
||||
- 🧠 Share ideas for AI-driven development workflows
|
||||
- 🛠️ Get help setting up or extending Automaker
|
||||
- 🚀 Show off projects built with AI agents
|
||||
- 🤝 Collaborate with other developers and contributors
|
||||
|
||||
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
👉 **Join the Discord:**
|
||||
https://discord.gg/jjem7aEDKU
|
||||
|
||||
---
|
||||
|
||||
@@ -102,31 +103,25 @@ In the Discord, you can:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 22+** (required: >=22.0.0 <23.0.0)
|
||||
- **npm** (comes with Node.js)
|
||||
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models.
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Start Automaker
|
||||
# 3. Run Automaker (pick your mode)
|
||||
npm run dev
|
||||
# Choose between:
|
||||
# 1. Web Application (browser at localhost:3007)
|
||||
# 2. Desktop Application (Electron - recommended)
|
||||
# Then choose your run mode when prompted, or use specific commands below
|
||||
```
|
||||
|
||||
**Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically.
|
||||
|
||||
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
|
||||
|
||||
## How to Run
|
||||
|
||||
### Development Mode
|
||||
@@ -162,207 +157,33 @@ npm run dev:electron:wsl:gpu
|
||||
npm run dev:web
|
||||
```
|
||||
|
||||
### Interactive TUI Launcher (Recommended for New Users)
|
||||
|
||||
For a user-friendly interactive menu, use the built-in TUI launcher script:
|
||||
|
||||
```bash
|
||||
# Show interactive menu with all launch options
|
||||
./start-automaker.sh
|
||||
|
||||
# Or launch directly without menu
|
||||
./start-automaker.sh web # Web browser
|
||||
./start-automaker.sh electron # Desktop app
|
||||
./start-automaker.sh electron-debug # Desktop + DevTools
|
||||
|
||||
# Additional options
|
||||
./start-automaker.sh --help # Show all available options
|
||||
./start-automaker.sh --version # Show version information
|
||||
./start-automaker.sh --check-deps # Verify project dependencies
|
||||
./start-automaker.sh --no-colors # Disable colored output
|
||||
./start-automaker.sh --no-history # Don't remember last choice
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- 🎨 Beautiful terminal UI with gradient colors and ASCII art
|
||||
- ⌨️ Interactive menu (press 1-3 to select, Q to exit)
|
||||
- 💾 Remembers your last choice
|
||||
- ✅ Pre-flight checks (validates Node.js, npm, dependencies)
|
||||
- 📏 Responsive layout (adapts to terminal size)
|
||||
- ⏱️ 30-second timeout for hands-free selection
|
||||
- 🌐 Cross-shell compatible (bash/zsh)
|
||||
|
||||
**History File:**
|
||||
Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs.
|
||||
|
||||
### Building for Production
|
||||
|
||||
#### Web Application
|
||||
|
||||
```bash
|
||||
# Build for web deployment (uses Vite)
|
||||
# Build Next.js app
|
||||
npm run build
|
||||
```
|
||||
|
||||
#### Desktop Application
|
||||
|
||||
```bash
|
||||
# Build for current platform (macOS/Windows/Linux)
|
||||
# Build Electron app for distribution
|
||||
npm run build:electron
|
||||
|
||||
# Platform-specific builds
|
||||
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
|
||||
npm run build:electron:win # Windows (NSIS installer, x64)
|
||||
npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64)
|
||||
|
||||
# Output directory: apps/ui/release/
|
||||
```
|
||||
|
||||
**Linux Distribution Packages:**
|
||||
|
||||
- **AppImage**: Universal format, works on any Linux distribution
|
||||
- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS
|
||||
- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE
|
||||
|
||||
**Installing on Fedora/RHEL:**
|
||||
### Running Production Build
|
||||
|
||||
```bash
|
||||
# Download the RPM package
|
||||
wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker-<version>-x86_64.rpm
|
||||
|
||||
# Install with dnf (Fedora)
|
||||
sudo dnf install ./Automaker-<version>-x86_64.rpm
|
||||
|
||||
# Or with yum (RHEL/CentOS)
|
||||
sudo yum localinstall ./Automaker-<version>-x86_64.rpm
|
||||
# Start production Next.js server
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
Docker provides the most secure way to run Automaker by isolating it from your host filesystem.
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Access UI at http://localhost:3007
|
||||
# API at http://localhost:3008
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
##### Authentication
|
||||
|
||||
Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below).
|
||||
|
||||
##### Working with Projects (Host Directory Access)
|
||||
|
||||
By default, the container is isolated from your host filesystem. To work on projects from your host machine, create a `docker-compose.override.yml` file (gitignored):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Mount your project directories
|
||||
- /path/to/your/project:/projects/your-project
|
||||
```
|
||||
|
||||
##### Claude CLI Authentication
|
||||
|
||||
Mount your Claude CLI config directory to use your authenticated CLI credentials:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Linux/macOS
|
||||
- ~/.claude:/home/automaker/.claude
|
||||
# Windows
|
||||
- C:/Users/YourName/.claude:/home/automaker/.claude
|
||||
```
|
||||
|
||||
**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files.
|
||||
|
||||
##### GitHub CLI Authentication (For Git Push/PR Operations)
|
||||
|
||||
To enable git push and GitHub CLI operations inside the container:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Mount GitHub CLI config
|
||||
# Linux/macOS
|
||||
- ~/.config/gh:/home/automaker/.config/gh
|
||||
# Windows
|
||||
- 'C:/Users/YourName/AppData/Roaming/GitHub CLI:/home/automaker/.config/gh'
|
||||
|
||||
# Mount git config for user identity (name, email)
|
||||
- ~/.gitconfig:/home/automaker/.gitconfig:ro
|
||||
environment:
|
||||
# GitHub token (required on Windows where tokens are in Credential Manager)
|
||||
# Get your token with: gh auth token
|
||||
- GH_TOKEN=${GH_TOKEN}
|
||||
```
|
||||
|
||||
Then add `GH_TOKEN` to your `.env` file:
|
||||
|
||||
```bash
|
||||
GH_TOKEN=gho_your_github_token_here
|
||||
```
|
||||
|
||||
##### Complete docker-compose.override.yml Example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
volumes:
|
||||
# Your projects
|
||||
- /path/to/project1:/projects/project1
|
||||
- /path/to/project2:/projects/project2
|
||||
|
||||
# Authentication configs
|
||||
- ~/.claude:/home/automaker/.claude
|
||||
- ~/.config/gh:/home/automaker/.config/gh
|
||||
- ~/.gitconfig:/home/automaker/.gitconfig:ro
|
||||
environment:
|
||||
- GH_TOKEN=${GH_TOKEN}
|
||||
```
|
||||
|
||||
##### Architecture Support
|
||||
|
||||
The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build.
|
||||
|
||||
### Testing
|
||||
|
||||
#### End-to-End Tests (Playwright)
|
||||
|
||||
```bash
|
||||
npm run test # Headless E2E tests
|
||||
npm run test:headed # Browser visible E2E tests
|
||||
# Run tests headless
|
||||
npm run test
|
||||
|
||||
# Run tests with browser visible
|
||||
npm run test:headed
|
||||
```
|
||||
|
||||
#### Unit Tests (Vitest)
|
||||
|
||||
```bash
|
||||
npm run test:server # Server unit tests
|
||||
npm run test:server:coverage # Server tests with coverage
|
||||
npm run test:packages # All shared package tests
|
||||
npm run test:all # Packages + server tests
|
||||
```
|
||||
|
||||
#### Test Configuration
|
||||
|
||||
- E2E tests run on ports 3007 (UI) and 3008 (server)
|
||||
- Automatically starts test servers before running
|
||||
- Uses Chromium browser via Playwright
|
||||
- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true`
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
@@ -370,279 +191,59 @@ npm run test:all # Packages + server tests
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
### Authentication Options
|
||||
|
||||
#### Optional - Server
|
||||
Automaker supports multiple authentication methods (in order of priority):
|
||||
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
|
||||
| Method | Environment Variable | Description |
|
||||
| ---------------- | -------------------- | ------------------------------- |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
|
||||
#### Optional - Security
|
||||
### Persistent Setup (Optional)
|
||||
|
||||
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only)
|
||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||
|
||||
#### Optional - Development
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||
- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI)
|
||||
- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production)
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription.
|
||||
|
||||
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
|
||||
|
||||
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
|
||||
Then restart your terminal or run `source ~/.bashrc`.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Workflow
|
||||
|
||||
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
||||
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
||||
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
|
||||
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
|
||||
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
|
||||
|
||||
### AI & Planning
|
||||
|
||||
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
|
||||
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
|
||||
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
|
||||
- ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins
|
||||
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
|
||||
|
||||
### Project Management
|
||||
|
||||
- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure
|
||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis
|
||||
- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference
|
||||
- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order
|
||||
- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization
|
||||
- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically
|
||||
|
||||
### Collaboration & Review
|
||||
|
||||
- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing
|
||||
- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work
|
||||
- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings
|
||||
- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history
|
||||
- 🔍 **Git Diff Viewer** - Review changes made by agents before approving
|
||||
|
||||
### Developer Tools
|
||||
|
||||
- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions
|
||||
- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context
|
||||
- ⚡ **Concurrent Execution** - Configure how many features can run simultaneously (default: 3)
|
||||
- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions
|
||||
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
|
||||
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
|
||||
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access
|
||||
- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees
|
||||
- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics
|
||||
- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings)
|
||||
- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory
|
||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
- **React 19** - UI framework
|
||||
- **Vite 7** - Build tool and development server
|
||||
- **Electron 39** - Desktop application framework
|
||||
- **TypeScript 5.9** - Type safety
|
||||
- **TanStack Router** - File-based routing
|
||||
- **Zustand 5** - State management with persistence
|
||||
- **Tailwind CSS 4** - Utility-first styling with 25+ themes
|
||||
- **Radix UI** - Accessible component primitives
|
||||
- **dnd-kit** - Drag and drop for Kanban board
|
||||
- **@xyflow/react** - Graph visualization for dependencies
|
||||
- **xterm.js** - Integrated terminal emulator
|
||||
- **CodeMirror 6** - Code editor for XML/syntax highlighting
|
||||
- **Lucide Icons** - Icon library
|
||||
|
||||
### Backend
|
||||
|
||||
- **Node.js** - JavaScript runtime with ES modules
|
||||
- **Express 5** - HTTP server framework
|
||||
- **TypeScript 5.9** - Type safety
|
||||
- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk)
|
||||
- **WebSocket (ws)** - Real-time event streaming
|
||||
- **node-pty** - PTY terminal sessions
|
||||
|
||||
### Testing & Quality
|
||||
|
||||
- **Playwright** - End-to-end testing
|
||||
- **Vitest** - Unit testing framework
|
||||
- **ESLint 9** - Code linting
|
||||
- **Prettier 3** - Code formatting
|
||||
- **Husky** - Git hooks for pre-commit formatting
|
||||
|
||||
### Shared Libraries
|
||||
|
||||
- **@automaker/types** - Shared TypeScript definitions
|
||||
- **@automaker/utils** - Logging, error handling, image processing
|
||||
- **@automaker/prompts** - AI prompt templates
|
||||
- **@automaker/platform** - Path management and security
|
||||
- **@automaker/model-resolver** - Claude model alias resolution
|
||||
- **@automaker/dependency-resolver** - Feature dependency ordering
|
||||
- **@automaker/git-utils** - Git operations and worktree management
|
||||
|
||||
## Available Views
|
||||
|
||||
Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts:
|
||||
|
||||
| View | Shortcut | Description |
|
||||
| ------------------ | -------- | ------------------------------------------------------------------------------------------------ |
|
||||
| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) |
|
||||
| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions |
|
||||
| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions |
|
||||
| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference |
|
||||
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
|
||||
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
|
||||
| **Graph** | `H` | Visualize feature dependencies with interactive graph visualization |
|
||||
| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance |
|
||||
| **Memory** | `Y` | View and manage agent memory and conversation history |
|
||||
| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks |
|
||||
| **GitHub PRs** | `R` | View and manage GitHub pull requests |
|
||||
| **Running Agents** | - | View all active agents across projects with status and progress |
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
All shortcuts are customizable in Settings. Default shortcuts:
|
||||
|
||||
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs)
|
||||
- **UI:** `` ` `` (Toggle sidebar)
|
||||
- **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker)
|
||||
- **Projects:** `Q`/`E` (Cycle previous/next project)
|
||||
- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
|
||||
|
||||
```text
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend
|
||||
│ └── server/ # Express + WebSocket backend
|
||||
└── libs/ # Shared packages
|
||||
├── types/ # Core TypeScript definitions
|
||||
├── utils/ # Logging, errors, utilities
|
||||
├── prompts/ # AI prompt templates
|
||||
├── platform/ # Path management, security
|
||||
├── model-resolver/ # Claude model aliasing
|
||||
├── dependency-resolver/ # Feature dependency ordering
|
||||
└── git-utils/ # Git operations & worktree management
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
|
||||
2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
|
||||
3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
|
||||
4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
|
||||
5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
|
||||
6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
|
||||
7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
|
||||
8. **Integration** - After approval, changes can be committed and PRs created from the worktree
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
|
||||
- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
|
||||
- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
|
||||
- **State Management** - Zustand with persistence for frontend state across restarts
|
||||
- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
|
||||
|
||||
### Security & Isolation
|
||||
|
||||
- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
|
||||
- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
|
||||
- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
|
||||
- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
|
||||
|
||||
### Data Storage
|
||||
|
||||
Automaker uses a file-based storage system (no database required):
|
||||
|
||||
#### Per-Project Data
|
||||
|
||||
Stored in `{projectPath}/.automaker/`:
|
||||
|
||||
```text
|
||||
.automaker/
|
||||
├── features/ # Feature JSON files and images
|
||||
│ └── {featureId}/
|
||||
│ ├── feature.json # Feature metadata
|
||||
│ ├── agent-output.md # AI agent output log
|
||||
│ └── images/ # Attached images
|
||||
├── context/ # Context files for AI agents
|
||||
├── worktrees/ # Git worktree metadata
|
||||
├── validations/ # GitHub issue validation results
|
||||
├── ideation/ # Brainstorming and analysis data
|
||||
│ └── analysis.json # Project structure analysis
|
||||
├── board/ # Board-related data
|
||||
├── images/ # Project-level images
|
||||
├── settings.json # Project-specific settings
|
||||
├── app_spec.txt # Project specification (XML format)
|
||||
├── active-branches.json # Active git branches tracking
|
||||
└── execution-state.json # Auto-mode execution state
|
||||
```
|
||||
|
||||
#### Global Data
|
||||
|
||||
Stored in `DATA_DIR` (default `./data`):
|
||||
|
||||
```text
|
||||
data/
|
||||
├── settings.json # Global settings, profiles, shortcuts
|
||||
├── credentials.json # API keys (encrypted)
|
||||
├── sessions-metadata.json # Chat session metadata
|
||||
└── agent-sessions/ # Conversation histories
|
||||
└── {sessionId}.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[!CAUTION]**
|
||||
>
|
||||
> ## Security Disclaimer
|
||||
>
|
||||
> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.**
|
||||
>
|
||||
> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it.
|
||||
>
|
||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||
>
|
||||
> **[Read the full disclaimer](./DISCLAIMER.md)**
|
||||
|
||||
---
|
||||
- [Next.js](https://nextjs.org) - React framework
|
||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
||||
|
||||
## Learn More
|
||||
|
||||
### Documentation
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
|
||||
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
|
||||
|
||||
### Community
|
||||
|
||||
Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
|
||||
|
||||
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## License
|
||||
|
||||
@@ -651,16 +252,19 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE
|
||||
**Summary of Terms:**
|
||||
|
||||
- **Allowed:**
|
||||
|
||||
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||
|
||||
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||
|
||||
- **No Resale:** You cannot resell Automaker itself.
|
||||
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||
|
||||
- **Liability:**
|
||||
|
||||
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||
|
||||
- **Contributing:**
|
||||
|
||||
310
REFACTORING_CANDIDATES.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Large Files - Refactoring Candidates
|
||||
|
||||
This document tracks files in the AutoMaker codebase that exceed 3000 lines or are significantly large (1000+ lines) and should be considered for refactoring into smaller, more maintainable components.
|
||||
|
||||
**Last Updated:** 2025-12-15
|
||||
**Total Large Files:** 8
|
||||
**Combined Size:** 15,027 lines
|
||||
|
||||
---
|
||||
|
||||
## 🔴 CRITICAL - Over 3000 Lines
|
||||
|
||||
### 1. board-view.tsx - 3,325 lines
|
||||
**Path:** `apps/app/src/components/views/board-view.tsx`
|
||||
**Type:** React Component (TSX)
|
||||
**Priority:** VERY HIGH
|
||||
|
||||
**Description:**
|
||||
Main Kanban board view component that serves as the centerpiece of the application.
|
||||
|
||||
**Current Responsibilities:**
|
||||
- Feature/task card management and drag-and-drop operations using @dnd-kit
|
||||
- Adding, editing, and deleting features
|
||||
- Running autonomous agents to implement features
|
||||
- Displaying feature status across multiple columns (Backlog, In Progress, Waiting Approval, Verified)
|
||||
- Model/AI profile selection for feature implementation
|
||||
- Advanced options configuration (thinking level, model selection, skip tests)
|
||||
- Search/filtering functionality for cards
|
||||
- Output modal for viewing agent results
|
||||
- Feature suggestions dialog
|
||||
- Board background customization
|
||||
- Integration with Electron APIs for IPC communication
|
||||
- Keyboard shortcuts support
|
||||
- 40+ state variables for managing UI state
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Extract into smaller components:
|
||||
- `AddFeatureDialog.tsx` - Feature creation dialog with image upload
|
||||
- `EditFeatureDialog.tsx` - Feature editing dialog
|
||||
- `AgentOutputModal.tsx` - Already exists, verify separation
|
||||
- `FeatureSuggestionsDialog.tsx` - Already exists, verify separation
|
||||
- `BoardHeader.tsx` - Header with controls and search
|
||||
- `BoardSearchBar.tsx` - Search and filter functionality
|
||||
- `ConcurrencyControl.tsx` - Concurrency slider component
|
||||
- `BoardActions.tsx` - Action buttons (add feature, auto mode, etc.)
|
||||
- `DragDropContext.tsx` - Wrap drag-and-drop logic
|
||||
- Custom hooks:
|
||||
- `useBoardFeatures.ts` - Feature loading and management
|
||||
- `useBoardDragDrop.ts` - Drag and drop handlers
|
||||
- `useBoardActions.ts` - Feature action handlers (run, verify, delete, etc.)
|
||||
- `useBoardKeyboardShortcuts.ts` - Keyboard shortcut logic
|
||||
|
||||
---
|
||||
|
||||
## 🟡 HIGH PRIORITY - 2000+ Lines
|
||||
|
||||
### 2. sidebar.tsx - 2,396 lines
|
||||
**Path:** `apps/app/src/components/layout/sidebar.tsx`
|
||||
**Type:** React Component (TSX)
|
||||
**Priority:** HIGH
|
||||
|
||||
**Description:**
|
||||
Main navigation sidebar with comprehensive project management.
|
||||
|
||||
**Current Responsibilities:**
|
||||
- Project folder navigation and selection
|
||||
- View mode switching (Board, Agent, Settings, etc.)
|
||||
- Project operations (create, delete, rename)
|
||||
- Theme and appearance controls
|
||||
- Terminal, Wiki, and other view launchers
|
||||
- Drag-and-drop project reordering
|
||||
- Settings and configuration access
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Split into focused components:
|
||||
- `ProjectSelector.tsx` - Project list and selection
|
||||
- `NavigationTabs.tsx` - View mode tabs
|
||||
- `ProjectActions.tsx` - Create, delete, rename operations
|
||||
- `SettingsMenu.tsx` - Settings dropdown
|
||||
- `ThemeSelector.tsx` - Theme controls
|
||||
- `ViewLaunchers.tsx` - Terminal, Wiki launchers
|
||||
- Custom hooks:
|
||||
- `useProjectManagement.ts` - Project CRUD operations
|
||||
- `useSidebarState.ts` - Sidebar state management
|
||||
|
||||
---
|
||||
|
||||
### 3. electron.ts - 2,356 lines
|
||||
**Path:** `apps/app/src/lib/electron.ts`
|
||||
**Type:** TypeScript Utility/API Bridge
|
||||
**Priority:** HIGH
|
||||
|
||||
**Description:**
|
||||
Electron IPC bridge and type definitions for frontend-backend communication.
|
||||
|
||||
**Current Responsibilities:**
|
||||
- File system operations (read, write, directory listing)
|
||||
- Project management APIs
|
||||
- Feature management APIs
|
||||
- Terminal/shell execution
|
||||
- Auto mode and agent execution APIs
|
||||
- Worktree management
|
||||
- Provider status APIs
|
||||
- Event handling and subscriptions
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Modularize into domain-specific API modules:
|
||||
- `api/file-system-api.ts` - File operations
|
||||
- `api/project-api.ts` - Project CRUD
|
||||
- `api/feature-api.ts` - Feature management
|
||||
- `api/execution-api.ts` - Auto mode and agent execution
|
||||
- `api/provider-api.ts` - Provider status and management
|
||||
- `api/worktree-api.ts` - Git worktree operations
|
||||
- `api/terminal-api.ts` - Terminal/shell APIs
|
||||
- `types/electron-types.ts` - Shared type definitions
|
||||
- `electron.ts` - Main export aggregator
|
||||
|
||||
---
|
||||
|
||||
### 4. app-store.ts - 2,174 lines
|
||||
**Path:** `apps/app/src/store/app-store.ts`
|
||||
**Type:** TypeScript State Management (Zustand Store)
|
||||
**Priority:** HIGH
|
||||
|
||||
**Description:**
|
||||
Centralized application state store using Zustand.
|
||||
|
||||
**Current Responsibilities:**
|
||||
- Global app state types and interfaces
|
||||
- Project and feature management state
|
||||
- Theme and appearance settings
|
||||
- API keys configuration
|
||||
- Keyboard shortcuts configuration
|
||||
- Terminal themes configuration
|
||||
- Auto mode settings
|
||||
- All store mutations and selectors
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Split into domain-specific stores:
|
||||
- `stores/projects-store.ts` - Project state and actions
|
||||
- `stores/features-store.ts` - Feature state and actions
|
||||
- `stores/ui-store.ts` - UI state (theme, sidebar, modals)
|
||||
- `stores/settings-store.ts` - User settings and preferences
|
||||
- `stores/execution-store.ts` - Auto mode and running tasks
|
||||
- `stores/provider-store.ts` - Provider configuration
|
||||
- `types/store-types.ts` - Shared type definitions
|
||||
- `app-store.ts` - Main store aggregator with combined selectors
|
||||
|
||||
---
|
||||
|
||||
## 🟢 MEDIUM PRIORITY - 1000-2000 Lines
|
||||
|
||||
### 5. auto-mode-service.ts - 1,232 lines
|
||||
**Path:** `apps/server/src/services/auto-mode-service.ts`
|
||||
**Type:** TypeScript Service (Backend)
|
||||
**Priority:** MEDIUM-HIGH
|
||||
|
||||
**Description:**
|
||||
Core autonomous feature implementation service.
|
||||
|
||||
**Current Responsibilities:**
|
||||
- Worktree creation and management
|
||||
- Feature execution with Claude Agent SDK
|
||||
- Concurrent execution with concurrency limits
|
||||
- Progress streaming via events
|
||||
- Verification and merge workflows
|
||||
- Provider management
|
||||
- Error handling and classification
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Extract into service modules:
|
||||
- `services/worktree-manager.ts` - Worktree operations
|
||||
- `services/feature-executor.ts` - Feature execution logic
|
||||
- `services/concurrency-manager.ts` - Concurrency control
|
||||
- `services/verification-service.ts` - Verification workflows
|
||||
- `utils/error-classifier.ts` - Error handling utilities
|
||||
|
||||
---
|
||||
|
||||
### 6. spec-view.tsx - 1,230 lines
|
||||
**Path:** `apps/app/src/components/views/spec-view.tsx`
|
||||
**Type:** React Component (TSX)
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Description:**
|
||||
Specification editor view component for feature specification management.
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Extract editor components and hooks:
|
||||
- `SpecEditor.tsx` - Main editor component
|
||||
- `SpecToolbar.tsx` - Editor toolbar
|
||||
- `SpecSidebar.tsx` - Spec navigation sidebar
|
||||
- `useSpecEditor.ts` - Editor state management
|
||||
|
||||
---
|
||||
|
||||
### 7. kanban-card.tsx - 1,180 lines
|
||||
**Path:** `apps/app/src/components/views/kanban-card.tsx`
|
||||
**Type:** React Component (TSX)
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Description:**
|
||||
Individual Kanban card component with rich feature display and interaction.
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Split into smaller card components:
|
||||
- `KanbanCardHeader.tsx` - Card title and metadata
|
||||
- `KanbanCardBody.tsx` - Card content
|
||||
- `KanbanCardActions.tsx` - Action buttons
|
||||
- `KanbanCardStatus.tsx` - Status indicators
|
||||
- `useKanbanCard.ts` - Card interaction logic
|
||||
|
||||
---
|
||||
|
||||
### 8. analysis-view.tsx - 1,134 lines
|
||||
**Path:** `apps/app/src/components/views/analysis-view.tsx`
|
||||
**Type:** React Component (TSX)
|
||||
**Priority:** MEDIUM
|
||||
|
||||
**Description:**
|
||||
Analysis view component for displaying and managing feature analysis data.
|
||||
|
||||
**Refactoring Recommendations:**
|
||||
Extract visualization and data components:
|
||||
- `AnalysisChart.tsx` - Chart/graph components
|
||||
- `AnalysisTable.tsx` - Data table
|
||||
- `AnalysisFilters.tsx` - Filter controls
|
||||
- `useAnalysisData.ts` - Data fetching and processing
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Strategy
|
||||
|
||||
### Phase 1: Critical (Immediate)
|
||||
1. **board-view.tsx** - Break into dialogs, header, and custom hooks
|
||||
- Extract all dialogs first (AddFeature, EditFeature)
|
||||
- Move to custom hooks for business logic
|
||||
- Split remaining UI into smaller components
|
||||
|
||||
### Phase 2: High Priority (Next Sprint)
|
||||
2. **sidebar.tsx** - Componentize navigation and project management
|
||||
3. **electron.ts** - Modularize into API domains
|
||||
4. **app-store.ts** - Split into domain stores
|
||||
|
||||
### Phase 3: Medium Priority (Future)
|
||||
5. **auto-mode-service.ts** - Extract service modules
|
||||
6. **spec-view.tsx** - Break into editor components
|
||||
7. **kanban-card.tsx** - Split card into sub-components
|
||||
8. **analysis-view.tsx** - Extract visualization components
|
||||
|
||||
---
|
||||
|
||||
## General Refactoring Guidelines
|
||||
|
||||
### When Refactoring Large Components:
|
||||
|
||||
1. **Extract Dialogs/Modals First**
|
||||
- Move dialog components to separate files
|
||||
- Keep dialog state management in parent initially
|
||||
- Later extract to custom hooks if complex
|
||||
|
||||
2. **Create Custom Hooks for Business Logic**
|
||||
- Move data fetching to `useFetch*` hooks
|
||||
- Move complex state logic to `use*State` hooks
|
||||
- Move side effects to `use*Effect` hooks
|
||||
|
||||
3. **Split UI into Presentational Components**
|
||||
- Header/toolbar components
|
||||
- Content area components
|
||||
- Footer/action components
|
||||
|
||||
4. **Move Utils and Helpers**
|
||||
- Extract pure functions to utility files
|
||||
- Move constants to separate constant files
|
||||
- Create type files for shared interfaces
|
||||
|
||||
### When Refactoring Large Files:
|
||||
|
||||
1. **Identify Domains/Concerns**
|
||||
- Group related functionality
|
||||
- Find natural boundaries
|
||||
|
||||
2. **Extract Gradually**
|
||||
- Start with least coupled code
|
||||
- Work towards core functionality
|
||||
- Test after each extraction
|
||||
|
||||
3. **Maintain Type Safety**
|
||||
- Export types from extracted modules
|
||||
- Use shared type files for common interfaces
|
||||
- Ensure no type errors after refactoring
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
- [ ] board-view.tsx (3,325 lines)
|
||||
- [ ] sidebar.tsx (2,396 lines)
|
||||
- [ ] electron.ts (2,356 lines)
|
||||
- [ ] app-store.ts (2,174 lines)
|
||||
- [ ] auto-mode-service.ts (1,232 lines)
|
||||
- [ ] spec-view.tsx (1,230 lines)
|
||||
- [ ] kanban-card.tsx (1,180 lines)
|
||||
- [ ] analysis-view.tsx (1,134 lines)
|
||||
|
||||
**Target:** All files under 500 lines, most under 300 lines
|
||||
|
||||
---
|
||||
|
||||
*Generated: 2025-12-15*
|
||||
18
apps/ui/.gitignore → apps/app/.gitignore
vendored
@@ -13,9 +13,12 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Vite
|
||||
/dist/
|
||||
/dist-electron/
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -30,8 +33,12 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
@@ -40,8 +47,5 @@ yarn-error.log*
|
||||
/playwright/.cache/
|
||||
|
||||
# Electron
|
||||
/release/
|
||||
/dist/
|
||||
/server-bundle/
|
||||
|
||||
# TanStack Router generated
|
||||
src/routeTree.gen.ts
|
||||
@@ -90,9 +90,9 @@ const {
|
||||
clearHistory, // Clear conversation
|
||||
error, // Error state
|
||||
} = useElectronAgent({
|
||||
sessionId: 'project_xyz',
|
||||
workingDirectory: '/path/to/project',
|
||||
onToolUse: (tool) => console.log('Using:', tool),
|
||||
sessionId: "project_xyz",
|
||||
workingDirectory: "/path/to/project",
|
||||
onToolUse: (tool) => console.log("Using:", tool),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -160,7 +160,7 @@ Each session file contains:
|
||||
Session IDs are generated from project paths:
|
||||
|
||||
```typescript
|
||||
const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
```
|
||||
|
||||
This ensures:
|
||||
@@ -7,28 +7,24 @@ The Automaker Agent Chat now supports multiple concurrent sessions, allowing you
|
||||
## Features
|
||||
|
||||
### ✨ Multiple Sessions
|
||||
|
||||
- Create unlimited agent sessions per project
|
||||
- Each session has its own conversation history
|
||||
- Switch between sessions instantly
|
||||
- Sessions persist across app restarts
|
||||
|
||||
### 📋 Session Organization
|
||||
|
||||
- Custom names for easy identification
|
||||
- Last message preview
|
||||
- Message count tracking
|
||||
- Sort by most recently updated
|
||||
|
||||
### 🗄️ Archive & Delete
|
||||
|
||||
- Archive old sessions to declutter
|
||||
- Unarchive when needed
|
||||
- Permanently delete sessions
|
||||
- Confirm before destructive actions
|
||||
|
||||
### 💾 Automatic Persistence
|
||||
|
||||
- All sessions auto-save to disk
|
||||
- Survive Next.js restarts
|
||||
- Survive Electron app restarts
|
||||
@@ -71,7 +67,6 @@ Click the panel icon in the header to show/hide the session manager.
|
||||
4. The new session is immediately active
|
||||
|
||||
**Example session names:**
|
||||
|
||||
- "Feature: Dark Mode"
|
||||
- "Bug: Login redirect"
|
||||
- "Refactor: API layer"
|
||||
@@ -98,7 +93,6 @@ Click the **"Clear"** button in the chat header to delete all messages from the
|
||||
3. Toggle **"Show Archived"** to view archived sessions
|
||||
|
||||
**When to archive:**
|
||||
|
||||
- Completed features
|
||||
- Resolved bugs
|
||||
- Old experiments
|
||||
@@ -123,19 +117,16 @@ Click the **"Clear"** button in the chat header to delete all messages from the
|
||||
Sessions are stored in your user data directory:
|
||||
|
||||
**macOS:**
|
||||
|
||||
```
|
||||
~/Library/Application Support/automaker/agent-sessions/
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```
|
||||
%APPDATA%/automaker/agent-sessions/
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```
|
||||
~/.config/automaker/agent-sessions/
|
||||
```
|
||||
@@ -224,14 +215,12 @@ Use prefixes to organize sessions by type:
|
||||
### When to Create Multiple Sessions
|
||||
|
||||
**Do create separate sessions for:**
|
||||
|
||||
- ✅ Different features
|
||||
- ✅ Unrelated bugs
|
||||
- ✅ Experimental work
|
||||
- ✅ Different contexts or approaches
|
||||
|
||||
**Don't create separate sessions for:**
|
||||
|
||||
- ❌ Same feature, different iterations
|
||||
- ❌ Related bug fixes
|
||||
- ❌ Continuation of previous work
|
||||
@@ -283,7 +272,7 @@ Use prefixes to organize sessions by type:
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
_(Coming soon)_
|
||||
*(Coming soon)*
|
||||
|
||||
- `Cmd/Ctrl + K` - Create new session
|
||||
- `Cmd/Ctrl + [` - Previous session
|
||||
@@ -295,13 +284,11 @@ _(Coming soon)_
|
||||
### Session Not Saving
|
||||
|
||||
**Check:**
|
||||
|
||||
- Electron has write permissions
|
||||
- Disk space available
|
||||
- Check Electron console for errors
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# macOS - Check permissions
|
||||
ls -la ~/Library/Application\ Support/automaker/
|
||||
@@ -313,13 +300,11 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
|
||||
### Can't Switch Sessions
|
||||
|
||||
**Check:**
|
||||
|
||||
- Session is not archived
|
||||
- No errors in console
|
||||
- Agent is not currently processing
|
||||
|
||||
**Solution:**
|
||||
|
||||
- Wait for current message to complete
|
||||
- Check for error messages
|
||||
- Try clearing and reloading
|
||||
@@ -327,13 +312,11 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
|
||||
### Session Disappeared
|
||||
|
||||
**Check:**
|
||||
|
||||
- Not filtered by archive status
|
||||
- Not accidentally deleted
|
||||
- Check backup files
|
||||
|
||||
**Recovery:**
|
||||
|
||||
- Toggle "Show Archived"
|
||||
- Check filesystem for `.json` files
|
||||
- Restore from backup if available
|
||||
@@ -343,17 +326,15 @@ chmod -R u+w ~/Library/Application\ Support/automaker/
|
||||
For developers integrating session management:
|
||||
|
||||
### Create Session
|
||||
|
||||
```typescript
|
||||
const result = await window.electronAPI.sessions.create(
|
||||
'Session Name',
|
||||
'/project/path',
|
||||
'/working/directory'
|
||||
"Session Name",
|
||||
"/project/path",
|
||||
"/working/directory"
|
||||
);
|
||||
```
|
||||
|
||||
### List Sessions
|
||||
|
||||
```typescript
|
||||
const { sessions } = await window.electronAPI.sessions.list(
|
||||
false // includeArchived
|
||||
@@ -361,20 +342,21 @@ const { sessions } = await window.electronAPI.sessions.list(
|
||||
```
|
||||
|
||||
### Update Session
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.sessions.update(sessionId, 'New Name', ['tag1', 'tag2']);
|
||||
await window.electronAPI.sessions.update(
|
||||
sessionId,
|
||||
"New Name",
|
||||
["tag1", "tag2"]
|
||||
);
|
||||
```
|
||||
|
||||
### Archive/Unarchive
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.sessions.archive(sessionId);
|
||||
await window.electronAPI.sessions.unarchive(sessionId);
|
||||
```
|
||||
|
||||
### Delete Session
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.sessions.delete(sessionId);
|
||||
```
|
||||
5
apps/app/electron/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
};
|
||||
435
apps/app/electron/main.js
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Simplified Electron main process
|
||||
*
|
||||
* This version spawns the backend server and uses HTTP API for most operations.
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||
|
||||
// Load environment variables from .env file (development only)
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
} catch (error) {
|
||||
console.warn("[Electron] dotenv not available:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let mainWindow = null;
|
||||
let serverProcess = null;
|
||||
let staticServer = null;
|
||||
const SERVER_PORT = 3008;
|
||||
const STATIC_PORT = 3007;
|
||||
|
||||
// Get icon path - works in both dev and production, cross-platform
|
||||
function getIconPath() {
|
||||
// Different icon formats for different platforms
|
||||
let iconFile;
|
||||
if (process.platform === "win32") {
|
||||
iconFile = "icon.ico";
|
||||
} else if (process.platform === "darwin") {
|
||||
iconFile = "logo_larger.png";
|
||||
} else {
|
||||
// Linux
|
||||
iconFile = "logo_larger.png";
|
||||
}
|
||||
|
||||
const iconPath = path.join(__dirname, "../public", iconFile);
|
||||
|
||||
// Verify the icon exists
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
console.warn(`[Electron] Icon not found at: ${iconPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start static file server for production builds
|
||||
*/
|
||||
async function startStaticServer() {
|
||||
const staticPath = path.join(__dirname, "../out");
|
||||
|
||||
staticServer = http.createServer((request, response) => {
|
||||
// Parse the URL and remove query string
|
||||
let filePath = path.join(staticPath, request.url.split("?")[0]);
|
||||
|
||||
// Default to index.html for directory requests
|
||||
if (filePath.endsWith("/")) {
|
||||
filePath = path.join(filePath, "index.html");
|
||||
} else if (!path.extname(filePath)) {
|
||||
filePath += ".html";
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fs.stat(filePath, (err, stats) => {
|
||||
if (err || !stats.isFile()) {
|
||||
// Try index.html for SPA fallback
|
||||
filePath = path.join(staticPath, "index.html");
|
||||
}
|
||||
|
||||
// Read and serve the file
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
response.writeHead(500);
|
||||
response.end("Server Error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set content type based on file extension
|
||||
const ext = path.extname(filePath);
|
||||
const contentTypes = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
};
|
||||
|
||||
response.writeHead(200, { "Content-Type": contentTypes[ext] || "application/octet-stream" });
|
||||
response.end(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
staticServer.listen(STATIC_PORT, (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`[Electron] Static server running at http://localhost:${STATIC_PORT}`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
*/
|
||||
async function startServer() {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// Server entry point - use tsx in dev, compiled version in production
|
||||
let command, args, serverPath;
|
||||
if (isDev) {
|
||||
// In development, use tsx to run TypeScript directly
|
||||
// Use node from PATH (process.execPath in Electron points to Electron, not Node.js)
|
||||
// spawn() resolves "node" from PATH on all platforms (Windows, Linux, macOS)
|
||||
command = "node";
|
||||
serverPath = path.join(__dirname, "../../server/src/index.ts");
|
||||
|
||||
// Find tsx CLI - check server node_modules first, then root
|
||||
const serverNodeModules = path.join(
|
||||
__dirname,
|
||||
"../../server/node_modules/tsx"
|
||||
);
|
||||
const rootNodeModules = path.join(__dirname, "../../../node_modules/tsx");
|
||||
|
||||
let tsxCliPath;
|
||||
if (fs.existsSync(path.join(serverNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(serverNodeModules, "dist/cli.mjs");
|
||||
} else if (fs.existsSync(path.join(rootNodeModules, "dist/cli.mjs"))) {
|
||||
tsxCliPath = path.join(rootNodeModules, "dist/cli.mjs");
|
||||
} else {
|
||||
// Last resort: try require.resolve
|
||||
try {
|
||||
tsxCliPath = require.resolve("tsx/cli.mjs", {
|
||||
paths: [path.join(__dirname, "../../server")],
|
||||
});
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Could not find tsx. Please run 'npm install' in the server directory."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
args = [tsxCliPath, "watch", serverPath];
|
||||
} else {
|
||||
// In production, use compiled JavaScript
|
||||
command = "node";
|
||||
serverPath = path.join(process.resourcesPath, "server", "index.js");
|
||||
args = [serverPath];
|
||||
|
||||
// Verify server files exist
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
throw new Error(`Server not found at: ${serverPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set environment variables for server
|
||||
const serverNodeModules = app.isPackaged
|
||||
? path.join(process.resourcesPath, "server", "node_modules")
|
||||
: path.join(__dirname, "../../server/node_modules");
|
||||
|
||||
// Set default workspace directory to user's Documents/Automaker
|
||||
const defaultWorkspaceDir = path.join(app.getPath("documents"), "Automaker");
|
||||
|
||||
// Ensure workspace directory exists
|
||||
if (!fs.existsSync(defaultWorkspaceDir)) {
|
||||
try {
|
||||
fs.mkdirSync(defaultWorkspaceDir, { recursive: true });
|
||||
console.log("[Electron] Created workspace directory:", defaultWorkspaceDir);
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to create workspace directory:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath("userData"),
|
||||
NODE_PATH: serverNodeModules,
|
||||
WORKSPACE_DIR: process.env.WORKSPACE_DIR || defaultWorkspaceDir,
|
||||
};
|
||||
|
||||
console.log("[Electron] Starting backend server...");
|
||||
console.log("[Electron] Server path:", serverPath);
|
||||
console.log("[Electron] NODE_PATH:", serverNodeModules);
|
||||
|
||||
serverProcess = spawn(command, args, {
|
||||
cwd: path.dirname(serverPath),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
serverProcess.stdout.on("data", (data) => {
|
||||
console.log(`[Server] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
console.error(`[Server Error] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.on("close", (code) => {
|
||||
console.log(`[Server] Process exited with code ${code}`);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
serverProcess.on("error", (err) => {
|
||||
console.error(`[Server] Failed to start server process:`, err);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be available
|
||||
*/
|
||||
async function waitForServer(maxAttempts = 30) {
|
||||
const http = require("http");
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get(
|
||||
`http://localhost:${SERVER_PORT}/api/health`,
|
||||
(res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Timeout"));
|
||||
});
|
||||
});
|
||||
console.log("[Electron] Server is ready");
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Server failed to start");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main window
|
||||
*/
|
||||
function createWindow() {
|
||||
const iconPath = getIconPath();
|
||||
const windowOptions = {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: "hiddenInset",
|
||||
backgroundColor: "#0a0a0a",
|
||||
};
|
||||
|
||||
// Only set icon if it exists
|
||||
if (iconPath) {
|
||||
windowOptions.icon = iconPath;
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions);
|
||||
|
||||
// Load Next.js dev server in development or static server in production
|
||||
const isDev = !app.isPackaged;
|
||||
mainWindow.loadURL(`http://localhost:${STATIC_PORT}`);
|
||||
if (isDev && process.env.OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Handle external links - open in default browser
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
// Set app icon (dock icon on macOS)
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
const iconPath = getIconPath();
|
||||
if (iconPath) {
|
||||
try {
|
||||
app.dock.setIcon(iconPath);
|
||||
} catch (error) {
|
||||
console.warn("[Electron] Failed to set dock icon:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Start static file server in production
|
||||
if (app.isPackaged) {
|
||||
await startStaticServer();
|
||||
}
|
||||
|
||||
// Start backend server
|
||||
await startServer();
|
||||
|
||||
// Create window
|
||||
createWindow();
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to start:", error);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
// Kill server process
|
||||
if (serverProcess) {
|
||||
console.log("[Electron] Stopping server...");
|
||||
serverProcess.kill();
|
||||
serverProcess = null;
|
||||
}
|
||||
|
||||
// Close static server
|
||||
if (staticServer) {
|
||||
console.log("[Electron] Stopping static server...");
|
||||
staticServer.close();
|
||||
staticServer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// IPC Handlers - Only native features
|
||||
// ============================================
|
||||
|
||||
// Native file dialogs
|
||||
ipcMain.handle("dialog:openDirectory", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Shell operations
|
||||
ipcMain.handle("shell:openExternal", async (_, url) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:openPath", async (_, filePath) => {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// App info
|
||||
ipcMain.handle("app:getPath", async (_, name) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getVersion", async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isPackaged", async () => {
|
||||
return app.isPackaged;
|
||||
});
|
||||
|
||||
// Ping - for connection check
|
||||
ipcMain.handle("ping", async () => {
|
||||
return "pong";
|
||||
});
|
||||
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle("server:getUrl", async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
37
apps/app/electron/preload.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Simplified Electron preload script
|
||||
*
|
||||
* Only exposes native features (dialogs, shell) and server URL.
|
||||
* All other operations go through HTTP API.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Expose minimal API for native features
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Platform info
|
||||
platform: process.platform,
|
||||
isElectron: true,
|
||||
|
||||
// Connection check
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
|
||||
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
|
||||
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
|
||||
|
||||
// Shell operations
|
||||
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
|
||||
|
||||
// App info
|
||||
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
|
||||
});
|
||||
|
||||
console.log("[Preload] Electron API exposed (simplified mode)");
|
||||
20
apps/app/eslint.config.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Electron files use CommonJS
|
||||
"electron/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
apps/app/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
194
apps/app/package.json
Normal file
@@ -0,0 +1,194 @@
|
||||
{
|
||||
"name": "@automaker/app",
|
||||
"version": "0.1.0",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/AutoMaker-Org/automaker.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Cody Seibert",
|
||||
"email": "webdevcody@gmail.com"
|
||||
},
|
||||
"private": true,
|
||||
"license": "Unlicense",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3007",
|
||||
"dev:web": "next dev -p 3007",
|
||||
"dev:electron": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron .\"",
|
||||
"dev:electron:debug": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && OPEN_DEVTOOLS=true electron .\"",
|
||||
"build": "next build",
|
||||
"build:electron": "node scripts/prepare-server.js && next build && electron-builder",
|
||||
"build:electron:win": "node scripts/prepare-server.js && next build && electron-builder --win",
|
||||
"build:electron:mac": "node scripts/prepare-server.js && next build && electron-builder --mac",
|
||||
"build:electron:linux": "node scripts/prepare-server.js && next build && electron-builder --linux",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"pretest": "node scripts/setup-e2e-fixtures.js",
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"dev:electron:wsl": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && electron . --no-sandbox --disable-gpu\"",
|
||||
"dev:electron:wsl:gpu": "concurrently \"next dev -p 3007\" \"wait-on http://localhost:3007 && MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA electron . --no-sandbox --disable-gpu-sandbox\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@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",
|
||||
"dotenv": "^17.2.3",
|
||||
"geist": "^1.5.1",
|
||||
"lucide-react": "^0.556.0",
|
||||
"next": "^16.0.10",
|
||||
"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"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "^1.29.2",
|
||||
"lightningcss-darwin-x64": "^1.29.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "^1.29.2",
|
||||
"lightningcss-linux-arm64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-arm64-musl": "^1.29.2",
|
||||
"lightningcss-linux-x64-gnu": "^1.29.2",
|
||||
"lightningcss-linux-x64-musl": "^1.29.2",
|
||||
"lightningcss-win32-arm64-msvc": "^1.29.2",
|
||||
"lightningcss-win32-x64-msvc": "^1.29.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "39.2.7",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.7",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.automaker.app",
|
||||
"productName": "Automaker",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"afterPack": "./scripts/rebuild-server-natives.js",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
"out/**/*",
|
||||
"public/**/*",
|
||||
"!node_modules/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "server-bundle/dist",
|
||||
"to": "server"
|
||||
},
|
||||
{
|
||||
"from": "server-bundle/node_modules",
|
||||
"to": "server/node_modules"
|
||||
},
|
||||
{
|
||||
"from": "server-bundle/package.json",
|
||||
"to": "server/package.json"
|
||||
},
|
||||
{
|
||||
"from": "../../.env",
|
||||
"to": ".env",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/logo_larger.png"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"category": "Development",
|
||||
"icon": "public/logo_larger.png",
|
||||
"maintainer": "webdevcody@gmail.com",
|
||||
"executableName": "automaker"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
59
apps/app/playwright.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
||||
const mockAgent = process.env.CI === "true" || process.env.AUTOMAKER_MOCK_AGENT === "true";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 30000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
...(reuseServer
|
||||
? {}
|
||||
: {
|
||||
webServer: [
|
||||
// Backend server - runs with mock agent enabled in CI
|
||||
{
|
||||
command: `cd ../server && npm run dev`,
|
||||
url: `http://localhost:${serverPort}/api/health`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 60000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
// Enable mock agent in CI to avoid real API calls
|
||||
AUTOMAKER_MOCK_AGENT: mockAgent ? "true" : "false",
|
||||
// Allow access to test directories and common project paths
|
||||
ALLOWED_PROJECT_DIRS: "/Users,/home,/tmp,/var/folders",
|
||||
},
|
||||
},
|
||||
// Frontend Next.js server
|
||||
{
|
||||
command: `npx next dev -p ${port}`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
...process.env,
|
||||
NEXT_PUBLIC_SKIP_SETUP: "true",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
7
apps/app/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
@@ -16,20 +16,8 @@ const __dirname = dirname(__filename);
|
||||
|
||||
const APP_DIR = join(__dirname, '..');
|
||||
const SERVER_DIR = join(APP_DIR, '..', 'server');
|
||||
const LIBS_DIR = join(APP_DIR, '..', '..', 'libs');
|
||||
const BUNDLE_DIR = join(APP_DIR, 'server-bundle');
|
||||
|
||||
// Local workspace packages that need to be bundled
|
||||
const LOCAL_PACKAGES = [
|
||||
'@automaker/types',
|
||||
'@automaker/utils',
|
||||
'@automaker/prompts',
|
||||
'@automaker/platform',
|
||||
'@automaker/model-resolver',
|
||||
'@automaker/dependency-resolver',
|
||||
'@automaker/git-utils',
|
||||
];
|
||||
|
||||
console.log('🔧 Preparing server for Electron bundling...\n');
|
||||
|
||||
// Step 1: Clean up previous bundle
|
||||
@@ -47,60 +35,24 @@ execSync('npm run build', { cwd: SERVER_DIR, stdio: 'inherit' });
|
||||
console.log('📋 Copying server dist...');
|
||||
cpSync(join(SERVER_DIR, 'dist'), join(BUNDLE_DIR, 'dist'), { recursive: true });
|
||||
|
||||
// Step 4: Copy local workspace packages
|
||||
console.log('📦 Copying local workspace packages...');
|
||||
const bundleLibsDir = join(BUNDLE_DIR, 'libs');
|
||||
mkdirSync(bundleLibsDir, { recursive: true });
|
||||
|
||||
for (const pkgName of LOCAL_PACKAGES) {
|
||||
const pkgDir = pkgName.replace('@automaker/', '');
|
||||
const srcDir = join(LIBS_DIR, pkgDir);
|
||||
const destDir = join(bundleLibsDir, pkgDir);
|
||||
|
||||
if (!existsSync(srcDir)) {
|
||||
console.warn(`⚠️ Warning: Package ${pkgName} not found at ${srcDir}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
// Copy dist folder
|
||||
if (existsSync(join(srcDir, 'dist'))) {
|
||||
cpSync(join(srcDir, 'dist'), join(destDir, 'dist'), { recursive: true });
|
||||
}
|
||||
|
||||
// Copy package.json
|
||||
if (existsSync(join(srcDir, 'package.json'))) {
|
||||
cpSync(join(srcDir, 'package.json'), join(destDir, 'package.json'));
|
||||
}
|
||||
|
||||
console.log(` ✓ ${pkgName}`);
|
||||
}
|
||||
|
||||
// Step 5: Create a minimal package.json for the server
|
||||
// Step 4: Create a minimal package.json for the server
|
||||
console.log('📝 Creating server package.json...');
|
||||
const serverPkg = JSON.parse(readFileSync(join(SERVER_DIR, 'package.json'), 'utf-8'));
|
||||
|
||||
// Replace local package versions with file: references
|
||||
const dependencies = { ...serverPkg.dependencies };
|
||||
for (const pkgName of LOCAL_PACKAGES) {
|
||||
if (dependencies[pkgName]) {
|
||||
const pkgDir = pkgName.replace('@automaker/', '');
|
||||
dependencies[pkgName] = `file:libs/${pkgDir}`;
|
||||
}
|
||||
}
|
||||
|
||||
const bundlePkg = {
|
||||
name: '@automaker/server-bundle',
|
||||
version: serverPkg.version,
|
||||
type: 'module',
|
||||
main: 'dist/index.js',
|
||||
dependencies,
|
||||
dependencies: serverPkg.dependencies
|
||||
};
|
||||
|
||||
writeFileSync(join(BUNDLE_DIR, 'package.json'), JSON.stringify(bundlePkg, null, 2));
|
||||
writeFileSync(
|
||||
join(BUNDLE_DIR, 'package.json'),
|
||||
JSON.stringify(bundlePkg, null, 2)
|
||||
);
|
||||
|
||||
// Step 6: Install production dependencies
|
||||
// Step 5: Install production dependencies
|
||||
console.log('📥 Installing server production dependencies...');
|
||||
execSync('npm install --omit=dev', {
|
||||
cwd: BUNDLE_DIR,
|
||||
@@ -108,23 +60,21 @@ execSync('npm install --omit=dev', {
|
||||
env: {
|
||||
...process.env,
|
||||
// Prevent npm from using workspace resolution
|
||||
npm_config_workspace: '',
|
||||
},
|
||||
npm_config_workspace: ''
|
||||
}
|
||||
});
|
||||
|
||||
// Step 7: Rebuild native modules for current architecture
|
||||
// Step 6: Rebuild native modules for current architecture
|
||||
// This is critical for modules like node-pty that have native bindings
|
||||
console.log('🔨 Rebuilding native modules for current architecture...');
|
||||
try {
|
||||
execSync('npm rebuild', {
|
||||
cwd: BUNDLE_DIR,
|
||||
stdio: 'inherit',
|
||||
stdio: 'inherit'
|
||||
});
|
||||
console.log('✅ Native modules rebuilt successfully');
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.'
|
||||
);
|
||||
console.warn('⚠️ Warning: Failed to rebuild native modules. Terminal functionality may not work.');
|
||||
console.warn(' Error:', error.message);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const path = require('path');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
exports.default = async function (context) {
|
||||
exports.default = async function(context) {
|
||||
const { appOutDir, electronPlatformName, arch, packager } = context;
|
||||
const electronVersion = packager.config.electronVersion;
|
||||
|
||||
@@ -33,9 +33,19 @@ exports.default = async function (context) {
|
||||
'node_modules'
|
||||
);
|
||||
} else if (electronPlatformName === 'win32') {
|
||||
serverNodeModulesPath = path.join(appOutDir, 'resources', 'server', 'node_modules');
|
||||
serverNodeModulesPath = path.join(
|
||||
appOutDir,
|
||||
'resources',
|
||||
'server',
|
||||
'node_modules'
|
||||
);
|
||||
} else {
|
||||
serverNodeModulesPath = path.join(appOutDir, 'resources', 'server', 'node_modules');
|
||||
serverNodeModulesPath = path.join(
|
||||
appOutDir,
|
||||
'resources',
|
||||
'server',
|
||||
'node_modules'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
49
apps/app/scripts/setup-e2e-fixtures.js
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Setup script for E2E test fixtures
|
||||
* Creates the necessary test fixture directories and files before running Playwright tests
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Resolve workspace root (apps/app/scripts -> workspace root)
|
||||
const WORKSPACE_ROOT = path.resolve(__dirname, "../../..");
|
||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, "test/fixtures/projectA");
|
||||
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, ".automaker/app_spec.txt");
|
||||
|
||||
const SPEC_CONTENT = `<app_spec>
|
||||
<name>Test Project A</name>
|
||||
<description>A test fixture project for Playwright testing</description>
|
||||
<tech_stack>
|
||||
<item>TypeScript</item>
|
||||
<item>React</item>
|
||||
</tech_stack>
|
||||
</app_spec>
|
||||
`;
|
||||
|
||||
function setupFixtures() {
|
||||
console.log("Setting up E2E test fixtures...");
|
||||
console.log(`Workspace root: ${WORKSPACE_ROOT}`);
|
||||
console.log(`Fixture path: ${FIXTURE_PATH}`);
|
||||
|
||||
// Create fixture directory
|
||||
const specDir = path.dirname(SPEC_FILE_PATH);
|
||||
if (!fs.existsSync(specDir)) {
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
console.log(`Created directory: ${specDir}`);
|
||||
}
|
||||
|
||||
// Create app_spec.txt
|
||||
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
|
||||
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
|
||||
|
||||
console.log("E2E test fixtures setup complete!");
|
||||
}
|
||||
|
||||
setupFixtures();
|
||||
97
apps/app/src/app/api/claude/test/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface AnthropicResponse {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
model?: string;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Send a simple test prompt to the Anthropic API
|
||||
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": effectiveApiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-sonnet-4-20250514",
|
||||
max_tokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as AnthropicResponse;
|
||||
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AnthropicResponse;
|
||||
|
||||
// Check if we got a valid response
|
||||
if (data.content && data.content.length > 0) {
|
||||
const textContent = data.content.find((block) => block.type === "text");
|
||||
if (textContent && textContent.type === "text" && textContent.text) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${textContent.text}"`,
|
||||
model: data.model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Claude responded.",
|
||||
model: data.model,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Claude API test error:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Claude API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
191
apps/app/src/app/api/gemini/test/route.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface GeminiContent {
|
||||
parts: Array<{
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
}>;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface GeminiRequest {
|
||||
contents: GeminiContent[];
|
||||
generationConfig?: {
|
||||
maxOutputTokens?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
content: {
|
||||
parts: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
role: string;
|
||||
};
|
||||
finishReason: string;
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
}>;
|
||||
promptFeedback?: {
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey, imageData, mimeType, prompt } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build the request body
|
||||
const requestBody: GeminiRequest = {
|
||||
contents: [
|
||||
{
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: 150,
|
||||
temperature: 0.4,
|
||||
},
|
||||
};
|
||||
|
||||
// Add image if provided
|
||||
if (imageData && mimeType) {
|
||||
requestBody.contents[0].parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: imageData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add text prompt
|
||||
const textPrompt = prompt || (imageData
|
||||
? "Describe what you see in this image briefly."
|
||||
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
|
||||
|
||||
requestBody.contents[0].parts.push({
|
||||
text: textPrompt,
|
||||
});
|
||||
|
||||
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
|
||||
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
|
||||
|
||||
const response = await fetch(geminiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data: GeminiResponse = await response.json();
|
||||
|
||||
// Check for API errors
|
||||
if (data.error) {
|
||||
const errorMessage = data.error.message || "Unknown Gemini API error";
|
||||
const statusCode = data.error.code || 500;
|
||||
|
||||
if (statusCode === 400 && errorMessage.includes("API key")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Google API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for valid response
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract response text
|
||||
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
|
||||
const responseText = data.candidates[0].content.parts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle blocked responses
|
||||
if (data.promptFeedback?.safetyRatings) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded (response may have been filtered).",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded.",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Gemini API test error:", error);
|
||||
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Network error. Unable to reach Gemini API." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Gemini API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
apps/app/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
5189
apps/app/src/app/globals.css
Normal file
42
apps/app/src/app/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
// Inter font for clean theme
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
// JetBrains Mono for clean theme
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jetbrains-mono",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Automaker - Autonomous AI Development Studio",
|
||||
description: "Build software autonomously with intelligent orchestration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} ${inter.variable} ${jetbrainsMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
235
apps/app/src/app/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { WelcomeView } from "@/components/views/welcome-view";
|
||||
import { BoardView } from "@/components/views/board-view";
|
||||
import { SpecView } from "@/components/views/spec-view";
|
||||
import { AgentView } from "@/components/views/agent-view";
|
||||
import { SettingsView } from "@/components/views/settings-view";
|
||||
import { InterviewView } from "@/components/views/interview-view";
|
||||
import { ContextView } from "@/components/views/context-view";
|
||||
import { ProfilesView } from "@/components/views/profiles-view";
|
||||
import { SetupView } from "@/components/views/setup-view";
|
||||
import { RunningAgentsView } from "@/components/views/running-agents-view";
|
||||
import { TerminalView } from "@/components/views/terminal-view";
|
||||
import { WikiView } from "@/components/views/wiki-view";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
||||
import {
|
||||
FileBrowserProvider,
|
||||
useFileBrowser,
|
||||
setGlobalFileBrowser,
|
||||
} from "@/contexts/file-browser-context";
|
||||
|
||||
function HomeContent() {
|
||||
const {
|
||||
currentView,
|
||||
setCurrentView,
|
||||
setIpcConnected,
|
||||
theme,
|
||||
currentProject,
|
||||
previewTheme,
|
||||
getEffectiveTheme,
|
||||
} = useAppStore();
|
||||
const { isFirstRun, setupComplete } = useSetupStore();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Hidden streamer panel - opens with "\" key
|
||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||
// Don't trigger when typing in inputs
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement) {
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
if (
|
||||
tagName === "input" ||
|
||||
tagName === "textarea" ||
|
||||
tagName === "select"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
||||
return;
|
||||
}
|
||||
const role = activeElement.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't trigger with modifier keys
|
||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for "\" key (backslash)
|
||||
if (event.key === "\\") {
|
||||
event.preventDefault();
|
||||
setStreamerPanelOpen((prev) => !prev);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Register the "\" shortcut for streamer panel
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleStreamerPanelShortcut);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleStreamerPanelShortcut);
|
||||
};
|
||||
}, [handleStreamerPanelShortcut]);
|
||||
|
||||
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
|
||||
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
|
||||
const effectiveTheme = getEffectiveTheme();
|
||||
|
||||
// Prevent hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Initialize global file browser for HttpApiClient
|
||||
useEffect(() => {
|
||||
setGlobalFileBrowser(openFileBrowser);
|
||||
}, [openFileBrowser]);
|
||||
|
||||
// Check if this is first run and redirect to setup if needed
|
||||
useEffect(() => {
|
||||
console.log("[Setup Flow] Checking setup state:", {
|
||||
isMounted,
|
||||
isFirstRun,
|
||||
setupComplete,
|
||||
currentView,
|
||||
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
|
||||
});
|
||||
|
||||
if (isMounted && isFirstRun && !setupComplete) {
|
||||
console.log(
|
||||
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
|
||||
);
|
||||
setCurrentView("setup");
|
||||
} else if (isMounted && setupComplete) {
|
||||
console.log("[Setup Flow] Setup already complete, showing normal view");
|
||||
}
|
||||
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
|
||||
|
||||
// Test IPC connection on mount
|
||||
useEffect(() => {
|
||||
const testConnection = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ping();
|
||||
setIpcConnected(result === "pong");
|
||||
} catch (error) {
|
||||
console.error("IPC connection failed:", error);
|
||||
setIpcConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
testConnection();
|
||||
}, [setIpcConnected]);
|
||||
|
||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const themeClasses = [
|
||||
"dark",
|
||||
"light",
|
||||
"retro",
|
||||
"dracula",
|
||||
"nord",
|
||||
"monokai",
|
||||
"tokyonight",
|
||||
"solarized",
|
||||
"gruvbox",
|
||||
"catppuccin",
|
||||
"onedark",
|
||||
"synthwave",
|
||||
"red",
|
||||
"cream",
|
||||
"sunset",
|
||||
"gray",
|
||||
"clean",
|
||||
];
|
||||
|
||||
// Remove all theme classes
|
||||
root.classList.remove(...themeClasses);
|
||||
|
||||
// Apply the effective theme
|
||||
if (themeClasses.includes(effectiveTheme)) {
|
||||
root.classList.add(effectiveTheme);
|
||||
} else if (effectiveTheme === "system") {
|
||||
// System theme - detect OS preference
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
}
|
||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case "welcome":
|
||||
return <WelcomeView />;
|
||||
case "setup":
|
||||
return <SetupView />;
|
||||
case "board":
|
||||
return <BoardView />;
|
||||
case "spec":
|
||||
return <SpecView />;
|
||||
case "agent":
|
||||
return <AgentView />;
|
||||
case "settings":
|
||||
return <SettingsView />;
|
||||
case "interview":
|
||||
return <InterviewView />;
|
||||
case "context":
|
||||
return <ContextView />;
|
||||
case "profiles":
|
||||
return <ProfilesView />;
|
||||
case "running-agents":
|
||||
return <RunningAgentsView />;
|
||||
case "terminal":
|
||||
return <TerminalView />;
|
||||
case "wiki":
|
||||
return <WikiView />;
|
||||
default:
|
||||
return <WelcomeView />;
|
||||
}
|
||||
};
|
||||
|
||||
// Setup view is full-screen without sidebar
|
||||
if (currentView === "setup") {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<SetupView />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<Sidebar />
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
||||
style={{ marginRight: streamerPanelOpen ? "250px" : "0" }}
|
||||
>
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
||||
streamerPanelOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<FileBrowserProvider>
|
||||
<HomeContent />
|
||||
</FileBrowserProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -5,9 +7,9 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface DeleteAllArchivedSessionsDialogProps {
|
||||
open: boolean;
|
||||
@@ -28,7 +30,8 @@ export function DeleteAllArchivedSessionsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete All Archived Sessions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete all archived sessions? This action cannot be undone.
|
||||
Are you sure you want to delete all archived sessions? This action
|
||||
cannot be undone.
|
||||
{archivedCount > 0 && (
|
||||
<span className="block mt-2 text-yellow-500">
|
||||
{archivedCount} session(s) will be deleted.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
import type { SessionListItem } from '@/types/electron';
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import type { SessionListItem } from "@/types/electron";
|
||||
|
||||
interface DeleteSessionDialogProps {
|
||||
open: boolean;
|
||||
@@ -38,8 +38,12 @@ export function DeleteSessionDialog({
|
||||
<MessageSquare className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{session.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{session.messageCount} messages</p>
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{session.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{session.messageCount} messages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1,41 +1,44 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
"use client";
|
||||
|
||||
const logger = createLogger('BoardBackgroundModal');
|
||||
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 { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
fileToBase64,
|
||||
validateImageFile,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
} from '@/lib/image-utils';
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore, defaultBackgroundSettings } from "@/store/app-store";
|
||||
import { getHttpApiClient } from "@/lib/http-api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
interface BoardBackgroundModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModalProps) {
|
||||
const { currentProject, boardBackgroundByProject } = useAppStore();
|
||||
export function BoardBackgroundModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BoardBackgroundModalProps) {
|
||||
const {
|
||||
currentProject,
|
||||
boardBackgroundByProject,
|
||||
setBoardBackground,
|
||||
setCardOpacity,
|
||||
setColumnOpacity,
|
||||
@@ -45,9 +48,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
setCardBorderOpacity,
|
||||
setHideScrollbar,
|
||||
clearBoardBackground,
|
||||
persistSettings,
|
||||
getCurrentSettings,
|
||||
} = useBoardBackgroundSettings();
|
||||
} = useAppStore();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -55,63 +56,70 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
|
||||
// Get current background settings (live from store)
|
||||
const backgroundSettings =
|
||||
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
|
||||
|
||||
// Local state for sliders during dragging (avoids store updates during drag)
|
||||
const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
|
||||
const [localColumnOpacity, setLocalColumnOpacity] = useState(backgroundSettings.columnOpacity);
|
||||
const [localCardBorderOpacity, setLocalCardBorderOpacity] = useState(
|
||||
backgroundSettings.cardBorderOpacity
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Sync local state with store when not dragging (e.g., on modal open or external changes)
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalCardOpacity(backgroundSettings.cardOpacity);
|
||||
setLocalColumnOpacity(backgroundSettings.columnOpacity);
|
||||
setLocalCardBorderOpacity(backgroundSettings.cardBorderOpacity);
|
||||
}
|
||||
}, [
|
||||
isDragging,
|
||||
backgroundSettings.cardOpacity,
|
||||
backgroundSettings.columnOpacity,
|
||||
backgroundSettings.cardBorderOpacity,
|
||||
]);
|
||||
(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 ?? Date.now().toString();
|
||||
const imagePath = getAuthenticatedImageUrl(
|
||||
backgroundSettings.imagePath,
|
||||
currentProject.path,
|
||||
cacheBuster
|
||||
);
|
||||
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');
|
||||
toast.error("No project selected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file
|
||||
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
|
||||
if (!validation.isValid) {
|
||||
toast.error(validation.error);
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
toast.error(
|
||||
"Unsupported file type. Please use JPG, PNG, GIF, or WebP."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > DEFAULT_MAX_FILE_SIZE) {
|
||||
const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024);
|
||||
toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,16 +140,16 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
);
|
||||
|
||||
if (result.success && result.path) {
|
||||
// Update store and persist to server
|
||||
await setBoardBackground(currentProject.path, result.path);
|
||||
toast.success('Background image saved');
|
||||
// Update store with the relative path (live update)
|
||||
setBoardBackground(currentProject.path, result.path);
|
||||
toast.success("Background image saved");
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to save background image');
|
||||
toast.error(result.error || "Failed to save background image");
|
||||
setPreviewImage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to process image:', error);
|
||||
toast.error('Failed to process image');
|
||||
console.error("Failed to process image:", error);
|
||||
toast.error("Failed to process image");
|
||||
setPreviewImage(null);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
@@ -184,7 +192,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[processFile]
|
||||
@@ -202,105 +210,78 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const httpClient = getHttpApiClient();
|
||||
const result = await httpClient.deleteBoardBackground(currentProject.path);
|
||||
const result = await httpClient.deleteBoardBackground(
|
||||
currentProject.path
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
await clearBoardBackground(currentProject.path);
|
||||
clearBoardBackground(currentProject.path);
|
||||
setPreviewImage(null);
|
||||
toast.success('Background image cleared');
|
||||
toast.success("Background image cleared");
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to clear background image');
|
||||
toast.error(result.error || "Failed to clear background image");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear background:', error);
|
||||
toast.error('Failed to clear background');
|
||||
console.error("Failed to clear background:", error);
|
||||
toast.error("Failed to clear background");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [currentProject, clearBoardBackground]);
|
||||
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleCardOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalCardOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleCardOpacityCommit = useCallback(
|
||||
// Live update opacity when sliders change
|
||||
const handleCardOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setCardOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
|
||||
},
|
||||
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setCardOpacity]
|
||||
);
|
||||
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleColumnOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalColumnOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleColumnOpacityCommit = useCallback(
|
||||
const handleColumnOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setColumnOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
|
||||
},
|
||||
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setColumnOpacity]
|
||||
);
|
||||
|
||||
const handleColumnBorderToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
await setColumnBorderEnabled(currentProject.path, checked);
|
||||
setColumnBorderEnabled(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setColumnBorderEnabled]
|
||||
);
|
||||
|
||||
const handleCardGlassmorphismToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
await setCardGlassmorphism(currentProject.path, checked);
|
||||
setCardGlassmorphism(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setCardGlassmorphism]
|
||||
);
|
||||
|
||||
const handleCardBorderToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
await setCardBorderEnabled(currentProject.path, checked);
|
||||
setCardBorderEnabled(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setCardBorderEnabled]
|
||||
);
|
||||
|
||||
// Live update local state during drag (modal-only, no store update)
|
||||
const handleCardBorderOpacityChange = useCallback((value: number[]) => {
|
||||
setIsDragging(true);
|
||||
setLocalCardBorderOpacity(value[0]);
|
||||
}, []);
|
||||
|
||||
// Update store and persist when slider is released
|
||||
const handleCardBorderOpacityCommit = useCallback(
|
||||
const handleCardBorderOpacityChange = useCallback(
|
||||
(value: number[]) => {
|
||||
if (!currentProject) return;
|
||||
setIsDragging(false);
|
||||
setCardBorderOpacity(currentProject.path, value[0]);
|
||||
const current = getCurrentSettings(currentProject.path);
|
||||
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
|
||||
},
|
||||
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
|
||||
[currentProject, setCardBorderOpacity]
|
||||
);
|
||||
|
||||
const handleHideScrollbarToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
(checked: boolean) => {
|
||||
if (!currentProject) return;
|
||||
await setHideScrollbar(currentProject.path, checked);
|
||||
setHideScrollbar(currentProject.path, checked);
|
||||
},
|
||||
[currentProject, setHideScrollbar]
|
||||
);
|
||||
@@ -318,7 +299,8 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
Board Background Settings
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-muted-foreground">
|
||||
Set a custom background image for your kanban board and adjust card/column opacity
|
||||
Set a custom background image for your kanban board and adjust
|
||||
card/column opacity
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -331,7 +313,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
@@ -343,13 +325,14 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
'relative rounded-lg border-2 border-dashed transition-all duration-200',
|
||||
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
||||
{
|
||||
'border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10':
|
||||
"border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10":
|
||||
isDragOver && !isProcessing,
|
||||
'border-muted-foreground/25': !isDragOver && !isProcessing,
|
||||
'border-muted-foreground/10 opacity-50 cursor-not-allowed': isProcessing,
|
||||
'hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5':
|
||||
"border-muted-foreground/25": !isDragOver && !isProcessing,
|
||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed":
|
||||
isProcessing,
|
||||
"hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5":
|
||||
!isProcessing && !isDragOver,
|
||||
}
|
||||
)}
|
||||
@@ -364,7 +347,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
/>
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<Spinner size="lg" />
|
||||
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -397,26 +380,26 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full p-3 mb-3',
|
||||
"rounded-full p-3 mb-3",
|
||||
isDragOver && !isProcessing
|
||||
? 'bg-brand-500/10 dark:bg-brand-500/20'
|
||||
: 'bg-muted'
|
||||
? "bg-brand-500/10 dark:bg-brand-500/20"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Spinner size="lg" />
|
||||
<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'}
|
||||
? "Drop image here"
|
||||
: "Click to upload or drag and drop"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
JPG, PNG, GIF, or WebP (max {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}
|
||||
MB)
|
||||
JPG, PNG, GIF, or WebP (max{" "}
|
||||
{Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -428,12 +411,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{cardOpacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localCardOpacity]}
|
||||
value={[cardOpacity]}
|
||||
onValueChange={handleCardOpacityChange}
|
||||
onValueCommit={handleCardOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -444,12 +428,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Column Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{columnOpacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localColumnOpacity]}
|
||||
value={[columnOpacity]}
|
||||
onValueChange={handleColumnOpacityChange}
|
||||
onValueCommit={handleColumnOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -476,7 +461,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
checked={cardGlassmorphism}
|
||||
onCheckedChange={handleCardGlassmorphismToggle}
|
||||
/>
|
||||
<Label htmlFor="card-glassmorphism-toggle" className="cursor-pointer">
|
||||
<Label
|
||||
htmlFor="card-glassmorphism-toggle"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Card Glassmorphism (blur effect)
|
||||
</Label>
|
||||
</div>
|
||||
@@ -498,12 +486,13 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Card Border Opacity</Label>
|
||||
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{cardBorderOpacity}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[localCardBorderOpacity]}
|
||||
value={[cardBorderOpacity]}
|
||||
onValueChange={handleCardBorderOpacityChange}
|
||||
onValueCommit={handleCardBorderOpacityCommit}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { FolderOpen, Folder, ChevronRight, HardDrive, Clock, X } from 'lucide-react';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Folder,
|
||||
ChevronRight,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
HardDrive,
|
||||
CornerDownLeft,
|
||||
Clock,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,14 +19,9 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PathInput } from '@/components/ui/path-input';
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
import { useOSDetection } from '@/hooks';
|
||||
import { apiPost } from '@/lib/api-fetch';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
@@ -40,162 +47,175 @@ interface FileBrowserDialogProps {
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const RECENT_FOLDERS_KEY = "file-browser-recent-folders";
|
||||
const MAX_RECENT_FOLDERS = 5;
|
||||
|
||||
function getRecentFolders(): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_FOLDERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
// Remove if already exists, then add to front
|
||||
const filtered = recent.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const recent = getRecentFolders();
|
||||
const updated = recent.filter((p) => p !== path);
|
||||
localStorage.setItem(RECENT_FOLDERS_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
title = 'Select Project Directory',
|
||||
description = 'Navigate to your project folder or paste a path directly',
|
||||
title = "Select Project Directory",
|
||||
description = "Navigate to your project folder or paste a path directly",
|
||||
initialPath,
|
||||
}: FileBrowserDialogProps) {
|
||||
const { isMac } = useOSDetection();
|
||||
const [currentPath, setCurrentPath] = useState<string>('');
|
||||
const [currentPath, setCurrentPath] = useState<string>("");
|
||||
const [pathInput, setPathInput] = useState<string>("");
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [warning, setWarning] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
const [warning, setWarning] = useState("");
|
||||
const [recentFolders, setRecentFolders] = useState<string[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use recent folders from app store (synced via API)
|
||||
const recentFolders = useAppStore((s) => s.recentFolders);
|
||||
const setRecentFolders = useAppStore((s) => s.setRecentFolders);
|
||||
const addRecentFolder = useAppStore((s) => s.addRecentFolder);
|
||||
|
||||
const handleRemoveRecent = useCallback(
|
||||
(e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = recentFolders.filter((p) => p !== path);
|
||||
setRecentFolders(updated);
|
||||
},
|
||||
[recentFolders, setRecentFolders]
|
||||
);
|
||||
|
||||
const browseDirectory = useCallback(async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setWarning('');
|
||||
|
||||
try {
|
||||
const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
setParentPath(result.parentPath);
|
||||
setDirectories(result.directories);
|
||||
setDrives(result.drives || []);
|
||||
setWarning(result.warning || '');
|
||||
} else {
|
||||
setError(result.error || 'Failed to browse directory');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load directories');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectRecent = useCallback(
|
||||
(path: string) => {
|
||||
browseDirectory(path);
|
||||
},
|
||||
[browseDirectory]
|
||||
);
|
||||
|
||||
// Reset state when dialog closes
|
||||
// Load recent folders when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCurrentPath('');
|
||||
setParentPath(null);
|
||||
setDirectories([]);
|
||||
setError('');
|
||||
setWarning('');
|
||||
if (open) {
|
||||
setRecentFolders(getRecentFolders());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load initial path or workspace directory when dialog opens
|
||||
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
const updated = removeRecentFolder(path);
|
||||
setRecentFolders(updated);
|
||||
}, []);
|
||||
|
||||
const handleSelectRecent = useCallback((path: string) => {
|
||||
browseDirectory(path);
|
||||
}, []);
|
||||
|
||||
const browseDirectory = async (dirPath?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setWarning("");
|
||||
|
||||
try {
|
||||
// Get server URL from environment or default
|
||||
const serverUrl =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dirPath }),
|
||||
});
|
||||
|
||||
const result: BrowseResult = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setCurrentPath(result.currentPath);
|
||||
setPathInput(result.currentPath);
|
||||
setParentPath(result.parentPath);
|
||||
setDirectories(result.directories);
|
||||
setDrives(result.drives || []);
|
||||
setWarning(result.warning || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to browse directory");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load directories"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset current path when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCurrentPath("");
|
||||
setPathInput("");
|
||||
setParentPath(null);
|
||||
setDirectories([]);
|
||||
setError("");
|
||||
setWarning("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load initial path or home directory when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && !currentPath) {
|
||||
// Priority order:
|
||||
// 1. Last selected directory from this file browser (from localStorage)
|
||||
// 2. initialPath prop (from parent component)
|
||||
// 3. Default workspace directory
|
||||
// 4. Home directory
|
||||
const loadInitialPath = async () => {
|
||||
try {
|
||||
// First, check for last selected directory from getDefaultWorkspaceDirectory
|
||||
// which already implements the priority: last used > Documents/Automaker > DATA_DIR
|
||||
const defaultDir = await getDefaultWorkspaceDirectory();
|
||||
|
||||
// If we have a default directory, use it (unless initialPath is explicitly provided and different)
|
||||
const pathToUse = initialPath || defaultDir;
|
||||
|
||||
if (pathToUse) {
|
||||
browseDirectory(pathToUse);
|
||||
} else {
|
||||
// No default directory, browse home directory
|
||||
browseDirectory();
|
||||
}
|
||||
} catch {
|
||||
// If config fetch fails, try initialPath or fall back to home directory
|
||||
if (initialPath) {
|
||||
browseDirectory(initialPath);
|
||||
} else {
|
||||
browseDirectory();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialPath();
|
||||
browseDirectory(initialPath);
|
||||
}
|
||||
}, [open, initialPath, currentPath, browseDirectory]);
|
||||
}, [open, initialPath]);
|
||||
|
||||
const handleSelectDirectory = (dir: DirectoryEntry) => {
|
||||
browseDirectory(dir.path);
|
||||
};
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
browseDirectory();
|
||||
}, [browseDirectory]);
|
||||
const handleGoToParent = () => {
|
||||
if (parentPath) {
|
||||
browseDirectory(parentPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
browseDirectory(path);
|
||||
},
|
||||
[browseDirectory]
|
||||
);
|
||||
const handleGoHome = () => {
|
||||
browseDirectory();
|
||||
};
|
||||
|
||||
const handleSelectDrive = (drivePath: string) => {
|
||||
browseDirectory(drivePath);
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
const handleGoToPath = () => {
|
||||
const trimmedPath = pathInput.trim();
|
||||
if (trimmedPath) {
|
||||
browseDirectory(trimmedPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleGoToPath();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = () => {
|
||||
if (currentPath) {
|
||||
addRecentFolder(currentPath);
|
||||
// Save to last project directory so it's used as default next time
|
||||
saveLastProjectDirectory(currentPath);
|
||||
onSelect(currentPath);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}, [currentPath, onSelect, onOpenChange]);
|
||||
|
||||
// Handle Command/Ctrl+Enter keyboard shortcut to select current folder
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check for Command+Enter (Mac) or Ctrl+Enter (Windows/Linux)
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (currentPath && !loading) {
|
||||
handleSelect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, currentPath, loading, handleSelect]);
|
||||
};
|
||||
|
||||
// Helper to get folder name from path
|
||||
const getFolderName = (path: string) => {
|
||||
@@ -205,7 +225,7 @@ export function FileBrowserDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none">
|
||||
<DialogContent className="bg-popover border-border max-w-3xl max-h-[85vh] overflow-hidden flex flex-col p-4">
|
||||
<DialogHeader className="pb-1">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FolderOpen className="w-4 h-4 text-brand-500" />
|
||||
@@ -217,21 +237,31 @@ export function FileBrowserDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2 min-h-[350px] flex-1 overflow-hidden py-1">
|
||||
{/* Path navigation */}
|
||||
<PathInput
|
||||
currentPath={currentPath}
|
||||
parentPath={parentPath}
|
||||
loading={loading}
|
||||
error={!!error}
|
||||
onNavigate={handleNavigate}
|
||||
onHome={handleGoHome}
|
||||
entries={directories.map((dir) => ({ ...dir, isDirectory: true }))}
|
||||
onSelectEntry={(entry) => {
|
||||
if (entry.isDirectory) {
|
||||
handleSelectDirectory(entry);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Direct path input */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
type="text"
|
||||
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
|
||||
value={pathInput}
|
||||
onChange={(e) => setPathInput(e.target.value)}
|
||||
onKeyDown={handlePathInputKeyDown}
|
||||
className="flex-1 font-mono text-xs h-8"
|
||||
data-testid="path-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGoToPath}
|
||||
disabled={loading || !pathInput.trim()}
|
||||
data-testid="go-to-path-button"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<CornerDownLeft className="w-3.5 h-3.5 mr-1" />
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recent folders */}
|
||||
{recentFolders.length > 0 && (
|
||||
@@ -272,23 +302,54 @@ export function FileBrowserDialog({
|
||||
{drives.map((drive) => (
|
||||
<Button
|
||||
key={drive}
|
||||
variant={currentPath.startsWith(drive) ? 'default' : 'outline'}
|
||||
variant={
|
||||
currentPath.startsWith(drive) ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => handleSelectDrive(drive)}
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{drive.replace('\\', '')}
|
||||
{drive.replace("\\", "")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 p-2 rounded-md bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoHome}
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-6 px-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 font-mono text-xs truncate text-muted-foreground">
|
||||
{currentPath || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled">
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">Loading directories...</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Loading directories...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -306,7 +367,9 @@ export function FileBrowserDialog({
|
||||
|
||||
{!loading && !error && !warning && directories.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-xs text-muted-foreground">No subdirectories found</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No subdirectories found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -328,8 +391,8 @@ export function FileBrowserDialog({
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Paste a full path above, or click on folders to navigate. Press Enter or click → to jump
|
||||
to a path.
|
||||
Paste a full path above, or click on folders to navigate. Press
|
||||
Enter or click Go to jump to a path.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -337,18 +400,9 @@ export function FileBrowserDialog({
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSelect}
|
||||
disabled={!currentPath || loading}
|
||||
title="Select current folder (Cmd+Enter / Ctrl+Enter)"
|
||||
>
|
||||
<Button size="sm" onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1.5" />
|
||||
Select Current Folder
|
||||
<KbdGroup className="ml-1">
|
||||
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
|
||||
<Kbd>↵</Kbd>
|
||||
</KbdGroup>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
167
apps/app/src/components/layout/project-setup-dialog.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles, Clock } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Feature count options
|
||||
export type FeatureCount = 20 | 50 | 100;
|
||||
const FEATURE_COUNT_OPTIONS: {
|
||||
value: FeatureCount;
|
||||
label: string;
|
||||
warning?: string;
|
||||
}[] = [
|
||||
{ value: 20, label: "20" },
|
||||
{ value: 50, label: "50", warning: "May take up to 5 minutes" },
|
||||
{ value: 100, label: "100", warning: "May take up to 5 minutes" },
|
||||
];
|
||||
|
||||
interface ProjectSetupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectOverview: string;
|
||||
onProjectOverviewChange: (value: string) => void;
|
||||
generateFeatures: boolean;
|
||||
onGenerateFeaturesChange: (value: boolean) => void;
|
||||
featureCount: FeatureCount;
|
||||
onFeatureCountChange: (value: FeatureCount) => void;
|
||||
onCreateSpec: () => void;
|
||||
onSkip: () => void;
|
||||
isCreatingSpec: boolean;
|
||||
}
|
||||
|
||||
export function ProjectSetupDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectOverview,
|
||||
onProjectOverviewChange,
|
||||
generateFeatures,
|
||||
onGenerateFeaturesChange,
|
||||
featureCount,
|
||||
onFeatureCountChange,
|
||||
onCreateSpec,
|
||||
onSkip,
|
||||
isCreatingSpec,
|
||||
}: ProjectSetupDialogProps) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open);
|
||||
if (!open && !isCreatingSpec) {
|
||||
onSkip();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Up Your Project</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
We didn't find an app_spec.txt file. Let us help you generate
|
||||
your app_spec.txt to help describe your project for our system.
|
||||
We'll analyze your project's tech stack and create a
|
||||
comprehensive specification.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project Overview</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Describe what your project does and what features you want to
|
||||
build. Be as detailed as you want - this will help us create a
|
||||
better specification.
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full h-48 p-3 rounded-md border border-border bg-background font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={projectOverview}
|
||||
onChange={(e) => onProjectOverviewChange(e.target.value)}
|
||||
placeholder="e.g., A project management tool that allows teams to track tasks, manage sprints, and visualize progress through kanban boards. It should support user authentication, real-time updates, and file attachments..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 pt-2">
|
||||
<Checkbox
|
||||
id="sidebar-generate-features"
|
||||
checked={generateFeatures}
|
||||
onCheckedChange={(checked) =>
|
||||
onGenerateFeaturesChange(checked === true)
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="sidebar-generate-features"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Generate feature list
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically create features in the features folder from the
|
||||
implementation roadmap after the spec is generated.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Count Selection - only shown when generateFeatures is enabled */}
|
||||
{generateFeatures && (
|
||||
<div className="space-y-2 pt-2 pl-7">
|
||||
<label className="text-sm font-medium">Number of Features</label>
|
||||
<div className="flex gap-2">
|
||||
{FEATURE_COUNT_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={
|
||||
featureCount === option.value ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => onFeatureCountChange(option.value)}
|
||||
className={cn(
|
||||
"flex-1 transition-all",
|
||||
featureCount === option.value
|
||||
? "bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
: "bg-muted/30 hover:bg-muted/50 border-border"
|
||||
)}
|
||||
data-testid={`feature-count-${option.value}`}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
|
||||
?.warning && (
|
||||
<p className="text-xs text-amber-500 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{
|
||||
FEATURE_COUNT_OPTIONS.find((o) => o.value === featureCount)
|
||||
?.warning
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onSkip}>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button onClick={onCreateSpec} disabled={!projectOverview.trim()}>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Generate Spec
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2429
apps/app/src/components/layout/sidebar.tsx
Normal file
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,22 +8,30 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFileBrowser } from '@/contexts/file-browser-context';
|
||||
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
|
||||
} from "@/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";
|
||||
|
||||
const logger = createLogger('NewProjectModal');
|
||||
const LAST_PROJECT_DIR_KEY = "automaker:lastProjectDir";
|
||||
|
||||
interface ValidationErrors {
|
||||
projectName?: boolean;
|
||||
@@ -34,13 +43,20 @@ interface ValidationErrors {
|
||||
interface NewProjectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBlankProject: (projectName: string, parentDir: string) => Promise<void>;
|
||||
onCreateBlankProject: (
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
onCreateFromTemplate: (
|
||||
template: StarterTemplate,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
onCreateFromCustomUrl: (repoUrl: string, projectName: string, parentDir: string) => Promise<void>;
|
||||
onCreateFromCustomUrl: (
|
||||
repoUrl: string,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
@@ -52,28 +68,39 @@ export function NewProjectModal({
|
||||
onCreateFromCustomUrl,
|
||||
isCreating,
|
||||
}: NewProjectModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'blank' | 'template'>('blank');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [workspaceDir, setWorkspaceDir] = useState<string>('');
|
||||
const [activeTab, setActiveTab] = useState<"blank" | "template">("blank");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [workspaceDir, setWorkspaceDir] = useState<string>("");
|
||||
const [isLoadingWorkspace, setIsLoadingWorkspace] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<StarterTemplate | null>(null);
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<StarterTemplate | null>(null);
|
||||
const [useCustomUrl, setUseCustomUrl] = useState(false);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [customUrl, setCustomUrl] = useState("");
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const { openFileBrowser } = useFileBrowser();
|
||||
|
||||
// Fetch workspace directory when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// First, check localStorage for last used directory
|
||||
const lastUsedDir = localStorage.getItem(LAST_PROJECT_DIR_KEY);
|
||||
if (lastUsedDir) {
|
||||
setWorkspaceDir(lastUsedDir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to server config if no saved directory
|
||||
setIsLoadingWorkspace(true);
|
||||
getDefaultWorkspaceDirectory()
|
||||
.then((defaultDir) => {
|
||||
if (defaultDir) {
|
||||
setWorkspaceDir(defaultDir);
|
||||
const httpClient = getHttpApiClient();
|
||||
httpClient.workspace
|
||||
.getConfig()
|
||||
.then((result) => {
|
||||
if (result.success && result.workspaceDir) {
|
||||
setWorkspaceDir(result.workspaceDir);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to get default workspace directory:', error);
|
||||
console.error("Failed to get workspace config:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingWorkspace(false);
|
||||
@@ -84,11 +111,11 @@ export function NewProjectModal({
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setProjectName('');
|
||||
setProjectName("");
|
||||
setSelectedTemplate(null);
|
||||
setUseCustomUrl(false);
|
||||
setCustomUrl('');
|
||||
setActiveTab('blank');
|
||||
setCustomUrl("");
|
||||
setActiveTab("blank");
|
||||
setErrors({});
|
||||
}
|
||||
}, [open]);
|
||||
@@ -101,7 +128,10 @@ export function NewProjectModal({
|
||||
}, [projectName, errors.projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((selectedTemplate || (useCustomUrl && customUrl)) && errors.templateSelection) {
|
||||
if (
|
||||
(selectedTemplate || (useCustomUrl && customUrl)) &&
|
||||
errors.templateSelection
|
||||
) {
|
||||
setErrors((prev) => ({ ...prev, templateSelection: false }));
|
||||
}
|
||||
}, [selectedTemplate, useCustomUrl, customUrl, errors.templateSelection]);
|
||||
@@ -126,7 +156,7 @@ export function NewProjectModal({
|
||||
}
|
||||
|
||||
// Check template selection (only for template tab)
|
||||
if (activeTab === 'template') {
|
||||
if (activeTab === "template") {
|
||||
if (useCustomUrl) {
|
||||
if (!customUrl.trim()) {
|
||||
newErrors.customUrl = true;
|
||||
@@ -145,7 +175,7 @@ export function NewProjectModal({
|
||||
// Clear errors and proceed
|
||||
setErrors({});
|
||||
|
||||
if (activeTab === 'blank') {
|
||||
if (activeTab === "blank") {
|
||||
await onCreateBlankProject(projectName, workspaceDir);
|
||||
} else if (useCustomUrl && customUrl) {
|
||||
await onCreateFromCustomUrl(customUrl, projectName, workspaceDir);
|
||||
@@ -162,7 +192,7 @@ export function NewProjectModal({
|
||||
const handleSelectTemplate = (template: StarterTemplate) => {
|
||||
setSelectedTemplate(template);
|
||||
setUseCustomUrl(false);
|
||||
setCustomUrl('');
|
||||
setCustomUrl("");
|
||||
};
|
||||
|
||||
const handleToggleCustomUrl = () => {
|
||||
@@ -174,14 +204,15 @@ export function NewProjectModal({
|
||||
|
||||
const handleBrowseDirectory = async () => {
|
||||
const selectedPath = await openFileBrowser({
|
||||
title: 'Select Base Project Directory',
|
||||
description: 'Choose the parent directory where your project will be created',
|
||||
title: "Select Base Project Directory",
|
||||
description:
|
||||
"Choose the parent directory where your project will be created",
|
||||
initialPath: workspaceDir || undefined,
|
||||
});
|
||||
if (selectedPath) {
|
||||
setWorkspaceDir(selectedPath);
|
||||
// Save to localStorage for next time
|
||||
saveLastProjectDirectory(selectedPath);
|
||||
localStorage.setItem(LAST_PROJECT_DIR_KEY, selectedPath);
|
||||
// Clear any workspace error when a valid directory is selected
|
||||
if (errors.workspaceDir) {
|
||||
setErrors((prev) => ({ ...prev, workspaceDir: false }));
|
||||
@@ -191,12 +222,15 @@ export function NewProjectModal({
|
||||
|
||||
// Use platform-specific path separator
|
||||
const pathSep =
|
||||
typeof window !== 'undefined' && window.electronAPI
|
||||
? navigator.platform.indexOf('Win') !== -1
|
||||
? '\\'
|
||||
: '/'
|
||||
: '/';
|
||||
const projectPath = workspaceDir && projectName ? `${workspaceDir}${pathSep}${projectName}` : '';
|
||||
typeof window !== "undefined" && (window as any).electronAPI
|
||||
? navigator.platform.indexOf("Win") !== -1
|
||||
? "\\"
|
||||
: "/"
|
||||
: "/";
|
||||
const projectPath =
|
||||
workspaceDir && projectName
|
||||
? `${workspaceDir}${pathSep}${projectName}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -205,7 +239,9 @@ export function NewProjectModal({
|
||||
data-testid="new-project-modal"
|
||||
>
|
||||
<DialogHeader className="pb-2">
|
||||
<DialogTitle className="text-foreground">Create New Project</DialogTitle>
|
||||
<DialogTitle className="text-foreground">
|
||||
Create New Project
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Start with a blank project or choose from a starter template.
|
||||
</DialogDescription>
|
||||
@@ -216,9 +252,13 @@ export function NewProjectModal({
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="project-name"
|
||||
className={cn('text-foreground', errors.projectName && 'text-red-500')}
|
||||
className={cn(
|
||||
"text-foreground",
|
||||
errors.projectName && "text-red-500"
|
||||
)}
|
||||
>
|
||||
Project Name {errors.projectName && <span className="text-red-500">*</span>}
|
||||
Project Name{" "}
|
||||
{errors.projectName && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
@@ -226,39 +266,40 @@ export function NewProjectModal({
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className={cn(
|
||||
'bg-input text-foreground placeholder:text-muted-foreground',
|
||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||
errors.projectName
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
|
||||
: 'border-border'
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-border"
|
||||
)}
|
||||
data-testid="project-name-input"
|
||||
autoFocus
|
||||
/>
|
||||
{errors.projectName && <p className="text-xs text-red-500">Project name is required</p>}
|
||||
{errors.projectName && (
|
||||
<p className="text-xs text-red-500">Project name is required</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Workspace Directory Display */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm',
|
||||
errors.workspaceDir ? 'text-red-500' : 'text-muted-foreground'
|
||||
"flex items-center gap-2 text-sm",
|
||||
errors.workspaceDir ? "text-red-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Folder className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<Folder className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{isLoadingWorkspace ? (
|
||||
'Loading workspace...'
|
||||
"Loading workspace..."
|
||||
) : workspaceDir ? (
|
||||
<>
|
||||
<span>Will be created at:</span>
|
||||
<code
|
||||
className="text-xs bg-muted px-1.5 py-0.5 rounded truncate block max-w-full"
|
||||
title={projectPath || workspaceDir}
|
||||
>
|
||||
Will be created at:{" "}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded truncate">
|
||||
{projectPath || workspaceDir}
|
||||
</code>
|
||||
</>
|
||||
) : null}
|
||||
) : (
|
||||
<span className="text-red-500">No workspace configured</span>
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -277,7 +318,7 @@ export function NewProjectModal({
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'blank' | 'template')}
|
||||
onValueChange={(v) => setActiveTab(v as "blank" | "template")}
|
||||
className="flex-1 flex flex-col overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full justify-start">
|
||||
@@ -295,8 +336,9 @@ export function NewProjectModal({
|
||||
<TabsContent value="blank" className="mt-0">
|
||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create an empty project with the standard .automaker directory structure. Perfect
|
||||
for starting from scratch or importing an existing codebase.
|
||||
Create an empty project with the standard .automaker directory
|
||||
structure. Perfect for starting from scratch or importing an
|
||||
existing codebase.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -313,18 +355,18 @@ export function NewProjectModal({
|
||||
{/* Preset Templates */}
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-3 rounded-lg p-1 -m-1',
|
||||
errors.templateSelection && 'ring-2 ring-red-500/50'
|
||||
"space-y-3 rounded-lg p-1 -m-1",
|
||||
errors.templateSelection && "ring-2 ring-red-500/50"
|
||||
)}
|
||||
>
|
||||
{starterTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={cn(
|
||||
'p-4 rounded-lg border cursor-pointer transition-all',
|
||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||
selectedTemplate?.id === template.id && !useCustomUrl
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
data-testid={`template-${template.id}`}
|
||||
@@ -332,10 +374,13 @@ export function NewProjectModal({
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-foreground">{template.name}</h4>
|
||||
{selectedTemplate?.id === template.id && !useCustomUrl && (
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
)}
|
||||
<h4 className="font-medium text-foreground">
|
||||
{template.name}
|
||||
</h4>
|
||||
{selectedTemplate?.id === template.id &&
|
||||
!useCustomUrl && (
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{template.description}
|
||||
@@ -344,7 +389,11 @@ export function NewProjectModal({
|
||||
{/* Tech Stack */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{template.techStack.slice(0, 6).map((tech) => (
|
||||
<Badge key={tech} variant="secondary" className="text-xs">
|
||||
<Badge
|
||||
key={tech}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{tech}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -358,7 +407,7 @@ export function NewProjectModal({
|
||||
{/* Key Features */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Features: </span>
|
||||
{template.features.slice(0, 3).join(' · ')}
|
||||
{template.features.slice(0, 3).join(" · ")}
|
||||
{template.features.length > 3 &&
|
||||
` · +${template.features.length - 3} more`}
|
||||
</div>
|
||||
@@ -383,38 +432,47 @@ export function NewProjectModal({
|
||||
{/* Custom URL Option */}
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 rounded-lg border cursor-pointer transition-all',
|
||||
"p-4 rounded-lg border cursor-pointer transition-all",
|
||||
useCustomUrl
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50'
|
||||
? "border-brand-500 bg-brand-500/10"
|
||||
: "border-border bg-muted/30 hover:border-border-glass hover:bg-muted/50"
|
||||
)}
|
||||
onClick={handleToggleCustomUrl}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="font-medium text-foreground">Custom GitHub URL</h4>
|
||||
{useCustomUrl && <Check className="w-4 h-4 text-brand-500" />}
|
||||
<h4 className="font-medium text-foreground">
|
||||
Custom GitHub URL
|
||||
</h4>
|
||||
{useCustomUrl && (
|
||||
<Check className="w-4 h-4 text-brand-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Clone any public GitHub repository as a starting point.
|
||||
</p>
|
||||
|
||||
{useCustomUrl && (
|
||||
<div onClick={(e) => e.stopPropagation()} className="space-y-1">
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="space-y-1"
|
||||
>
|
||||
<Input
|
||||
placeholder="https://github.com/username/repository"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
className={cn(
|
||||
'bg-input text-foreground placeholder:text-muted-foreground',
|
||||
"bg-input text-foreground placeholder:text-muted-foreground",
|
||||
errors.customUrl
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500/20'
|
||||
: 'border-border'
|
||||
? "border-red-500 focus:border-red-500 focus:ring-red-500/20"
|
||||
: "border-border"
|
||||
)}
|
||||
data-testid="custom-url-input"
|
||||
/>
|
||||
{errors.customUrl && (
|
||||
<p className="text-xs text-red-500">GitHub URL is required</p>
|
||||
<p className="text-xs text-red-500">
|
||||
GitHub URL is required
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -437,14 +495,14 @@ export function NewProjectModal({
|
||||
onClick={validateAndCreate}
|
||||
disabled={isCreating}
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-white border-0"
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
data-testid="confirm-create-project"
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{activeTab === "template" ? "Cloning..." : "Creating..."}
|
||||
</>
|
||||
) : (
|
||||
<>Create Project</>
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
"use client";
|
||||
|
||||
const logger = createLogger('SessionManager');
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
@@ -17,69 +20,67 @@ import {
|
||||
Check,
|
||||
X,
|
||||
ArchiveRestore,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionListItem } from '@/types/electron';
|
||||
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useSessions } from '@/hooks/queries';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { DeleteSessionDialog } from '@/components/dialogs/delete-session-dialog';
|
||||
import { DeleteAllArchivedSessionsDialog } from '@/components/dialogs/delete-all-archived-sessions-dialog';
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionListItem } from "@/types/electron";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
|
||||
import { DeleteAllArchivedSessionsDialog } from "@/components/delete-all-archived-sessions-dialog";
|
||||
|
||||
// Random session name generator
|
||||
const adjectives = [
|
||||
'Swift',
|
||||
'Bright',
|
||||
'Clever',
|
||||
'Dynamic',
|
||||
'Eager',
|
||||
'Focused',
|
||||
'Gentle',
|
||||
'Happy',
|
||||
'Inventive',
|
||||
'Jolly',
|
||||
'Keen',
|
||||
'Lively',
|
||||
'Mighty',
|
||||
'Noble',
|
||||
'Optimal',
|
||||
'Peaceful',
|
||||
'Quick',
|
||||
'Radiant',
|
||||
'Smart',
|
||||
'Tranquil',
|
||||
'Unique',
|
||||
'Vibrant',
|
||||
'Wise',
|
||||
'Zealous',
|
||||
"Swift",
|
||||
"Bright",
|
||||
"Clever",
|
||||
"Dynamic",
|
||||
"Eager",
|
||||
"Focused",
|
||||
"Gentle",
|
||||
"Happy",
|
||||
"Inventive",
|
||||
"Jolly",
|
||||
"Keen",
|
||||
"Lively",
|
||||
"Mighty",
|
||||
"Noble",
|
||||
"Optimal",
|
||||
"Peaceful",
|
||||
"Quick",
|
||||
"Radiant",
|
||||
"Smart",
|
||||
"Tranquil",
|
||||
"Unique",
|
||||
"Vibrant",
|
||||
"Wise",
|
||||
"Zealous",
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
'Agent',
|
||||
'Builder',
|
||||
'Coder',
|
||||
'Developer',
|
||||
'Explorer',
|
||||
'Forge',
|
||||
'Garden',
|
||||
'Helper',
|
||||
'Innovator',
|
||||
'Journey',
|
||||
'Kernel',
|
||||
'Lighthouse',
|
||||
'Mission',
|
||||
'Navigator',
|
||||
'Oracle',
|
||||
'Project',
|
||||
'Quest',
|
||||
'Runner',
|
||||
'Spark',
|
||||
'Task',
|
||||
'Unicorn',
|
||||
'Voyage',
|
||||
'Workshop',
|
||||
"Agent",
|
||||
"Builder",
|
||||
"Coder",
|
||||
"Developer",
|
||||
"Explorer",
|
||||
"Forge",
|
||||
"Garden",
|
||||
"Helper",
|
||||
"Innovator",
|
||||
"Journey",
|
||||
"Kernel",
|
||||
"Lighthouse",
|
||||
"Mission",
|
||||
"Navigator",
|
||||
"Oracle",
|
||||
"Project",
|
||||
"Quest",
|
||||
"Runner",
|
||||
"Spark",
|
||||
"Task",
|
||||
"Unicorn",
|
||||
"Voyage",
|
||||
"Workshop",
|
||||
];
|
||||
|
||||
function generateRandomSessionName(): string {
|
||||
@@ -105,25 +106,21 @@ export function SessionManager({
|
||||
onQuickCreateRef,
|
||||
}: SessionManagerProps) {
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active');
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newSessionName, setNewSessionName] = useState('');
|
||||
const [runningSessions, setRunningSessions] = useState<Set<string>>(new Set());
|
||||
const [newSessionName, setNewSessionName] = useState("");
|
||||
const [runningSessions, setRunningSessions] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
|
||||
const [isDeleteAllArchivedDialogOpen, setIsDeleteAllArchivedDialogOpen] = useState(false);
|
||||
|
||||
// Use React Query for sessions list - always include archived, filter client-side
|
||||
const { data: sessions = [], refetch: refetchSessions } = useSessions(true);
|
||||
|
||||
// Ref to track if we've done the initial running sessions check
|
||||
const hasCheckedInitialRef = useRef(false);
|
||||
|
||||
// Check running state for all sessions
|
||||
const checkRunningSessions = useCallback(async (sessionList: SessionListItem[]) => {
|
||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) return;
|
||||
|
||||
@@ -138,30 +135,33 @@ export function SessionManager({
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors for individual session checks
|
||||
logger.warn(`Failed to check running state for ${session.id}:`, err);
|
||||
console.warn(
|
||||
`[SessionManager] Failed to check running state for ${session.id}:`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setRunningSessions(runningIds);
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Helper to invalidate sessions cache and refetch
|
||||
const invalidateSessions = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all(true) });
|
||||
// Also check running state after invalidation
|
||||
const result = await refetchSessions();
|
||||
if (result.data) {
|
||||
await checkRunningSessions(result.data);
|
||||
// Load sessions
|
||||
const loadSessions = async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) return;
|
||||
|
||||
// Always load all sessions and filter client-side
|
||||
const result = await api.sessions.list(true);
|
||||
if (result.success && result.sessions) {
|
||||
setSessions(result.sessions);
|
||||
// Check running state for all sessions
|
||||
await checkRunningSessions(result.sessions);
|
||||
}
|
||||
}, [queryClient, refetchSessions, checkRunningSessions]);
|
||||
};
|
||||
|
||||
// Check running state on initial load (runs only once when sessions first load)
|
||||
useEffect(() => {
|
||||
if (sessions.length > 0 && !hasCheckedInitialRef.current) {
|
||||
hasCheckedInitialRef.current = true;
|
||||
checkRunningSessions(sessions);
|
||||
}
|
||||
}, [sessions, checkRunningSessions]);
|
||||
loadSessions();
|
||||
}, []);
|
||||
|
||||
// Periodically check running state for sessions (useful for detecting when agents finish)
|
||||
useEffect(() => {
|
||||
@@ -175,7 +175,7 @@ export function SessionManager({
|
||||
}, 3000); // Check every 3 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions, runningSessions.size, isCurrentSessionThinking, checkRunningSessions]);
|
||||
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
|
||||
|
||||
// Create new session with random name
|
||||
const handleCreateSession = async () => {
|
||||
@@ -184,12 +184,16 @@ export function SessionManager({
|
||||
|
||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||
|
||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
setNewSessionName('');
|
||||
setNewSessionName("");
|
||||
setIsCreating(false);
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
@@ -201,10 +205,14 @@ export function SessionManager({
|
||||
|
||||
const sessionName = generateRandomSessionName();
|
||||
|
||||
const result = await api.sessions.create(sessionName, projectPath, projectPath);
|
||||
const result = await api.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.session?.id) {
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
onSelectSession(result.session.id);
|
||||
}
|
||||
};
|
||||
@@ -226,12 +234,16 @@ export function SessionManager({
|
||||
const api = getElectronAPI();
|
||||
if (!editingName.trim() || !api?.sessions) return;
|
||||
|
||||
const result = await api.sessions.update(sessionId, editingName, undefined);
|
||||
const result = await api.sessions.update(
|
||||
sessionId,
|
||||
editingName,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setEditingSessionId(null);
|
||||
setEditingName('');
|
||||
await invalidateSessions();
|
||||
setEditingName("");
|
||||
await loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,7 +251,7 @@ export function SessionManager({
|
||||
const handleArchiveSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) {
|
||||
logger.error('[SessionManager] Sessions API not available');
|
||||
console.error("[SessionManager] Sessions API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,12 +262,12 @@ export function SessionManager({
|
||||
if (currentSessionId === sessionId) {
|
||||
onSelectSession(null);
|
||||
}
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
} else {
|
||||
logger.error('[SessionManager] Archive failed:', result.error);
|
||||
console.error("[SessionManager] Archive failed:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[SessionManager] Archive error:', error);
|
||||
console.error("[SessionManager] Archive error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -263,19 +275,19 @@ export function SessionManager({
|
||||
const handleUnarchiveSession = async (sessionId: string) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.sessions) {
|
||||
logger.error('[SessionManager] Sessions API not available');
|
||||
console.error("[SessionManager] Sessions API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.sessions.unarchive(sessionId);
|
||||
if (result.success) {
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
} else {
|
||||
logger.error('[SessionManager] Unarchive failed:', result.error);
|
||||
console.error("[SessionManager] Unarchive failed:", result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[SessionManager] Unarchive error:', error);
|
||||
console.error("[SessionManager] Unarchive error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -292,7 +304,7 @@ export function SessionManager({
|
||||
|
||||
const result = await api.sessions.delete(sessionId);
|
||||
if (result.success) {
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
if (currentSessionId === sessionId) {
|
||||
// Switch to another session or create a new one
|
||||
const activeSessionsList = sessions.filter((s) => !s.isArchived);
|
||||
@@ -314,13 +326,14 @@ export function SessionManager({
|
||||
await api.sessions.delete(session.id);
|
||||
}
|
||||
|
||||
await invalidateSessions();
|
||||
await loadSessions();
|
||||
setIsDeleteAllArchivedDialogOpen(false);
|
||||
};
|
||||
|
||||
const activeSessions = sessions.filter((s) => !s.isArchived);
|
||||
const archivedSessions = sessions.filter((s) => s.isArchived);
|
||||
const displayedSessions = activeTab === 'active' ? activeSessions : archivedSessions;
|
||||
const displayedSessions =
|
||||
activeTab === "active" ? activeSessions : archivedSessions;
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col rounded-none">
|
||||
@@ -332,8 +345,8 @@ export function SessionManager({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Switch to active tab if on archived tab
|
||||
if (activeTab === 'archived') {
|
||||
setActiveTab('active');
|
||||
if (activeTab === "archived") {
|
||||
setActiveTab("active");
|
||||
}
|
||||
handleQuickCreateSession();
|
||||
}}
|
||||
@@ -349,7 +362,9 @@ export function SessionManager({
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'active' | 'archived')}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as "active" | "archived")
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
@@ -365,7 +380,10 @@ export function SessionManager({
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-2" data-testid="session-list">
|
||||
<CardContent
|
||||
className="flex-1 overflow-y-auto space-y-2"
|
||||
data-testid="session-list"
|
||||
>
|
||||
{/* Create new session */}
|
||||
{isCreating && (
|
||||
<div className="p-3 border rounded-lg bg-muted/50">
|
||||
@@ -375,10 +393,10 @@ export function SessionManager({
|
||||
value={newSessionName}
|
||||
onChange={(e) => setNewSessionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateSession();
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Enter") handleCreateSession();
|
||||
if (e.key === "Escape") {
|
||||
setIsCreating(false);
|
||||
setNewSessionName('');
|
||||
setNewSessionName("");
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
@@ -391,7 +409,7 @@ export function SessionManager({
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewSessionName('');
|
||||
setNewSessionName("");
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -401,7 +419,7 @@ export function SessionManager({
|
||||
)}
|
||||
|
||||
{/* Delete All Archived button - shown at the top of archived sessions */}
|
||||
{activeTab === 'archived' && archivedSessions.length > 0 && (
|
||||
{activeTab === "archived" && archivedSessions.length > 0 && (
|
||||
<div className="pb-2 border-b mb-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -421,9 +439,9 @@ export function SessionManager({
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50',
|
||||
currentSessionId === session.id && 'bg-primary/10 border-primary',
|
||||
session.isArchived && 'opacity-60'
|
||||
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
|
||||
currentSessionId === session.id && "bg-primary/10 border-primary",
|
||||
session.isArchived && "opacity-60"
|
||||
)}
|
||||
onClick={() => !session.isArchived && onSelectSession(session.id)}
|
||||
data-testid={`session-item-${session.id}`}
|
||||
@@ -436,10 +454,10 @@ export function SessionManager({
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameSession(session.id);
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Enter") handleRenameSession(session.id);
|
||||
if (e.key === "Escape") {
|
||||
setEditingSessionId(null);
|
||||
setEditingName('');
|
||||
setEditingName("");
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -462,7 +480,7 @@ export function SessionManager({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSessionId(null);
|
||||
setEditingName('');
|
||||
setEditingName("");
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
@@ -473,14 +491,16 @@ export function SessionManager({
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
|
||||
{(currentSessionId === session.id && isCurrentSessionThinking) ||
|
||||
{(currentSessionId === session.id &&
|
||||
isCurrentSessionThinking) ||
|
||||
runningSessions.has(session.id) ? (
|
||||
<Spinner size="sm" className="shrink-0" />
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
|
||||
) : (
|
||||
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<h3 className="font-medium truncate">{session.name}</h3>
|
||||
{((currentSessionId === session.id && isCurrentSessionThinking) ||
|
||||
{((currentSessionId === session.id &&
|
||||
isCurrentSessionThinking) ||
|
||||
runningSessions.has(session.id)) && (
|
||||
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
|
||||
thinking...
|
||||
@@ -488,7 +508,9 @@ export function SessionManager({
|
||||
)}
|
||||
</div>
|
||||
{session.preview && (
|
||||
<p className="text-xs text-muted-foreground truncate">{session.preview}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{session.preview}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -505,7 +527,10 @@ export function SessionManager({
|
||||
|
||||
{/* Actions */}
|
||||
{!session.isArchived && (
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="flex gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -530,7 +555,10 @@ export function SessionManager({
|
||||
)}
|
||||
|
||||
{session.isArchived && (
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="flex gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -558,12 +586,14 @@ export function SessionManager({
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{activeTab === 'active' ? 'No active sessions' : 'No archived sessions'}
|
||||
{activeTab === "active"
|
||||
? "No active sessions"
|
||||
: "No archived sessions"}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{activeTab === 'active'
|
||||
? 'Create your first session to get started'
|
||||
: 'Archive sessions to see them here'}
|
||||
{activeTab === "active"
|
||||
? "Create your first session to get started"
|
||||
: "Archive sessions to see them here"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AccordionType = 'single' | 'multiple';
|
||||
type AccordionType = "single" | "multiple";
|
||||
|
||||
interface AccordionContextValue {
|
||||
type: AccordionType;
|
||||
@@ -13,10 +13,12 @@ interface AccordionContextValue {
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
||||
const AccordionContext = React.createContext<AccordionContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
type?: 'single' | 'multiple';
|
||||
type?: "single" | "multiple";
|
||||
value?: string | string[];
|
||||
defaultValue?: string | string[];
|
||||
onValueChange?: (value: string | string[]) => void;
|
||||
@@ -26,7 +28,7 @@ interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||
(
|
||||
{
|
||||
type = 'single',
|
||||
type = "single",
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
@@ -37,11 +39,13 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [internalValue, setInternalValue] = React.useState<string | string[]>(() => {
|
||||
if (value !== undefined) return value;
|
||||
if (defaultValue !== undefined) return defaultValue;
|
||||
return type === 'single' ? '' : [];
|
||||
});
|
||||
const [internalValue, setInternalValue] = React.useState<string | string[]>(
|
||||
() => {
|
||||
if (value !== undefined) return value;
|
||||
if (defaultValue !== undefined) return defaultValue;
|
||||
return type === "single" ? "" : [];
|
||||
}
|
||||
);
|
||||
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
|
||||
@@ -49,9 +53,9 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||
(itemValue: string) => {
|
||||
let newValue: string | string[];
|
||||
|
||||
if (type === 'single') {
|
||||
if (type === "single") {
|
||||
if (currentValue === itemValue && collapsible) {
|
||||
newValue = '';
|
||||
newValue = "";
|
||||
} else if (currentValue === itemValue && !collapsible) {
|
||||
return;
|
||||
} else {
|
||||
@@ -88,21 +92,27 @@ const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={contextValue}>
|
||||
<div ref={ref} data-slot="accordion" className={cn('w-full', className)} {...props}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="accordion"
|
||||
className={cn("w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
Accordion.displayName = 'Accordion';
|
||||
Accordion.displayName = "Accordion";
|
||||
|
||||
interface AccordionItemContextValue {
|
||||
value: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
|
||||
const AccordionItemContext =
|
||||
React.createContext<AccordionItemContextValue | null>(null);
|
||||
|
||||
interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string;
|
||||
@@ -113,22 +123,25 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||
const accordionContext = React.useContext(AccordionContext);
|
||||
|
||||
if (!accordionContext) {
|
||||
throw new Error('AccordionItem must be used within an Accordion');
|
||||
throw new Error("AccordionItem must be used within an Accordion");
|
||||
}
|
||||
|
||||
const isOpen = Array.isArray(accordionContext.value)
|
||||
? accordionContext.value.includes(value)
|
||||
: accordionContext.value === value;
|
||||
|
||||
const contextValue = React.useMemo(() => ({ value, isOpen }), [value, isOpen]);
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ value, isOpen }),
|
||||
[value, isOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItemContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="accordion-item"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
className={cn('border-b border-border', className)}
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
className={cn("border-b border-border", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -137,45 +150,47 @@ const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||
);
|
||||
}
|
||||
);
|
||||
AccordionItem.displayName = 'AccordionItem';
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
interface AccordionTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||
interface AccordionTriggerProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||
|
||||
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const accordionContext = React.useContext(AccordionContext);
|
||||
const itemContext = React.useContext(AccordionItemContext);
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
AccordionTriggerProps
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const accordionContext = React.useContext(AccordionContext);
|
||||
const itemContext = React.useContext(AccordionItemContext);
|
||||
|
||||
if (!accordionContext || !itemContext) {
|
||||
throw new Error('AccordionTrigger must be used within an AccordionItem');
|
||||
}
|
||||
|
||||
const { onValueChange } = accordionContext;
|
||||
const { value, isOpen } = itemContext;
|
||||
|
||||
return (
|
||||
<div data-slot="accordion-header" className="flex">
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-slot="accordion-trigger"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => onValueChange(value)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!accordionContext || !itemContext) {
|
||||
throw new Error("AccordionTrigger must be used within an AccordionItem");
|
||||
}
|
||||
);
|
||||
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||
|
||||
const { onValueChange } = accordionContext;
|
||||
const { value, isOpen } = itemContext;
|
||||
|
||||
return (
|
||||
<div data-slot="accordion-header" className="flex">
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
data-slot="accordion-trigger"
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => onValueChange(value)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AccordionTrigger.displayName = "AccordionTrigger";
|
||||
|
||||
interface AccordionContentProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
@@ -186,7 +201,7 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
|
||||
const [height, setHeight] = React.useState<number | undefined>(undefined);
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error('AccordionContent must be used within an AccordionItem');
|
||||
throw new Error("AccordionContent must be used within an AccordionItem");
|
||||
}
|
||||
|
||||
const { isOpen } = itemContext;
|
||||
@@ -206,16 +221,16 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
|
||||
return (
|
||||
<div
|
||||
data-slot="accordion-content"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
className="overflow-hidden text-sm transition-all duration-200 ease-out"
|
||||
style={{
|
||||
height: isOpen ? (height !== undefined ? `${height}px` : 'auto') : 0,
|
||||
height: isOpen ? (height !== undefined ? `${height}px` : "auto") : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div ref={ref} className={cn('pb-4 pt-0', className)}>
|
||||
<div ref={ref} className={cn("pb-4 pt-0", className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,6 +238,6 @@ const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>
|
||||
);
|
||||
}
|
||||
);
|
||||
AccordionContent.displayName = 'AccordionContent';
|
||||
AccordionContent.displayName = "AccordionContent";
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { Check, ChevronsUpDown, LucideIcon } from 'lucide-react';
|
||||
"use client";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as React from "react";
|
||||
import { Check, ChevronsUpDown, LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -10,8 +12,12 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export interface AutocompleteOption {
|
||||
value: string;
|
||||
@@ -29,16 +35,15 @@ interface AutocompleteProps {
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
icon?: LucideIcon;
|
||||
allowCreate?: boolean;
|
||||
createLabel?: (value: string) => string;
|
||||
'data-testid'?: string;
|
||||
"data-testid"?: string;
|
||||
itemTestIdPrefix?: string;
|
||||
}
|
||||
|
||||
function normalizeOption(opt: string | AutocompleteOption): AutocompleteOption {
|
||||
if (typeof opt === 'string') {
|
||||
if (typeof opt === "string") {
|
||||
return { value: opt, label: opt };
|
||||
}
|
||||
return { ...opt, label: opt.label ?? opt.value };
|
||||
@@ -48,24 +53,26 @@ export function Autocomplete({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select an option...',
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No results found.',
|
||||
placeholder = "Select an option...",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No results found.",
|
||||
className,
|
||||
disabled = false,
|
||||
error = false,
|
||||
icon: Icon,
|
||||
allowCreate = false,
|
||||
createLabel = (v) => `Create "${v}"`,
|
||||
'data-testid': testId,
|
||||
itemTestIdPrefix = 'option',
|
||||
"data-testid": testId,
|
||||
itemTestIdPrefix = "option",
|
||||
}: AutocompleteProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const normalizedOptions = React.useMemo(() => options.map(normalizeOption), [options]);
|
||||
const normalizedOptions = React.useMemo(
|
||||
() => options.map(normalizeOption),
|
||||
[options]
|
||||
);
|
||||
|
||||
// Update trigger width when component mounts or value changes
|
||||
React.useEffect(() => {
|
||||
@@ -90,7 +97,9 @@ export function Autocomplete({
|
||||
if (!inputValue) return normalizedOptions;
|
||||
const lower = inputValue.toLowerCase();
|
||||
return normalizedOptions.filter(
|
||||
(opt) => opt.value.toLowerCase().includes(lower) || opt.label?.toLowerCase().includes(lower)
|
||||
(opt) =>
|
||||
opt.value.toLowerCase().includes(lower) ||
|
||||
opt.label?.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [normalizedOptions, inputValue]);
|
||||
|
||||
@@ -98,7 +107,9 @@ export function Autocomplete({
|
||||
const isNewValue =
|
||||
allowCreate &&
|
||||
inputValue.trim() &&
|
||||
!normalizedOptions.some((opt) => opt.value.toLowerCase() === inputValue.toLowerCase());
|
||||
!normalizedOptions.some(
|
||||
(opt) => opt.value.toLowerCase() === inputValue.toLowerCase()
|
||||
);
|
||||
|
||||
// Get display value
|
||||
const displayValue = React.useMemo(() => {
|
||||
@@ -117,15 +128,16 @@ export function Autocomplete({
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
Icon && 'font-mono text-sm',
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
"w-full justify-between",
|
||||
Icon && "font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{Icon && <Icon className="w-4 h-4 shrink-0 text-muted-foreground" />}
|
||||
{Icon && (
|
||||
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="opacity-50 shrink-0" />
|
||||
@@ -137,8 +149,6 @@ export function Autocomplete({
|
||||
width: Math.max(triggerWidth, 200),
|
||||
}}
|
||||
data-testid={testId ? `${testId}-list` : undefined}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
@@ -151,7 +161,8 @@ export function Autocomplete({
|
||||
<CommandEmpty>
|
||||
{isNewValue ? (
|
||||
<div className="py-2 px-3 text-sm">
|
||||
Press enter to create <code className="bg-muted px-1 rounded">{inputValue}</code>
|
||||
Press enter to create{" "}
|
||||
<code className="bg-muted px-1 rounded">{inputValue}</code>
|
||||
</div>
|
||||
) : (
|
||||
emptyMessage
|
||||
@@ -164,7 +175,7 @@ export function Autocomplete({
|
||||
value={inputValue}
|
||||
onSelect={() => {
|
||||
onChange(inputValue);
|
||||
setInputValue('');
|
||||
setInputValue("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-[var(--status-success)]"
|
||||
@@ -172,7 +183,9 @@ export function Autocomplete({
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{createLabel(inputValue)}
|
||||
<span className="ml-auto text-xs text-muted-foreground">(new)</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
(new)
|
||||
</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{filteredOptions.map((option) => (
|
||||
@@ -180,19 +193,24 @@ export function Autocomplete({
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
onChange(currentValue === value ? '' : currentValue);
|
||||
setInputValue('');
|
||||
onChange(currentValue === value ? "" : currentValue);
|
||||
setInputValue("");
|
||||
setOpen(false);
|
||||
}}
|
||||
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, '-')}`}
|
||||
data-testid={`${itemTestIdPrefix}-${option.value.toLowerCase().replace(/[\s/\\]+/g, "-")}`}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mr-2" />}
|
||||
{option.label}
|
||||
<Check
|
||||
className={cn('ml-auto', value === option.value ? 'opacity-100' : 'opacity-0')}
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
value === option.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.badge && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">({option.badge})</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({option.badge})
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
57
apps/app/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white hover:bg-destructive/90",
|
||||
outline:
|
||||
"text-foreground border-border bg-background/50 backdrop-blur-sm",
|
||||
// Semantic status variants using CSS variables
|
||||
success:
|
||||
"border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30",
|
||||
warning:
|
||||
"border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30",
|
||||
error:
|
||||
"border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30",
|
||||
info:
|
||||
"border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30",
|
||||
// Muted variants for subtle indication
|
||||
muted:
|
||||
"border-border/50 bg-muted/50 text-muted-foreground",
|
||||
brand:
|
||||
"border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30",
|
||||
},
|
||||
size: {
|
||||
default: "px-2.5 py-0.5 text-xs",
|
||||
sm: "px-2 py-0.5 text-[10px]",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
53
apps/app/src/components/ui/branch-autocomplete.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { Autocomplete, AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
|
||||
interface BranchAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
branches: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function BranchAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
branches,
|
||||
placeholder = "Select a branch...",
|
||||
className,
|
||||
disabled = false,
|
||||
"data-testid": testId,
|
||||
}: BranchAutocompleteProps) {
|
||||
// Always include "main" at the top of suggestions
|
||||
const branchOptions: AutocompleteOption[] = React.useMemo(() => {
|
||||
const branchSet = new Set(["main", ...branches]);
|
||||
return Array.from(branchSet).map((branch) => ({
|
||||
value: branch,
|
||||
label: branch,
|
||||
badge: branch === "main" ? "default" : undefined,
|
||||
}));
|
||||
}, [branches]);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={branchOptions}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search or type new branch..."
|
||||
emptyMessage="No branches found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
icon={GitBranch}
|
||||
allowCreate
|
||||
createLabel={(v) => `Create "${v}"`}
|
||||
data-testid={testId}
|
||||
itemTestIdPrefix="branch-option"
|
||||
/>
|
||||
);
|
||||
}
|
||||
116
apps/app/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/25",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md hover:shadow-destructive/25 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline active:scale-100",
|
||||
"animated-outline":
|
||||
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Loading spinner component
|
||||
function ButtonSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<Loader2
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
// Special handling for animated-outline variant
|
||||
if (variant === "animated-outline" && !asChild) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
"p-[1px]", // Force 1px padding for the gradient border
|
||||
className
|
||||
)}
|
||||
data-slot="button"
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{/* Animated rotating gradient border - smoother animation */}
|
||||
<span className="absolute inset-[-1000%] animate-[spin_3s_linear_infinite] animated-outline-gradient opacity-75 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
{/* Inner content container */}
|
||||
<span
|
||||
className={cn(
|
||||
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all duration-200",
|
||||
size === "sm" && "px-3 text-xs gap-1.5",
|
||||
size === "lg" && "px-8",
|
||||
size === "icon" && "p-0 gap-0"
|
||||
)}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading && <ButtonSpinner />}
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
100
apps/app/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CardProps extends React.ComponentProps<"div"> {
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
function Card({ className, gradient = false, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-white/10 backdrop-blur-md py-6",
|
||||
// Premium layered shadow
|
||||
"shadow-[0_1px_2px_rgba(0,0,0,0.05),0_4px_6px_rgba(0,0,0,0.05),0_10px_20px_rgba(0,0,0,0.04)]",
|
||||
// Gradient border option
|
||||
gradient && "relative before:absolute before:inset-0 before:rounded-xl before:p-[1px] before:bg-gradient-to-br before:from-white/20 before:to-transparent before:pointer-events-none before:-z-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center gap-3 px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Tag } from 'lucide-react';
|
||||
import { Autocomplete } from '@/components/ui/autocomplete';
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Autocomplete } from "@/components/ui/autocomplete";
|
||||
|
||||
interface CategoryAutocompleteProps {
|
||||
value: string;
|
||||
@@ -8,19 +10,17 @@ interface CategoryAutocompleteProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
'data-testid'?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function CategoryAutocomplete({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
placeholder = 'Select or type a category...',
|
||||
placeholder = "Select or type a category...",
|
||||
className,
|
||||
disabled = false,
|
||||
error = false,
|
||||
'data-testid': testId,
|
||||
"data-testid": testId,
|
||||
}: CategoryAutocompleteProps) {
|
||||
return (
|
||||
<Autocomplete
|
||||
@@ -28,14 +28,10 @@ export function CategoryAutocomplete({
|
||||
onChange={onChange}
|
||||
options={suggestions}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder="Search or type new category..."
|
||||
searchPlaceholder="Search category..."
|
||||
emptyMessage="No category found."
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
icon={Tag}
|
||||
allowCreate
|
||||
createLabel={(v) => `Create "${v}"`}
|
||||
data-testid={testId}
|
||||
itemTestIdPrefix="category-option"
|
||||
/>
|
||||
@@ -1,15 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
"use client";
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
interface CheckboxProps extends Omit<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'checked' | 'defaultChecked'
|
||||
> {
|
||||
checked?: boolean | 'indeterminate';
|
||||
defaultChecked?: boolean | 'indeterminate';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CheckboxProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked" | "defaultChecked"> {
|
||||
checked?: boolean | "indeterminate";
|
||||
defaultChecked?: boolean | "indeterminate";
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
required?: boolean;
|
||||
}
|
||||
@@ -33,7 +32,7 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
<CheckboxRoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80',
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
||||
className
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -44,7 +43,9 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxIndicator className={cn('flex items-center justify-center text-current')}>
|
||||
<CheckboxIndicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
@@ -1,41 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
"use client"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
@@ -44,7 +49,7 @@ function CommandDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn('overflow-hidden p-0', className)}
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
@@ -52,7 +57,7 @@ function CommandDialog({
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
@@ -60,45 +65,49 @@ function CommandInput({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
|
||||
// Mobile touch scrolling support
|
||||
'touch-pan-y overscroll-contain',
|
||||
// iOS Safari momentum scrolling
|
||||
'[&]:[-webkit-overflow-scrolling:touch]',
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
@@ -109,12 +118,12 @@ function CommandGroup({
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
@@ -124,13 +133,16 @@ function CommandSeparator({
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
@@ -140,17 +152,23 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -163,4 +181,4 @@ export {
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
interface CountUpTimerProps {
|
||||
startedAt: string; // ISO timestamp string
|
||||
@@ -15,8 +17,8 @@ function formatElapsedTime(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const paddedMinutes = minutes.toString().padStart(2, '0');
|
||||
const paddedSeconds = remainingSeconds.toString().padStart(2, '0');
|
||||
const paddedMinutes = minutes.toString().padStart(2, "0");
|
||||
const paddedSeconds = remainingSeconds.toString().padStart(2, "0");
|
||||
|
||||
return `${paddedMinutes}:${paddedSeconds}`;
|
||||
}
|
||||
@@ -25,7 +27,7 @@ function formatElapsedTime(seconds: number): string {
|
||||
* CountUpTimer component that displays elapsed time since a given start time
|
||||
* Updates every second to show the current elapsed time in MM:SS format
|
||||
*/
|
||||
export function CountUpTimer({ startedAt, className = '' }: CountUpTimerProps) {
|
||||
export function CountUpTimer({ startedAt, className = "" }: CountUpTimerProps) {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
88
apps/app/src/components/ui/course-promo-badge.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface CoursePromoBadgeProps {
|
||||
sidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function CoursePromoBadge({ sidebarOpen = true }: CoursePromoBadgeProps) {
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collapsed state - show only icon with tooltip
|
||||
if (!sidebarOpen) {
|
||||
return (
|
||||
<div className="p-2 pb-0 flex justify-center">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="https://agenticjumpstart.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group cursor-pointer flex items-center justify-center w-10 h-10 bg-primary/10 text-primary rounded-lg hover:bg-primary/20 transition-all border border-primary/30"
|
||||
data-testid="course-promo-badge-collapsed"
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2">
|
||||
<span>Become a 10x Dev</span>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="p-0.5 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded state - show full badge
|
||||
return (
|
||||
<div className="p-2 pb-0">
|
||||
<a
|
||||
href="https://agenticjumpstart.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group cursor-pointer flex items-center justify-between w-full px-2 lg:px-3 py-2.5 bg-primary/10 text-primary rounded-lg font-medium text-sm hover:bg-primary/20 transition-all border border-primary/30"
|
||||
data-testid="course-promo-badge"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 shrink-0" />
|
||||
<span className="hidden lg:block">Become a 10x Dev</span>
|
||||
</div>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDismissed(true);
|
||||
}}
|
||||
className="hidden lg:block p-1 rounded-full hover:bg-primary/30 transition-colors cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import type { ReactNode } from 'react';
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -34,9 +34,9 @@ export function DeleteConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
confirmText = 'Delete',
|
||||
testId = 'delete-confirm-dialog',
|
||||
confirmTestId = 'confirm-delete-button',
|
||||
confirmText = "Delete",
|
||||
testId = "delete-confirm-dialog",
|
||||
confirmTestId = "confirm-delete-button",
|
||||
}: DeleteConfirmDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
@@ -45,13 +45,18 @@ export function DeleteConfirmDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-md" data-testid={testId}>
|
||||
<DialogContent
|
||||
className="bg-popover border-border max-w-md"
|
||||
data-testid={testId}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-destructive" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{children}
|
||||
@@ -69,7 +74,7 @@ export function DeleteConfirmDialog({
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
data-testid={confirmTestId}
|
||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={open}
|
||||
className="px-4"
|
||||
>
|
||||
@@ -1,43 +1,27 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
"use client";
|
||||
|
||||
const logger = createLogger('DescriptionImageDropZone');
|
||||
import { ImageIcon, X, FileText } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store';
|
||||
import {
|
||||
sanitizeFilename,
|
||||
fileToBase64,
|
||||
fileToText,
|
||||
isTextFile,
|
||||
isImageFile,
|
||||
validateTextFile,
|
||||
getTextFileMimeType,
|
||||
generateFileId,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
ACCEPTED_TEXT_EXTENSIONS,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_TEXT_FILE_SIZE,
|
||||
formatFileSize,
|
||||
} from '@/lib/image-utils';
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Loader2 } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
export interface FeatureImagePath {
|
||||
id: string;
|
||||
path: string; // Path to the temp file
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
// Map to store preview data by image ID (persisted across component re-mounts)
|
||||
export type ImagePreviewMap = Map<string, string>;
|
||||
|
||||
// Re-export for convenience
|
||||
export type { FeatureImagePath, FeatureTextFilePath };
|
||||
|
||||
interface DescriptionImageDropZoneProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
images: FeatureImagePath[];
|
||||
onImagesChange: (images: FeatureImagePath[]) => void;
|
||||
textFiles?: FeatureTextFilePath[];
|
||||
onTextFilesChange?: (textFiles: FeatureTextFilePath[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@@ -50,14 +34,21 @@ interface DescriptionImageDropZoneProps {
|
||||
error?: boolean; // Show error state with red border
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function DescriptionImageDropZone({
|
||||
value,
|
||||
onChange,
|
||||
images,
|
||||
onImagesChange,
|
||||
textFiles = [],
|
||||
onTextFilesChange,
|
||||
placeholder = 'Describe the feature...',
|
||||
placeholder = "Describe the feature...",
|
||||
className,
|
||||
disabled = false,
|
||||
maxFiles = 5,
|
||||
@@ -76,60 +67,71 @@ export function DescriptionImageDropZone({
|
||||
|
||||
// Determine which preview map to use - prefer parent-controlled state
|
||||
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const setPreviewImages = useCallback(
|
||||
(updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
||||
if (onPreviewMapChange) {
|
||||
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
||||
onPreviewMapChange(newMap);
|
||||
} else {
|
||||
setLocalPreviewImages((prev) => {
|
||||
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
},
|
||||
[onPreviewMapChange, previewMap, localPreviewImages]
|
||||
);
|
||||
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
||||
if (onPreviewMapChange) {
|
||||
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
||||
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
||||
onPreviewMapChange(newMap);
|
||||
} else {
|
||||
setLocalPreviewImages((prev) => {
|
||||
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [onPreviewMapChange, previewMap, localPreviewImages]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
// Construct server URL for loading saved images
|
||||
const getImageServerUrl = useCallback(
|
||||
(imagePath: string): string => {
|
||||
const projectPath = currentProject?.path || '';
|
||||
return getAuthenticatedImageUrl(imagePath, projectPath);
|
||||
},
|
||||
[currentProject?.path]
|
||||
);
|
||||
const getImageServerUrl = useCallback((imagePath: string): string => {
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
const projectPath = currentProject?.path || "";
|
||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const saveImageToTemp = useCallback(
|
||||
async (base64Data: string, filename: string, mimeType: string): Promise<string | null> => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
// Check if saveImageToTemp method exists
|
||||
if (!api.saveImageToTemp) {
|
||||
// Fallback path when saveImageToTemp is not available
|
||||
logger.info('Using fallback path for image');
|
||||
return `.automaker/images/${Date.now()}_${filename}`;
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
// Get projectPath from the store if available
|
||||
const projectPath = currentProject?.path;
|
||||
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
|
||||
if (result.success && result.path) {
|
||||
return result.path;
|
||||
}
|
||||
logger.error('Failed to save image:', result.error);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('Error saving image:', error);
|
||||
return null;
|
||||
const saveImageToTemp = useCallback(async (
|
||||
base64Data: string,
|
||||
filename: string,
|
||||
mimeType: string
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
// Check if saveImageToTemp method exists
|
||||
if (!api.saveImageToTemp) {
|
||||
// Fallback path when saveImageToTemp is not available
|
||||
console.log("[DescriptionImageDropZone] Using fallback path for image");
|
||||
return `.automaker/images/${Date.now()}_${filename}`;
|
||||
}
|
||||
},
|
||||
[currentProject?.path]
|
||||
);
|
||||
|
||||
// Get projectPath from the store if available
|
||||
const projectPath = currentProject?.path;
|
||||
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
|
||||
if (result.success && result.path) {
|
||||
return result.path;
|
||||
}
|
||||
console.error("[DescriptionImageDropZone] Failed to save image:", result.error);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[DescriptionImageDropZone] Error saving image:", error);
|
||||
return null;
|
||||
}
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
@@ -137,89 +139,58 @@ export function DescriptionImageDropZone({
|
||||
|
||||
setIsProcessing(true);
|
||||
const newImages: FeatureImagePath[] = [];
|
||||
const newTextFiles: FeatureTextFilePath[] = [];
|
||||
const newPreviews = new Map(previewImages);
|
||||
const errors: string[] = [];
|
||||
|
||||
// Calculate total current files
|
||||
const currentTotalFiles = images.length + textFiles.length;
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Check if it's a text file
|
||||
if (isTextFile(file)) {
|
||||
const validation = validateTextFile(file, DEFAULT_MAX_TEXT_FILE_SIZE);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles;
|
||||
if (totalFiles >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} files allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fileToText(file);
|
||||
const sanitizedName = sanitizeFilename(file.name);
|
||||
const textFilePath: FeatureTextFilePath = {
|
||||
id: generateFileId(),
|
||||
path: '', // Text files don't need to be saved to disk
|
||||
filename: sanitizedName,
|
||||
mimeType: getTextFileMimeType(file.name),
|
||||
content,
|
||||
};
|
||||
newTextFiles.push(textFilePath);
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to read text file.`);
|
||||
}
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Check if it's an image file
|
||||
else if (isImageFile(file)) {
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles;
|
||||
if (totalFiles >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} files allowed.`);
|
||||
break;
|
||||
}
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const sanitizedName = sanitizeFilename(file.name);
|
||||
const tempPath = await saveImageToTemp(base64, sanitizedName, file.type);
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + images.length >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (tempPath) {
|
||||
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const imagePathRef: FeatureImagePath = {
|
||||
id: imageId,
|
||||
path: tempPath,
|
||||
filename: sanitizedName,
|
||||
mimeType: file.type,
|
||||
};
|
||||
newImages.push(imagePathRef);
|
||||
// Store preview for display
|
||||
newPreviews.set(imageId, base64);
|
||||
} else {
|
||||
errors.push(`${file.name}: Failed to save image.`);
|
||||
}
|
||||
} catch {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const tempPath = await saveImageToTemp(base64, file.name, file.type);
|
||||
|
||||
if (tempPath) {
|
||||
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const imagePathRef: FeatureImagePath = {
|
||||
id: imageId,
|
||||
path: tempPath,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
};
|
||||
newImages.push(imagePathRef);
|
||||
// Store preview for display
|
||||
newPreviews.set(imageId, base64);
|
||||
} else {
|
||||
errors.push(`${file.name}: Failed to save image.`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn('File upload errors:', errors);
|
||||
console.warn("Image upload errors:", errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
@@ -227,24 +198,9 @@ export function DescriptionImageDropZone({
|
||||
setPreviewImages(newPreviews);
|
||||
}
|
||||
|
||||
if (newTextFiles.length > 0 && onTextFilesChange) {
|
||||
onTextFilesChange([...textFiles, ...newTextFiles]);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
isProcessing,
|
||||
images,
|
||||
textFiles,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
onImagesChange,
|
||||
onTextFilesChange,
|
||||
previewImages,
|
||||
saveImageToTemp,
|
||||
]
|
||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -288,7 +244,7 @@ export function DescriptionImageDropZone({
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[processFiles]
|
||||
@@ -312,15 +268,6 @@ export function DescriptionImageDropZone({
|
||||
[images, onImagesChange]
|
||||
);
|
||||
|
||||
const removeTextFile = useCallback(
|
||||
(fileId: string) => {
|
||||
if (onTextFilesChange) {
|
||||
onTextFilesChange(textFiles.filter((file) => file.id !== fileId));
|
||||
}
|
||||
},
|
||||
[textFiles, onTextFilesChange]
|
||||
);
|
||||
|
||||
// Handle paste events to detect and process images from clipboard
|
||||
// Works across all OS (Windows, Linux, macOS)
|
||||
const handlePaste = useCallback(
|
||||
@@ -337,15 +284,17 @@ export function DescriptionImageDropZone({
|
||||
const item = clipboardItems[i];
|
||||
|
||||
// Check if the item is an image
|
||||
if (item.type.startsWith('image/')) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
// Generate a filename for pasted images since they don't have one
|
||||
const extension = item.type.split('/')[1] || 'png';
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const renamedFile = new File([file], `pasted-image-${timestamp}.${extension}`, {
|
||||
type: file.type,
|
||||
});
|
||||
const extension = item.type.split("/")[1] || "png";
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const renamedFile = new File(
|
||||
[file],
|
||||
`pasted-image-${timestamp}.${extension}`,
|
||||
{ type: file.type }
|
||||
);
|
||||
imageFiles.push(renamedFile);
|
||||
}
|
||||
}
|
||||
@@ -366,17 +315,17 @@ export function DescriptionImageDropZone({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className={cn("relative", className)}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={[...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_TEXT_EXTENSIONS].join(',')}
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
data-testid="description-file-input"
|
||||
data-testid="description-image-input"
|
||||
/>
|
||||
|
||||
{/* Drop zone wrapper */}
|
||||
@@ -384,9 +333,13 @@ export function DescriptionImageDropZone({
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn('relative rounded-md transition-all duration-200', {
|
||||
'ring-2 ring-blue-400 ring-offset-2 ring-offset-background': isDragOver && !disabled,
|
||||
})}
|
||||
className={cn(
|
||||
"relative rounded-md transition-all duration-200",
|
||||
{
|
||||
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
|
||||
isDragOver && !disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && !disabled && (
|
||||
@@ -396,7 +349,7 @@ export function DescriptionImageDropZone({
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-blue-400">
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-sm font-medium">Drop files here</span>
|
||||
<span className="text-sm font-medium">Drop images here</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -410,14 +363,17 @@ export function DescriptionImageDropZone({
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
aria-invalid={error}
|
||||
className={cn('min-h-[120px]', isProcessing && 'opacity-50 pointer-events-none')}
|
||||
className={cn(
|
||||
"min-h-[120px]",
|
||||
isProcessing && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
data-testid="feature-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hint text */}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Paste, drag and drop files, or{' '}
|
||||
Paste, drag and drop images, or{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrowseClick}
|
||||
@@ -425,34 +381,30 @@ export function DescriptionImageDropZone({
|
||||
disabled={disabled || isProcessing}
|
||||
>
|
||||
browse
|
||||
</button>{' '}
|
||||
to attach context (images, .txt, .md)
|
||||
</button>{" "}
|
||||
to attach context images
|
||||
</p>
|
||||
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span>Processing files...</span>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Saving images...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File previews (images and text files) */}
|
||||
{(images.length > 0 || textFiles.length > 0) && (
|
||||
<div className="mt-3 space-y-2" data-testid="description-file-previews">
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{images.length + textFiles.length} file
|
||||
{images.length + textFiles.length > 1 ? 's' : ''} attached
|
||||
{images.length} image{images.length > 1 ? "s" : ""} attached
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onImagesChange([]);
|
||||
setPreviewImages(new Map());
|
||||
if (onTextFilesChange) {
|
||||
onTextFilesChange([]);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
@@ -461,7 +413,6 @@ export function DescriptionImageDropZone({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Image previews */}
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
@@ -504,39 +455,9 @@ export function DescriptionImageDropZone({
|
||||
)}
|
||||
{/* Filename tooltip on hover */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">{image.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Text file previews */}
|
||||
{textFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
|
||||
data-testid={`description-text-file-preview-${file.id}`}
|
||||
>
|
||||
{/* Text file icon */}
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
|
||||
<FileText className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTextFile(file.id);
|
||||
}}
|
||||
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
data-testid={`remove-description-text-file-${file.id}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
{/* Filename and size tooltip on hover */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">{file.filename}</p>
|
||||
<p className="text-[9px] text-white/70">{formatFileSize(file.content.length)}</p>
|
||||
<p className="text-[10px] text-white truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
228
apps/app/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const DialogContentPrimitive = DialogPrimitive.Content as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DialogClosePrimitive = DialogPrimitive.Close as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const DialogTitlePrimitive = DialogPrimitive.Title as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLHeadingElement>
|
||||
>;
|
||||
|
||||
const DialogDescriptionPrimitive = DialogPrimitive.Description as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
} & React.RefAttributes<HTMLParagraphElement>
|
||||
>;
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
const DialogOverlayPrimitive = DialogPrimitive.Overlay as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogOverlayPrimitive
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
compact = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
// Check if className contains a custom max-width
|
||||
const hasCustomMaxWidth =
|
||||
typeof className === "string" && className.includes("max-w-");
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogContentPrimitive
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
|
||||
"flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]",
|
||||
"bg-card border border-border rounded-xl shadow-2xl",
|
||||
// Premium shadow
|
||||
"shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]",
|
||||
// Animations - smoother with scale
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:slide-out-to-top-[2%] data-[state=open]:slide-in-from-top-[2%]",
|
||||
"duration-200",
|
||||
compact
|
||||
? "max-w-4xl p-4"
|
||||
: !hasCustomMaxWidth
|
||||
? "sm:max-w-2xl p-6"
|
||||
: "p-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogClosePrimitive
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"absolute rounded-lg opacity-60 transition-all duration-200 cursor-pointer",
|
||||
"hover:opacity-100 hover:bg-muted",
|
||||
"focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
|
||||
"p-1.5",
|
||||
compact ? "top-2 right-3" : "top-4 right-4"
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClosePrimitive>
|
||||
)}
|
||||
</DialogContentPrimitive>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end mt-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogTitlePrimitive
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogTitlePrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogDescriptionPrimitive
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm leading-relaxed", className)}
|
||||
title={title}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogDescriptionPrimitive>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,49 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
"use client"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
|
||||
const DropdownMenuTriggerPrimitive =
|
||||
DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
const DropdownMenuTriggerPrimitive = DropdownMenuPrimitive.Trigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> & {
|
||||
children?: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
} & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuSubTriggerPrimitive =
|
||||
DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
const DropdownMenuSubTriggerPrimitive = DropdownMenuPrimitive.SubTrigger as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuRadioGroupPrimitive =
|
||||
DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
const DropdownMenuRadioGroupPrimitive = DropdownMenuPrimitive.RadioGroup as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioGroup> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuItemPrimitive = DropdownMenuPrimitive.Item as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuRadioItemPrimitive =
|
||||
DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
const DropdownMenuRadioItemPrimitive = DropdownMenuPrimitive.RadioItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement> & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
@@ -52,29 +48,26 @@ const DropdownMenuLabelPrimitive = DropdownMenuPrimitive.Label as React.ForwardR
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuCheckboxItemPrimitive =
|
||||
DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
const DropdownMenuCheckboxItemPrimitive = DropdownMenuPrimitive.CheckboxItem as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuItemIndicatorPrimitive =
|
||||
DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
const DropdownMenuItemIndicatorPrimitive = DropdownMenuPrimitive.ItemIndicator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.ItemIndicator> & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLSpanElement>
|
||||
>;
|
||||
|
||||
const DropdownMenuSeparatorPrimitive =
|
||||
DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
const DropdownMenuSeparatorPrimitive = DropdownMenuPrimitive.Separator as React.ForwardRefExoticComponent<
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> & {
|
||||
className?: string;
|
||||
} & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
children,
|
||||
@@ -88,35 +81,39 @@ function DropdownMenuTrigger({
|
||||
<DropdownMenuTriggerPrimitive asChild={asChild} {...props}>
|
||||
{children}
|
||||
</DropdownMenuTriggerPrimitive>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup> & { children?: React.ReactNode }) {
|
||||
return <DropdownMenuRadioGroupPrimitive {...props}>{children}</DropdownMenuRadioGroupPrimitive>;
|
||||
return (
|
||||
<DropdownMenuRadioGroupPrimitive {...props}>
|
||||
{children}
|
||||
</DropdownMenuRadioGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuSubTriggerPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent',
|
||||
inset && 'pl-8',
|
||||
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -124,8 +121,8 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuSubTriggerPrimitive>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -137,14 +134,14 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -157,35 +154,35 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
children?: React.ReactNode;
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
|
||||
inset && 'pl-8',
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItemPrimitive>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
@@ -197,7 +194,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuCheckboxItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -210,19 +207,20 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuCheckboxItemPrimitive>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
children?: React.ReactNode;
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuRadioItemPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent',
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -234,26 +232,30 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuRadioItemPrimitive>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
inset?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuLabelPrimitive
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuLabelPrimitive>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
@@ -263,21 +265,24 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuSeparatorPrimitive
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-brand-400/70', className)}
|
||||
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
@@ -295,4 +300,4 @@ export {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { cn } from '@/lib/utils';
|
||||
"use client";
|
||||
|
||||
const logger = createLogger('FeatureImageUpload');
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
fileToBase64,
|
||||
generateImageId,
|
||||
ACCEPTED_IMAGE_TYPES,
|
||||
DEFAULT_MAX_FILE_SIZE,
|
||||
DEFAULT_MAX_FILES,
|
||||
validateImageFile,
|
||||
} from '@/lib/image-utils';
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Upload } from "lucide-react";
|
||||
|
||||
export interface FeatureImage {
|
||||
id: string;
|
||||
@@ -31,10 +21,19 @@ interface FeatureImageUploadProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function FeatureImageUpload({
|
||||
images,
|
||||
onImagesChange,
|
||||
maxFiles = DEFAULT_MAX_FILES,
|
||||
maxFiles = 5,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
className,
|
||||
disabled = false,
|
||||
@@ -43,6 +42,21 @@ export function FeatureImageUpload({
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read file as base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
@@ -52,10 +66,20 @@ export function FeatureImageUpload({
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file
|
||||
const validation = validateImageFile(file, maxFileSize);
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.error!);
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(
|
||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(
|
||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -68,20 +92,20 @@ export function FeatureImageUpload({
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: FeatureImage = {
|
||||
id: generateImageId(),
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.warn('Image upload errors:', errors);
|
||||
console.warn("Image upload errors:", errors);
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
@@ -134,7 +158,7 @@ export function FeatureImageUpload({
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[processFiles]
|
||||
@@ -157,14 +181,22 @@ export function FeatureImageUpload({
|
||||
onImagesChange([]);
|
||||
}, [onImagesChange]);
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className={cn("relative", className)}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
@@ -178,12 +210,13 @@ export function FeatureImageUpload({
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={handleBrowseClick}
|
||||
className={cn(
|
||||
'relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer',
|
||||
"relative rounded-lg border-2 border-dashed transition-all duration-200 cursor-pointer",
|
||||
{
|
||||
'border-blue-400 bg-blue-50 dark:bg-blue-950/20': isDragOver && !disabled,
|
||||
'border-muted-foreground/25': !isDragOver && !disabled,
|
||||
'border-muted-foreground/10 opacity-50 cursor-not-allowed': disabled,
|
||||
'hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10':
|
||||
"border-blue-400 bg-blue-50 dark:bg-blue-950/20":
|
||||
isDragOver && !disabled,
|
||||
"border-muted-foreground/25": !isDragOver && !disabled,
|
||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
|
||||
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10":
|
||||
!disabled && !isDragOver,
|
||||
}
|
||||
)}
|
||||
@@ -192,21 +225,26 @@ export function FeatureImageUpload({
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full p-2 mb-2',
|
||||
isDragOver && !disabled ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-muted'
|
||||
"rounded-full p-2 mb-2",
|
||||
isDragOver && !disabled
|
||||
? "bg-blue-100 dark:bg-blue-900/30"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Spinner size="md" />
|
||||
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDragOver && !disabled ? 'Drop images here' : 'Click or drag images here'}
|
||||
{isDragOver && !disabled
|
||||
? "Drop images here"
|
||||
: "Click or drag images here"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Up to {maxFiles} images, max {Math.round(maxFileSize / (1024 * 1024))}MB each
|
||||
Up to {maxFiles} images, max{" "}
|
||||
{Math.round(maxFileSize / (1024 * 1024))}MB each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,7 +254,7 @@ export function FeatureImageUpload({
|
||||
<div className="mt-3 space-y-2" data-testid="feature-image-previews">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{images.length} image{images.length > 1 ? 's' : ''} selected
|
||||
{images.length} image{images.length > 1 ? "s" : ""} selected
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -258,7 +296,9 @@ export function FeatureImageUpload({
|
||||
)}
|
||||
{/* Filename tooltip on hover */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white truncate">{image.filename}</p>
|
||||
<p className="text-[10px] text-white truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
File,
|
||||
FileText,
|
||||
@@ -8,14 +11,13 @@ import {
|
||||
FilePen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from './button';
|
||||
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
} from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import type { FileStatus } from "@/types/electron";
|
||||
|
||||
interface GitDiffPanelProps {
|
||||
projectPath: string;
|
||||
@@ -30,7 +32,7 @@ interface GitDiffPanelProps {
|
||||
interface ParsedDiffHunk {
|
||||
header: string;
|
||||
lines: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}[];
|
||||
@@ -46,16 +48,16 @@ interface ParsedFileDiff {
|
||||
|
||||
const getFileIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
case '?':
|
||||
case "A":
|
||||
case "?":
|
||||
return <FilePlus className="w-4 h-4 text-green-500" />;
|
||||
case 'D':
|
||||
case "D":
|
||||
return <FileX className="w-4 h-4 text-red-500" />;
|
||||
case 'M':
|
||||
case 'U':
|
||||
case "M":
|
||||
case "U":
|
||||
return <FilePen className="w-4 h-4 text-amber-500" />;
|
||||
case 'R':
|
||||
case 'C':
|
||||
case "R":
|
||||
case "C":
|
||||
return <File className="w-4 h-4 text-blue-500" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4 text-muted-foreground" />;
|
||||
@@ -64,40 +66,40 @@ const getFileIcon = (status: string) => {
|
||||
|
||||
const getStatusBadgeColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
case '?':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/30';
|
||||
case 'D':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/30';
|
||||
case 'M':
|
||||
case 'U':
|
||||
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||
case 'R':
|
||||
case 'C':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
case "A":
|
||||
case "?":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "D":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "M":
|
||||
case "U":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "R":
|
||||
case "C":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground border-border';
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDisplayName = (status: string) => {
|
||||
switch (status) {
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'U':
|
||||
return 'Updated';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
case "A":
|
||||
return "Added";
|
||||
case "?":
|
||||
return "Untracked";
|
||||
case "D":
|
||||
return "Deleted";
|
||||
case "M":
|
||||
return "Modified";
|
||||
case "U":
|
||||
return "Updated";
|
||||
case "R":
|
||||
return "Renamed";
|
||||
case "C":
|
||||
return "Copied";
|
||||
default:
|
||||
return 'Changed';
|
||||
return "Changed";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,7 +110,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
if (!diffText) return [];
|
||||
|
||||
const files: ParsedFileDiff[] = [];
|
||||
const lines = diffText.split('\n');
|
||||
const lines = diffText.split("\n");
|
||||
let currentFile: ParsedFileDiff | null = null;
|
||||
let currentHunk: ParsedDiffHunk | null = null;
|
||||
let oldLineNum = 0;
|
||||
@@ -118,7 +120,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
const line = lines[i];
|
||||
|
||||
// New file diff
|
||||
if (line.startsWith('diff --git')) {
|
||||
if (line.startsWith("diff --git")) {
|
||||
if (currentFile) {
|
||||
if (currentHunk) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
@@ -128,7 +130,7 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
// Extract file path from diff header
|
||||
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
|
||||
currentFile = {
|
||||
filePath: match ? match[2] : 'unknown',
|
||||
filePath: match ? match[2] : "unknown",
|
||||
hunks: [],
|
||||
};
|
||||
currentHunk = null;
|
||||
@@ -136,30 +138,34 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
}
|
||||
|
||||
// New file indicator
|
||||
if (line.startsWith('new file mode')) {
|
||||
if (line.startsWith("new file mode")) {
|
||||
if (currentFile) currentFile.isNew = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted file indicator
|
||||
if (line.startsWith('deleted file mode')) {
|
||||
if (line.startsWith("deleted file mode")) {
|
||||
if (currentFile) currentFile.isDeleted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Renamed file indicator
|
||||
if (line.startsWith('rename from') || line.startsWith('rename to')) {
|
||||
if (line.startsWith("rename from") || line.startsWith("rename to")) {
|
||||
if (currentFile) currentFile.isRenamed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip index, ---/+++ lines
|
||||
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
|
||||
if (
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("--- ") ||
|
||||
line.startsWith("+++ ")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header
|
||||
if (line.startsWith('@@')) {
|
||||
if (line.startsWith("@@")) {
|
||||
if (currentHunk && currentFile) {
|
||||
currentFile.hunks.push(currentHunk);
|
||||
}
|
||||
@@ -169,31 +175,31 @@ function parseDiff(diffText: string): ParsedFileDiff[] {
|
||||
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
|
||||
currentHunk = {
|
||||
header: line,
|
||||
lines: [{ type: 'header', content: line }],
|
||||
lines: [{ type: "header", content: line }],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diff content lines
|
||||
if (currentHunk) {
|
||||
if (line.startsWith('+')) {
|
||||
if (line.startsWith("+")) {
|
||||
currentHunk.lines.push({
|
||||
type: 'addition',
|
||||
type: "addition",
|
||||
content: line.substring(1),
|
||||
lineNumber: { new: newLineNum },
|
||||
});
|
||||
newLineNum++;
|
||||
} else if (line.startsWith('-')) {
|
||||
} else if (line.startsWith("-")) {
|
||||
currentHunk.lines.push({
|
||||
type: 'deletion',
|
||||
type: "deletion",
|
||||
content: line.substring(1),
|
||||
lineNumber: { old: oldLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
} else if (line.startsWith(' ') || line === '') {
|
||||
} else if (line.startsWith(" ") || line === "") {
|
||||
currentHunk.lines.push({
|
||||
type: 'context',
|
||||
content: line.substring(1) || '',
|
||||
type: "context",
|
||||
content: line.substring(1) || "",
|
||||
lineNumber: { old: oldLineNum, new: newLineNum },
|
||||
});
|
||||
oldLineNum++;
|
||||
@@ -218,52 +224,52 @@ function DiffLine({
|
||||
content,
|
||||
lineNumber,
|
||||
}: {
|
||||
type: 'context' | 'addition' | 'deletion' | 'header';
|
||||
type: "context" | "addition" | "deletion" | "header";
|
||||
content: string;
|
||||
lineNumber?: { old?: number; new?: number };
|
||||
}) {
|
||||
const bgClass = {
|
||||
context: 'bg-transparent',
|
||||
addition: 'bg-green-500/10',
|
||||
deletion: 'bg-red-500/10',
|
||||
header: 'bg-blue-500/10',
|
||||
context: "bg-transparent",
|
||||
addition: "bg-green-500/10",
|
||||
deletion: "bg-red-500/10",
|
||||
header: "bg-blue-500/10",
|
||||
};
|
||||
|
||||
const textClass = {
|
||||
context: 'text-foreground-secondary',
|
||||
addition: 'text-green-400',
|
||||
deletion: 'text-red-400',
|
||||
header: 'text-blue-400',
|
||||
context: "text-foreground-secondary",
|
||||
addition: "text-green-400",
|
||||
deletion: "text-red-400",
|
||||
header: "text-blue-400",
|
||||
};
|
||||
|
||||
const prefix = {
|
||||
context: ' ',
|
||||
addition: '+',
|
||||
deletion: '-',
|
||||
header: '',
|
||||
context: " ",
|
||||
addition: "+",
|
||||
deletion: "-",
|
||||
header: "",
|
||||
};
|
||||
|
||||
if (type === 'header') {
|
||||
if (type === "header") {
|
||||
return (
|
||||
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
|
||||
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex font-mono text-xs', bgClass[type])}>
|
||||
<div className={cn("flex font-mono text-xs", bgClass[type])}>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.old ?? ''}
|
||||
{lineNumber?.old ?? ""}
|
||||
</span>
|
||||
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
|
||||
{lineNumber?.new ?? ''}
|
||||
{lineNumber?.new ?? ""}
|
||||
</span>
|
||||
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
|
||||
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
|
||||
{prefix[type]}
|
||||
</span>
|
||||
<span className={cn('flex-1 px-2 whitespace-pre-wrap break-all', textClass[type])}>
|
||||
{content || '\u00A0'}
|
||||
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
|
||||
{content || "\u00A0"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -279,11 +285,11 @@ function FileDiffSection({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const additions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
);
|
||||
const deletions = fileDiff.hunks.reduce(
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
);
|
||||
|
||||
@@ -318,8 +324,12 @@ function FileDiffSection({
|
||||
renamed
|
||||
</span>
|
||||
)}
|
||||
{additions > 0 && <span className="text-xs text-green-400">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-xs text-red-400">-{deletions}</span>}
|
||||
{additions > 0 && (
|
||||
<span className="text-xs text-green-400">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-xs text-red-400">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
@@ -350,44 +360,56 @@ export function GitDiffPanel({
|
||||
useWorktrees = false,
|
||||
}: GitDiffPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [diffContent, setDiffContent] = useState<string>("");
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
// Use worktree diffs hook when worktrees are enabled and panel is expanded
|
||||
// Pass undefined for featureId when not using worktrees to disable the query
|
||||
const {
|
||||
data: worktreeDiffsData,
|
||||
isLoading: isLoadingWorktree,
|
||||
error: worktreeError,
|
||||
refetch: refetchWorktree,
|
||||
} = useWorktreeDiffs(
|
||||
useWorktrees && isExpanded ? projectPath : undefined,
|
||||
useWorktrees && isExpanded ? featureId : undefined
|
||||
);
|
||||
const loadDiffs = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Use git diffs hook when worktrees are disabled and panel is expanded
|
||||
const {
|
||||
data: gitDiffsData,
|
||||
isLoading: isLoadingGit,
|
||||
error: gitError,
|
||||
refetch: refetchGit,
|
||||
} = useGitDiffs(projectPath, !useWorktrees && isExpanded);
|
||||
// Use worktree API if worktrees are enabled, otherwise use git API for main project
|
||||
if (useWorktrees) {
|
||||
if (!api?.worktree?.getDiffs) {
|
||||
throw new Error("Worktree API not available");
|
||||
}
|
||||
const result = await api.worktree.getDiffs(projectPath, featureId);
|
||||
if (result.success) {
|
||||
setFiles(result.files || []);
|
||||
setDiffContent(result.diff || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to load diffs");
|
||||
}
|
||||
} else {
|
||||
// Use git API for main project diffs
|
||||
if (!api?.git?.getDiffs) {
|
||||
throw new Error("Git API not available");
|
||||
}
|
||||
const result = await api.git.getDiffs(projectPath);
|
||||
if (result.success) {
|
||||
setFiles(result.files || []);
|
||||
setDiffContent(result.diff || "");
|
||||
} else {
|
||||
setError(result.error || "Failed to load diffs");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load diffs");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, featureId, useWorktrees]);
|
||||
|
||||
// Select the appropriate data based on useWorktrees prop
|
||||
const diffsData = useWorktrees ? worktreeDiffsData : gitDiffsData;
|
||||
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
|
||||
const queryError = useWorktrees ? worktreeError : gitError;
|
||||
|
||||
// Extract files and diff content from the data
|
||||
const files: FileStatus[] = diffsData?.files ?? [];
|
||||
const diffContent = diffsData?.diff ?? '';
|
||||
const error = queryError
|
||||
? queryError instanceof Error
|
||||
? queryError.message
|
||||
: 'Failed to load diffs'
|
||||
: null;
|
||||
|
||||
// Refetch function
|
||||
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
|
||||
// Load diffs when expanded
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
loadDiffs();
|
||||
}
|
||||
}, [isExpanded, loadDiffs]);
|
||||
|
||||
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
|
||||
|
||||
@@ -416,7 +438,8 @@ export function GitDiffPanel({
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'addition').length,
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
@@ -425,7 +448,8 @@ export function GitDiffPanel({
|
||||
(acc, file) =>
|
||||
acc +
|
||||
file.hunks.reduce(
|
||||
(hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === 'deletion').length,
|
||||
(hAcc, hunk) =>
|
||||
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
|
||||
0
|
||||
),
|
||||
0
|
||||
@@ -434,7 +458,7 @@ export function GitDiffPanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden',
|
||||
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
|
||||
className
|
||||
)}
|
||||
data-testid="git-diff-panel"
|
||||
@@ -458,10 +482,14 @@ export function GitDiffPanel({
|
||||
{!isExpanded && files.length > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? 'file' : 'files'}
|
||||
{files.length} {files.length === 1 ? "file" : "files"}
|
||||
</span>
|
||||
{totalAdditions > 0 && <span className="text-green-400">+{totalAdditions}</span>}
|
||||
{totalDeletions > 0 && <span className="text-red-400">-{totalDeletions}</span>}
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">+{totalAdditions}</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">-{totalDeletions}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -472,14 +500,19 @@ export function GitDiffPanel({
|
||||
<div className="border-t border-border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Spinner size="md" />
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Loading changes...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => void loadDiffs()} className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadDiffs}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
@@ -496,22 +529,19 @@ export function GitDiffPanel({
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{(() => {
|
||||
// Group files by status
|
||||
const statusGroups = files.reduce(
|
||||
(acc, file) => {
|
||||
const status = file.status;
|
||||
if (!acc[status]) {
|
||||
acc[status] = {
|
||||
count: 0,
|
||||
statusText: getStatusDisplayName(status),
|
||||
files: [],
|
||||
};
|
||||
}
|
||||
acc[status].count += 1;
|
||||
acc[status].files.push(file.path);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { count: number; statusText: string; files: string[] }>
|
||||
);
|
||||
const statusGroups = files.reduce((acc, file) => {
|
||||
const status = file.status;
|
||||
if (!acc[status]) {
|
||||
acc[status] = {
|
||||
count: 0,
|
||||
statusText: getStatusDisplayName(status),
|
||||
files: []
|
||||
};
|
||||
}
|
||||
acc[status].count += 1;
|
||||
acc[status].files.push(file.path);
|
||||
return acc;
|
||||
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
|
||||
|
||||
return Object.entries(statusGroups).map(([status, group]) => (
|
||||
<div
|
||||
@@ -523,7 +553,7 @@ export function GitDiffPanel({
|
||||
{getFileIcon(status)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
||||
"text-xs px-1.5 py-0.5 rounded border font-medium",
|
||||
getStatusBadgeColor(status)
|
||||
)}
|
||||
>
|
||||
@@ -553,7 +583,7 @@ export function GitDiffPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void loadDiffs()}
|
||||
onClick={loadDiffs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
@@ -565,13 +595,17 @@ export function GitDiffPanel({
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
{files.length} {files.length === 1 ? 'file' : 'files'} changed
|
||||
{files.length} {files.length === 1 ? "file" : "files"} changed
|
||||
</span>
|
||||
{totalAdditions > 0 && (
|
||||
<span className="text-green-400">+{totalAdditions} additions</span>
|
||||
<span className="text-green-400">
|
||||
+{totalAdditions} additions
|
||||
</span>
|
||||
)}
|
||||
{totalDeletions > 0 && (
|
||||
<span className="text-red-400">-{totalDeletions} deletions</span>
|
||||
<span className="text-red-400">
|
||||
-{totalDeletions} deletions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -601,7 +635,7 @@ export function GitDiffPanel({
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded border font-medium',
|
||||
"text-xs px-1.5 py-0.5 rounded border font-medium",
|
||||
getStatusBadgeColor(file.status)
|
||||
)}
|
||||
>
|
||||
@@ -609,9 +643,9 @@ export function GitDiffPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground bg-background border-t border-border">
|
||||
{file.status === '?' ? (
|
||||
{file.status === "?" ? (
|
||||
<span>New file - content preview not available</span>
|
||||
) : file.status === 'D' ? (
|
||||
) : file.status === "D" ? (
|
||||
<span>File deleted</span>
|
||||
) : (
|
||||
<span>Diff content not available</span>
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { Button, buttonVariants } from './button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Button, buttonVariants } from "./button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
export interface HotkeyConfig {
|
||||
/** The key to trigger the hotkey (e.g., "Enter", "s", "n") */
|
||||
@@ -17,7 +20,8 @@ export interface HotkeyConfig {
|
||||
}
|
||||
|
||||
export interface HotkeyButtonProps
|
||||
extends React.ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
|
||||
extends React.ComponentProps<"button">,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
/** Hotkey configuration - can be a simple key string or a full config object */
|
||||
hotkey?: string | HotkeyConfig;
|
||||
/** Whether to show the hotkey indicator badge */
|
||||
@@ -36,14 +40,14 @@ export interface HotkeyButtonProps
|
||||
* Get the modifier key symbol based on platform
|
||||
*/
|
||||
function getModifierSymbol(isMac: boolean): string {
|
||||
return isMac ? '⌘' : 'Ctrl';
|
||||
return isMac ? "⌘" : "Ctrl";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hotkey config into a normalized format
|
||||
*/
|
||||
function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
|
||||
if (typeof hotkey === 'string') {
|
||||
if (typeof hotkey === "string") {
|
||||
return { key: hotkey };
|
||||
}
|
||||
return hotkey;
|
||||
@@ -52,7 +56,10 @@ function parseHotkeyConfig(hotkey: string | HotkeyConfig): HotkeyConfig {
|
||||
/**
|
||||
* Generate the display label for the hotkey
|
||||
*/
|
||||
function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.ReactNode {
|
||||
function getHotkeyDisplayLabel(
|
||||
config: HotkeyConfig,
|
||||
isMac: boolean
|
||||
): React.ReactNode {
|
||||
if (config.label) {
|
||||
return config.label;
|
||||
}
|
||||
@@ -69,7 +76,10 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
|
||||
|
||||
if (config.shift) {
|
||||
parts.push(
|
||||
<span key="shift" className="leading-none flex items-center justify-center">
|
||||
<span
|
||||
key="shift"
|
||||
className="leading-none flex items-center justify-center"
|
||||
>
|
||||
⇧
|
||||
</span>
|
||||
);
|
||||
@@ -78,7 +88,7 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
|
||||
if (config.alt) {
|
||||
parts.push(
|
||||
<span key="alt" className="leading-none flex items-center justify-center">
|
||||
{isMac ? '⌥' : 'Alt'}
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -86,36 +96,36 @@ function getHotkeyDisplayLabel(config: HotkeyConfig, isMac: boolean): React.Reac
|
||||
// Convert key to display format
|
||||
let keyDisplay = config.key;
|
||||
switch (config.key.toLowerCase()) {
|
||||
case 'enter':
|
||||
keyDisplay = '↵';
|
||||
case "enter":
|
||||
keyDisplay = "↵";
|
||||
break;
|
||||
case 'escape':
|
||||
case 'esc':
|
||||
keyDisplay = 'Esc';
|
||||
case "escape":
|
||||
case "esc":
|
||||
keyDisplay = "Esc";
|
||||
break;
|
||||
case 'arrowup':
|
||||
keyDisplay = '↑';
|
||||
case "arrowup":
|
||||
keyDisplay = "↑";
|
||||
break;
|
||||
case 'arrowdown':
|
||||
keyDisplay = '↓';
|
||||
case "arrowdown":
|
||||
keyDisplay = "↓";
|
||||
break;
|
||||
case 'arrowleft':
|
||||
keyDisplay = '←';
|
||||
case "arrowleft":
|
||||
keyDisplay = "←";
|
||||
break;
|
||||
case 'arrowright':
|
||||
keyDisplay = '→';
|
||||
case "arrowright":
|
||||
keyDisplay = "→";
|
||||
break;
|
||||
case 'backspace':
|
||||
keyDisplay = '⌫';
|
||||
case "backspace":
|
||||
keyDisplay = "⌫";
|
||||
break;
|
||||
case 'delete':
|
||||
keyDisplay = '⌦';
|
||||
case "delete":
|
||||
keyDisplay = "⌦";
|
||||
break;
|
||||
case 'tab':
|
||||
keyDisplay = '⇥';
|
||||
case "tab":
|
||||
keyDisplay = "⇥";
|
||||
break;
|
||||
case ' ':
|
||||
keyDisplay = 'Space';
|
||||
case " ":
|
||||
keyDisplay = "Space";
|
||||
break;
|
||||
default:
|
||||
// Capitalize single letters
|
||||
@@ -140,16 +150,16 @@ function isInputElement(element: Element | null): boolean {
|
||||
if (!element) return false;
|
||||
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.getAttribute('contenteditable') === 'true') {
|
||||
if (element.getAttribute("contenteditable") === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const role = element.getAttribute('role');
|
||||
if (role === 'textbox' || role === 'searchbox' || role === 'combobox') {
|
||||
const role = element.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -186,7 +196,7 @@ export function HotkeyButton({
|
||||
|
||||
// Detect platform on mount
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toLowerCase().includes('mac'));
|
||||
setIsMac(navigator.platform.toLowerCase().includes("mac"));
|
||||
}, []);
|
||||
|
||||
const config = hotkey ? parseHotkeyConfig(hotkey) : null;
|
||||
@@ -197,7 +207,11 @@ export function HotkeyButton({
|
||||
|
||||
// Don't trigger when typing in inputs (unless explicitly scoped or using cmdCtrl modifier)
|
||||
// cmdCtrl shortcuts like Cmd+Enter should work even in inputs as they're intentional submit actions
|
||||
if (!scopeRef && !config.cmdCtrl && isInputElement(document.activeElement)) {
|
||||
if (
|
||||
!scopeRef &&
|
||||
!config.cmdCtrl &&
|
||||
isInputElement(document.activeElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,7 +235,8 @@ export function HotkeyButton({
|
||||
if (scopeRef && scopeRef.current) {
|
||||
const scopeEl = scopeRef.current;
|
||||
const isVisible =
|
||||
scopeEl.offsetParent !== null || getComputedStyle(scopeEl).display !== 'none';
|
||||
scopeEl.offsetParent !== null ||
|
||||
getComputedStyle(scopeEl).display !== "none";
|
||||
if (!isVisible) return;
|
||||
}
|
||||
|
||||
@@ -244,9 +259,9 @@ export function HotkeyButton({
|
||||
useEffect(() => {
|
||||
if (!config || !hotkeyActive) return;
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [config, hotkeyActive, handleKeyDown]);
|
||||
|
||||
@@ -272,7 +287,7 @@ export function HotkeyButton({
|
||||
asChild={asChild}
|
||||
{...props}
|
||||
>
|
||||
{typeof children === 'string' ? (
|
||||
{typeof children === "string" ? (
|
||||
<>
|
||||
{children}
|
||||
{hotkeyIndicator}
|
||||
292
apps/app/src/components/ui/image-drop-zone.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Upload } from "lucide-react";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
|
||||
interface ImageDropZoneProps {
|
||||
onImagesSelected: (images: ImageAttachment[]) => void;
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // in bytes, default 10MB
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
images?: ImageAttachment[]; // Optional controlled images prop
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export function ImageDropZone({
|
||||
onImagesSelected,
|
||||
maxFiles = 5,
|
||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
images,
|
||||
}: ImageDropZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [internalImages, setInternalImages] = useState<ImageAttachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use controlled images if provided, otherwise use internal state
|
||||
const selectedImages = images ?? internalImages;
|
||||
|
||||
// Update images - for controlled mode, just call the callback; for uncontrolled, also update internal state
|
||||
const updateImages = useCallback((newImages: ImageAttachment[]) => {
|
||||
if (images === undefined) {
|
||||
setInternalImages(newImages);
|
||||
}
|
||||
onImagesSelected(newImages);
|
||||
}, [images, onImagesSelected]);
|
||||
|
||||
const processFiles = useCallback(async (files: FileList) => {
|
||||
if (disabled || isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
const newImages: ImageAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
||||
errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've reached max files
|
||||
if (newImages.length + selectedImages.length >= maxFiles) {
|
||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
const imageAttachment: ImageAttachment = {
|
||||
id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data: base64,
|
||||
mimeType: file.type,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
};
|
||||
newImages.push(imageAttachment);
|
||||
} catch (error) {
|
||||
errors.push(`${file.name}: Failed to process image.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.warn('Image upload errors:', errors);
|
||||
// You could show these errors to the user via a toast or notification
|
||||
}
|
||||
|
||||
if (newImages.length > 0) {
|
||||
const allImages = [...selectedImages, ...newImages];
|
||||
updateImages(allImages);
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
}, [disabled, isProcessing, maxFiles, maxFileSize, selectedImages, updateImages]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
}, [disabled, processFiles]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [processFiles]);
|
||||
|
||||
const handleBrowseClick = useCallback(() => {
|
||||
if (!disabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const removeImage = useCallback((imageId: string) => {
|
||||
const updated = selectedImages.filter(img => img.id !== imageId);
|
||||
updateImages(updated);
|
||||
}, [selectedImages, updateImages]);
|
||||
|
||||
const clearAllImages = useCallback(() => {
|
||||
updateImages([]);
|
||||
}, [updateImages]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 border-dashed transition-all duration-200",
|
||||
{
|
||||
"border-blue-400 bg-blue-50 dark:bg-blue-950/20": isDragOver && !disabled,
|
||||
"border-muted-foreground/25": !isDragOver && !disabled,
|
||||
"border-muted-foreground/10 opacity-50 cursor-not-allowed": disabled,
|
||||
"hover:border-blue-400 hover:bg-blue-50/50 dark:hover:bg-blue-950/10": !disabled && !isDragOver,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{children || (
|
||||
<div className="flex flex-col items-center justify-center p-6 text-center">
|
||||
<div className={cn(
|
||||
"rounded-full p-3 mb-4",
|
||||
isDragOver && !disabled ? "bg-blue-100 dark:bg-blue-900/30" : "bg-muted"
|
||||
)}>
|
||||
{isProcessing ? (
|
||||
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">
|
||||
{isDragOver && !disabled ? "Drop your images here" : "Drag images here or click to browse"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maxFiles > 1 ? `Up to ${maxFiles} images` : "1 image"}, max {Math.round(maxFileSize / (1024 * 1024))}MB each
|
||||
</p>
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={handleBrowseClick}
|
||||
className="mt-2 text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Browse files
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image previews */}
|
||||
{selectedImages.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{selectedImages.length} image{selectedImages.length > 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<button
|
||||
onClick={clearAllImages}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedImages.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/* Image info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground truncate">
|
||||
{image.filename}
|
||||
</p>
|
||||
{image.size !== undefined && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(image.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Remove button */}
|
||||
{!disabled && image.id && (
|
||||
<button
|
||||
onClick={() => removeImage(image.id!)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface InputProps extends React.ComponentProps<'input'> {
|
||||
interface InputProps extends React.ComponentProps<"input"> {
|
||||
startAddon?: React.ReactNode;
|
||||
endAddon?: React.ReactNode;
|
||||
}
|
||||
@@ -15,17 +15,17 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
"file:text-foreground placeholder:text-muted-foreground/60 selection:bg-primary selection:text-primary-foreground bg-input border-border h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
// Inner shadow for depth
|
||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||
// Animated focus ring
|
||||
'transition-[color,box-shadow,border-color] duration-200 ease-out',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
|
||||
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
// Adjust padding for addons
|
||||
startAddon && 'pl-0',
|
||||
endAddon && 'pr-0',
|
||||
hasAddons && 'border-0 shadow-none focus-visible:ring-0',
|
||||
startAddon && "pl-0",
|
||||
endAddon && "pr-0",
|
||||
hasAddons && "border-0 shadow-none focus-visible:ring-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,12 +39,12 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs',
|
||||
'shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]',
|
||||
'transition-[box-shadow,border-color] duration-200 ease-out',
|
||||
'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',
|
||||
'has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed',
|
||||
'has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive'
|
||||
"flex items-center h-9 w-full rounded-md border border-border bg-input shadow-xs",
|
||||
"shadow-[inset_0_1px_2px_rgba(0,0,0,0.05)]",
|
||||
"transition-[box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
"has-[input:disabled]:opacity-50 has-[input:disabled]:cursor-not-allowed",
|
||||
"has-[input[aria-invalid]]:ring-destructive/20 has-[input[aria-invalid]]:border-destructive"
|
||||
)}
|
||||
>
|
||||
{startAddon && (
|
||||
@@ -62,4 +62,4 @@ function Input({ className, type, startAddon, endAddon, ...props }: InputProps)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
export { Input }
|
||||
@@ -1,165 +1,154 @@
|
||||
import * as React from 'react';
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useAppStore,
|
||||
DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
parseShortcut,
|
||||
formatShortcut,
|
||||
} from '@/store/app-store';
|
||||
import type { KeyboardShortcuts } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle2, X, RotateCcw, Edit2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// Detect if running on Mac
|
||||
const isMac =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
// Keyboard layout - US QWERTY
|
||||
const KEYBOARD_ROWS = [
|
||||
// Number row
|
||||
[
|
||||
{ key: '`', label: '`', width: 1 },
|
||||
{ key: '1', label: '1', width: 1 },
|
||||
{ key: '2', label: '2', width: 1 },
|
||||
{ key: '3', label: '3', width: 1 },
|
||||
{ key: '4', label: '4', width: 1 },
|
||||
{ key: '5', label: '5', width: 1 },
|
||||
{ key: '6', label: '6', width: 1 },
|
||||
{ key: '7', label: '7', width: 1 },
|
||||
{ key: '8', label: '8', width: 1 },
|
||||
{ key: '9', label: '9', width: 1 },
|
||||
{ key: '0', label: '0', width: 1 },
|
||||
{ key: '-', label: '-', width: 1 },
|
||||
{ key: '=', label: '=', width: 1 },
|
||||
{ key: "`", label: "`", width: 1 },
|
||||
{ key: "1", label: "1", width: 1 },
|
||||
{ key: "2", label: "2", width: 1 },
|
||||
{ key: "3", label: "3", width: 1 },
|
||||
{ key: "4", label: "4", width: 1 },
|
||||
{ key: "5", label: "5", width: 1 },
|
||||
{ key: "6", label: "6", width: 1 },
|
||||
{ key: "7", label: "7", width: 1 },
|
||||
{ key: "8", label: "8", width: 1 },
|
||||
{ key: "9", label: "9", width: 1 },
|
||||
{ key: "0", label: "0", width: 1 },
|
||||
{ key: "-", label: "-", width: 1 },
|
||||
{ key: "=", label: "=", width: 1 },
|
||||
],
|
||||
// Top letter row
|
||||
[
|
||||
{ key: 'Q', label: 'Q', width: 1 },
|
||||
{ key: 'W', label: 'W', width: 1 },
|
||||
{ key: 'E', label: 'E', width: 1 },
|
||||
{ key: 'R', label: 'R', width: 1 },
|
||||
{ key: 'T', label: 'T', width: 1 },
|
||||
{ key: 'Y', label: 'Y', width: 1 },
|
||||
{ key: 'U', label: 'U', width: 1 },
|
||||
{ key: 'I', label: 'I', width: 1 },
|
||||
{ key: 'O', label: 'O', width: 1 },
|
||||
{ key: 'P', label: 'P', width: 1 },
|
||||
{ key: '[', label: '[', width: 1 },
|
||||
{ key: ']', label: ']', width: 1 },
|
||||
{ key: '\\', label: '\\', width: 1 },
|
||||
{ key: "Q", label: "Q", width: 1 },
|
||||
{ key: "W", label: "W", width: 1 },
|
||||
{ key: "E", label: "E", width: 1 },
|
||||
{ key: "R", label: "R", width: 1 },
|
||||
{ key: "T", label: "T", width: 1 },
|
||||
{ key: "Y", label: "Y", width: 1 },
|
||||
{ key: "U", label: "U", width: 1 },
|
||||
{ key: "I", label: "I", width: 1 },
|
||||
{ key: "O", label: "O", width: 1 },
|
||||
{ key: "P", label: "P", width: 1 },
|
||||
{ key: "[", label: "[", width: 1 },
|
||||
{ key: "]", label: "]", width: 1 },
|
||||
{ key: "\\", label: "\\", width: 1 },
|
||||
],
|
||||
// Home row
|
||||
[
|
||||
{ key: 'A', label: 'A', width: 1 },
|
||||
{ key: 'S', label: 'S', width: 1 },
|
||||
{ key: 'D', label: 'D', width: 1 },
|
||||
{ key: 'F', label: 'F', width: 1 },
|
||||
{ key: 'G', label: 'G', width: 1 },
|
||||
{ key: 'H', label: 'H', width: 1 },
|
||||
{ key: 'J', label: 'J', width: 1 },
|
||||
{ key: 'K', label: 'K', width: 1 },
|
||||
{ key: 'L', label: 'L', width: 1 },
|
||||
{ key: ';', label: ';', width: 1 },
|
||||
{ key: "A", label: "A", width: 1 },
|
||||
{ key: "S", label: "S", width: 1 },
|
||||
{ key: "D", label: "D", width: 1 },
|
||||
{ key: "F", label: "F", width: 1 },
|
||||
{ key: "G", label: "G", width: 1 },
|
||||
{ key: "H", label: "H", width: 1 },
|
||||
{ key: "J", label: "J", width: 1 },
|
||||
{ key: "K", label: "K", width: 1 },
|
||||
{ key: "L", label: "L", width: 1 },
|
||||
{ key: ";", label: ";", width: 1 },
|
||||
{ key: "'", label: "'", width: 1 },
|
||||
],
|
||||
// Bottom letter row
|
||||
[
|
||||
{ key: 'Z', label: 'Z', width: 1 },
|
||||
{ key: 'X', label: 'X', width: 1 },
|
||||
{ key: 'C', label: 'C', width: 1 },
|
||||
{ key: 'V', label: 'V', width: 1 },
|
||||
{ key: 'B', label: 'B', width: 1 },
|
||||
{ key: 'N', label: 'N', width: 1 },
|
||||
{ key: 'M', label: 'M', width: 1 },
|
||||
{ key: ',', label: ',', width: 1 },
|
||||
{ key: '.', label: '.', width: 1 },
|
||||
{ key: '/', label: '/', width: 1 },
|
||||
{ key: "Z", label: "Z", width: 1 },
|
||||
{ key: "X", label: "X", width: 1 },
|
||||
{ key: "C", label: "C", width: 1 },
|
||||
{ key: "V", label: "V", width: 1 },
|
||||
{ key: "B", label: "B", width: 1 },
|
||||
{ key: "N", label: "N", width: 1 },
|
||||
{ key: "M", label: "M", width: 1 },
|
||||
{ key: ",", label: ",", width: 1 },
|
||||
{ key: ".", label: ".", width: 1 },
|
||||
{ key: "/", label: "/", width: 1 },
|
||||
],
|
||||
];
|
||||
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: 'Kanban Board',
|
||||
graph: 'Graph View',
|
||||
agent: 'Agent Runner',
|
||||
spec: 'Spec Editor',
|
||||
context: 'Context',
|
||||
memory: 'Memory',
|
||||
settings: 'Settings',
|
||||
projectSettings: 'Project Settings',
|
||||
terminal: 'Terminal',
|
||||
ideation: 'Ideation',
|
||||
notifications: 'Notifications',
|
||||
githubIssues: 'GitHub Issues',
|
||||
githubPrs: 'Pull Requests',
|
||||
toggleSidebar: 'Toggle Sidebar',
|
||||
addFeature: 'Add Feature',
|
||||
addContextFile: 'Add Context File',
|
||||
startNext: 'Start Next',
|
||||
newSession: 'New Session',
|
||||
openProject: 'Open Project',
|
||||
projectPicker: 'Project Picker',
|
||||
cyclePrevProject: 'Prev Project',
|
||||
cycleNextProject: 'Next Project',
|
||||
splitTerminalRight: 'Split Right',
|
||||
splitTerminalDown: 'Split Down',
|
||||
closeTerminal: 'Close Terminal',
|
||||
newTerminalTab: 'New Tab',
|
||||
board: "Kanban Board",
|
||||
agent: "Agent Runner",
|
||||
spec: "Spec Editor",
|
||||
context: "Context",
|
||||
settings: "Settings",
|
||||
profiles: "AI Profiles",
|
||||
terminal: "Terminal",
|
||||
toggleSidebar: "Toggle Sidebar",
|
||||
addFeature: "Add Feature",
|
||||
addContextFile: "Add Context File",
|
||||
startNext: "Start Next",
|
||||
newSession: "New Session",
|
||||
openProject: "Open Project",
|
||||
projectPicker: "Project Picker",
|
||||
cyclePrevProject: "Prev Project",
|
||||
cycleNextProject: "Next Project",
|
||||
addProfile: "Add Profile",
|
||||
splitTerminalRight: "Split Right",
|
||||
splitTerminalDown: "Split Down",
|
||||
closeTerminal: "Close Terminal",
|
||||
};
|
||||
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' | 'action'> = {
|
||||
board: 'navigation',
|
||||
graph: 'navigation',
|
||||
agent: 'navigation',
|
||||
spec: 'navigation',
|
||||
context: 'navigation',
|
||||
memory: 'navigation',
|
||||
settings: 'navigation',
|
||||
projectSettings: 'navigation',
|
||||
terminal: 'navigation',
|
||||
ideation: 'navigation',
|
||||
notifications: 'navigation',
|
||||
githubIssues: 'navigation',
|
||||
githubPrs: 'navigation',
|
||||
toggleSidebar: 'ui',
|
||||
addFeature: 'action',
|
||||
addContextFile: 'action',
|
||||
startNext: 'action',
|
||||
newSession: 'action',
|
||||
openProject: 'action',
|
||||
projectPicker: 'action',
|
||||
cyclePrevProject: 'action',
|
||||
cycleNextProject: 'action',
|
||||
splitTerminalRight: 'action',
|
||||
splitTerminalDown: 'action',
|
||||
closeTerminal: 'action',
|
||||
newTerminalTab: 'action',
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
||||
board: "navigation",
|
||||
agent: "navigation",
|
||||
spec: "navigation",
|
||||
context: "navigation",
|
||||
settings: "navigation",
|
||||
profiles: "navigation",
|
||||
terminal: "navigation",
|
||||
toggleSidebar: "ui",
|
||||
addFeature: "action",
|
||||
addContextFile: "action",
|
||||
startNext: "action",
|
||||
newSession: "action",
|
||||
openProject: "action",
|
||||
projectPicker: "action",
|
||||
cyclePrevProject: "action",
|
||||
cycleNextProject: "action",
|
||||
addProfile: "action",
|
||||
splitTerminalRight: "action",
|
||||
splitTerminalDown: "action",
|
||||
closeTerminal: "action",
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const CATEGORY_COLORS = {
|
||||
navigation: {
|
||||
bg: 'bg-blue-500/20',
|
||||
border: 'border-blue-500/50',
|
||||
text: 'text-blue-400',
|
||||
label: 'Navigation',
|
||||
bg: "bg-blue-500/20",
|
||||
border: "border-blue-500/50",
|
||||
text: "text-blue-400",
|
||||
label: "Navigation",
|
||||
},
|
||||
ui: {
|
||||
bg: 'bg-purple-500/20',
|
||||
border: 'border-purple-500/50',
|
||||
text: 'text-purple-400',
|
||||
label: 'UI Controls',
|
||||
bg: "bg-purple-500/20",
|
||||
border: "border-purple-500/50",
|
||||
text: "text-purple-400",
|
||||
label: "UI Controls",
|
||||
},
|
||||
action: {
|
||||
bg: 'bg-green-500/20',
|
||||
border: 'border-green-500/50',
|
||||
text: 'text-green-400',
|
||||
label: 'Actions',
|
||||
bg: "bg-green-500/20",
|
||||
border: "border-green-500/50",
|
||||
text: "text-green-400",
|
||||
label: "Actions",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -173,13 +162,10 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
const { keyboardShortcuts } = useAppStore();
|
||||
|
||||
// Merge with defaults to ensure new shortcuts are always shown
|
||||
const mergedShortcuts = React.useMemo(
|
||||
() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}),
|
||||
[keyboardShortcuts]
|
||||
);
|
||||
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(() => {
|
||||
@@ -202,10 +188,12 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||
const normalizedKey = keyDef.key.toUpperCase();
|
||||
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
|
||||
const shortcuts = shortcutInfos.map((s) => s.name);
|
||||
const shortcuts = shortcutInfos.map(s => s.name);
|
||||
const isBound = shortcuts.length > 0;
|
||||
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||
const isModified = shortcuts.some((s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]);
|
||||
const isModified = shortcuts.some(
|
||||
(s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
||||
);
|
||||
|
||||
// Get category for coloring (use first shortcut's category if multiple)
|
||||
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
|
||||
@@ -216,25 +204,25 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
key={keyDef.key}
|
||||
onClick={() => onKeySelect?.(keyDef.key)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg border transition-all',
|
||||
'h-12 min-w-11 py-1',
|
||||
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
|
||||
"h-12 min-w-11 py-1",
|
||||
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
|
||||
// Base styles
|
||||
!isBound && 'bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20',
|
||||
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
|
||||
// Bound key styles
|
||||
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
|
||||
// Selected state
|
||||
isSelected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
|
||||
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
|
||||
// Modified indicator
|
||||
isModified && 'ring-1 ring-yellow-500/50'
|
||||
isModified && "ring-1 ring-yellow-500/50"
|
||||
)}
|
||||
data-testid={`keyboard-key-${keyDef.key}`}
|
||||
>
|
||||
{/* Key label - always at top */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-mono font-bold leading-none',
|
||||
isBound && colors ? colors.text : 'text-muted-foreground'
|
||||
"text-sm font-mono font-bold leading-none",
|
||||
isBound && colors ? colors.text : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{keyDef.label}
|
||||
@@ -242,20 +230,17 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
{/* Shortcut label - always takes up space to maintain consistent height */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5',
|
||||
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
|
||||
isBound && shortcuts.length > 0
|
||||
? colors
|
||||
? colors.text
|
||||
: 'text-muted-foreground'
|
||||
: 'opacity-0'
|
||||
? (colors ? colors.text : "text-muted-foreground")
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
{
|
||||
isBound && shortcuts.length > 0
|
||||
? shortcuts.length === 1
|
||||
? (SHORTCUT_LABELS[shortcuts[0]]?.split(' ')[0] ?? shortcuts[0])
|
||||
: `${shortcuts.length}x`
|
||||
: '\u00A0' // Non-breaking space to maintain height
|
||||
{isBound && shortcuts.length > 0
|
||||
? (shortcuts.length === 1
|
||||
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
|
||||
: `${shortcuts.length}x`)
|
||||
: "\u00A0" // Non-breaking space to maintain height
|
||||
}
|
||||
</span>
|
||||
{isModified && (
|
||||
@@ -278,11 +263,10 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
<div key={shortcut} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
SHORTCUT_CATEGORIES[shortcut] &&
|
||||
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
|
||||
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace('/20', '')
|
||||
: 'bg-muted-foreground'
|
||||
"w-2 h-2 rounded-full",
|
||||
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
|
||||
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
|
||||
@@ -305,52 +289,62 @@ export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMap
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className={cn('w-4 h-4 rounded border', colors.bg, colors.border)} />
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
<TooltipProvider>
|
||||
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded border",
|
||||
colors.bg,
|
||||
colors.border
|
||||
)}
|
||||
/>
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
|
||||
configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong>{' '}
|
||||
shortcuts configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyToShortcuts).length}</strong> keys in
|
||||
use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{' '}
|
||||
keys available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -361,27 +355,19 @@ interface ShortcutReferencePanelProps {
|
||||
|
||||
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
|
||||
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
|
||||
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(
|
||||
null
|
||||
);
|
||||
const [keyValue, setKeyValue] = React.useState('');
|
||||
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
|
||||
const [keyValue, setKeyValue] = React.useState("");
|
||||
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
||||
|
||||
// Merge with defaults to ensure new shortcuts are always shown
|
||||
const mergedShortcuts = React.useMemo(
|
||||
() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}),
|
||||
[keyboardShortcuts]
|
||||
);
|
||||
const mergedShortcuts = React.useMemo(() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}), [keyboardShortcuts]);
|
||||
|
||||
const groupedShortcuts = React.useMemo(() => {
|
||||
const groups: Record<
|
||||
string,
|
||||
Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>
|
||||
> = {
|
||||
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||
navigation: [],
|
||||
ui: [],
|
||||
action: [],
|
||||
@@ -403,25 +389,20 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
// Build the full shortcut string from key + modifiers
|
||||
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
|
||||
const parts: string[] = [];
|
||||
if (mods.cmdCtrl) parts.push(isMac ? 'Cmd' : 'Ctrl');
|
||||
if (mods.alt) parts.push(isMac ? 'Opt' : 'Alt');
|
||||
if (mods.shift) parts.push('Shift');
|
||||
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
|
||||
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
|
||||
if (mods.shift) parts.push("Shift");
|
||||
parts.push(key.toUpperCase());
|
||||
return parts.join('+');
|
||||
return parts.join("+");
|
||||
}, []);
|
||||
|
||||
// Check for conflicts with other shortcuts
|
||||
const checkConflict = React.useCallback(
|
||||
(shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
const conflict = Object.entries(mergedShortcuts).find(
|
||||
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
|
||||
);
|
||||
return conflict
|
||||
? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0])
|
||||
: null;
|
||||
},
|
||||
[mergedShortcuts]
|
||||
);
|
||||
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
const conflict = Object.entries(mergedShortcuts).find(
|
||||
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
|
||||
);
|
||||
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
||||
const currentValue = mergedShortcuts[key];
|
||||
@@ -441,14 +422,14 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
const shortcutStr = buildShortcutString(keyValue, modifiers);
|
||||
setKeyboardShortcut(editingShortcut, shortcutStr);
|
||||
setEditingShortcut(null);
|
||||
setKeyValue('');
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingShortcut(null);
|
||||
setKeyValue('');
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
@@ -457,7 +438,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
setKeyValue(value);
|
||||
// Check for conflicts with full shortcut string
|
||||
if (!value) {
|
||||
setShortcutError('Key cannot be empty');
|
||||
setShortcutError("Key cannot be empty");
|
||||
} else {
|
||||
const shortcutStr = buildShortcutString(value, modifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
@@ -469,11 +450,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
}
|
||||
};
|
||||
|
||||
const handleModifierChange = (
|
||||
modifier: keyof typeof modifiers,
|
||||
checked: boolean,
|
||||
currentKey: keyof KeyboardShortcuts
|
||||
) => {
|
||||
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
|
||||
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
|
||||
const newModifiers = checked
|
||||
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
|
||||
@@ -494,9 +471,9 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !shortcutError && keyValue) {
|
||||
if (e.key === "Enter" && !shortcutError && keyValue) {
|
||||
handleSaveShortcut();
|
||||
} else if (e.key === 'Escape') {
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
@@ -506,7 +483,8 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
@@ -525,7 +503,9 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className={cn('text-sm font-semibold', colors.text)}>{colors.label}</h4>
|
||||
<h4 className={cn("text-sm font-semibold", colors.text)}>
|
||||
{colors.label}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shortcuts.map(({ key, label, value }) => {
|
||||
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||
@@ -535,9 +515,9 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors',
|
||||
isEditing ? 'border-brand-500' : 'border-sidebar-border',
|
||||
editable && !isEditing && 'hover:bg-sidebar-accent/20 cursor-pointer'
|
||||
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
|
||||
isEditing ? "border-brand-500" : "border-sidebar-border",
|
||||
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
|
||||
)}
|
||||
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||
data-testid={`shortcut-row-${key}`}
|
||||
@@ -545,57 +525,39 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Modifier checkboxes */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-cmd-${key}`}
|
||||
checked={modifiers.cmdCtrl}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('cmdCtrl', !!checked, key)
|
||||
}
|
||||
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-cmd-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{isMac ? '⌘' : 'Ctrl'}
|
||||
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌘" : "Ctrl"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-alt-${key}`}
|
||||
checked={modifiers.alt}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('alt', !!checked, key)
|
||||
}
|
||||
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-alt-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{isMac ? '⌥' : 'Alt'}
|
||||
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-shift-${key}`}
|
||||
checked={modifiers.shift}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModifierChange('shift', !!checked, key)
|
||||
}
|
||||
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`mod-shift-${key}`}
|
||||
className="text-xs text-muted-foreground cursor-pointer"
|
||||
>
|
||||
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
⇧
|
||||
</Label>
|
||||
</div>
|
||||
@@ -606,8 +568,8 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'w-12 h-7 text-center font-mono text-xs uppercase',
|
||||
shortcutError && 'border-red-500 focus-visible:ring-red-500'
|
||||
"w-12 h-7 text-center font-mono text-xs uppercase",
|
||||
shortcutError && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
placeholder="Key"
|
||||
maxLength={1}
|
||||
@@ -644,7 +606,7 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
<>
|
||||
<kbd
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs font-mono rounded border',
|
||||
"px-2 py-1 text-xs font-mono rounded border",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.text
|
||||
@@ -686,14 +648,13 @@ export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePa
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editingShortcut &&
|
||||
shortcutError &&
|
||||
SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
24
apps/app/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||