mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
530 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e03af2dc6 | ||
|
|
ab9ef0d560 | ||
|
|
844be657c8 | ||
|
|
90c89ef338 | ||
|
|
fb46c0c9ea | ||
|
|
81bd57cf6a | ||
|
|
59d47928a7 | ||
|
|
bd432b1da3 | ||
|
|
b51aed849c | ||
|
|
90e62b8add | ||
|
|
67c6c9a9e7 | ||
|
|
2d66e38fa7 | ||
|
|
50aac1c218 | ||
|
|
8c8a4875ca | ||
|
|
eec36268fe | ||
|
|
f6efbd1b26 | ||
|
|
019793e047 | ||
|
|
a8a3711246 | ||
|
|
b867ca1407 | ||
|
|
75143c0792 | ||
|
|
f32f3e82b2 | ||
|
|
abe272ef4d | ||
|
|
6d4ab9cc13 | ||
|
|
98381441b9 | ||
|
|
eae60ab6b9 | ||
|
|
1d7b64cea8 | ||
|
|
6337e266c5 | ||
|
|
da38adcba6 | ||
|
|
af493fb73e | ||
|
|
79bf1c9bec | ||
|
|
b9a6e29ee8 | ||
|
|
2828431cca | ||
|
|
d3f46f565b | ||
|
|
3f4f2199eb | ||
|
|
38f0b16530 | ||
|
|
bd22323149 | ||
|
|
f6ce03d59a | ||
|
|
63816043cf | ||
|
|
eafe474dbc | ||
|
|
59bbbd43c5 | ||
|
|
2b89b0606c | ||
|
|
07327e48b4 | ||
|
|
e818922b0d | ||
|
|
04aac7ec07 | ||
|
|
944e2f5ffe | ||
|
|
847a8ff327 | ||
|
|
504c19aef5 | ||
|
|
ed2da7932c | ||
|
|
968d889346 | ||
|
|
04aca1c8cb | ||
|
|
784d7fc059 | ||
|
|
d6705fbfb5 | ||
|
|
c5ae9ad262 | ||
|
|
5a0ad75059 | ||
|
|
cf62dbbf7a | ||
|
|
a4d1a1497a | ||
|
|
b798260491 | ||
|
|
1fcaa52f72 | ||
|
|
46caae05d2 | ||
|
|
59a6a23f9b | ||
|
|
88bb5b923f | ||
|
|
504d9aa9d7 | ||
|
|
ab0cd95d9a | ||
|
|
4c65855140 | ||
|
|
adfc353b2d | ||
|
|
d5aea8355b | ||
|
|
e498f39153 | ||
|
|
d66259b411 | ||
|
|
e556521c8d | ||
|
|
e448d6d4e5 | ||
|
|
65a09b2d38 | ||
|
|
469ee5ff85 | ||
|
|
04e6ed30a2 | ||
|
|
ec3d78922e | ||
|
|
bc0ef47323 | ||
|
|
579246dc26 | ||
|
|
d68de99c15 | ||
|
|
57b7f92e61 | ||
|
|
dd822c41c5 | ||
|
|
7016985bf2 | ||
|
|
67a6c10edc | ||
|
|
0317dadcaf | ||
|
|
625fddb71e | ||
|
|
63b0ccd035 | ||
|
|
19aa86c027 | ||
|
|
76ad6667f1 | ||
|
|
25c9259b50 | ||
|
|
0e1e855cc5 | ||
|
|
69a847fe8c | ||
|
|
6f2402e16d | ||
|
|
bacd4f385d | ||
|
|
cc42b79fbc | ||
|
|
eaeb503ee7 | ||
|
|
d028932dc8 | ||
|
|
6bdac230df | ||
|
|
43728e451e | ||
|
|
b93b59951b | ||
|
|
b5a8ed229c | ||
|
|
97ae4b6362 | ||
|
|
5a1e53ca7c | ||
|
|
876d383936 | ||
|
|
96196f906f | ||
|
|
0ee9313441 | ||
|
|
496ace8a8e | ||
|
|
0a21c11a35 | ||
|
|
495af733da | ||
|
|
a526869f21 | ||
|
|
789b807542 | ||
|
|
35b3d3931e | ||
|
|
bad4393dda | ||
|
|
6012e8312b | ||
|
|
8f458e55e2 | ||
|
|
61881d99e2 | ||
|
|
3c719f05a1 | ||
|
|
9cba2e509a | ||
|
|
c61eaff525 | ||
|
|
ef0a96182a | ||
|
|
a680f3a9c1 | ||
|
|
ea6a39c6ab | ||
|
|
f0c2860dec | ||
|
|
1321a8bd4d | ||
|
|
85dfabec0a | ||
|
|
15dca79fb7 | ||
|
|
e9b366fa18 | ||
|
|
145dcf4b97 | ||
|
|
4a708aa305 | ||
|
|
3a1781eb39 | ||
|
|
5f328a4c13 | ||
|
|
f7a0365bee | ||
|
|
4eae231166 | ||
|
|
ba4540b13e | ||
|
|
01911287f2 | ||
|
|
7b7de2b601 | ||
|
|
b65fccbcf7 | ||
|
|
71c17e1fbb | ||
|
|
296ef20ef7 | ||
|
|
23d6756f03 | ||
|
|
01e6b7fa52 | ||
|
|
348a4d95e9 | ||
|
|
94e166636b | ||
|
|
920dcd105f | ||
|
|
b60e8f0392 | ||
|
|
35d2d8cc01 | ||
|
|
d4b2a3eb27 | ||
|
|
2caa63ae21 | ||
|
|
4c16e5e09c | ||
|
|
ad983c6422 | ||
|
|
0fe6a12d20 | ||
|
|
ce78165b59 | ||
|
|
17c1c733b7 | ||
|
|
3bb9d27dc6 | ||
|
|
04a5ae48e2 | ||
|
|
6d3314f980 | ||
|
|
35541f810d | ||
|
|
3d361028b3 | ||
|
|
7f4b60b8c0 | ||
|
|
1c59eabf5f | ||
|
|
f95282069d | ||
|
|
a3fcf5bda1 | ||
|
|
a7de6406ed | ||
|
|
fd51abb3ce | ||
|
|
cd30306afe | ||
|
|
bed8038d16 | ||
|
|
862a33982d | ||
|
|
90ebb52536 | ||
|
|
072ad72f14 | ||
|
|
387bb15a3d | ||
|
|
077dd31b4f | ||
|
|
99a19cb2a2 | ||
|
|
407cf633e0 | ||
|
|
b0ce01d008 | ||
|
|
3154121840 | ||
|
|
8f2d134d03 | ||
|
|
07bcb6b767 | ||
|
|
8a0226512d | ||
|
|
5418d04529 | ||
|
|
3325b91de9 | ||
|
|
aad5dfc745 | ||
|
|
60d4b5c877 | ||
|
|
9dee9fb366 | ||
|
|
ccc7c6c21d | ||
|
|
896e183e41 | ||
|
|
7c0d70ab3c | ||
|
|
91eeda3a73 | ||
|
|
e4235cbd4b | ||
|
|
fc7f342617 | ||
|
|
6aa9e5fbc9 | ||
|
|
97af998066 | ||
|
|
44e341ab41 | ||
|
|
34c0d39e39 | ||
|
|
686a24d3c6 | ||
|
|
38addacf1e | ||
|
|
a85e1aaa89 | ||
|
|
3307ff8100 | ||
|
|
502043f6de | ||
|
|
dd86e987a4 | ||
|
|
6cd2898923 | ||
|
|
7fec9e7c5c | ||
|
|
2c9a3c5161 | ||
|
|
bb3b1960c5 | ||
|
|
7007a8aa66 | ||
|
|
1ff617703c | ||
|
|
76b7cfec9e | ||
|
|
8d80c73faa | ||
|
|
0461045767 | ||
|
|
e07fba13d8 | ||
|
|
dbc21c8f73 | ||
|
|
7b61a274e5 | ||
|
|
ef8eaa0463 | ||
|
|
65319f93b4 | ||
|
|
dd27c5c4fb | ||
|
|
d1418aa054 | ||
|
|
0c9f05ee38 | ||
|
|
d50b15e639 | ||
|
|
172f1a7a3f | ||
|
|
5edb38691c | ||
|
|
f1f149c6c0 | ||
|
|
e0c5f55fe7 | ||
|
|
4958ee1dda | ||
|
|
3d00f40ea0 | ||
|
|
c9e0957dfe | ||
|
|
9d4f912c93 | ||
|
|
4898a1307e | ||
|
|
6acb751eb3 | ||
|
|
629b7e7433 | ||
|
|
190f18ecae | ||
|
|
e6eb5ad97e | ||
|
|
5f0ecc8dd6 | ||
|
|
e95912f931 | ||
|
|
eb1875f558 | ||
|
|
c761ce8120 | ||
|
|
ee9cb4deec | ||
|
|
a881d175bc | ||
|
|
17ed2be918 | ||
|
|
5a5165818e | ||
|
|
9a7d21438b | ||
|
|
d4d4b8fb3d | ||
|
|
48955e9a71 | ||
|
|
870df88cd1 | ||
|
|
7618a75d85 | ||
|
|
51281095ea | ||
|
|
50a595a8da | ||
|
|
a398367f00 | ||
|
|
fe6faf9aae | ||
|
|
a1331ed514 | ||
|
|
38f2e0beea | ||
|
|
ef4035a462 | ||
|
|
cb07206dae | ||
|
|
cc0405cf27 | ||
|
|
4dd00a98e4 | ||
|
|
b3c321ce02 | ||
|
|
12a796bcbb | ||
|
|
ffcdbf7d75 | ||
|
|
e70c3b7722 | ||
|
|
524a9736b4 | ||
|
|
036a7d9d26 | ||
|
|
c4df2c141a | ||
|
|
c4a2f2c2a8 | ||
|
|
d08be3c7f9 | ||
|
|
7c75c24b5c | ||
|
|
a69611dcb2 | ||
|
|
2588ecaafa | ||
|
|
83af319be3 | ||
|
|
55d7120576 | ||
|
|
73e7a8558d | ||
|
|
a071097c0d | ||
|
|
0b8a79bc25 | ||
|
|
59cb48b7fa | ||
|
|
f45ba5a4f5 | ||
|
|
a0fd19fe17 | ||
|
|
b930091c42 | ||
|
|
4cff240520 | ||
|
|
e40881ed1d | ||
|
|
6a8b2067fd | ||
|
|
3f3f02905f | ||
|
|
edef4c7cee | ||
|
|
53c1a46409 | ||
|
|
0c508ce130 | ||
|
|
3c48b2ceb7 | ||
|
|
64bf02d59c | ||
|
|
a2030d5877 | ||
|
|
f17d062440 | ||
|
|
3a43033fa6 | ||
|
|
9586589453 | ||
|
|
a85dec6dbb | ||
|
|
632d3181f2 | ||
|
|
4e876de458 | ||
|
|
dea504a671 | ||
|
|
877fcb094e | ||
|
|
fc75923211 | ||
|
|
73cab38ba5 | ||
|
|
0cd3275e4a | ||
|
|
9702f142c4 | ||
|
|
2906fec500 | ||
|
|
5e2718f8b2 | ||
|
|
3b0a1a7eb2 | ||
|
|
35cda4eb8c | ||
|
|
fd39f96b4c | ||
|
|
398f7b8fdd | ||
|
|
e2718b37e3 | ||
|
|
95bcd9a7ec | ||
|
|
8d578558ff | ||
|
|
584f5a3426 | ||
|
|
17c69ea1ca | ||
|
|
4ce163691e | ||
|
|
042fc61542 | ||
|
|
7869ec046a | ||
|
|
9beefd1ac3 | ||
|
|
0c59add31f | ||
|
|
26236d3d5b | ||
|
|
43c93fe19a | ||
|
|
7b1b2fa463 | ||
|
|
20cf120b8a | ||
|
|
9ea80123fd | ||
|
|
ee9ccd03d6 | ||
|
|
af7a7ebacc | ||
|
|
7ddd9f8be1 | ||
|
|
a40bb6df24 | ||
|
|
aafd0b3991 | ||
|
|
b641884c37 | ||
|
|
7fac115a36 | ||
|
|
7e8995df24 | ||
|
|
e47b34288b | ||
|
|
6365cc137c | ||
|
|
41ea6f78eb | ||
|
|
9f97426859 | ||
|
|
6e341c1c15 | ||
|
|
b00568176c | ||
|
|
b18672f66d | ||
|
|
887fb93b3b | ||
|
|
d3005393df | ||
|
|
49f04cf403 | ||
|
|
ebaecca949 | ||
|
|
13095a4445 | ||
|
|
b80773b90d | ||
|
|
7dff6ea0ed | ||
|
|
3b39df4b12 | ||
|
|
ab65d46d08 | ||
|
|
077a63b03b | ||
|
|
5be85a45b1 | ||
|
|
6028889909 | ||
|
|
2b5479ae0d | ||
|
|
6a13c8e16e | ||
|
|
89acada310 | ||
|
|
3a2d8d118d | ||
|
|
1b8d23688e | ||
|
|
1209e923fc | ||
|
|
012d1c452b | ||
|
|
ab0487664a | ||
|
|
f504a00ce6 | ||
|
|
f2582c4453 | ||
|
|
820f43078b | ||
|
|
6533a15653 | ||
|
|
7416c8b428 | ||
|
|
8f5e782583 | ||
|
|
39b21830dc | ||
|
|
86cbb2f970 | ||
|
|
0e944e274a | ||
|
|
5e789c2817 | ||
|
|
6150926a75 | ||
|
|
0a2b4287ff | ||
|
|
18ccfa21e0 | ||
|
|
ebc7c9a7a0 | ||
|
|
5bd2b705dc | ||
|
|
2b1a7660b6 | ||
|
|
195b98e688 | ||
|
|
5aedb4fadf | ||
|
|
9cf12b9006 | ||
|
|
86d92e610b | ||
|
|
f2c40ab21a | ||
|
|
0ce6b6d4b1 | ||
|
|
55c49516c8 | ||
|
|
f3c9e828e2 | ||
|
|
3928539ade | ||
|
|
c1386caeb2 | ||
|
|
ade80484bb | ||
|
|
49a5a7448c | ||
|
|
873429db19 | ||
|
|
d6baf4583a | ||
|
|
0bcd52290b | ||
|
|
823e42e635 | ||
|
|
30f4315c17 | ||
|
|
f30240267f | ||
|
|
8cccf74ace | ||
|
|
9b798732b2 | ||
|
|
a7c19f15cd | ||
|
|
493c392422 | ||
|
|
67788bee0b | ||
|
|
0cef537a3d | ||
|
|
46994bea34 | ||
|
|
7ab65b22ec | ||
|
|
9bc245bd40 | ||
|
|
32e2315697 | ||
|
|
3a0a2e3019 | ||
|
|
8ff4b5912a | ||
|
|
7d0656bb14 | ||
|
|
e65c4aead2 | ||
|
|
f43a9288fb | ||
|
|
92e7945329 | ||
|
|
723274523d | ||
|
|
01d78be748 | ||
|
|
bcd87cc7c5 | ||
|
|
c9e7e4f1e0 | ||
|
|
532d03c231 | ||
|
|
f367db741a | ||
|
|
f4f7b4d25b | ||
|
|
63c581577f | ||
|
|
6190bd5f39 | ||
|
|
e29880254e | ||
|
|
ba7904c189 | ||
|
|
46210c5a26 | ||
|
|
ee40f2720a | ||
|
|
f1eba5ea56 | ||
|
|
c76ba691a4 | ||
|
|
ace736c7c2 | ||
|
|
1a78304ca2 | ||
|
|
0c6447a6f5 | ||
|
|
fb87c8bbb9 | ||
|
|
1a4e6ff17b | ||
|
|
3e7695dd2d | ||
|
|
8fcc6cb4db | ||
|
|
dcf19fbd45 | ||
|
|
80ab5ddad2 | ||
|
|
84832a130b | ||
|
|
fcb2e904eb | ||
|
|
36e007e647 | ||
|
|
36b4bd6c5e | ||
|
|
1b676717ea | ||
|
|
4afd360f66 | ||
|
|
dd610b7ed9 | ||
|
|
56ab21558d | ||
|
|
89c53acdcf | ||
|
|
a84f2e5942 | ||
|
|
6cb085f192 | ||
|
|
19fd23c39c | ||
|
|
cf7a737646 | ||
|
|
ff6a5a5565 | ||
|
|
3842eb1328 | ||
|
|
bb5f68c2f0 | ||
|
|
ec7c2892c2 | ||
|
|
5c01706806 | ||
|
|
6c25680115 | ||
|
|
3ca1daf44c | ||
|
|
80cf932ea4 | ||
|
|
abc55cf5e9 | ||
|
|
d4365de4b9 | ||
|
|
5f92af4c0a | ||
|
|
1fc40da052 | ||
|
|
5907cc0c04 | ||
|
|
57588bfc20 | ||
|
|
4afa73521d | ||
|
|
3a69f973d0 | ||
|
|
108d52ce9f | ||
|
|
dd58b70730 | ||
|
|
7ad7b63da2 | ||
|
|
060a789b45 | ||
|
|
bafddd627a | ||
|
|
6f4269aacd | ||
|
|
8b31039557 | ||
|
|
27b80b3e08 | ||
|
|
bdb65f5729 | ||
|
|
f4b95ea5bf | ||
|
|
b149607747 | ||
|
|
9bfcb91774 | ||
|
|
6a8f5c6d9c | ||
|
|
d104a24446 | ||
|
|
a26ef4347a | ||
|
|
e9dba8c9e5 | ||
|
|
2b02db8ae3 | ||
|
|
1ad3b1739b | ||
|
|
9110693c75 | ||
|
|
b8afb6c804 | ||
|
|
37ce09e07c | ||
|
|
334b82bfb4 | ||
|
|
17a2053e79 | ||
|
|
96b0e74794 | ||
|
|
7d6ed0cb37 | ||
|
|
396100686c | ||
|
|
35ecb0dd2d | ||
|
|
8d6dae7495 | ||
|
|
a14ef30c69 | ||
|
|
3e015591d3 | ||
|
|
2a1ab218ec | ||
|
|
1656b4fb7a | ||
|
|
419e954230 | ||
|
|
1a83c9b256 | ||
|
|
e508f9c1d1 | ||
|
|
8c2c54b0a4 | ||
|
|
dd8862ce21 | ||
|
|
0c2192d039 | ||
|
|
a85390b289 | ||
|
|
c4a90d7f29 | ||
|
|
8794156f28 | ||
|
|
7603c827f6 | ||
|
|
7ad70a3923 | ||
|
|
95c6a69610 | ||
|
|
157dd71efa | ||
|
|
06ed965a3b | ||
|
|
ea7e273fb4 | ||
|
|
dcf05e4f1c | ||
|
|
f9db4fffa7 | ||
|
|
1cb6daaa07 | ||
|
|
adf9307796 | ||
|
|
7fdc2b2fab | ||
|
|
0d8043f1f2 | ||
|
|
2c079623a8 | ||
|
|
899c45fc1b | ||
|
|
c763f2a545 | ||
|
|
45dd1d45a1 | ||
|
|
a860b3cf45 | ||
|
|
019ac56ceb | ||
|
|
e1c3b7528f | ||
|
|
e78bfc80ec | ||
|
|
3eac848d4f | ||
|
|
76cb72812f | ||
|
|
bfc8f9bc26 | ||
|
|
8f2e06bc32 | ||
|
|
2c9f77356f | ||
|
|
ea1b10fea6 | ||
|
|
e6a63ccae1 | ||
|
|
784188cf52 | ||
|
|
1d945f4f75 | ||
|
|
df50cccb7b | ||
|
|
b0a9c89157 | ||
|
|
45eaf91cb3 | ||
|
|
c20b224f30 | ||
|
|
96b941b008 | ||
|
|
ad4da23743 | ||
|
|
5136c32b68 | ||
|
|
9954feafd8 |
74
.claude/commands/gh-issue.md
Normal file
74
.claude/commands/gh-issue.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 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
|
||||
77
.claude/commands/release.md
Normal file
77
.claude/commands/release.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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)
|
||||
49
.claude/commands/validate-build.md
Normal file
49
.claude/commands/validate-build.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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.).
|
||||
36
.claude/commands/validate-tests.md
Normal file
36
.claude/commands/validate-tests.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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).
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
117
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
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
|
||||
71
.github/actions/setup-project/action.yml
vendored
Normal file
71
.github/actions/setup-project/action.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: 'Setup Project'
|
||||
description: 'Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules'
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '22'
|
||||
check-lockfile:
|
||||
description: 'Run lockfile lint check for SSH URLs'
|
||||
required: false
|
||||
default: 'false'
|
||||
rebuild-node-pty-path:
|
||||
description: 'Working directory for node-pty rebuild (empty = root)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Check for SSH URLs in lockfile
|
||||
if: inputs.check-lockfile == 'true'
|
||||
shell: bash
|
||||
run: npm run lint:lockfile
|
||||
|
||||
- name: Configure Git for HTTPS
|
||||
shell: bash
|
||||
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
|
||||
# This is needed because SSH authentication isn't available in CI
|
||||
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
||||
# Skip scripts to avoid electron-builder install-app-deps which uses too much memory
|
||||
run: npm install --ignore-scripts
|
||||
|
||||
- name: Install Linux native bindings
|
||||
shell: bash
|
||||
# Workaround for npm optional dependencies bug (npm/cli#4828)
|
||||
# Explicitly install Linux bindings needed for build tools
|
||||
run: |
|
||||
npm install --no-save --force --ignore-scripts \
|
||||
@rollup/rollup-linux-x64-gnu@4.53.3 \
|
||||
@tailwindcss/oxide-linux-x64-gnu@4.1.17
|
||||
|
||||
- name: Build shared packages
|
||||
shell: bash
|
||||
# Build shared packages (types, utils, platform, etc.) before apps can use them
|
||||
run: npm run build:packages
|
||||
|
||||
- name: Rebuild native modules (root)
|
||||
if: inputs.rebuild-node-pty-path == ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules for Electron
|
||||
run: npm rebuild node-pty
|
||||
|
||||
- name: Rebuild native modules (workspace)
|
||||
if: inputs.rebuild-node-pty-path != ''
|
||||
shell: bash
|
||||
# Rebuild node-pty and other native modules needed for server
|
||||
run: npm rebuild node-pty
|
||||
working-directory: ${{ inputs.rebuild-node-pty-path }}
|
||||
179
.github/scripts/upload-to-r2.js
vendored
179
.github/scripts/upload-to-r2.js
vendored
@@ -1,15 +1,11 @@
|
||||
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,
|
||||
@@ -28,14 +24,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;
|
||||
@@ -85,7 +81,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
||||
resolve({
|
||||
accessible: false,
|
||||
statusCode,
|
||||
error: "Redirect without location header",
|
||||
error: 'Redirect without location header',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -93,18 +89,16 @@ 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,
|
||||
@@ -113,38 +107,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,
|
||||
@@ -152,12 +146,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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -168,22 +162,14 @@ 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}`);
|
||||
@@ -191,9 +177,7 @@ 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));
|
||||
}
|
||||
}
|
||||
@@ -207,12 +191,7 @@ 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) {
|
||||
@@ -220,39 +199,33 @@ 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}`));
|
||||
});
|
||||
@@ -260,8 +233,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)) {
|
||||
@@ -292,40 +265,30 @@ 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)) {
|
||||
@@ -345,11 +308,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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,9 +327,7 @@ 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);
|
||||
@@ -376,19 +337,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
Normal file
49
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
45
.github/workflows/e2e-tests.yml
vendored
45
.github/workflows/e2e-tests.yml
vendored
@@ -3,7 +3,7 @@ name: E2E Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -18,34 +18,15 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
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)
|
||||
# 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
|
||||
check-lockfile: 'true'
|
||||
rebuild-node-pty-path: 'apps/server'
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: apps/app
|
||||
working-directory: apps/ui
|
||||
|
||||
- name: Build server
|
||||
run: npm run build --workspace=apps/server
|
||||
@@ -71,20 +52,20 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
# 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
|
||||
# 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
|
||||
env:
|
||||
CI: true
|
||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3008
|
||||
NEXT_PUBLIC_SKIP_SETUP: "true"
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/app/playwright-report/
|
||||
path: apps/ui/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
@@ -92,5 +73,5 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: apps/app/test-results/
|
||||
path: apps/ui/test-results/
|
||||
retention-days: 7
|
||||
|
||||
31
.github/workflows/format-check.yml
vendored
Normal file
31
.github/workflows/format-check.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Format Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --ignore-scripts
|
||||
|
||||
- name: Check formatting
|
||||
run: npm run format:check
|
||||
35
.github/workflows/pr-check.yml
vendored
35
.github/workflows/pr-check.yml
vendored
@@ -3,7 +3,7 @@ name: PR Build Check
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -17,33 +17,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
check-lockfile: 'true'
|
||||
|
||||
- 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
|
||||
- name: Run build:electron (dir only - faster CI)
|
||||
run: npm run build:electron:dir
|
||||
|
||||
211
.github/workflows/release.yml
vendored
211
.github/workflows/release.yml
vendored
@@ -1,180 +1,111 @@
|
||||
name: Build and Release Electron App
|
||||
name: Release Build
|
||||
|
||||
on:
|
||||
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"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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
|
||||
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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: 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
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
||||
VERSION="${VERSION_TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
||||
# Update the app's package.json version
|
||||
cd apps/app
|
||||
npm version $VERSION --no-git-tag-version
|
||||
cd ../..
|
||||
echo "Updated apps/app/package.json to version $VERSION"
|
||||
# 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}"
|
||||
|
||||
- name: Build Electron App (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --mac --x64 --arm64
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
run: |
|
||||
node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build Electron App (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --win --x64
|
||||
|
||||
- name: Build Electron App (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run build:electron -- --linux --x64
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v1
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.version || github.ref_name }}
|
||||
files: |
|
||||
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 }}
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Upload macOS artifacts for R2
|
||||
- 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
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:win --workspace=apps/ui
|
||||
|
||||
- name: Build Electron app (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
shell: bash
|
||||
run: npm run build:electron:linux --workspace=apps/ui
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
if: matrix.os == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.dmg
|
||||
retention-days: 1
|
||||
name: macos-builds
|
||||
path: apps/ui/release/*.{dmg,zip}
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts for R2
|
||||
- name: Upload Windows artifacts
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.exe
|
||||
retention-days: 1
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts for R2
|
||||
- name: Upload Linux artifacts
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact-name }}
|
||||
path: apps/app/dist/*.AppImage
|
||||
retention-days: 1
|
||||
name: linux-builds
|
||||
path: apps/ui/release/*.{AppImage,deb}
|
||||
retention-days: 30
|
||||
|
||||
upload-to-r2:
|
||||
needs: build-and-release
|
||||
upload:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.release.draft == false
|
||||
|
||||
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
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
name: macos-builds
|
||||
path: artifacts/macos-builds
|
||||
|
||||
- name: Install AWS SDK
|
||||
run: npm install @aws-sdk/client-s3
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: artifacts/windows-builds
|
||||
|
||||
- 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: Download Linux artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: artifacts/linux-builds
|
||||
|
||||
- name: Upload to R2 and update releases.json
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
artifacts/macos-builds/*
|
||||
artifacts/windows-builds/*
|
||||
artifacts/linux-builds/*
|
||||
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
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
30
.github/workflows/security-audit.yml
vendored
Normal file
30
.github/workflows/security-audit.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Security Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
schedule:
|
||||
# Run weekly on Mondays at 9 AM UTC
|
||||
- cron: '0 9 * * 1'
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: false
|
||||
32
.github/workflows/test.yml
vendored
32
.github/workflows/test.yml
vendored
@@ -3,7 +3,7 @@ name: Test Suite
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
- '*'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -17,30 +17,16 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup project
|
||||
uses: ./.github/actions/setup-project
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
check-lockfile: 'true'
|
||||
rebuild-node-pty-path: 'apps/server'
|
||||
|
||||
- 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 package tests
|
||||
run: npm run test:packages
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Run server tests with coverage
|
||||
run: npm run test:server:coverage
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -78,3 +78,9 @@ blob-report/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
|
||||
docker-compose.override.yml
|
||||
.claude/docker-compose.override.yml
|
||||
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
39
.prettierignore
Normal file
39
.prettierignore
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.turbo/
|
||||
release/
|
||||
|
||||
# Automaker
|
||||
.automaker/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
coverage/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# IDE/Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Electron
|
||||
dist-electron/
|
||||
server-bundle/
|
||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
172
CLAUDE.md
Normal file
172
CLAUDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 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)
|
||||
- `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
|
||||
685
CONTRIBUTING.md
Normal file
685
CONTRIBUTING.md
Normal file
@@ -0,0 +1,685 @@
|
||||
# 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)
|
||||
- [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.
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Rebase your branch on main (if needed)
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
#### 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. Ensure the base repository is `AutoMaker-Org/automaker` and base branch is `main`
|
||||
4. Fill out the PR template completely
|
||||
|
||||
#### PR Requirements Checklist
|
||||
|
||||
Your PR should include:
|
||||
|
||||
- [ ] **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 main 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!
|
||||
@@ -30,6 +30,26 @@ 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:
|
||||
|
||||
154
Dockerfile
Normal file
154
Dockerfile
Normal file
@@ -0,0 +1,154 @@
|
||||
# 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-alpine AS base
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
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/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 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-alpine AS server
|
||||
|
||||
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
|
||||
RUN apk add --no-cache git curl bash && \
|
||||
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}
|
||||
|
||||
# Install Claude CLI globally
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
|
||||
# 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'
|
||||
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# 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;"]
|
||||
559
README.md
559
README.md
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="apps/app/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||
<img src="apps/ui/public/readme_logo.svg" 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).
|
||||
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
|
||||
|
||||
# Automaker
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||
- [The Workflow](#the-workflow)
|
||||
- [Powered by Claude Code](#powered-by-claude-code)
|
||||
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
|
||||
- [Why This Matters](#why-this-matters)
|
||||
- [Security Disclaimer](#security-disclaimer)
|
||||
- [Community & Support](#community--support)
|
||||
@@ -28,22 +28,36 @@
|
||||
- [Quick Start](#quick-start)
|
||||
- [How to Run](#how-to-run)
|
||||
- [Development Mode](#development-mode)
|
||||
- [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)
|
||||
- [Authentication Options](#authentication-options)
|
||||
- [Persistent Setup (Optional)](#persistent-setup-optional)
|
||||
- [Environment Configuration](#environment-configuration)
|
||||
- [Authentication Setup](#authentication-setup)
|
||||
- [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 Code automatically implement them.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -59,43 +73,27 @@ 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 Code
|
||||
### Powered by Claude Agent SDK
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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:**
|
||||
https://discord.gg/jjem7aEDKU
|
||||
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
|
||||
---
|
||||
|
||||
@@ -103,25 +101,49 @@ https://discord.gg/jjem7aEDKU
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
||||
- **Node.js 18+** (tested with Node.js 22)
|
||||
- **npm** (comes with Node.js)
|
||||
- **Authentication** (choose one):
|
||||
- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** (recommended) - Install and authenticate, credentials used automatically
|
||||
- **Anthropic API Key** - Direct API key for Claude Agent SDK ([get one here](https://console.anthropic.com/))
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||
cd automaker
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Run Automaker (pick your mode)
|
||||
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||
npm run build:packages
|
||||
|
||||
# 4. Set up authentication (skip if using Claude Code CLI)
|
||||
# If using Claude Code CLI: credentials are detected automatically
|
||||
# If using API key directly, choose one method:
|
||||
|
||||
# Option A: Environment variable
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
|
||||
# Option B: Create .env file in project root
|
||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||
|
||||
# 5. Start Automaker (interactive launcher)
|
||||
npm run dev
|
||||
# Then choose your run mode when prompted, or use specific commands below
|
||||
# Choose between:
|
||||
# 1. Web Application (browser at localhost:3007)
|
||||
# 2. Desktop Application (Electron - recommended)
|
||||
```
|
||||
|
||||
**Note:** The `npm run dev` command will:
|
||||
|
||||
- Check for dependencies and install if needed
|
||||
- Install Playwright browsers for E2E tests
|
||||
- Kill any processes on ports 3007/3008
|
||||
- Present an interactive menu to choose your run mode
|
||||
|
||||
## How to Run
|
||||
|
||||
### Development Mode
|
||||
@@ -159,31 +181,162 @@ npm run dev:web
|
||||
|
||||
### Building for Production
|
||||
|
||||
#### Web Application
|
||||
|
||||
```bash
|
||||
# Build Next.js app
|
||||
# Build for web deployment (uses Vite)
|
||||
npm run build
|
||||
|
||||
# Build Electron app for distribution
|
||||
npm run build:electron
|
||||
```
|
||||
|
||||
### Running Production Build
|
||||
|
||||
```bash
|
||||
# Start production Next.js server
|
||||
# Run production build
|
||||
npm run start
|
||||
```
|
||||
|
||||
### Testing
|
||||
#### Desktop Application
|
||||
|
||||
```bash
|
||||
# Run tests headless
|
||||
npm run test
|
||||
# Build for current platform (macOS/Windows/Linux)
|
||||
npm run build:electron
|
||||
|
||||
# Run tests with browser visible
|
||||
npm run test:headed
|
||||
# Platform-specific builds
|
||||
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
|
||||
npm run build:electron:win # Windows (NSIS installer, x64)
|
||||
npm run build:electron:linux # Linux (AppImage + DEB, x64)
|
||||
|
||||
# Output directory: apps/ui/release/
|
||||
```
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
##### Configuration
|
||||
|
||||
Create a `.env` file in the project root if using API key authentication:
|
||||
|
||||
```bash
|
||||
# Optional: Anthropic API key (not needed if using Claude CLI authentication)
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
**Note:** Most users authenticate via Claude CLI instead of API keys. See [Claude CLI Authentication](#claude-cli-authentication-optional) 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 (Optional)
|
||||
|
||||
To use Claude Code CLI authentication instead of an API key, mount your Claude CLI config directory:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
#### 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
|
||||
@@ -191,59 +344,300 @@ npm run test:headed
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Authentication Options
|
||||
### Environment Configuration
|
||||
|
||||
Automaker supports multiple authentication methods (in order of priority):
|
||||
#### Authentication (if not using Claude Code CLI)
|
||||
|
||||
| Method | Environment Variable | Description |
|
||||
| ---------------- | -------------------- | ------------------------------- |
|
||||
| API Key (env) | `ANTHROPIC_API_KEY` | Anthropic API key |
|
||||
| API Key (stored) | — | Anthropic API key stored in app |
|
||||
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
|
||||
|
||||
### Persistent Setup (Optional)
|
||||
#### Optional - Server
|
||||
|
||||
- `PORT` - Server port (default: 3008)
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
|
||||
|
||||
#### Optional - Security
|
||||
|
||||
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `CORS_ORIGIN` - CORS policy (default: \*)
|
||||
|
||||
#### Optional - Development
|
||||
|
||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
#### Option 1: Claude Code CLI (Recommended)
|
||||
|
||||
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
|
||||
|
||||
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
|
||||
|
||||
#### Option 2: Direct API Key
|
||||
|
||||
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
|
||||
|
||||
##### 2a. Shell Configuration
|
||||
|
||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="YOUR_API_KEY_HERE"
|
||||
export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
```
|
||||
|
||||
Then restart your terminal or run `source ~/.bashrc`.
|
||||
Then restart your terminal or run `source ~/.bashrc` (or `source ~/.zshrc`).
|
||||
|
||||
##### 2b. .env File
|
||||
|
||||
Create a `.env` file in the project root (gitignored):
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
PORT=3008
|
||||
DATA_DIR=./data
|
||||
```
|
||||
|
||||
##### 2c. In-App Storage
|
||||
|
||||
The application can store your API key securely in the settings UI. The key is persisted in the `DATA_DIR` directory.
|
||||
|
||||
## Features
|
||||
|
||||
### 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"
|
||||
- 🧠 **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
|
||||
- 🔀 **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
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [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
|
||||
### 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 |
|
||||
| **Profiles** | `M` | Create and manage AI agent profiles with custom prompts and configurations |
|
||||
| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more |
|
||||
| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions |
|
||||
| **GitHub Issues** | - | Import and validate GitHub issues, convert to tasks |
|
||||
| **Running Agents** | - | View all active agents across projects with status and progress |
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
All shortcuts are customizable in Settings. Default shortcuts:
|
||||
|
||||
- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `M` (Profiles), `T` (Terminal)
|
||||
- **UI:** `` ` `` (Toggle sidebar)
|
||||
- **Actions:** `N` (New item in current view), `G` (Start next features), `O` (Open project), `P` (Project picker)
|
||||
- **Projects:** `Q`/`E` (Cycle previous/next project)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
Automaker is built as an npm workspace monorepo with two main applications and seven shared packages:
|
||||
|
||||
```text
|
||||
automaker/
|
||||
├── apps/
|
||||
│ ├── ui/ # React + Vite + Electron frontend
|
||||
│ └── server/ # Express + WebSocket backend
|
||||
└── libs/ # Shared packages
|
||||
├── types/ # Core TypeScript definitions
|
||||
├── utils/ # Logging, errors, utilities
|
||||
├── prompts/ # AI prompt templates
|
||||
├── platform/ # Path management, security
|
||||
├── model-resolver/ # Claude model aliasing
|
||||
├── dependency-resolver/ # Feature dependency ordering
|
||||
└── git-utils/ # Git operations & worktree management
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration
|
||||
2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development
|
||||
3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access
|
||||
4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring
|
||||
5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval
|
||||
6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation
|
||||
7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff
|
||||
8. **Integration** - After approval, changes can be committed and PRs created from the worktree
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
- **Event-Driven Architecture** - All server operations emit events that stream to the frontend
|
||||
- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers)
|
||||
- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings
|
||||
- **State Management** - Zustand with persistence for frontend state across restarts
|
||||
- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory
|
||||
|
||||
### Security & Isolation
|
||||
|
||||
- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch
|
||||
- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access
|
||||
- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access
|
||||
- **Plan Approval** - Optional plan review before implementation prevents unwanted changes
|
||||
|
||||
### Data Storage
|
||||
|
||||
Automaker uses a file-based storage system (no database required):
|
||||
|
||||
#### Per-Project Data
|
||||
|
||||
Stored in `{projectPath}/.automaker/`:
|
||||
|
||||
```text
|
||||
.automaker/
|
||||
├── features/ # Feature JSON files and images
|
||||
│ └── {featureId}/
|
||||
│ ├── feature.json # Feature metadata
|
||||
│ ├── agent-output.md # AI agent output log
|
||||
│ └── images/ # Attached images
|
||||
├── context/ # Context files for AI agents
|
||||
├── settings.json # Project-specific settings
|
||||
├── spec.md # Project specification
|
||||
├── analysis.json # Project structure analysis
|
||||
└── feature-suggestions.json # AI-generated suggestions
|
||||
```
|
||||
|
||||
#### Global Data
|
||||
|
||||
Stored in `DATA_DIR` (default `./data`):
|
||||
|
||||
```text
|
||||
data/
|
||||
├── settings.json # Global settings, profiles, shortcuts
|
||||
├── credentials.json # API keys (encrypted)
|
||||
├── sessions-metadata.json # Chat session metadata
|
||||
└── agent-sessions/ # Conversation histories
|
||||
└── {sessionId}.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[!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)**
|
||||
|
||||
---
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
### Documentation
|
||||
|
||||
- [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.
|
||||
- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
|
||||
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
|
||||
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages
|
||||
|
||||
### Community
|
||||
|
||||
Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**:
|
||||
|
||||
👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||
|
||||
## License
|
||||
|
||||
@@ -252,19 +646,16 @@ 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:**
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# 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*
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
};
|
||||
@@ -1,435 +0,0 @@
|
||||
/**
|
||||
* 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}`;
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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)");
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,196 +0,0 @@
|
||||
{
|
||||
"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-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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: 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,97 +0,0 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
interface GeminiContent {
|
||||
parts: Array<{
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
}>;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface GeminiRequest {
|
||||
contents: GeminiContent[];
|
||||
generationConfig?: {
|
||||
maxOutputTokens?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{
|
||||
content: {
|
||||
parts: Array<{
|
||||
text: string;
|
||||
}>;
|
||||
role: string;
|
||||
};
|
||||
finishReason: string;
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
}>;
|
||||
promptFeedback?: {
|
||||
safetyRatings?: Array<{
|
||||
category: string;
|
||||
probability: string;
|
||||
}>;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { apiKey, imageData, mimeType, prompt } = await request.json();
|
||||
|
||||
// Use provided API key or fall back to environment variable
|
||||
const effectiveApiKey = apiKey || process.env.GOOGLE_API_KEY;
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "No API key provided or configured in environment" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build the request body
|
||||
const requestBody: GeminiRequest = {
|
||||
contents: [
|
||||
{
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
maxOutputTokens: 150,
|
||||
temperature: 0.4,
|
||||
},
|
||||
};
|
||||
|
||||
// Add image if provided
|
||||
if (imageData && mimeType) {
|
||||
requestBody.contents[0].parts.push({
|
||||
inlineData: {
|
||||
mimeType: mimeType,
|
||||
data: imageData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add text prompt
|
||||
const textPrompt = prompt || (imageData
|
||||
? "Describe what you see in this image briefly."
|
||||
: "Respond with exactly: 'Gemini SDK connection successful!' and nothing else.");
|
||||
|
||||
requestBody.contents[0].parts.push({
|
||||
text: textPrompt,
|
||||
});
|
||||
|
||||
// Call Gemini API - using gemini-1.5-flash as it supports both text and vision
|
||||
const model = imageData ? "gemini-1.5-flash" : "gemini-1.5-flash";
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${effectiveApiKey}`;
|
||||
|
||||
const response = await fetch(geminiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data: GeminiResponse = await response.json();
|
||||
|
||||
// Check for API errors
|
||||
if (data.error) {
|
||||
const errorMessage = data.error.message || "Unknown Gemini API error";
|
||||
const statusCode = data.error.code || 500;
|
||||
|
||||
if (statusCode === 400 && errorMessage.includes("API key")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid API key. Please check your Google API key." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Rate limit exceeded. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `API error: ${errorMessage}` },
|
||||
{ status: statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for valid response
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `HTTP error: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract response text
|
||||
if (data.candidates && data.candidates.length > 0 && data.candidates[0].content?.parts?.length > 0) {
|
||||
const responseText = data.candidates[0].content.parts
|
||||
.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Connection successful! Response: "${responseText.substring(0, 200)}${responseText.length > 200 ? '...' : ''}"`,
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle blocked responses
|
||||
if (data.promptFeedback?.safetyRatings) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded (response may have been filtered).",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Connection successful! Gemini responded.",
|
||||
model: model,
|
||||
hasImage: !!imageData,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("Gemini API test error:", error);
|
||||
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Network error. Unable to reach Gemini API." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to connect to Gemini API";
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
export const metadata: Metadata = {
|
||||
title: "Automaker - Autonomous AI Development Studio",
|
||||
description: "Build software autonomously with intelligent orchestration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
"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",
|
||||
];
|
||||
|
||||
// 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,167 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shadow-sm",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white hover:bg-destructive/90",
|
||||
outline:
|
||||
"text-foreground border-border bg-background/50 backdrop-blur-sm",
|
||||
// Semantic status variants using CSS variables
|
||||
success:
|
||||
"border-transparent bg-[var(--status-success-bg)] text-[var(--status-success)] border border-[var(--status-success)]/30",
|
||||
warning:
|
||||
"border-transparent bg-[var(--status-warning-bg)] text-[var(--status-warning)] border border-[var(--status-warning)]/30",
|
||||
error:
|
||||
"border-transparent bg-[var(--status-error-bg)] text-[var(--status-error)] border border-[var(--status-error)]/30",
|
||||
info:
|
||||
"border-transparent bg-[var(--status-info-bg)] text-[var(--status-info)] border border-[var(--status-info)]/30",
|
||||
// Muted variants for subtle indication
|
||||
muted:
|
||||
"border-border/50 bg-muted/50 text-muted-foreground",
|
||||
brand:
|
||||
"border-transparent bg-brand-500/15 text-brand-500 border border-brand-500/30",
|
||||
},
|
||||
size: {
|
||||
default: "px-2.5 py-0.5 text-xs",
|
||||
sm: "px-2 py-0.5 text-[10px]",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,100 +0,0 @@
|
||||
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,88 +0,0 @@
|
||||
"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,233 +0,0 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
"ref"
|
||||
> & {
|
||||
showCloseButton?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
DialogContentProps
|
||||
>(({ className, children, showCloseButton = true, compact = false, ...props }, ref) => {
|
||||
// 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
|
||||
ref={ref}
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
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,292 +0,0 @@
|
||||
"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,660 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut } from "@/store/app-store";
|
||||
import type { KeyboardShortcuts } from "@/store/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle2, X, RotateCcw, Edit2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// Detect if running on Mac
|
||||
const isMac = typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
|
||||
// Keyboard layout - US QWERTY
|
||||
const KEYBOARD_ROWS = [
|
||||
// Number row
|
||||
[
|
||||
{ key: "`", label: "`", width: 1 },
|
||||
{ key: "1", label: "1", width: 1 },
|
||||
{ key: "2", label: "2", width: 1 },
|
||||
{ key: "3", label: "3", width: 1 },
|
||||
{ key: "4", label: "4", width: 1 },
|
||||
{ key: "5", label: "5", width: 1 },
|
||||
{ key: "6", label: "6", width: 1 },
|
||||
{ key: "7", label: "7", width: 1 },
|
||||
{ key: "8", label: "8", width: 1 },
|
||||
{ key: "9", label: "9", width: 1 },
|
||||
{ key: "0", label: "0", width: 1 },
|
||||
{ key: "-", label: "-", width: 1 },
|
||||
{ key: "=", label: "=", width: 1 },
|
||||
],
|
||||
// Top letter row
|
||||
[
|
||||
{ key: "Q", label: "Q", width: 1 },
|
||||
{ key: "W", label: "W", width: 1 },
|
||||
{ key: "E", label: "E", width: 1 },
|
||||
{ key: "R", label: "R", width: 1 },
|
||||
{ key: "T", label: "T", width: 1 },
|
||||
{ key: "Y", label: "Y", width: 1 },
|
||||
{ key: "U", label: "U", width: 1 },
|
||||
{ key: "I", label: "I", width: 1 },
|
||||
{ key: "O", label: "O", width: 1 },
|
||||
{ key: "P", label: "P", width: 1 },
|
||||
{ key: "[", label: "[", width: 1 },
|
||||
{ key: "]", label: "]", width: 1 },
|
||||
{ key: "\\", label: "\\", width: 1 },
|
||||
],
|
||||
// Home row
|
||||
[
|
||||
{ key: "A", label: "A", width: 1 },
|
||||
{ key: "S", label: "S", width: 1 },
|
||||
{ key: "D", label: "D", width: 1 },
|
||||
{ key: "F", label: "F", width: 1 },
|
||||
{ key: "G", label: "G", width: 1 },
|
||||
{ key: "H", label: "H", width: 1 },
|
||||
{ key: "J", label: "J", width: 1 },
|
||||
{ key: "K", label: "K", width: 1 },
|
||||
{ key: "L", label: "L", width: 1 },
|
||||
{ key: ";", label: ";", width: 1 },
|
||||
{ key: "'", label: "'", width: 1 },
|
||||
],
|
||||
// Bottom letter row
|
||||
[
|
||||
{ key: "Z", label: "Z", width: 1 },
|
||||
{ key: "X", label: "X", width: 1 },
|
||||
{ key: "C", label: "C", width: 1 },
|
||||
{ key: "V", label: "V", width: 1 },
|
||||
{ key: "B", label: "B", width: 1 },
|
||||
{ key: "N", label: "N", width: 1 },
|
||||
{ key: "M", label: "M", width: 1 },
|
||||
{ key: ",", label: ",", width: 1 },
|
||||
{ key: ".", label: ".", width: 1 },
|
||||
{ key: "/", label: "/", width: 1 },
|
||||
],
|
||||
];
|
||||
|
||||
// Map shortcut names to human-readable labels
|
||||
const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
|
||||
board: "Kanban Board",
|
||||
agent: "Agent Runner",
|
||||
spec: "Spec Editor",
|
||||
context: "Context",
|
||||
settings: "Settings",
|
||||
profiles: "AI Profiles",
|
||||
terminal: "Terminal",
|
||||
toggleSidebar: "Toggle Sidebar",
|
||||
addFeature: "Add Feature",
|
||||
addContextFile: "Add Context File",
|
||||
startNext: "Start Next",
|
||||
newSession: "New Session",
|
||||
openProject: "Open Project",
|
||||
projectPicker: "Project Picker",
|
||||
cyclePrevProject: "Prev Project",
|
||||
cycleNextProject: "Next Project",
|
||||
addProfile: "Add Profile",
|
||||
splitTerminalRight: "Split Right",
|
||||
splitTerminalDown: "Split Down",
|
||||
closeTerminal: "Close Terminal",
|
||||
};
|
||||
|
||||
// Categorize shortcuts for color coding
|
||||
const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, "navigation" | "ui" | "action"> = {
|
||||
board: "navigation",
|
||||
agent: "navigation",
|
||||
spec: "navigation",
|
||||
context: "navigation",
|
||||
settings: "navigation",
|
||||
profiles: "navigation",
|
||||
terminal: "navigation",
|
||||
toggleSidebar: "ui",
|
||||
addFeature: "action",
|
||||
addContextFile: "action",
|
||||
startNext: "action",
|
||||
newSession: "action",
|
||||
openProject: "action",
|
||||
projectPicker: "action",
|
||||
cyclePrevProject: "action",
|
||||
cycleNextProject: "action",
|
||||
addProfile: "action",
|
||||
splitTerminalRight: "action",
|
||||
splitTerminalDown: "action",
|
||||
closeTerminal: "action",
|
||||
};
|
||||
|
||||
// Category colors
|
||||
const CATEGORY_COLORS = {
|
||||
navigation: {
|
||||
bg: "bg-blue-500/20",
|
||||
border: "border-blue-500/50",
|
||||
text: "text-blue-400",
|
||||
label: "Navigation",
|
||||
},
|
||||
ui: {
|
||||
bg: "bg-purple-500/20",
|
||||
border: "border-purple-500/50",
|
||||
text: "text-purple-400",
|
||||
label: "UI Controls",
|
||||
},
|
||||
action: {
|
||||
bg: "bg-green-500/20",
|
||||
border: "border-green-500/50",
|
||||
text: "text-green-400",
|
||||
label: "Actions",
|
||||
},
|
||||
};
|
||||
|
||||
interface KeyboardMapProps {
|
||||
onKeySelect?: (key: string) => void;
|
||||
selectedKey?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KeyboardMap({ onKeySelect, selectedKey, className }: KeyboardMapProps) {
|
||||
const { keyboardShortcuts } = useAppStore();
|
||||
|
||||
// Merge with defaults to ensure new shortcuts are always shown
|
||||
const mergedShortcuts = React.useMemo(() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}), [keyboardShortcuts]);
|
||||
|
||||
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
||||
const keyToShortcuts = React.useMemo(() => {
|
||||
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
||||
(Object.entries(mergedShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||
([shortcutName, shortcutStr]) => {
|
||||
if (!shortcutStr) return; // Skip undefined shortcuts
|
||||
const parsed = parseShortcut(shortcutStr);
|
||||
const normalizedKey = parsed.key.toUpperCase();
|
||||
const hasModifiers = !!(parsed.shift || parsed.cmdCtrl || parsed.alt);
|
||||
if (!map[normalizedKey]) {
|
||||
map[normalizedKey] = [];
|
||||
}
|
||||
map[normalizedKey].push({ name: shortcutName, hasModifiers });
|
||||
}
|
||||
);
|
||||
return map;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
const renderKey = (keyDef: { key: string; label: string; width: number }) => {
|
||||
const normalizedKey = keyDef.key.toUpperCase();
|
||||
const shortcutInfos = keyToShortcuts[normalizedKey] || [];
|
||||
const shortcuts = shortcutInfos.map(s => s.name);
|
||||
const isBound = shortcuts.length > 0;
|
||||
const isSelected = selectedKey?.toUpperCase() === normalizedKey;
|
||||
const isModified = shortcuts.some(
|
||||
(s) => mergedShortcuts[s] !== DEFAULT_KEYBOARD_SHORTCUTS[s]
|
||||
);
|
||||
|
||||
// Get category for coloring (use first shortcut's category if multiple)
|
||||
const category = shortcuts.length > 0 ? SHORTCUT_CATEGORIES[shortcuts[0]] : null;
|
||||
const colors = category ? CATEGORY_COLORS[category] : null;
|
||||
|
||||
const keyElement = (
|
||||
<button
|
||||
key={keyDef.key}
|
||||
onClick={() => onKeySelect?.(keyDef.key)}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center rounded-lg border transition-all",
|
||||
"h-12 min-w-11 py-1",
|
||||
keyDef.width > 1 && `w-[${keyDef.width * 2.75}rem]`,
|
||||
// Base styles
|
||||
!isBound && "bg-sidebar-accent/10 border-sidebar-border hover:bg-sidebar-accent/20",
|
||||
// Bound key styles
|
||||
isBound && colors && `${colors.bg} ${colors.border} hover:brightness-110`,
|
||||
// Selected state
|
||||
isSelected && "ring-2 ring-brand-500 ring-offset-2 ring-offset-background",
|
||||
// Modified indicator
|
||||
isModified && "ring-1 ring-yellow-500/50"
|
||||
)}
|
||||
data-testid={`keyboard-key-${keyDef.key}`}
|
||||
>
|
||||
{/* Key label - always at top */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-mono font-bold leading-none",
|
||||
isBound && colors ? colors.text : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{keyDef.label}
|
||||
</span>
|
||||
{/* Shortcut label - always takes up space to maintain consistent height */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] leading-tight text-center px-0.5 truncate max-w-full h-3 mt-0.5",
|
||||
isBound && shortcuts.length > 0
|
||||
? (colors ? colors.text : "text-muted-foreground")
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
{isBound && shortcuts.length > 0
|
||||
? (shortcuts.length === 1
|
||||
? (SHORTCUT_LABELS[shortcuts[0]]?.split(" ")[0] ?? shortcuts[0])
|
||||
: `${shortcuts.length}x`)
|
||||
: "\u00A0" // Non-breaking space to maintain height
|
||||
}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Wrap in tooltip if bound
|
||||
if (isBound) {
|
||||
return (
|
||||
<Tooltip key={keyDef.key}>
|
||||
<TooltipTrigger asChild>{keyElement}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{shortcuts.map((shortcut) => {
|
||||
const shortcutStr = mergedShortcuts[shortcut];
|
||||
const displayShortcut = formatShortcut(shortcutStr, true);
|
||||
return (
|
||||
<div key={shortcut} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
SHORTCUT_CATEGORIES[shortcut] && CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]]
|
||||
? CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
||||
: "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm">{SHORTCUT_LABELS[shortcut] ?? shortcut}</span>
|
||||
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
||||
{displayShortcut}
|
||||
</kbd>
|
||||
{mergedShortcuts[shortcut] !== DEFAULT_KEYBOARD_SHORTCUTS[shortcut] && (
|
||||
<span className="text-xs text-yellow-400">(custom)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return keyElement;
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className={cn("space-y-4", className)} data-testid="keyboard-map">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 justify-center text-xs">
|
||||
{Object.entries(CATEGORY_COLORS).map(([key, colors]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded border",
|
||||
colors.bg,
|
||||
colors.border
|
||||
)}
|
||||
/>
|
||||
<span className={colors.text}>{colors.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-sidebar-accent/10 border border-sidebar-border" />
|
||||
<span className="text-muted-foreground">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-yellow-400">Modified</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard layout */}
|
||||
<div className="flex flex-col items-center gap-1.5 p-4 rounded-xl bg-sidebar-accent/5 border border-sidebar-border">
|
||||
{KEYBOARD_ROWS.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="flex gap-1.5 justify-center">
|
||||
{row.map(renderKey)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-6 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<strong className="text-foreground">{Object.keys(keyboardShortcuts).length}</strong> shortcuts
|
||||
configured
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys in use
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-foreground">
|
||||
{KEYBOARD_ROWS.flat().length - Object.keys(keyToShortcuts).length}
|
||||
</strong>{" "}
|
||||
keys available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Full shortcut reference panel with editing capability
|
||||
interface ShortcutReferencePanelProps {
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function ShortcutReferencePanel({ editable = false }: ShortcutReferencePanelProps) {
|
||||
const { keyboardShortcuts, setKeyboardShortcut, resetKeyboardShortcuts } = useAppStore();
|
||||
const [editingShortcut, setEditingShortcut] = React.useState<keyof KeyboardShortcuts | null>(null);
|
||||
const [keyValue, setKeyValue] = React.useState("");
|
||||
const [modifiers, setModifiers] = React.useState({ shift: false, cmdCtrl: false, alt: false });
|
||||
const [shortcutError, setShortcutError] = React.useState<string | null>(null);
|
||||
|
||||
// Merge with defaults to ensure new shortcuts are always shown
|
||||
const mergedShortcuts = React.useMemo(() => ({
|
||||
...DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
...keyboardShortcuts,
|
||||
}), [keyboardShortcuts]);
|
||||
|
||||
const groupedShortcuts = React.useMemo(() => {
|
||||
const groups: Record<string, Array<{ key: keyof KeyboardShortcuts; label: string; value: string }>> = {
|
||||
navigation: [],
|
||||
ui: [],
|
||||
action: [],
|
||||
};
|
||||
|
||||
(Object.entries(SHORTCUT_CATEGORIES) as [keyof KeyboardShortcuts, string][]).forEach(
|
||||
([shortcut, category]) => {
|
||||
groups[category].push({
|
||||
key: shortcut,
|
||||
label: SHORTCUT_LABELS[shortcut] ?? shortcut,
|
||||
value: mergedShortcuts[shortcut],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return groups;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
// Build the full shortcut string from key + modifiers
|
||||
const buildShortcutString = React.useCallback((key: string, mods: typeof modifiers) => {
|
||||
const parts: string[] = [];
|
||||
if (mods.cmdCtrl) parts.push(isMac ? "Cmd" : "Ctrl");
|
||||
if (mods.alt) parts.push(isMac ? "Opt" : "Alt");
|
||||
if (mods.shift) parts.push("Shift");
|
||||
parts.push(key.toUpperCase());
|
||||
return parts.join("+");
|
||||
}, []);
|
||||
|
||||
// Check for conflicts with other shortcuts
|
||||
const checkConflict = React.useCallback((shortcutStr: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
const conflict = Object.entries(mergedShortcuts).find(
|
||||
([k, v]) => k !== currentKey && v?.toUpperCase() === shortcutStr.toUpperCase()
|
||||
);
|
||||
return conflict ? (SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] ?? conflict[0]) : null;
|
||||
}, [mergedShortcuts]);
|
||||
|
||||
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
||||
const currentValue = mergedShortcuts[key];
|
||||
const parsed = parseShortcut(currentValue);
|
||||
setEditingShortcut(key);
|
||||
setKeyValue(parsed.key);
|
||||
setModifiers({
|
||||
shift: parsed.shift || false,
|
||||
cmdCtrl: parsed.cmdCtrl || false,
|
||||
alt: parsed.alt || false,
|
||||
});
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleSaveShortcut = () => {
|
||||
if (!editingShortcut || shortcutError || !keyValue) return;
|
||||
const shortcutStr = buildShortcutString(keyValue, modifiers);
|
||||
setKeyboardShortcut(editingShortcut, shortcutStr);
|
||||
setEditingShortcut(null);
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingShortcut(null);
|
||||
setKeyValue("");
|
||||
setModifiers({ shift: false, cmdCtrl: false, alt: false });
|
||||
setShortcutError(null);
|
||||
};
|
||||
|
||||
const handleKeyChange = (value: string, currentKey: keyof KeyboardShortcuts) => {
|
||||
setKeyValue(value);
|
||||
// Check for conflicts with full shortcut string
|
||||
if (!value) {
|
||||
setShortcutError("Key cannot be empty");
|
||||
} else {
|
||||
const shortcutStr = buildShortcutString(value, modifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
if (conflictLabel) {
|
||||
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleModifierChange = (modifier: keyof typeof modifiers, checked: boolean, currentKey: keyof KeyboardShortcuts) => {
|
||||
// Enforce single modifier: when checking, uncheck all others (radio-button behavior)
|
||||
const newModifiers = checked
|
||||
? { shift: false, cmdCtrl: false, alt: false, [modifier]: true }
|
||||
: { ...modifiers, [modifier]: false };
|
||||
|
||||
setModifiers(newModifiers);
|
||||
|
||||
// Recheck for conflicts
|
||||
if (keyValue) {
|
||||
const shortcutStr = buildShortcutString(keyValue, newModifiers);
|
||||
const conflictLabel = checkConflict(shortcutStr, currentKey);
|
||||
if (conflictLabel) {
|
||||
setShortcutError(`Already used by "${conflictLabel}"`);
|
||||
} else {
|
||||
setShortcutError(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !shortcutError && keyValue) {
|
||||
handleSaveShortcut();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetShortcut = (key: keyof KeyboardShortcuts) => {
|
||||
setKeyboardShortcut(key, DEFAULT_KEYBOARD_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4" data-testid="shortcut-reference-panel">
|
||||
{editable && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetKeyboardShortcuts()}
|
||||
className="gap-2 text-xs"
|
||||
data-testid="reset-all-shortcuts-button"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset All to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => {
|
||||
const colors = CATEGORY_COLORS[category as keyof typeof CATEGORY_COLORS];
|
||||
return (
|
||||
<div key={category} className="space-y-2">
|
||||
<h4 className={cn("text-sm font-semibold", colors.text)}>
|
||||
{colors.label}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{shortcuts.map(({ key, label, value }) => {
|
||||
const isModified = mergedShortcuts[key] !== DEFAULT_KEYBOARD_SHORTCUTS[key];
|
||||
const isEditing = editingShortcut === key;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 rounded-lg bg-sidebar-accent/10 border transition-colors",
|
||||
isEditing ? "border-brand-500" : "border-sidebar-border",
|
||||
editable && !isEditing && "hover:bg-sidebar-accent/20 cursor-pointer"
|
||||
)}
|
||||
onClick={() => editable && !isEditing && handleStartEdit(key)}
|
||||
data-testid={`shortcut-row-${key}`}
|
||||
>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Modifier checkboxes */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-cmd-${key}`}
|
||||
checked={modifiers.cmdCtrl}
|
||||
onCheckedChange={(checked) => handleModifierChange("cmdCtrl", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-cmd-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌘" : "Ctrl"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-alt-${key}`}
|
||||
checked={modifiers.alt}
|
||||
onCheckedChange={(checked) => handleModifierChange("alt", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-alt-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
{isMac ? "⌥" : "Alt"}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`mod-shift-${key}`}
|
||||
checked={modifiers.shift}
|
||||
onCheckedChange={(checked) => handleModifierChange("shift", !!checked, key)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<Label htmlFor={`mod-shift-${key}`} className="text-xs text-muted-foreground cursor-pointer">
|
||||
⇧
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground">+</span>
|
||||
<Input
|
||||
value={keyValue}
|
||||
onChange={(e) => handleKeyChange(e.target.value, key)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"w-12 h-7 text-center font-mono text-xs uppercase",
|
||||
shortcutError && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
placeholder="Key"
|
||||
maxLength={1}
|
||||
autoFocus
|
||||
data-testid={`edit-shortcut-input-${key}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-green-500/20 hover:text-green-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveShortcut();
|
||||
}}
|
||||
disabled={!!shortcutError || !keyValue}
|
||||
data-testid={`save-shortcut-${key}`}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 hover:bg-red-500/20 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelEdit();
|
||||
}}
|
||||
data-testid={`cancel-shortcut-${key}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<kbd
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs font-mono rounded border",
|
||||
colors.bg,
|
||||
colors.border,
|
||||
colors.text
|
||||
)}
|
||||
>
|
||||
{formatShortcut(value, true)}
|
||||
</kbd>
|
||||
{isModified && editable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 hover:bg-yellow-500/20 hover:text-yellow-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetShortcut(key);
|
||||
}}
|
||||
data-testid={`reset-shortcut-${key}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Reset to default ({DEFAULT_KEYBOARD_SHORTCUTS[key]})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isModified && !editable && (
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
)}
|
||||
{editable && !isModified && (
|
||||
<Edit2 className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{editingShortcut && shortcutError && SHORTCUT_CATEGORIES[editingShortcut] === category && (
|
||||
<p className="text-xs text-red-400 mt-1">{shortcutError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"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 }
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Markdown component for rendering markdown content
|
||||
* Theme-aware styling that adapts to all predefined themes
|
||||
*/
|
||||
export function Markdown({ children, className }: MarkdownProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none",
|
||||
// Headings
|
||||
"[&_h1]:text-xl [&_h1]:text-foreground [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2",
|
||||
"[&_h2]:text-lg [&_h2]:text-foreground [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2",
|
||||
"[&_h3]:text-base [&_h3]:text-foreground [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2",
|
||||
"[&_h4]:text-sm [&_h4]:text-foreground [&_h4]:font-semibold [&_h4]:mt-2 [&_h4]:mb-1",
|
||||
// Paragraphs
|
||||
"[&_p]:text-foreground-secondary [&_p]:leading-relaxed [&_p]:my-2",
|
||||
// Lists
|
||||
"[&_ul]:my-2 [&_ul]:pl-4 [&_ol]:my-2 [&_ol]:pl-4",
|
||||
"[&_li]:text-foreground-secondary [&_li]:my-0.5",
|
||||
// Code
|
||||
"[&_code]:text-chart-2 [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm",
|
||||
"[&_pre]:bg-card [&_pre]:border [&_pre]:border-border [&_pre]:rounded-lg [&_pre]:my-2 [&_pre]:p-3 [&_pre]:overflow-x-auto",
|
||||
"[&_pre_code]:bg-transparent [&_pre_code]:p-0",
|
||||
// Strong/Bold
|
||||
"[&_strong]:text-foreground [&_strong]:font-semibold",
|
||||
// Links
|
||||
"[&_a]:text-brand-500 [&_a]:no-underline hover:[&_a]:underline",
|
||||
// Blockquotes
|
||||
"[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic [&_blockquote]:my-2",
|
||||
// Horizontal rules
|
||||
"[&_hr]:border-border [&_hr]:my-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { Extension } from "@codemirror/state";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface XmlSyntaxEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
// Syntax highlighting that uses CSS variables from the app's theme system
|
||||
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
// XML tags - use primary color
|
||||
{ tag: t.tagName, color: "var(--primary)" },
|
||||
{ tag: t.angleBracket, color: "var(--muted-foreground)" },
|
||||
|
||||
// Attributes
|
||||
{ tag: t.attributeName, color: "var(--chart-2, oklch(0.6 0.118 184.704))" },
|
||||
{ tag: t.attributeValue, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
|
||||
|
||||
// Strings and content
|
||||
{ tag: t.string, color: "var(--chart-1, oklch(0.646 0.222 41.116))" },
|
||||
{ tag: t.content, color: "var(--foreground)" },
|
||||
|
||||
// Comments
|
||||
{ tag: t.comment, color: "var(--muted-foreground)", fontStyle: "italic" },
|
||||
|
||||
// Special
|
||||
{ tag: t.processingInstruction, color: "var(--muted-foreground)" },
|
||||
{ tag: t.documentMeta, color: "var(--muted-foreground)" },
|
||||
]);
|
||||
|
||||
// Editor theme using CSS variables
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "1rem",
|
||||
minHeight: "100%",
|
||||
caretColor: "var(--primary)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
backgroundColor: "oklch(0.55 0.25 265 / 0.3)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-line": {
|
||||
padding: "0",
|
||||
},
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-gutters": {
|
||||
display: "none",
|
||||
},
|
||||
".cm-placeholder": {
|
||||
color: "var(--muted-foreground)",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
});
|
||||
|
||||
// Combine all extensions
|
||||
const extensions: Extension[] = [
|
||||
xml(),
|
||||
syntaxHighlighting(syntaxColors),
|
||||
editorTheme,
|
||||
];
|
||||
|
||||
export function XmlSyntaxEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
}: XmlSyntaxEditorProps) {
|
||||
return (
|
||||
<div className={cn("w-full h-full", className)} data-testid={testId}>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme="none"
|
||||
placeholder={placeholder}
|
||||
className="h-full [&_.cm-editor]:h-full"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: true,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { KanbanCard } from "./kanban-card";
|
||||
export { KanbanColumn } from "./kanban-column";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
import { Feature } from "@/store/app-store";
|
||||
|
||||
export type ColumnId = Feature["status"];
|
||||
|
||||
export const COLUMNS: { id: ColumnId; title: string; colorClass: string }[] = [
|
||||
{ id: "backlog", title: "Backlog", colorClass: "bg-[var(--status-backlog)]" },
|
||||
{
|
||||
id: "in_progress",
|
||||
title: "In Progress",
|
||||
colorClass: "bg-[var(--status-in-progress)]",
|
||||
},
|
||||
{
|
||||
id: "waiting_approval",
|
||||
title: "Waiting Approval",
|
||||
colorClass: "bg-[var(--status-waiting)]",
|
||||
},
|
||||
{
|
||||
id: "verified",
|
||||
title: "Verified",
|
||||
colorClass: "bg-[var(--status-success)]",
|
||||
},
|
||||
];
|
||||
@@ -1,9 +0,0 @@
|
||||
export { AddFeatureDialog } from "./add-feature-dialog";
|
||||
export { AgentOutputModal } from "./agent-output-modal";
|
||||
export { CompletedFeaturesModal } from "./completed-features-modal";
|
||||
export { ArchiveAllVerifiedDialog } from "./archive-all-verified-dialog";
|
||||
export { DeleteCompletedFeatureDialog } from "./delete-completed-feature-dialog";
|
||||
export { EditFeatureDialog } from "./edit-feature-dialog";
|
||||
export { FeatureSuggestionsDialog } from "./feature-suggestions-dialog";
|
||||
export { FollowUpDialog } from "./follow-up-dialog";
|
||||
export { PlanApprovalDialog } from "./plan-approval-dialog";
|
||||
@@ -1,10 +0,0 @@
|
||||
export { useBoardFeatures } from "./use-board-features";
|
||||
export { useBoardDragDrop } from "./use-board-drag-drop";
|
||||
export { useBoardActions } from "./use-board-actions";
|
||||
export { useBoardKeyboardShortcuts } from "./use-board-keyboard-shortcuts";
|
||||
export { useBoardColumnFeatures } from "./use-board-column-features";
|
||||
export { useBoardEffects } from "./use-board-effects";
|
||||
export { useBoardBackground } from "./use-board-background";
|
||||
export { useBoardPersistence } from "./use-board-persistence";
|
||||
export { useFollowUpState } from "./use-follow-up-state";
|
||||
export { useSuggestionsState } from "./use-suggestions-state";
|
||||
@@ -1,8 +0,0 @@
|
||||
export * from "./model-constants";
|
||||
export * from "./model-selector";
|
||||
export * from "./thinking-level-selector";
|
||||
export * from "./profile-quick-select";
|
||||
export * from "./testing-tab-content";
|
||||
export * from "./priority-selector";
|
||||
export * from "./branch-selector";
|
||||
export * from "./planning-mode-selector";
|
||||
@@ -1,70 +0,0 @@
|
||||
import { AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||
import {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
export type ModelOption = {
|
||||
id: AgentModel;
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
provider: "claude";
|
||||
};
|
||||
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: "haiku",
|
||||
label: "Claude Haiku",
|
||||
description: "Fast and efficient for simple tasks.",
|
||||
badge: "Speed",
|
||||
provider: "claude",
|
||||
},
|
||||
{
|
||||
id: "sonnet",
|
||||
label: "Claude Sonnet",
|
||||
description: "Balanced performance with strong reasoning.",
|
||||
badge: "Balanced",
|
||||
provider: "claude",
|
||||
},
|
||||
{
|
||||
id: "opus",
|
||||
label: "Claude Opus",
|
||||
description: "Most capable model for complex work.",
|
||||
badge: "Premium",
|
||||
provider: "claude",
|
||||
},
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"ultrathink",
|
||||
];
|
||||
|
||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: "None",
|
||||
low: "Low",
|
||||
medium: "Med",
|
||||
high: "High",
|
||||
ultrathink: "Ultra",
|
||||
};
|
||||
|
||||
// Profile icon mapping
|
||||
export const PROFILE_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FlaskConical, Plus } from "lucide-react";
|
||||
|
||||
interface TestingTabContentProps {
|
||||
skipTests: boolean;
|
||||
onSkipTestsChange: (skipTests: boolean) => void;
|
||||
steps: string[];
|
||||
onStepsChange: (steps: string[]) => void;
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
export function TestingTabContent({
|
||||
skipTests,
|
||||
onSkipTestsChange,
|
||||
steps,
|
||||
onStepsChange,
|
||||
testIdPrefix = "",
|
||||
}: TestingTabContentProps) {
|
||||
const checkboxId = testIdPrefix ? `${testIdPrefix}-skip-tests` : "skip-tests";
|
||||
|
||||
const handleStepChange = (index: number, value: string) => {
|
||||
const newSteps = [...steps];
|
||||
newSteps[index] = value;
|
||||
onStepsChange(newSteps);
|
||||
};
|
||||
|
||||
const handleAddStep = () => {
|
||||
onStepsChange([...steps, ""]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={!skipTests}
|
||||
onCheckedChange={(checked) => onSkipTestsChange(checked !== true)}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}skip-tests-checkbox`}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={checkboxId} className="text-sm cursor-pointer">
|
||||
Enable automated testing
|
||||
</Label>
|
||||
<FlaskConical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, this feature will use automated TDD. When disabled, it
|
||||
will require manual verification.
|
||||
</p>
|
||||
|
||||
{/* Verification Steps - Only shown when skipTests is enabled */}
|
||||
{skipTests && (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label>Verification Steps</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Add manual steps to verify this feature works correctly.
|
||||
</p>
|
||||
{steps.map((step, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
value={step}
|
||||
placeholder={`Verification step ${index + 1}`}
|
||||
onChange={(e) => handleStepChange(index, e.target.value)}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}feature-step-${index}${testIdPrefix ? "" : "-input"}`}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddStep}
|
||||
data-testid={`${testIdPrefix ? testIdPrefix + "-" : ""}add-step-button`}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Verification Step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
export { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
export { WorktreeTab } from "./worktree-tab";
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Upload,
|
||||
Play,
|
||||
Square,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, DevServerInfo } from "../types";
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
isSelected: boolean;
|
||||
defaultEditorName: string;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeActionsDropdown({
|
||||
worktree,
|
||||
isSelected,
|
||||
defaultEditorName,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
onOpenChange,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeActionsDropdownProps) {
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-l-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{isDevServerRunning ? (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:{devServerInfo?.port})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" />
|
||||
Open in Browser
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStopDevServer(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 mr-2" />
|
||||
Stop Dev Server
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onStartDevServer(worktree)}
|
||||
disabled={isStartingDevServer}
|
||||
className="text-xs"
|
||||
>
|
||||
<Play
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 mr-2",
|
||||
isStartingDevServer && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
{isStartingDevServer ? "Starting..." : "Start Dev Server"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPull(worktree)}
|
||||
disabled={isPulling}
|
||||
className="text-xs"
|
||||
>
|
||||
<Download
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPulling && "animate-pulse")}
|
||||
/>
|
||||
{isPulling ? "Pulling..." : "Pull"}
|
||||
{behindCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-muted px-1.5 py-0.5 rounded">
|
||||
{behindCount} behind
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPush(worktree)}
|
||||
disabled={isPushing || aheadCount === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
<Upload
|
||||
className={cn("w-3.5 h-3.5 mr-2", isPushing && "animate-pulse")}
|
||||
/>
|
||||
{isPushing ? "Pushing..." : "Push"}
|
||||
{aheadCount > 0 && (
|
||||
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
|
||||
{aheadCount} ahead
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInEditor(worktree)}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {defaultEditorName}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{worktree.hasChanges && (
|
||||
<DropdownMenuItem onClick={() => onCommit(worktree)} className="text-xs">
|
||||
<GitCommit className="w-3.5 h-3.5 mr-2" />
|
||||
Commit Changes
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Show PR option for non-primary worktrees, or primary worktree with changes */}
|
||||
{(!worktree.isMain || worktree.hasChanges) && (
|
||||
<DropdownMenuItem onClick={() => onCreatePR(worktree)} className="text-xs">
|
||||
<GitPullRequest className="w-3.5 h-3.5 mr-2" />
|
||||
Create Pull Request
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!worktree.isMain && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteWorktree(worktree)}
|
||||
className="text-xs text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete Worktree
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Globe, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo } from "../types";
|
||||
import { BranchSwitchDropdown } from "./branch-switch-dropdown";
|
||||
import { WorktreeActionsDropdown } from "./worktree-actions-dropdown";
|
||||
|
||||
interface WorktreeTabProps {
|
||||
worktree: WorktreeInfo;
|
||||
cardCount?: number; // Number of unarchived cards for this branch
|
||||
isSelected: boolean;
|
||||
isRunning: boolean;
|
||||
isActivating: boolean;
|
||||
isDevServerRunning: boolean;
|
||||
devServerInfo?: DevServerInfo;
|
||||
defaultEditorName: string;
|
||||
branches: BranchInfo[];
|
||||
filteredBranches: BranchInfo[];
|
||||
branchFilter: string;
|
||||
isLoadingBranches: boolean;
|
||||
isSwitching: boolean;
|
||||
isPulling: boolean;
|
||||
isPushing: boolean;
|
||||
isStartingDevServer: boolean;
|
||||
aheadCount: number;
|
||||
behindCount: number;
|
||||
onSelectWorktree: (worktree: WorktreeInfo) => void;
|
||||
onBranchDropdownOpenChange: (open: boolean) => void;
|
||||
onActionsDropdownOpenChange: (open: boolean) => void;
|
||||
onBranchFilterChange: (value: string) => void;
|
||||
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
|
||||
onCreateBranch: (worktree: WorktreeInfo) => void;
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onDeleteWorktree: (worktree: WorktreeInfo) => void;
|
||||
onStartDevServer: (worktree: WorktreeInfo) => void;
|
||||
onStopDevServer: (worktree: WorktreeInfo) => void;
|
||||
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
|
||||
}
|
||||
|
||||
export function WorktreeTab({
|
||||
worktree,
|
||||
cardCount,
|
||||
isSelected,
|
||||
isRunning,
|
||||
isActivating,
|
||||
isDevServerRunning,
|
||||
devServerInfo,
|
||||
defaultEditorName,
|
||||
branches,
|
||||
filteredBranches,
|
||||
branchFilter,
|
||||
isLoadingBranches,
|
||||
isSwitching,
|
||||
isPulling,
|
||||
isPushing,
|
||||
isStartingDevServer,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
onSelectWorktree,
|
||||
onBranchDropdownOpenChange,
|
||||
onActionsDropdownOpenChange,
|
||||
onBranchFilterChange,
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onDeleteWorktree,
|
||||
onStartDevServer,
|
||||
onStopDevServer,
|
||||
onOpenDevServerUrl,
|
||||
}: WorktreeTabProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{worktree.isMain ? (
|
||||
<>
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 border-r-0 rounded-l-md rounded-r-none",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary"
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title="Click to preview main"
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<BranchSwitchDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onOpenChange={onBranchDropdownOpenChange}
|
||||
onFilterChange={onBranchFilterChange}
|
||||
onSwitchBranch={onSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 px-3 text-xs font-mono gap-1.5 rounded-l-md rounded-r-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
!worktree.hasWorktree && !isSelected && "opacity-70"
|
||||
)}
|
||||
onClick={() => onSelectWorktree(worktree)}
|
||||
disabled={isActivating}
|
||||
title={
|
||||
worktree.hasWorktree
|
||||
? "Click to switch to this worktree's branch"
|
||||
: "Click to switch to this branch"
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isDevServerRunning && (
|
||||
<Button
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 rounded-none border-r-0",
|
||||
isSelected && "bg-primary text-primary-foreground",
|
||||
!isSelected && "bg-secondary/50 hover:bg-secondary",
|
||||
"text-green-500"
|
||||
)}
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
title={`Open dev server (port ${devServerInfo?.port})`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<WorktreeActionsDropdown
|
||||
worktree={worktree}
|
||||
isSelected={isSelected}
|
||||
defaultEditorName={defaultEditorName}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
devServerInfo={devServerInfo}
|
||||
onOpenChange={onActionsDropdownOpenChange}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { useWorktrees } from "./use-worktrees";
|
||||
export { useDevServers } from "./use-dev-servers";
|
||||
export { useBranches } from "./use-branches";
|
||||
export { useWorktreeActions } from "./use-worktree-actions";
|
||||
export { useDefaultEditor } from "./use-default-editor";
|
||||
export { useRunningFeatures } from "./use-running-features";
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import type { BranchInfo } from "../types";
|
||||
|
||||
export function useBranches() {
|
||||
const [branches, setBranches] = useState<BranchInfo[]>([]);
|
||||
const [aheadCount, setAheadCount] = useState(0);
|
||||
const [behindCount, setBehindCount] = useState(0);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchFilter, setBranchFilter] = useState("");
|
||||
|
||||
const fetchBranches = useCallback(async (worktreePath: string) => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listBranches) {
|
||||
console.warn("List branches API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listBranches(worktreePath);
|
||||
if (result.success && result.result) {
|
||||
setBranches(result.result.branches);
|
||||
setAheadCount(result.result.aheadCount || 0);
|
||||
setBehindCount(result.result.behindCount || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch branches:", error);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetBranchFilter = useCallback(() => {
|
||||
setBranchFilter("");
|
||||
}, []);
|
||||
|
||||
const filteredBranches = branches.filter((b) =>
|
||||
b.name.toLowerCase().includes(branchFilter.toLowerCase())
|
||||
);
|
||||
|
||||
return {
|
||||
branches,
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreeInfo } from "../types";
|
||||
|
||||
interface UseWorktreesOptions {
|
||||
projectPath: string;
|
||||
refreshTrigger?: number;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
}
|
||||
|
||||
export function useWorktrees({ projectPath, refreshTrigger = 0, onRemovedWorktrees }: UseWorktreesOptions) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [worktrees, setWorktrees] = useState<WorktreeInfo[]>([]);
|
||||
|
||||
const currentWorktree = useAppStore((s) => s.getCurrentWorktree(projectPath));
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const setWorktreesInStore = useAppStore((s) => s.setWorktrees);
|
||||
const useWorktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
|
||||
const fetchWorktrees = useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.listAll) {
|
||||
console.warn("Worktree API not available");
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.listAll(projectPath, true);
|
||||
if (result.success && result.worktrees) {
|
||||
setWorktrees(result.worktrees);
|
||||
setWorktreesInStore(projectPath, result.worktrees);
|
||||
}
|
||||
// Return removed worktrees so they can be handled by the caller
|
||||
return result.removedWorktrees;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch worktrees:", error);
|
||||
return undefined;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectPath, setWorktreesInStore]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorktrees();
|
||||
}, [fetchWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshTrigger > 0) {
|
||||
fetchWorktrees().then((removedWorktrees) => {
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [refreshTrigger, fetchWorktrees, onRemovedWorktrees]);
|
||||
|
||||
useEffect(() => {
|
||||
if (worktrees.length > 0) {
|
||||
const currentPath = currentWorktree?.path;
|
||||
const currentWorktreeExists = currentPath === null
|
||||
? true
|
||||
: worktrees.some((w) => !w.isMain && pathsEqual(w.path, currentPath));
|
||||
|
||||
if (currentWorktree == null || (currentPath !== null && !currentWorktreeExists)) {
|
||||
// Find the primary worktree and get its branch name
|
||||
// Fallback to "main" only if worktrees haven't loaded yet
|
||||
const mainWorktree = worktrees.find((w) => w.isMain);
|
||||
const mainBranch = mainWorktree?.branch || "main";
|
||||
setCurrentWorktree(projectPath, null, mainBranch);
|
||||
}
|
||||
}
|
||||
}, [worktrees, currentWorktree, projectPath, setCurrentWorktree]);
|
||||
|
||||
const handleSelectWorktree = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
setCurrentWorktree(
|
||||
projectPath,
|
||||
worktree.isMain ? null : worktree.path,
|
||||
worktree.branch
|
||||
);
|
||||
},
|
||||
[projectPath, setCurrentWorktree]
|
||||
);
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const selectedWorktree = currentWorktreePath
|
||||
? worktrees.find((w) => pathsEqual(w.path, currentWorktreePath))
|
||||
: worktrees.find((w) => w.isMain);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
worktrees,
|
||||
currentWorktree,
|
||||
currentWorktreePath,
|
||||
selectedWorktree,
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
};
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GitBranch, Plus, RefreshCw } from "lucide-react";
|
||||
import { cn, pathsEqual } from "@/lib/utils";
|
||||
import type { WorktreePanelProps, WorktreeInfo } from "./types";
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
useBranches,
|
||||
useWorktreeActions,
|
||||
useDefaultEditor,
|
||||
useRunningFeatures,
|
||||
} from "./hooks";
|
||||
import { WorktreeTab } from "./components";
|
||||
|
||||
export function WorktreePanel({
|
||||
projectPath,
|
||||
onCreateWorktree,
|
||||
onDeleteWorktree,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onCreateBranch,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
features = [],
|
||||
branchCardCounts,
|
||||
refreshTrigger = 0,
|
||||
}: WorktreePanelProps) {
|
||||
const {
|
||||
isLoading,
|
||||
worktrees,
|
||||
currentWorktree,
|
||||
currentWorktreePath,
|
||||
useWorktreesEnabled,
|
||||
fetchWorktrees,
|
||||
handleSelectWorktree,
|
||||
} = useWorktrees({ projectPath, refreshTrigger, onRemovedWorktrees });
|
||||
|
||||
const {
|
||||
isStartingDevServer,
|
||||
getWorktreeKey,
|
||||
isDevServerRunning,
|
||||
getDevServerInfo,
|
||||
handleStartDevServer,
|
||||
handleStopDevServer,
|
||||
handleOpenDevServerUrl,
|
||||
} = useDevServers({ projectPath });
|
||||
|
||||
const {
|
||||
branches,
|
||||
filteredBranches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
isLoadingBranches,
|
||||
branchFilter,
|
||||
setBranchFilter,
|
||||
resetBranchFilter,
|
||||
fetchBranches,
|
||||
} = useBranches();
|
||||
|
||||
const {
|
||||
isPulling,
|
||||
isPushing,
|
||||
isSwitching,
|
||||
isActivating,
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInEditor,
|
||||
} = useWorktreeActions({
|
||||
fetchWorktrees,
|
||||
fetchBranches,
|
||||
});
|
||||
|
||||
const { defaultEditorName } = useDefaultEditor();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
runningFeatureIds,
|
||||
features,
|
||||
});
|
||||
|
||||
const isWorktreeSelected = (worktree: WorktreeInfo) => {
|
||||
return worktree.isMain
|
||||
? currentWorktree === null ||
|
||||
currentWorktree === undefined ||
|
||||
currentWorktree.path === null
|
||||
: pathsEqual(worktree.path, currentWorktreePath);
|
||||
};
|
||||
|
||||
const handleBranchDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
resetBranchFilter();
|
||||
}
|
||||
};
|
||||
|
||||
const handleActionsDropdownOpenChange = (worktree: WorktreeInfo) => (open: boolean) => {
|
||||
if (open) {
|
||||
fetchBranches(worktree.path);
|
||||
}
|
||||
};
|
||||
|
||||
if (!useWorktreesEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{worktrees.map((worktree) => {
|
||||
const cardCount = branchCardCounts?.[worktree.branch];
|
||||
return (
|
||||
<WorktreeTab
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
cardCount={cardCount}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
isActivating={isActivating}
|
||||
isDevServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
defaultEditorName={defaultEditorName}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("w-3.5 h-3.5", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,859 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
Save,
|
||||
Upload,
|
||||
File,
|
||||
X,
|
||||
BookOpen,
|
||||
EditIcon,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Markdown } from "../ui/markdown";
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
type: "text" | "image";
|
||||
content?: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function ContextView() {
|
||||
const { currentProject } = useAppStore();
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [editedContent, setEditedContent] = useState("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameFileName, setRenameFileName] = useState("");
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [newFileContent, setNewFileContent] = useState("");
|
||||
const [isDropHovering, setIsDropHovering] = useState(false);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: shortcuts.addContextFile,
|
||||
action: () => setIsAddDialogOpen(true),
|
||||
description: "Add new context file",
|
||||
},
|
||||
],
|
||||
[shortcuts]
|
||||
);
|
||||
useKeyboardShortcuts(contextShortcuts);
|
||||
|
||||
// Get context directory path for user-added context files
|
||||
const getContextPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
return `${currentProject.path}/.automaker/context`;
|
||||
}, [currentProject]);
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
||||
return ext === ".md" || ext === ".markdown";
|
||||
};
|
||||
|
||||
// Determine if a file is an image based on extension
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
const imageExtensions = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".bmp",
|
||||
];
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
// Load context files
|
||||
const loadContextFiles = useCallback(async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
|
||||
// Ensure context directory exists
|
||||
await api.mkdir(contextPath);
|
||||
|
||||
// Read directory contents
|
||||
const result = await api.readdir(contextPath);
|
||||
if (result.success && result.entries) {
|
||||
const files: ContextFile[] = result.entries
|
||||
.filter((entry) => entry.isFile)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: isImageFile(entry.name) ? "image" : "text",
|
||||
path: `${contextPath}/${entry.name}`,
|
||||
}));
|
||||
setContextFiles(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load context files:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [getContextPath]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContextFiles();
|
||||
}, [loadContextFiles]);
|
||||
|
||||
// Load selected file content
|
||||
const loadFileContent = useCallback(async (file: ContextFile) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.readFile(file.path);
|
||||
if (result.success && result.content !== undefined) {
|
||||
setEditedContent(result.content);
|
||||
setSelectedFile({ ...file, content: result.content });
|
||||
setHasChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load file content:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Select a file
|
||||
const handleSelectFile = (file: ContextFile) => {
|
||||
if (hasChanges) {
|
||||
// Could add a confirmation dialog here
|
||||
}
|
||||
loadFileContent(file);
|
||||
setIsPreviewMode(isMarkdownFile(file.name));
|
||||
};
|
||||
|
||||
// Save current file
|
||||
const saveFile = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.writeFile(selectedFile.path, editedContent);
|
||||
setSelectedFile({ ...selectedFile, content: editedContent });
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save file:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle content change
|
||||
const handleContentChange = (value: string) => {
|
||||
setEditedContent(value);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// Add new context file
|
||||
const handleAddFile = async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath || !newFileName.trim()) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
let filename = newFileName.trim();
|
||||
|
||||
// Add default extension if not provided
|
||||
if (newFileType === "text" && !filename.includes(".")) {
|
||||
filename += ".md";
|
||||
}
|
||||
|
||||
const filePath = `${contextPath}/${filename}`;
|
||||
|
||||
if (newFileType === "image" && uploadedImageData) {
|
||||
// Write image data
|
||||
await api.writeFile(filePath, uploadedImageData);
|
||||
} else {
|
||||
// Write text file with content (or empty if no content)
|
||||
await api.writeFile(filePath, newFileContent);
|
||||
}
|
||||
|
||||
// Only reload files on success
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to add file:", error);
|
||||
// Optionally show error toast to user here
|
||||
} finally {
|
||||
// Close dialog and reset state
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setNewFileType("text");
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setIsDropHovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete selected file
|
||||
const handleDeleteFile = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
await api.deleteFile(selectedFile.path);
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
setEditedContent("");
|
||||
setHasChanges(false);
|
||||
await loadContextFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Rename selected file
|
||||
const handleRenameFile = async () => {
|
||||
const contextPath = getContextPath();
|
||||
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
|
||||
|
||||
const newName = renameFileName.trim();
|
||||
if (newName === selectedFile.name) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const newPath = `${contextPath}/${newName}`;
|
||||
|
||||
// Check if file with new name already exists
|
||||
const exists = await api.exists(newPath);
|
||||
if (exists) {
|
||||
console.error("A file with this name already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read current file content
|
||||
const result = await api.readFile(selectedFile.path);
|
||||
if (!result.success || result.content === undefined) {
|
||||
console.error("Failed to read file for rename");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to new path
|
||||
await api.writeFile(newPath, result.content);
|
||||
|
||||
// Delete old file
|
||||
await api.deleteFile(selectedFile.path);
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName("");
|
||||
|
||||
// Reload files and select the renamed file
|
||||
await loadContextFiles();
|
||||
|
||||
// Update selected file with new name and path
|
||||
const renamedFile: ContextFile = {
|
||||
name: newName,
|
||||
type: isImageFile(newName) ? "image" : "text",
|
||||
path: newPath,
|
||||
content: result.content,
|
||||
};
|
||||
setSelectedFile(renamedFile);
|
||||
} catch (error) {
|
||||
console.error("Failed to rename file:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image upload
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target?.result as string;
|
||||
setUploadedImageData(base64);
|
||||
if (!newFileName) {
|
||||
setNewFileName(file.name);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// Handle drag and drop for file upload
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const contextPath = getContextPath();
|
||||
if (!contextPath) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
|
||||
for (const file of files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const content = event.target?.result as string;
|
||||
const filePath = `${contextPath}/${file.name}`;
|
||||
await api.writeFile(filePath, content);
|
||||
await loadContextFiles();
|
||||
};
|
||||
|
||||
if (isImageFile(file.name)) {
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Handle drag and drop for .txt and .md files in the add context dialog textarea
|
||||
const handleTextAreaDrop = async (
|
||||
e: React.DragEvent<HTMLTextAreaElement>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
const file = files[0]; // Only handle the first file
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
// Only accept .txt and .md files
|
||||
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
|
||||
console.warn("Only .txt and .md files are supported for drag and drop");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result as string;
|
||||
setNewFileContent(content);
|
||||
|
||||
// Auto-fill filename if empty
|
||||
if (!newFileName) {
|
||||
setNewFileName(file.name);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(true);
|
||||
};
|
||||
|
||||
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropHovering(false);
|
||||
};
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="context-view-no-project"
|
||||
>
|
||||
<p className="text-muted-foreground">No project selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center"
|
||||
data-testid="context-view-loading"
|
||||
>
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="context-view"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Context Files</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add context files to include in AI prompts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="add-context-file"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add File
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area with file list and editor */}
|
||||
<div
|
||||
className="flex-1 flex overflow-hidden"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
data-testid="context-drop-zone"
|
||||
>
|
||||
{/* Left Panel - File List */}
|
||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||
<div className="p-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
Context Files ({contextFiles.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-2"
|
||||
data-testid="context-file-list"
|
||||
>
|
||||
{contextFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No context files yet.
|
||||
<br />
|
||||
Drop files here or click Add File.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{contextFiles.map((file) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className={cn(
|
||||
"group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors",
|
||||
selectedFile?.path === file.path
|
||||
? "bg-primary/20 text-foreground border border-primary/30"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleSelectFile(file)}
|
||||
className="flex-1 flex items-center gap-2 text-left min-w-0"
|
||||
data-testid={`context-file-${file.name}`}
|
||||
>
|
||||
{file.type === "image" ? (
|
||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate text-sm">{file.name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameFileName(file.name);
|
||||
setSelectedFile(file);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-accent rounded transition-opacity"
|
||||
data-testid={`rename-context-file-${file.name}`}
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Editor/Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
{/* File toolbar */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFile.type === "image" ? (
|
||||
<ImageIcon className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{selectedFile.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedFile.type === "text" &&
|
||||
isMarkdownFile(selectedFile.name) && (
|
||||
<Button
|
||||
variant={"outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
data-testid="toggle-preview-mode"
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<EditIcon className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{selectedFile.type === "text" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveFile}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-context-file"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||
data-testid="delete-context-file"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
{selectedFile.type === "image" ? (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-card rounded-lg"
|
||||
data-testid="image-preview"
|
||||
>
|
||||
<img
|
||||
src={editedContent}
|
||||
alt={selectedFile.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : isPreviewMode ? (
|
||||
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
|
||||
<Markdown>{editedContent}</Markdown>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-full overflow-hidden">
|
||||
<textarea
|
||||
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
|
||||
value={editedContent}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
placeholder="Enter context content here..."
|
||||
spellCheck={false}
|
||||
data-testid="context-editor"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-foreground-secondary">
|
||||
Select a file to view or edit
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Or drop files here to add them
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add File Dialog */}
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogContent
|
||||
data-testid="add-context-dialog"
|
||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new text or image file to the context.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={newFileType === "text" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setNewFileType("text")}
|
||||
data-testid="add-text-type"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Text
|
||||
</Button>
|
||||
<Button
|
||||
variant={newFileType === "image" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setNewFileType("image")}
|
||||
data-testid="add-image-type"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
Image
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filename">File Name</Label>
|
||||
<Input
|
||||
id="filename"
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder={
|
||||
newFileType === "text" ? "context.md" : "image.png"
|
||||
}
|
||||
data-testid="new-file-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newFileType === "text" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-content">Context Content</Label>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-lg transition-colors",
|
||||
isDropHovering && "ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
id="context-content"
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
onDrop={handleTextAreaDrop}
|
||||
onDragOver={handleTextAreaDragOver}
|
||||
onDragLeave={handleTextAreaDragLeave}
|
||||
placeholder="Enter context content here or drag & drop a .txt or .md file..."
|
||||
className={cn(
|
||||
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
|
||||
isDropHovering && "border-primary bg-primary/10"
|
||||
)}
|
||||
spellCheck={false}
|
||||
data-testid="new-file-content"
|
||||
/>
|
||||
{isDropHovering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
|
||||
<div className="flex flex-col items-center text-primary">
|
||||
<Upload className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm font-medium">
|
||||
Drop .txt or .md file here
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drag & drop .txt or .md files to import their content
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newFileType === "image" && (
|
||||
<div className="space-y-2">
|
||||
<Label>Upload Image</Label>
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
data-testid="image-upload-input"
|
||||
/>
|
||||
<label
|
||||
htmlFor="image-upload"
|
||||
className="cursor-pointer flex flex-col items-center"
|
||||
>
|
||||
{uploadedImageData ? (
|
||||
<img
|
||||
src={uploadedImageData}
|
||||
alt="Preview"
|
||||
className="max-w-32 max-h-32 object-contain mb-2"
|
||||
/>
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{uploadedImageData
|
||||
? "Click to change"
|
||||
: "Click to upload"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAddDialogOpen(false);
|
||||
setNewFileName("");
|
||||
setUploadedImageData(null);
|
||||
setNewFileContent("");
|
||||
setIsDropHovering(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
onClick={handleAddFile}
|
||||
disabled={
|
||||
!newFileName.trim() ||
|
||||
(newFileType === "image" && !uploadedImageData)
|
||||
}
|
||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
||||
hotkeyActive={isAddDialogOpen}
|
||||
data-testid="confirm-add-file"
|
||||
>
|
||||
Add File
|
||||
</HotkeyButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent data-testid="delete-context-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{selectedFile?.name}"? This
|
||||
action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteFile}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
data-testid="confirm-delete-file"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent data-testid="rename-context-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Context File</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for "{selectedFile?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rename-filename">File Name</Label>
|
||||
<Input
|
||||
id="rename-filename"
|
||||
value={renameFileName}
|
||||
onChange={(e) => setRenameFileName(e.target.value)}
|
||||
placeholder="Enter new filename"
|
||||
data-testid="rename-file-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && renameFileName.trim()) {
|
||||
handleRenameFile();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsRenameDialogOpen(false);
|
||||
setRenameFileName("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRenameFile}
|
||||
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
||||
data-testid="confirm-rename-file"
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { SortableProfileCard } from "./sortable-profile-card";
|
||||
export { ProfileForm } from "./profile-form";
|
||||
export { ProfilesHeader } from "./profiles-header";
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { AgentModel, ThinkingLevel } from "@/store/app-store";
|
||||
|
||||
// Icon mapping for profiles
|
||||
export const PROFILE_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
Brain,
|
||||
Zap,
|
||||
Scale,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
};
|
||||
|
||||
// Available icons for selection
|
||||
export const ICON_OPTIONS = [
|
||||
{ name: "Brain", icon: Brain },
|
||||
{ name: "Zap", icon: Zap },
|
||||
{ name: "Scale", icon: Scale },
|
||||
{ name: "Cpu", icon: Cpu },
|
||||
{ name: "Rocket", icon: Rocket },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
];
|
||||
|
||||
// Model options for the form
|
||||
export const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
||||
{ id: "haiku", label: "Claude Haiku" },
|
||||
{ id: "sonnet", label: "Claude Sonnet" },
|
||||
{ id: "opus", label: "Claude Opus" },
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "ultrathink", label: "Ultrathink" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
import { useCliStatus, useSettingsView } from "./settings-view/hooks";
|
||||
import { NAV_ITEMS } from "./settings-view/config/navigation";
|
||||
import { SettingsHeader } from "./settings-view/components/settings-header";
|
||||
import { KeyboardMapDialog } from "./settings-view/components/keyboard-map-dialog";
|
||||
import { DeleteProjectDialog } from "./settings-view/components/delete-project-dialog";
|
||||
import { SettingsNavigation } from "./settings-view/components/settings-navigation";
|
||||
import { ApiKeysSection } from "./settings-view/api-keys/api-keys-section";
|
||||
import { ClaudeCliStatus } from "./settings-view/cli-status/claude-cli-status";
|
||||
import { AIEnhancementSection } from "./settings-view/ai-enhancement";
|
||||
import { AppearanceSection } from "./settings-view/appearance/appearance-section";
|
||||
import { AudioSection } from "./settings-view/audio/audio-section";
|
||||
import { KeyboardShortcutsSection } from "./settings-view/keyboard-shortcuts/keyboard-shortcuts-section";
|
||||
import { FeatureDefaultsSection } from "./settings-view/feature-defaults/feature-defaults-section";
|
||||
import { DangerZoneSection } from "./settings-view/danger-zone/danger-zone-section";
|
||||
import type {
|
||||
Project as SettingsProject,
|
||||
Theme,
|
||||
} from "./settings-view/shared/types";
|
||||
import type { Project as ElectronProject } from "@/lib/electron";
|
||||
|
||||
export function SettingsView() {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
setProjectTheme,
|
||||
defaultSkipTests,
|
||||
setDefaultSkipTests,
|
||||
enableDependencyBlocking,
|
||||
setEnableDependencyBlocking,
|
||||
useWorktrees,
|
||||
setUseWorktrees,
|
||||
showProfilesOnly,
|
||||
setShowProfilesOnly,
|
||||
muteDoneSound,
|
||||
setMuteDoneSound,
|
||||
currentProject,
|
||||
moveProjectToTrash,
|
||||
defaultPlanningMode,
|
||||
setDefaultPlanningMode,
|
||||
defaultRequirePlanApproval,
|
||||
setDefaultRequirePlanApproval,
|
||||
} = useAppStore();
|
||||
|
||||
// Convert electron Project to settings-view Project type
|
||||
const convertProject = (
|
||||
project: ElectronProject | null
|
||||
): SettingsProject | null => {
|
||||
if (!project) return null;
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
path: project.path,
|
||||
theme: project.theme as Theme | undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const settingsProject = convertProject(currentProject);
|
||||
|
||||
// Compute the effective theme for the current project
|
||||
const effectiveTheme = (settingsProject?.theme || theme) as Theme;
|
||||
|
||||
// Handler to set theme - always updates global theme (user's preference),
|
||||
// and also sets per-project theme if a project is selected
|
||||
const handleSetTheme = (newTheme: typeof theme) => {
|
||||
// Always update global theme so user's preference persists across all projects
|
||||
setTheme(newTheme);
|
||||
// Also set per-project theme if a project is selected
|
||||
if (currentProject) {
|
||||
setProjectTheme(currentProject.id, newTheme);
|
||||
}
|
||||
};
|
||||
|
||||
// Use CLI status hook
|
||||
const { claudeCliStatus, isCheckingClaudeCli, handleRefreshClaudeCli } =
|
||||
useCliStatus();
|
||||
|
||||
// Use settings view navigation hook
|
||||
const { activeView, navigateTo } = useSettingsView();
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
||||
|
||||
// Render the active section based on current view
|
||||
const renderActiveSection = () => {
|
||||
switch (activeView) {
|
||||
case "claude":
|
||||
return (
|
||||
<ClaudeCliStatus
|
||||
status={claudeCliStatus}
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
);
|
||||
case "ai-enhancement":
|
||||
return <AIEnhancementSection />;
|
||||
case "appearance":
|
||||
return (
|
||||
<AppearanceSection
|
||||
effectiveTheme={effectiveTheme}
|
||||
currentProject={settingsProject}
|
||||
onThemeChange={handleSetTheme}
|
||||
/>
|
||||
);
|
||||
case "keyboard":
|
||||
return (
|
||||
<KeyboardShortcutsSection
|
||||
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
||||
/>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<AudioSection
|
||||
muteDoneSound={muteDoneSound}
|
||||
onMuteDoneSoundChange={setMuteDoneSound}
|
||||
/>
|
||||
);
|
||||
case "defaults":
|
||||
return (
|
||||
<FeatureDefaultsSection
|
||||
showProfilesOnly={showProfilesOnly}
|
||||
defaultSkipTests={defaultSkipTests}
|
||||
enableDependencyBlocking={enableDependencyBlocking}
|
||||
useWorktrees={useWorktrees}
|
||||
defaultPlanningMode={defaultPlanningMode}
|
||||
defaultRequirePlanApproval={defaultRequirePlanApproval}
|
||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
||||
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
|
||||
onUseWorktreesChange={setUseWorktrees}
|
||||
onDefaultPlanningModeChange={setDefaultPlanningMode}
|
||||
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
|
||||
/>
|
||||
);
|
||||
case "danger":
|
||||
return (
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <ApiKeysSection />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
||||
data-testid="settings-view"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<SettingsHeader />
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Side Navigation - No longer scrolls, just switches views */}
|
||||
<SettingsNavigation
|
||||
navItems={NAV_ITEMS}
|
||||
activeSection={activeView}
|
||||
currentProject={currentProject}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
|
||||
{/* Content Panel - Shows only the active section */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Map Dialog */}
|
||||
<KeyboardMapDialog
|
||||
open={showKeyboardMapDialog}
|
||||
onOpenChange={setShowKeyboardMapDialog}
|
||||
/>
|
||||
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<DeleteProjectDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
project={currentProject}
|
||||
onConfirm={moveProjectToTrash}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { AIEnhancementSection } from "./ai-enhancement-section";
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Palette } from "lucide-react";
|
||||
import { themeOptions } from "@/config/theme-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Theme, Project } from "../shared/types";
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
effectiveTheme: Theme;
|
||||
currentProject: Project | null;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export function AppearanceSection({
|
||||
effectiveTheme,
|
||||
currentProject,
|
||||
onThemeChange,
|
||||
}: AppearanceSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl overflow-hidden",
|
||||
"border border-border/50",
|
||||
"bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl",
|
||||
"shadow-sm shadow-black/5"
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Palette className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Appearance</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize the look and feel of your application.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<Label className="text-foreground font-medium">
|
||||
Theme{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
|
||||
</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{themeOptions.map(({ value, label, Icon, testId }) => {
|
||||
const isActive = effectiveTheme === value;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onThemeChange(value)}
|
||||
className={cn(
|
||||
"group flex items-center justify-center gap-2.5 px-4 py-3.5 rounded-xl",
|
||||
"text-sm font-medium transition-all duration-200 ease-out",
|
||||
isActive
|
||||
? [
|
||||
"bg-gradient-to-br from-brand-500/15 to-brand-600/10",
|
||||
"border-2 border-brand-500/40",
|
||||
"text-foreground",
|
||||
"shadow-md shadow-brand-500/10",
|
||||
]
|
||||
: [
|
||||
"bg-accent/30 hover:bg-accent/50",
|
||||
"border border-border/50 hover:border-border",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"hover:shadow-sm",
|
||||
],
|
||||
"hover:scale-[1.02] active:scale-[0.98]"
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"w-4 h-4 transition-all duration-200",
|
||||
isActive ? "text-brand-500" : "group-hover:text-brand-400"
|
||||
)} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingsHeaderProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function SettingsHeader({
|
||||
title = "Settings",
|
||||
description = "Configure your API keys and preferences",
|
||||
}: SettingsHeaderProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"shrink-0",
|
||||
"border-b border-border/50",
|
||||
"bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl"
|
||||
)}>
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"w-12 h-12 rounded-2xl flex items-center justify-center",
|
||||
"bg-gradient-to-br from-brand-500 to-brand-600",
|
||||
"shadow-lg shadow-brand-500/25",
|
||||
"ring-1 ring-white/10"
|
||||
)}>
|
||||
<Settings className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Key,
|
||||
Terminal,
|
||||
Palette,
|
||||
Settings2,
|
||||
Volume2,
|
||||
FlaskConical,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { SettingsViewId } from "../hooks/use-settings-view";
|
||||
|
||||
export interface NavigationItem {
|
||||
id: SettingsViewId;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
// Navigation items for the settings side panel
|
||||
export const NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
{ id: "claude", label: "Claude", icon: Terminal },
|
||||
{ id: "ai-enhancement", label: "AI Enhancement", icon: Sparkles },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
||||
];
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Folder, AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Project } from "../shared/types";
|
||||
|
||||
interface DangerZoneSectionProps {
|
||||
project: Project | null;
|
||||
onDeleteClick: () => void;
|
||||
}
|
||||
|
||||
export function DangerZoneSection({
|
||||
project,
|
||||
onDeleteClick,
|
||||
}: DangerZoneSectionProps) {
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl overflow-hidden",
|
||||
"border border-destructive/30",
|
||||
"bg-gradient-to-br from-destructive/5 via-card/70 to-card/80 backdrop-blur-xl",
|
||||
"shadow-sm shadow-destructive/5"
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-destructive/20 bg-gradient-to-r from-destructive/5 via-transparent to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-destructive/20 to-destructive/10 flex items-center justify-center border border-destructive/20">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Permanently remove this project from Automaker.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
|
||||
{project.path}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteClick}
|
||||
data-testid="delete-project-button"
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
"shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25",
|
||||
"transition-all duration-200 ease-out",
|
||||
"hover:scale-[1.02] active:scale-[0.98]"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useCliStatus } from "./use-cli-status";
|
||||
export { useSettingsView, type SettingsViewId } from "./use-settings-view";
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { StepIndicator } from "./setup-view/components";
|
||||
import {
|
||||
WelcomeStep,
|
||||
CompleteStep,
|
||||
ClaudeSetupStep,
|
||||
GitHubSetupStep,
|
||||
} from "./setup-view/steps";
|
||||
|
||||
// Main Setup View
|
||||
export function SetupView() {
|
||||
const {
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
completeSetup,
|
||||
setSkipClaudeSetup,
|
||||
} = useSetupStore();
|
||||
const { setCurrentView } = useAppStore();
|
||||
|
||||
const steps = ["welcome", "claude", "github", "complete"] as const;
|
||||
type StepName = (typeof steps)[number];
|
||||
const getStepName = (): StepName => {
|
||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
||||
return "claude";
|
||||
if (currentStep === "welcome") return "welcome";
|
||||
if (currentStep === "github") return "github";
|
||||
return "complete";
|
||||
};
|
||||
const currentIndex = steps.indexOf(getStepName());
|
||||
|
||||
const handleNext = (from: string) => {
|
||||
console.log(
|
||||
"[Setup Flow] handleNext called from:",
|
||||
from,
|
||||
"currentStep:",
|
||||
currentStep
|
||||
);
|
||||
switch (from) {
|
||||
case "welcome":
|
||||
console.log("[Setup Flow] Moving to claude_detect step");
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
case "claude":
|
||||
console.log("[Setup Flow] Moving to github step");
|
||||
setCurrentStep("github");
|
||||
break;
|
||||
case "github":
|
||||
console.log("[Setup Flow] Moving to complete step");
|
||||
setCurrentStep("complete");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = (from: string) => {
|
||||
console.log("[Setup Flow] handleBack called from:", from);
|
||||
switch (from) {
|
||||
case "claude":
|
||||
setCurrentStep("welcome");
|
||||
break;
|
||||
case "github":
|
||||
setCurrentStep("claude_detect");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipClaude = () => {
|
||||
console.log("[Setup Flow] Skipping Claude setup");
|
||||
setSkipClaudeSetup(true);
|
||||
setCurrentStep("github");
|
||||
};
|
||||
|
||||
const handleSkipGithub = () => {
|
||||
console.log("[Setup Flow] Skipping GitHub setup");
|
||||
setCurrentStep("complete");
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
console.log("[Setup Flow] handleFinish called - completing setup");
|
||||
completeSetup();
|
||||
console.log("[Setup Flow] Setup completed, redirecting to welcome view");
|
||||
setCurrentView("welcome");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col content-bg" data-testid="setup-view">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md titlebar-drag-region">
|
||||
<div className="px-8 py-4">
|
||||
<div className="flex items-center gap-3 titlebar-no-drag">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="Automaker" className="w-8 h-8" />
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
Automaker Setup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="p-8">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<StepIndicator
|
||||
currentStep={currentIndex}
|
||||
totalSteps={steps.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="py-8">
|
||||
{currentStep === "welcome" && (
|
||||
<WelcomeStep onNext={() => handleNext("welcome")} />
|
||||
)}
|
||||
|
||||
{(currentStep === "claude_detect" ||
|
||||
currentStep === "claude_auth") && (
|
||||
<ClaudeSetupStep
|
||||
onNext={() => handleNext("claude")}
|
||||
onBack={() => handleBack("claude")}
|
||||
onSkip={handleSkipClaude}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "github" && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext("github")}
|
||||
onBack={() => handleBack("github")}
|
||||
onSkip={handleSkipGithub}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === "complete" && (
|
||||
<CompleteStep onFinish={handleFinish} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Re-export all setup-view components for easier imports
|
||||
export { StepIndicator } from "./step-indicator";
|
||||
export { StatusBadge } from "./status-badge";
|
||||
export { StatusRow } from "./status-row";
|
||||
export { TerminalOutput } from "./terminal-output";
|
||||
export { CopyableCommandField } from "./copyable-command-field";
|
||||
export { CliInstallationCard } from "./cli-installation-card";
|
||||
export { ReadyStateCard } from "./ready-state-card";
|
||||
export { AuthMethodSelector } from "./auth-method-selector";
|
||||
@@ -1,18 +0,0 @@
|
||||
interface TerminalOutputProps {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export function TerminalOutput({ lines }: TerminalOutputProps) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto">
|
||||
{lines.map((line, index) => (
|
||||
<div key={index} className="text-zinc-400">
|
||||
<span className="text-green-500">$</span> {line}
|
||||
</div>
|
||||
))}
|
||||
{lines.length === 0 && (
|
||||
<div className="text-zinc-500 italic">Waiting for output...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Re-export all hooks for easier imports
|
||||
export { useCliStatus } from "./use-cli-status";
|
||||
export { useCliInstallation } from "./use-cli-installation";
|
||||
export { useTokenSave } from "./use-token-save";
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { CheckCircle2, AlertCircle, Shield, Sparkles } from "lucide-react";
|
||||
import { useSetupStore } from "@/store/setup-store";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
interface CompleteStepProps {
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
export function CompleteStep({ onFinish }: CompleteStepProps) {
|
||||
const { claudeCliStatus, claudeAuthStatus } = useSetupStore();
|
||||
const { apiKeys } = useAppStore();
|
||||
|
||||
const claudeReady =
|
||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
||||
apiKeys.anthropic;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-green-500 to-emerald-600 shadow-lg shadow-green-500/30 flex items-center justify-center mx-auto">
|
||||
<CheckCircle2 className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
||||
Setup Complete!
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Your development environment is configured. You're ready to start
|
||||
building with AI-powered assistance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
|
||||
onClick={onFinish}
|
||||
data-testid="setup-finish-button"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Start Building
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Re-export all setup step components for easier imports
|
||||
export { WelcomeStep } from "./welcome-step";
|
||||
export { CompleteStep } from "./complete-step";
|
||||
export { ClaudeSetupStep } from "./claude-setup-step";
|
||||
export { GitHubSetupStep } from "./github-setup-step";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,722 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
Terminal as TerminalIcon,
|
||||
Plus,
|
||||
Lock,
|
||||
Unlock,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
X,
|
||||
SquarePlus,
|
||||
} from "lucide-react";
|
||||
import { useAppStore, type TerminalPanelContent, type TerminalTab } from "@/store/app-store";
|
||||
import { useKeyboardShortcutsConfig, type KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Panel,
|
||||
PanelGroup,
|
||||
PanelResizeHandle,
|
||||
} from "react-resizable-panels";
|
||||
import { TerminalPanel } from "./terminal-view/terminal-panel";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
} from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TerminalStatus {
|
||||
enabled: boolean;
|
||||
passwordRequired: boolean;
|
||||
platform: {
|
||||
platform: string;
|
||||
isWSL: boolean;
|
||||
defaultShell: string;
|
||||
arch: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Tab component with drop target support
|
||||
function TerminalTabButton({
|
||||
tab,
|
||||
isActive,
|
||||
onClick,
|
||||
onClose,
|
||||
isDropTarget,
|
||||
}: {
|
||||
tab: TerminalTab;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onClose: () => void;
|
||||
isDropTarget: boolean;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `tab-${tab.id}`,
|
||||
data: { type: "tab", tabId: tab.id },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 text-sm rounded-t-md border-b-2 cursor-pointer transition-colors",
|
||||
isActive
|
||||
? "bg-background border-brand-500 text-foreground"
|
||||
: "bg-muted border-transparent text-muted-foreground hover:text-foreground hover:bg-accent",
|
||||
isOver && isDropTarget && "ring-2 ring-green-500"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<TerminalIcon className="h-3 w-3" />
|
||||
<span className="max-w-24 truncate">{tab.name}</span>
|
||||
<button
|
||||
className="ml-1 p-0.5 rounded hover:bg-accent text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// New tab drop zone
|
||||
function NewTabDropZone({ isDropTarget }: { isDropTarget: boolean }) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "new-tab-zone",
|
||||
data: { type: "new-tab" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex items-center justify-center px-3 py-1.5 rounded-t-md border-2 border-dashed transition-all",
|
||||
isOver && isDropTarget
|
||||
? "border-green-500 bg-green-500/10 text-green-500"
|
||||
: "border-transparent text-muted-foreground hover:border-border"
|
||||
)}
|
||||
>
|
||||
<SquarePlus className="h-4 w-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalView() {
|
||||
const {
|
||||
terminalState,
|
||||
setTerminalUnlocked,
|
||||
addTerminalToLayout,
|
||||
removeTerminalFromLayout,
|
||||
setActiveTerminalSession,
|
||||
swapTerminals,
|
||||
currentProject,
|
||||
addTerminalTab,
|
||||
removeTerminalTab,
|
||||
setActiveTerminalTab,
|
||||
moveTerminalToTab,
|
||||
setTerminalPanelFontSize,
|
||||
} = useAppStore();
|
||||
|
||||
const [status, setStatus] = useState<TerminalStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
|
||||
const lastCreateTimeRef = useRef<number>(0);
|
||||
const isCreatingRef = useRef<boolean>(false);
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
|
||||
|
||||
// Helper to check if terminal creation should be debounced
|
||||
const canCreateTerminal = (debounceMessage: string): boolean => {
|
||||
const now = Date.now();
|
||||
if (now - lastCreateTimeRef.current < CREATE_COOLDOWN_MS || isCreatingRef.current) {
|
||||
console.log(debounceMessage);
|
||||
return false;
|
||||
}
|
||||
lastCreateTimeRef.current = now;
|
||||
isCreatingRef.current = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get active tab
|
||||
const activeTab = terminalState.tabs.find(t => t.id === terminalState.activeTabId);
|
||||
|
||||
// DnD sensors with activation constraint to avoid accidental drags
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(event.active.id as string);
|
||||
}, []);
|
||||
|
||||
// Handle drag over - track which tab we're hovering
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { over } = event;
|
||||
if (over?.data?.current?.type === "tab") {
|
||||
setDragOverTabId(over.data.current.tabId);
|
||||
} else if (over?.data?.current?.type === "new-tab") {
|
||||
setDragOverTabId("new");
|
||||
} else {
|
||||
setDragOverTabId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveDragId(null);
|
||||
setDragOverTabId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overData = over.data?.current;
|
||||
|
||||
// If dropped on a tab, move terminal to that tab
|
||||
if (overData?.type === "tab") {
|
||||
moveTerminalToTab(activeId, overData.tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If dropped on new tab zone, create new tab with this terminal
|
||||
if (overData?.type === "new-tab") {
|
||||
moveTerminalToTab(activeId, "new");
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, swap terminals within current tab
|
||||
if (active.id !== over.id) {
|
||||
swapTerminals(activeId, over.id as string);
|
||||
}
|
||||
}, [swapTerminals, moveTerminalToTab]);
|
||||
|
||||
// Fetch terminal status
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await fetch(`${serverUrl}/api/terminal/status`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setStatus(data.data);
|
||||
if (!data.data.passwordRequired) {
|
||||
setTerminalUnlocked(true);
|
||||
}
|
||||
} else {
|
||||
setError(data.error || "Failed to get terminal status");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to connect to server");
|
||||
console.error("[Terminal] Status fetch error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [serverUrl, setTerminalUnlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Handle password authentication
|
||||
const handleAuth = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setAuthLoading(true);
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTerminalUnlocked(true, data.data.token);
|
||||
setPassword("");
|
||||
} else {
|
||||
setAuthError(data.error || "Authentication failed");
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError("Failed to authenticate");
|
||||
console.error("[Terminal] Auth error:", err);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new terminal session
|
||||
// targetSessionId: the terminal to split (if splitting an existing terminal)
|
||||
const createTerminal = async (direction?: "horizontal" | "vertical", targetSessionId?: string) => {
|
||||
if (!canCreateTerminal("[Terminal] Debounced terminal creation")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (terminalState.authToken) {
|
||||
headers["X-Terminal-Token"] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentProject?.path || undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
addTerminalToLayout(data.data.id, direction, targetSessionId);
|
||||
} else {
|
||||
console.error("[Terminal] Failed to create session:", data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Terminal] Create session error:", err);
|
||||
} finally {
|
||||
isCreatingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create terminal in new tab
|
||||
const createTerminalInNewTab = async () => {
|
||||
if (!canCreateTerminal("[Terminal] Debounced terminal tab creation")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = addTerminalTab();
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (terminalState.authToken) {
|
||||
headers["X-Terminal-Token"] = terminalState.authToken;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
cwd: currentProject?.path || undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Add to the newly created tab
|
||||
const { addTerminalToTab } = useAppStore.getState();
|
||||
addTerminalToTab(data.data.id, tabId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Terminal] Create session error:", err);
|
||||
} finally {
|
||||
isCreatingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Kill a terminal session
|
||||
const killTerminal = async (sessionId: string) => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (terminalState.authToken) {
|
||||
headers["X-Terminal-Token"] = terminalState.authToken;
|
||||
}
|
||||
|
||||
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
removeTerminalFromLayout(sessionId);
|
||||
} catch (err) {
|
||||
console.error("[Terminal] Kill session error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Get keyboard shortcuts config
|
||||
const shortcuts = useKeyboardShortcutsConfig();
|
||||
|
||||
// Handle terminal-specific keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle shortcuts when terminal is unlocked and has an active session
|
||||
if (!terminalState.isUnlocked || !terminalState.activeSessionId) return;
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Parse shortcut string to check for match
|
||||
const matchesShortcut = (shortcutStr: string | undefined) => {
|
||||
if (!shortcutStr) return false;
|
||||
const parts = shortcutStr.toLowerCase().split('+');
|
||||
const key = parts[parts.length - 1];
|
||||
const needsCmd = parts.includes('cmd');
|
||||
const needsShift = parts.includes('shift');
|
||||
const needsAlt = parts.includes('alt');
|
||||
|
||||
// Check modifiers
|
||||
const cmdMatches = needsCmd ? cmdOrCtrl : !cmdOrCtrl;
|
||||
const shiftMatches = needsShift ? e.shiftKey : !e.shiftKey;
|
||||
const altMatches = needsAlt ? e.altKey : !e.altKey;
|
||||
|
||||
return (
|
||||
e.key.toLowerCase() === key &&
|
||||
cmdMatches &&
|
||||
shiftMatches &&
|
||||
altMatches
|
||||
);
|
||||
};
|
||||
|
||||
// Split terminal right (Cmd+D / Ctrl+D)
|
||||
if (matchesShortcut(shortcuts.splitTerminalRight)) {
|
||||
e.preventDefault();
|
||||
createTerminal("horizontal", terminalState.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split terminal down (Cmd+Shift+D / Ctrl+Shift+D)
|
||||
if (matchesShortcut(shortcuts.splitTerminalDown)) {
|
||||
e.preventDefault();
|
||||
createTerminal("vertical", terminalState.activeSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close terminal (Cmd+W / Ctrl+W)
|
||||
if (matchesShortcut(shortcuts.closeTerminal)) {
|
||||
e.preventDefault();
|
||||
killTerminal(terminalState.activeSessionId);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [terminalState.isUnlocked, terminalState.activeSessionId, shortcuts]);
|
||||
|
||||
// Collect all terminal IDs from a panel tree in order
|
||||
const getTerminalIds = (panel: TerminalPanelContent): string[] => {
|
||||
if (panel.type === "terminal") {
|
||||
return [panel.sessionId];
|
||||
}
|
||||
return panel.panels.flatMap(getTerminalIds);
|
||||
};
|
||||
|
||||
// Get a STABLE key for a panel - based only on terminal IDs, not tree structure
|
||||
// This prevents unnecessary remounts when layout structure changes
|
||||
const getPanelKey = (panel: TerminalPanelContent): string => {
|
||||
if (panel.type === "terminal") {
|
||||
return panel.sessionId;
|
||||
}
|
||||
// Use joined terminal IDs - stable regardless of nesting depth
|
||||
return `group-${getTerminalIds(panel).join("-")}`;
|
||||
};
|
||||
|
||||
// Render panel content recursively
|
||||
const renderPanelContent = (content: TerminalPanelContent): React.ReactNode => {
|
||||
if (content.type === "terminal") {
|
||||
// Use per-terminal fontSize or fall back to default
|
||||
const terminalFontSize = content.fontSize ?? terminalState.defaultFontSize;
|
||||
return (
|
||||
<TerminalPanel
|
||||
key={content.sessionId}
|
||||
sessionId={content.sessionId}
|
||||
authToken={terminalState.authToken}
|
||||
isActive={terminalState.activeSessionId === content.sessionId}
|
||||
onFocus={() => setActiveTerminalSession(content.sessionId)}
|
||||
onClose={() => killTerminal(content.sessionId)}
|
||||
onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)}
|
||||
onSplitVertical={() => createTerminal("vertical", content.sessionId)}
|
||||
isDragging={activeDragId === content.sessionId}
|
||||
isDropTarget={activeDragId !== null && activeDragId !== content.sessionId}
|
||||
fontSize={terminalFontSize}
|
||||
onFontSizeChange={(size) => setTerminalPanelFontSize(content.sessionId, size)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isHorizontal = content.direction === "horizontal";
|
||||
const defaultSizePerPanel = 100 / content.panels.length;
|
||||
|
||||
return (
|
||||
<PanelGroup direction={content.direction}>
|
||||
{content.panels.map((panel, index) => {
|
||||
const panelSize = panel.type === "terminal" && panel.size
|
||||
? panel.size
|
||||
: defaultSizePerPanel;
|
||||
|
||||
const panelKey = getPanelKey(panel);
|
||||
return (
|
||||
<React.Fragment key={panelKey}>
|
||||
{index > 0 && (
|
||||
<PanelResizeHandle
|
||||
key={`handle-${panelKey}`}
|
||||
className={
|
||||
isHorizontal
|
||||
? "w-1 h-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
|
||||
: "h-1 w-full bg-border hover:bg-brand-500 transition-colors data-[resize-handle-state=hover]:bg-brand-500 data-[resize-handle-state=drag]:bg-brand-500"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Panel id={panelKey} order={index} defaultSize={panelSize} minSize={30}>
|
||||
{renderPanelContent(panel)}
|
||||
</Panel>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</PanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-destructive/10 mb-4">
|
||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Terminal Unavailable</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-4">{error}</p>
|
||||
<Button variant="outline" onClick={fetchStatus}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
if (!status?.enabled) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<TerminalIcon className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Terminal Disabled</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Terminal access has been disabled. Set <code className="px-1.5 py-0.5 rounded bg-muted">TERMINAL_ENABLED=true</code> in your server .env file to enable it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Password gate
|
||||
if (status.passwordRequired && !terminalState.isUnlocked) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||
<Lock className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Terminal Protected</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-6">
|
||||
Terminal access requires authentication. Enter the password to unlock.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleAuth} className="w-full max-w-xs space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={authLoading}
|
||||
autoFocus
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-sm text-destructive">{authError}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={authLoading || !password}>
|
||||
{authLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Unlock Terminal
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{status.platform && (
|
||||
<p className="text-xs text-muted-foreground mt-6">
|
||||
Platform: {status.platform.platform}
|
||||
{status.platform.isWSL && " (WSL)"}
|
||||
{" | "}Shell: {status.platform.defaultShell}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No terminals yet - show welcome screen
|
||||
if (terminalState.tabs.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<div className="p-4 rounded-full bg-brand-500/10 mb-4">
|
||||
<TerminalIcon className="h-12 w-12 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-medium mb-2">Terminal</h2>
|
||||
<p className="text-muted-foreground max-w-md mb-6">
|
||||
Create a new terminal session to start executing commands.
|
||||
{currentProject && (
|
||||
<span className="block mt-2 text-sm">
|
||||
Working directory: <code className="px-1.5 py-0.5 rounded bg-muted">{currentProject.path}</code>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Button onClick={() => createTerminal()}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Terminal
|
||||
</Button>
|
||||
|
||||
{status?.platform && (
|
||||
<p className="text-xs text-muted-foreground mt-6">
|
||||
Platform: {status.platform.platform}
|
||||
{status.platform.isWSL && " (WSL)"}
|
||||
{" | "}Shell: {status.platform.defaultShell}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Terminal view with tabs
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center bg-card border-b border-border px-2">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 flex-1 overflow-x-auto py-1">
|
||||
{terminalState.tabs.map((tab) => (
|
||||
<TerminalTabButton
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === terminalState.activeTabId}
|
||||
onClick={() => setActiveTerminalTab(tab.id)}
|
||||
onClose={() => removeTerminalTab(tab.id)}
|
||||
isDropTarget={activeDragId !== null}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* New tab drop zone (visible when dragging) */}
|
||||
{activeDragId && (
|
||||
<NewTabDropZone isDropTarget={true} />
|
||||
)}
|
||||
|
||||
{/* New tab button */}
|
||||
<button
|
||||
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
|
||||
onClick={createTerminalInNewTab}
|
||||
title="New Tab"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar buttons */}
|
||||
<div className="flex items-center gap-1 pl-2 border-l border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => createTerminal("horizontal")}
|
||||
title="Split Right"
|
||||
>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => createTerminal("vertical")}
|
||||
title="Split Down"
|
||||
>
|
||||
<SplitSquareVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active tab content */}
|
||||
<div className="flex-1 overflow-hidden bg-background">
|
||||
{activeTab?.layout ? (
|
||||
renderPanelContent(activeTab.layout)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6">
|
||||
<p className="text-muted-foreground mb-4">This tab is empty</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => createTerminal()}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Terminal
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag overlay */}
|
||||
<DragOverlay dropAnimation={null} zIndex={1000}>
|
||||
{activeDragId ? (
|
||||
<div className="relative inline-flex items-center gap-2 px-3.5 py-2 bg-card border-2 border-brand-500 rounded-lg shadow-xl pointer-events-none overflow-hidden">
|
||||
<TerminalIcon className="h-4 w-4 text-brand-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{dragOverTabId === "new"
|
||||
? "New tab"
|
||||
: dragOverTabId
|
||||
? "Move to tab"
|
||||
: "Terminal"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,479 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Rocket,
|
||||
Layers,
|
||||
Sparkles,
|
||||
GitBranch,
|
||||
FolderTree,
|
||||
Component,
|
||||
Settings,
|
||||
PlayCircle,
|
||||
Bot,
|
||||
LayoutGrid,
|
||||
FileText,
|
||||
Terminal,
|
||||
Palette,
|
||||
Keyboard,
|
||||
Cpu,
|
||||
Zap,
|
||||
Image,
|
||||
TestTube,
|
||||
Brain,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
interface WikiSection {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
section,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
section: WikiSection;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const Icon = section.icon;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-card/50 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-brand-500/10 text-brand-500">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="flex-1 font-medium text-foreground">{section.title}</span>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<div className="pt-4 text-sm text-muted-foreground leading-relaxed">
|
||||
{section.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ children, title }: { children: string; title?: string }) {
|
||||
return (
|
||||
<div className="my-3 rounded-lg overflow-hidden border border-border">
|
||||
{title && (
|
||||
<div className="px-3 py-1.5 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<pre className="p-3 bg-muted/30 overflow-x-auto text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureList({ items }: { items: { icon: React.ElementType; title: string; description: string }[] }) {
|
||||
return (
|
||||
<div className="grid gap-3 mt-3">
|
||||
{items.map((item, index) => {
|
||||
const ItemIcon = item.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded bg-brand-500/10 text-brand-500 shrink-0 mt-0.5">
|
||||
<ItemIcon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground text-sm">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WikiView() {
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["overview"]));
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setOpenSections(new Set(sections.map((s) => s.id)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setOpenSections(new Set());
|
||||
};
|
||||
|
||||
const sections: WikiSection[] = [
|
||||
{
|
||||
id: "overview",
|
||||
title: "Project Overview",
|
||||
icon: Rocket,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
<strong className="text-foreground">Automaker</strong> is an autonomous AI development studio that helps developers build software faster using AI agents.
|
||||
</p>
|
||||
<p>
|
||||
At its core, Automaker provides a visual Kanban board to manage features. When you're ready, AI agents automatically implement those features in your codebase, complete with git worktree isolation for safe parallel development.
|
||||
</p>
|
||||
<div className="p-3 rounded-lg bg-brand-500/10 border border-brand-500/20 mt-4">
|
||||
<p className="text-brand-400 text-sm">
|
||||
Think of it as having a team of AI developers that can work on multiple features simultaneously while you focus on the bigger picture.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "architecture",
|
||||
title: "Architecture",
|
||||
icon: Layers,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>Automaker is built as a monorepo with two main applications:</p>
|
||||
<ul className="list-disc list-inside space-y-2 ml-2">
|
||||
<li>
|
||||
<strong className="text-foreground">apps/app</strong> - Next.js + Electron frontend for the desktop application
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">apps/server</strong> - Express backend handling API requests and agent orchestration
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="font-medium text-foreground">Key Technologies:</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Electron wraps Next.js for cross-platform desktop support</li>
|
||||
<li>Real-time communication via WebSocket for live agent updates</li>
|
||||
<li>State management with Zustand for reactive UI updates</li>
|
||||
<li>Claude Agent SDK for AI capabilities</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
title: "Key Features",
|
||||
icon: Sparkles,
|
||||
content: (
|
||||
<div>
|
||||
<FeatureList
|
||||
items={[
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: "Kanban Board",
|
||||
description: "4 columns: Backlog, In Progress, Waiting Approval, Verified. Drag and drop to manage feature lifecycle.",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "AI Agent Integration",
|
||||
description: "Powered by Claude via the Agent SDK with full file, bash, and git access.",
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
title: "Multi-Model Support",
|
||||
description: "Claude Haiku/Sonnet/Opus models. Choose the right model for each task.",
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "Extended Thinking",
|
||||
description: "Configurable thinking levels (none, low, medium, high, ultrathink) for complex tasks.",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Real-time Streaming",
|
||||
description: "Watch AI agents work in real-time with live output streaming.",
|
||||
},
|
||||
{
|
||||
icon: GitBranch,
|
||||
title: "Git Worktree Isolation",
|
||||
description: "Each feature runs in its own git worktree for safe parallel development.",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "AI Profiles",
|
||||
description: "Pre-configured model + thinking level combinations for different task types.",
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: "Integrated Terminal",
|
||||
description: "Built-in terminal with tab support and split panes.",
|
||||
},
|
||||
{
|
||||
icon: Keyboard,
|
||||
title: "Keyboard Shortcuts",
|
||||
description: "Fully customizable shortcuts for power users.",
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: "14 Themes",
|
||||
description: "From light to dark, retro to synthwave - pick your style.",
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: "Image Support",
|
||||
description: "Attach images to features for visual context.",
|
||||
},
|
||||
{
|
||||
icon: TestTube,
|
||||
title: "Test Integration",
|
||||
description: "Automatic test running and TDD support for quality assurance.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "data-flow",
|
||||
title: "How It Works (Data Flow)",
|
||||
icon: GitBranch,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>Here's what happens when you use Automaker to implement a feature:</p>
|
||||
<ol className="list-decimal list-inside space-y-3 ml-2 mt-4">
|
||||
<li className="text-foreground">
|
||||
<strong>Create Feature</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Add a new feature card to the Kanban board with description and steps</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Feature Saved</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Feature saved to <code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/features/{id}/feature.json</code></p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Start Work</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Drag to "In Progress" or enable auto mode to start implementation</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Git Worktree Created</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Backend AutoModeService creates isolated git worktree (if enabled)</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Agent Executes</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Claude Agent SDK runs with file/bash/git tool access</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Progress Streamed</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Real-time updates via WebSocket as agent works</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Completion</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">On success, feature moves to "waiting_approval" for your review</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Verify</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Review changes and move to "verified" when satisfied</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "structure",
|
||||
title: "Project Structure",
|
||||
icon: FolderTree,
|
||||
content: (
|
||||
<div>
|
||||
<p className="mb-3">The Automaker codebase is organized as follows:</p>
|
||||
<CodeBlock title="Directory Structure">
|
||||
{`/automaker/
|
||||
├── apps/
|
||||
│ ├── app/ # Frontend (Next.js + Electron)
|
||||
│ │ ├── electron/ # Electron main process
|
||||
│ │ └── src/
|
||||
│ │ ├── app/ # Next.js App Router pages
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── store/ # Zustand state management
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ └── lib/ # Utilities and helpers
|
||||
│ └── server/ # Backend (Express)
|
||||
│ └── src/
|
||||
│ ├── routes/ # API endpoints
|
||||
│ └── services/ # Business logic (AutoModeService, etc.)
|
||||
├── docs/ # Documentation
|
||||
└── package.json # Workspace root`}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "components",
|
||||
title: "Key Components",
|
||||
icon: Component,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>The main UI components that make up Automaker:</p>
|
||||
<div className="grid gap-2 mt-4">
|
||||
{[
|
||||
{ file: "sidebar.tsx", desc: "Main navigation with project picker and view switching" },
|
||||
{ file: "board-view.tsx", desc: "Kanban board with drag-and-drop cards" },
|
||||
{ file: "agent-view.tsx", desc: "AI chat interface for conversational development" },
|
||||
{ file: "spec-view.tsx", desc: "Project specification editor" },
|
||||
{ file: "context-view.tsx", desc: "Context file manager for AI context" },
|
||||
{ file: "terminal-view.tsx", desc: "Integrated terminal with splits and tabs" },
|
||||
{ file: "profiles-view.tsx", desc: "AI profile management (model + thinking presets)" },
|
||||
{ file: "app-store.ts", desc: "Central Zustand state management" },
|
||||
].map((item) => (
|
||||
<div key={item.file} className="flex items-center gap-3 p-2 rounded bg-muted/30 border border-border/50">
|
||||
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">{item.file}</code>
|
||||
<span className="text-xs text-muted-foreground">{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "configuration",
|
||||
title: "Configuration",
|
||||
icon: Settings,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>Automaker stores project configuration in the <code className="px-1 py-0.5 bg-muted rounded text-xs">.automaker/</code> directory:</p>
|
||||
<div className="grid gap-2 mt-4">
|
||||
{[
|
||||
{ file: "app_spec.txt", desc: "Project specification describing your app for AI context" },
|
||||
{ file: "context/", desc: "Additional context files (docs, examples) for AI" },
|
||||
{ file: "features/", desc: "Feature definitions with descriptions and steps" },
|
||||
].map((item) => (
|
||||
<div key={item.file} className="flex items-center gap-3 p-2 rounded bg-muted/30 border border-border/50">
|
||||
<code className="text-xs font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded">{item.file}</code>
|
||||
<span className="text-xs text-muted-foreground">{item.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<p className="text-sm text-foreground font-medium mb-2">Tip: App Spec Best Practices</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs text-muted-foreground">
|
||||
<li>Include your tech stack and key dependencies</li>
|
||||
<li>Describe the project structure and conventions</li>
|
||||
<li>List any important patterns or architectural decisions</li>
|
||||
<li>Note testing requirements and coding standards</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "getting-started",
|
||||
title: "Getting Started",
|
||||
icon: PlayCircle,
|
||||
content: (
|
||||
<div className="space-y-3">
|
||||
<p>Follow these steps to start building with Automaker:</p>
|
||||
<ol className="list-decimal list-inside space-y-4 ml-2 mt-4">
|
||||
<li className="text-foreground">
|
||||
<strong>Create or Open a Project</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Use the sidebar to create a new project or open an existing folder</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Write an App Spec</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Go to Spec Editor and describe your project. This helps AI understand your codebase.</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Add Context (Optional)</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Add relevant documentation or examples to the Context view for better AI results</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Create Features</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Add feature cards to your Kanban board with clear descriptions and implementation steps</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Configure AI Profile</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Choose an AI profile or customize model/thinking settings per feature</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Start Implementation</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Drag features to "In Progress" or enable auto mode to let AI work</p>
|
||||
</li>
|
||||
<li className="text-foreground">
|
||||
<strong>Review and Verify</strong>
|
||||
<p className="text-muted-foreground ml-5 mt-1">Check completed features, review changes, and mark as verified</p>
|
||||
</li>
|
||||
</ol>
|
||||
<div className="mt-6 p-4 rounded-lg bg-brand-500/10 border border-brand-500/20">
|
||||
<p className="text-brand-400 text-sm font-medium mb-2">Pro Tips:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-xs text-brand-400/80">
|
||||
<li>Use keyboard shortcuts for faster navigation (press <code className="px-1 py-0.5 bg-brand-500/20 rounded">?</code> to see all)</li>
|
||||
<li>Enable git worktree isolation for parallel feature development</li>
|
||||
<li>Start with "Quick Edit" profile for simple tasks, use "Heavy Task" for complex work</li>
|
||||
<li>Keep your app spec up to date as your project evolves</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-card/30 backdrop-blur-sm px-6 py-4 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Wiki</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Learn how Automaker works and how to use it effectively
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
Expand All
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-6 py-6 space-y-3">
|
||||
{sections.map((section) => (
|
||||
<CollapsibleSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
isOpen={openSections.has(section.id)}
|
||||
onToggle={() => toggleSection(section.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Model Configuration - Centralized model settings for the app
|
||||
*
|
||||
* Models can be overridden via environment variables:
|
||||
* - AUTOMAKER_MODEL_CHAT: Model for chat interactions
|
||||
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Claude model aliases for convenience
|
||||
*/
|
||||
export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
haiku: "claude-haiku-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
opus: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default models per use case
|
||||
*/
|
||||
export const DEFAULT_MODELS = {
|
||||
chat: "claude-opus-4-5-20251101",
|
||||
default: "claude-opus-4-5-20251101",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve a model alias to a full model string
|
||||
*/
|
||||
export function resolveModelString(
|
||||
modelKey?: string,
|
||||
defaultModel: string = DEFAULT_MODELS.default
|
||||
): string {
|
||||
if (!modelKey) {
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
// Full Claude model string - pass through
|
||||
if (modelKey.includes("claude-")) {
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
// Check alias map
|
||||
const resolved = CLAUDE_MODEL_MAP[modelKey];
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Unknown key - use default
|
||||
return defaultModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model for chat operations
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit model parameter
|
||||
* 2. AUTOMAKER_MODEL_CHAT environment variable
|
||||
* 3. AUTOMAKER_MODEL_DEFAULT environment variable
|
||||
* 4. Default chat model
|
||||
*/
|
||||
export function getChatModel(explicitModel?: string): string {
|
||||
if (explicitModel) {
|
||||
return resolveModelString(explicitModel);
|
||||
}
|
||||
|
||||
const envModel =
|
||||
process.env.AUTOMAKER_MODEL_CHAT || process.env.AUTOMAKER_MODEL_DEFAULT;
|
||||
|
||||
if (envModel) {
|
||||
return resolveModelString(envModel);
|
||||
}
|
||||
|
||||
return DEFAULT_MODELS.chat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default allowed tools for chat interactions
|
||||
*/
|
||||
export const CHAT_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Default max turns for chat
|
||||
*/
|
||||
export const CHAT_MAX_TURNS = 1000;
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
/**
|
||||
* Terminal themes that match the app themes
|
||||
* Each theme provides colors for xterm.js terminal emulator
|
||||
*/
|
||||
|
||||
import type { ThemeMode } from "@/store/app-store";
|
||||
|
||||
export interface TerminalTheme {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor: string;
|
||||
cursorAccent: string;
|
||||
selectionBackground: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
}
|
||||
|
||||
// Dark theme (default)
|
||||
const darkTheme: TerminalTheme = {
|
||||
background: "#0a0a0a",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
cursorAccent: "#0a0a0a",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#1e1e1e",
|
||||
red: "#f44747",
|
||||
green: "#6a9955",
|
||||
yellow: "#dcdcaa",
|
||||
blue: "#569cd6",
|
||||
magenta: "#c586c0",
|
||||
cyan: "#4ec9b0",
|
||||
white: "#d4d4d4",
|
||||
brightBlack: "#808080",
|
||||
brightRed: "#f44747",
|
||||
brightGreen: "#6a9955",
|
||||
brightYellow: "#dcdcaa",
|
||||
brightBlue: "#569cd6",
|
||||
brightMagenta: "#c586c0",
|
||||
brightCyan: "#4ec9b0",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Light theme
|
||||
const lightTheme: TerminalTheme = {
|
||||
background: "#ffffff",
|
||||
foreground: "#383a42",
|
||||
cursor: "#383a42",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#add6ff",
|
||||
black: "#383a42",
|
||||
red: "#e45649",
|
||||
green: "#50a14f",
|
||||
yellow: "#c18401",
|
||||
blue: "#4078f2",
|
||||
magenta: "#a626a4",
|
||||
cyan: "#0184bc",
|
||||
white: "#fafafa",
|
||||
brightBlack: "#4f525e",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Retro / Cyberpunk theme - neon green on black
|
||||
const retroTheme: TerminalTheme = {
|
||||
background: "#000000",
|
||||
foreground: "#39ff14",
|
||||
cursor: "#39ff14",
|
||||
cursorAccent: "#000000",
|
||||
selectionBackground: "#39ff14",
|
||||
selectionForeground: "#000000",
|
||||
black: "#000000",
|
||||
red: "#ff0055",
|
||||
green: "#39ff14",
|
||||
yellow: "#ffff00",
|
||||
blue: "#00ffff",
|
||||
magenta: "#ff00ff",
|
||||
cyan: "#00ffff",
|
||||
white: "#39ff14",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#ff5555",
|
||||
brightGreen: "#55ff55",
|
||||
brightYellow: "#ffff55",
|
||||
brightBlue: "#55ffff",
|
||||
brightMagenta: "#ff55ff",
|
||||
brightCyan: "#55ffff",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Dracula theme
|
||||
const draculaTheme: TerminalTheme = {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Nord theme
|
||||
const nordTheme: TerminalTheme = {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
};
|
||||
|
||||
// Monokai theme
|
||||
const monokaiTheme: TerminalTheme = {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
};
|
||||
|
||||
// Tokyo Night theme
|
||||
const tokyonightTheme: TerminalTheme = {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#c0caf5",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#33467c",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
};
|
||||
|
||||
// Solarized Dark theme
|
||||
const solarizedTheme: TerminalTheme = {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
};
|
||||
|
||||
// Gruvbox Dark theme
|
||||
const gruvboxTheme: TerminalTheme = {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
};
|
||||
|
||||
// Catppuccin Mocha theme
|
||||
const catppuccinTheme: TerminalTheme = {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#45475a",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#cba6f7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#cba6f7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
};
|
||||
|
||||
// One Dark theme
|
||||
const onedarkTheme: TerminalTheme = {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Synthwave '84 theme
|
||||
const synthwaveTheme: TerminalTheme = {
|
||||
background: "#262335",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ff7edb",
|
||||
cursorAccent: "#262335",
|
||||
selectionBackground: "#463465",
|
||||
black: "#262335",
|
||||
red: "#fe4450",
|
||||
green: "#72f1b8",
|
||||
yellow: "#fede5d",
|
||||
blue: "#03edf9",
|
||||
magenta: "#ff7edb",
|
||||
cyan: "#03edf9",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#614d85",
|
||||
brightRed: "#fe4450",
|
||||
brightGreen: "#72f1b8",
|
||||
brightYellow: "#f97e72",
|
||||
brightBlue: "#03edf9",
|
||||
brightMagenta: "#ff7edb",
|
||||
brightCyan: "#03edf9",
|
||||
brightWhite: "#ffffff",
|
||||
};
|
||||
|
||||
// Red theme - Dark theme with red accents
|
||||
const redTheme: TerminalTheme = {
|
||||
background: "#1a0a0a",
|
||||
foreground: "#c8b0b0",
|
||||
cursor: "#ff4444",
|
||||
cursorAccent: "#1a0a0a",
|
||||
selectionBackground: "#5a2020",
|
||||
black: "#2a1010",
|
||||
red: "#ff4444",
|
||||
green: "#6a9a6a",
|
||||
yellow: "#ccaa55",
|
||||
blue: "#6688aa",
|
||||
magenta: "#aa5588",
|
||||
cyan: "#558888",
|
||||
white: "#b0a0a0",
|
||||
brightBlack: "#6a4040",
|
||||
brightRed: "#ff6666",
|
||||
brightGreen: "#88bb88",
|
||||
brightYellow: "#ddbb66",
|
||||
brightBlue: "#88aacc",
|
||||
brightMagenta: "#cc77aa",
|
||||
brightCyan: "#77aaaa",
|
||||
brightWhite: "#d0c0c0",
|
||||
};
|
||||
|
||||
// Cream theme - Warm, soft, easy on the eyes
|
||||
const creamTheme: TerminalTheme = {
|
||||
background: "#f5f3ee",
|
||||
foreground: "#5a4a3a",
|
||||
cursor: "#9d6b53",
|
||||
cursorAccent: "#f5f3ee",
|
||||
selectionBackground: "#d4c4b0",
|
||||
black: "#5a4a3a",
|
||||
red: "#c85a4f",
|
||||
green: "#7a9a6a",
|
||||
yellow: "#c9a554",
|
||||
blue: "#6b8aaa",
|
||||
magenta: "#a66a8a",
|
||||
cyan: "#5a9a8a",
|
||||
white: "#b0a090",
|
||||
brightBlack: "#8a7a6a",
|
||||
brightRed: "#e07060",
|
||||
brightGreen: "#90b080",
|
||||
brightYellow: "#e0bb70",
|
||||
brightBlue: "#80a0c0",
|
||||
brightMagenta: "#c080a0",
|
||||
brightCyan: "#70b0a0",
|
||||
brightWhite: "#d0c0b0",
|
||||
};
|
||||
|
||||
// Sunset theme - Mellow oranges and soft pastels
|
||||
const sunsetTheme: TerminalTheme = {
|
||||
background: "#1e1a24",
|
||||
foreground: "#f2e8dd",
|
||||
cursor: "#dd8855",
|
||||
cursorAccent: "#1e1a24",
|
||||
selectionBackground: "#3a2a40",
|
||||
black: "#1e1a24",
|
||||
red: "#dd6655",
|
||||
green: "#88bb77",
|
||||
yellow: "#ddaa66",
|
||||
blue: "#6699cc",
|
||||
magenta: "#cc7799",
|
||||
cyan: "#66ccaa",
|
||||
white: "#e8d8c8",
|
||||
brightBlack: "#4a3a50",
|
||||
brightRed: "#ee8866",
|
||||
brightGreen: "#99cc88",
|
||||
brightYellow: "#eebb77",
|
||||
brightBlue: "#88aadd",
|
||||
brightMagenta: "#dd88aa",
|
||||
brightCyan: "#88ddbb",
|
||||
brightWhite: "#f5e8dd",
|
||||
};
|
||||
|
||||
// Gray theme - Modern, minimal gray scheme inspired by Cursor
|
||||
const grayTheme: TerminalTheme = {
|
||||
background: "#2a2d32",
|
||||
foreground: "#d0d0d5",
|
||||
cursor: "#8fa0c0",
|
||||
cursorAccent: "#2a2d32",
|
||||
selectionBackground: "#3a3f48",
|
||||
black: "#2a2d32",
|
||||
red: "#d87070",
|
||||
green: "#78b088",
|
||||
yellow: "#d0b060",
|
||||
blue: "#7090c0",
|
||||
magenta: "#a880b0",
|
||||
cyan: "#60a0b0",
|
||||
white: "#b0b0b8",
|
||||
brightBlack: "#606068",
|
||||
brightRed: "#e88888",
|
||||
brightGreen: "#90c8a0",
|
||||
brightYellow: "#e0c878",
|
||||
brightBlue: "#90b0d8",
|
||||
brightMagenta: "#c098c8",
|
||||
brightCyan: "#80b8c8",
|
||||
brightWhite: "#e0e0e8",
|
||||
};
|
||||
|
||||
// Theme mapping
|
||||
const terminalThemes: Record<ThemeMode, TerminalTheme> = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
system: darkTheme, // Will be resolved at runtime
|
||||
retro: retroTheme,
|
||||
dracula: draculaTheme,
|
||||
nord: nordTheme,
|
||||
monokai: monokaiTheme,
|
||||
tokyonight: tokyonightTheme,
|
||||
solarized: solarizedTheme,
|
||||
gruvbox: gruvboxTheme,
|
||||
catppuccin: catppuccinTheme,
|
||||
onedark: onedarkTheme,
|
||||
synthwave: synthwaveTheme,
|
||||
red: redTheme,
|
||||
cream: creamTheme,
|
||||
sunset: sunsetTheme,
|
||||
gray: grayTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get terminal theme for the given app theme
|
||||
* For "system" theme, it checks the user's system preference
|
||||
*/
|
||||
export function getTerminalTheme(theme: ThemeMode): TerminalTheme {
|
||||
if (theme === "system") {
|
||||
// Check system preference
|
||||
if (typeof window !== "undefined") {
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
return prefersDark ? darkTheme : lightTheme;
|
||||
}
|
||||
return darkTheme; // Default to dark for SSR
|
||||
}
|
||||
return terminalThemes[theme] || darkTheme;
|
||||
}
|
||||
|
||||
export default terminalThemes;
|
||||
@@ -1,116 +0,0 @@
|
||||
import {
|
||||
type LucideIcon,
|
||||
Atom,
|
||||
Cat,
|
||||
CloudSun,
|
||||
Coffee,
|
||||
Eclipse,
|
||||
Flame,
|
||||
Ghost,
|
||||
Heart,
|
||||
Moon,
|
||||
Radio,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Square,
|
||||
Sun,
|
||||
Terminal,
|
||||
Trees,
|
||||
} from "lucide-react";
|
||||
import { Theme } from "@/components/views/settings-view/shared/types";
|
||||
|
||||
export interface ThemeOption {
|
||||
value: Theme;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export const themeOptions: ReadonlyArray<ThemeOption> = [
|
||||
{ value: "dark", label: "Dark", Icon: Moon, testId: "dark-mode-button" },
|
||||
{ value: "light", label: "Light", Icon: Sun, testId: "light-mode-button" },
|
||||
{
|
||||
value: "retro",
|
||||
label: "Retro",
|
||||
Icon: Terminal,
|
||||
testId: "retro-mode-button",
|
||||
},
|
||||
{
|
||||
value: "dracula",
|
||||
label: "Dracula",
|
||||
Icon: Ghost,
|
||||
testId: "dracula-mode-button",
|
||||
},
|
||||
{
|
||||
value: "nord",
|
||||
label: "Nord",
|
||||
Icon: Snowflake,
|
||||
testId: "nord-mode-button",
|
||||
},
|
||||
{
|
||||
value: "monokai",
|
||||
label: "Monokai",
|
||||
Icon: Flame,
|
||||
testId: "monokai-mode-button",
|
||||
},
|
||||
{
|
||||
value: "tokyonight",
|
||||
label: "Tokyo Night",
|
||||
Icon: Sparkles,
|
||||
testId: "tokyonight-mode-button",
|
||||
},
|
||||
{
|
||||
value: "solarized",
|
||||
label: "Solarized",
|
||||
Icon: Eclipse,
|
||||
testId: "solarized-mode-button",
|
||||
},
|
||||
{
|
||||
value: "gruvbox",
|
||||
label: "Gruvbox",
|
||||
Icon: Trees,
|
||||
testId: "gruvbox-mode-button",
|
||||
},
|
||||
{
|
||||
value: "catppuccin",
|
||||
label: "Catppuccin",
|
||||
Icon: Cat,
|
||||
testId: "catppuccin-mode-button",
|
||||
},
|
||||
{
|
||||
value: "onedark",
|
||||
label: "One Dark",
|
||||
Icon: Atom,
|
||||
testId: "onedark-mode-button",
|
||||
},
|
||||
{
|
||||
value: "synthwave",
|
||||
label: "Synthwave",
|
||||
Icon: Radio,
|
||||
testId: "synthwave-mode-button",
|
||||
},
|
||||
{
|
||||
value: "red",
|
||||
label: "Red",
|
||||
Icon: Heart,
|
||||
testId: "red-mode-button",
|
||||
},
|
||||
{
|
||||
value: "cream",
|
||||
label: "Cream",
|
||||
Icon: Coffee,
|
||||
testId: "cream-mode-button",
|
||||
},
|
||||
{
|
||||
value: "sunset",
|
||||
label: "Sunset",
|
||||
Icon: CloudSun,
|
||||
testId: "sunset-mode-button",
|
||||
},
|
||||
{
|
||||
value: "gray",
|
||||
label: "Gray",
|
||||
Icon: Square,
|
||||
testId: "gray-mode-button",
|
||||
},
|
||||
];
|
||||
@@ -1,428 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Message, StreamEvent } from "@/types/electron";
|
||||
import { useMessageQueue } from "./use-message-queue";
|
||||
import type { ImageAttachment } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
|
||||
interface UseElectronAgentOptions {
|
||||
sessionId: string;
|
||||
workingDirectory?: string;
|
||||
model?: string;
|
||||
onToolUse?: (toolName: string, toolInput: unknown) => void;
|
||||
}
|
||||
|
||||
interface UseElectronAgentResult {
|
||||
messages: Message[];
|
||||
isProcessing: boolean;
|
||||
isConnected: boolean;
|
||||
sendMessage: (content: string, images?: ImageAttachment[]) => Promise<void>;
|
||||
stopExecution: () => Promise<void>;
|
||||
clearHistory: () => Promise<void>;
|
||||
error: string | null;
|
||||
// Queue-related state
|
||||
queuedMessages: { id: string; content: string; images?: ImageAttachment[]; timestamp: Date }[];
|
||||
isQueueProcessing: boolean;
|
||||
clearMessageQueue: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for interacting with the Electron-based Claude agent
|
||||
*
|
||||
* This hook provides a clean interface to the agent running in the Electron
|
||||
* main process, which survives Next.js restarts.
|
||||
*/
|
||||
export function useElectronAgent({
|
||||
sessionId,
|
||||
workingDirectory,
|
||||
model,
|
||||
onToolUse,
|
||||
}: UseElectronAgentOptions): UseElectronAgentResult {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
const currentMessageRef = useRef<Message | null>(null);
|
||||
|
||||
// Send message directly to the agent (bypassing queue)
|
||||
const sendMessageDirectly = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessing) {
|
||||
throw new Error("Agent is already processing a message");
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Sending message directly", {
|
||||
hasImages: images && images.length > 0,
|
||||
imageCount: images?.length || 0
|
||||
});
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
imagePaths = [];
|
||||
for (const image of images) {
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
image.mimeType,
|
||||
workingDirectory // Pass workingDirectory as projectPath
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
imagePaths.push(result.path);
|
||||
console.log("[useElectronAgent] Saved image to .automaker/images:", result.path);
|
||||
} else {
|
||||
console.error("[useElectronAgent] Failed to save image:", result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
// Note: We don't set isProcessing to false here because
|
||||
// it will be set by the "complete" or "error" stream event
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to send message:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[sessionId, workingDirectory, model, isProcessing]
|
||||
);
|
||||
|
||||
// Message queue for queuing messages when agent is busy
|
||||
const {
|
||||
queuedMessages,
|
||||
isProcessingQueue,
|
||||
addToQueue,
|
||||
clearQueue,
|
||||
processNext,
|
||||
} = useMessageQueue({
|
||||
onProcessNext: async (queuedMessage) => {
|
||||
await sendMessageDirectly(queuedMessage.content, queuedMessage.images);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize connection and load history
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
// No session selected - reset state
|
||||
setMessages([]);
|
||||
setIsConnected(false);
|
||||
setIsProcessing(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const initialize = async () => {
|
||||
// Reset error state when switching sessions
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Starting session:", sessionId);
|
||||
const result = await api.agent!.start(
|
||||
sessionId,
|
||||
workingDirectory
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.success && result.messages) {
|
||||
console.log("[useElectronAgent] Loaded", result.messages.length, "messages");
|
||||
setMessages(result.messages);
|
||||
setIsConnected(true);
|
||||
|
||||
// Check if the agent is currently running for this session
|
||||
const historyResult = await api.agent!.getHistory(sessionId);
|
||||
if (mounted && historyResult.success) {
|
||||
const isRunning = historyResult.isRunning || false;
|
||||
console.log("[useElectronAgent] Session running state:", isRunning);
|
||||
setIsProcessing(isRunning);
|
||||
}
|
||||
} else {
|
||||
setError(result.error || "Failed to start session");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
console.error("[useElectronAgent] Failed to initialize:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to initialize");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [sessionId, workingDirectory]);
|
||||
|
||||
// Auto-process queue when agent finishes processing
|
||||
useEffect(() => {
|
||||
if (!isProcessing && !isProcessingQueue && queuedMessages.length > 0) {
|
||||
console.log("[useElectronAgent] Auto-processing next queued message");
|
||||
processNext();
|
||||
}
|
||||
}, [isProcessing, isProcessingQueue, queuedMessages.length, processNext]);
|
||||
|
||||
// Subscribe to streaming events
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) return;
|
||||
if (!sessionId) return; // Don't subscribe if no session
|
||||
|
||||
console.log("[useElectronAgent] Subscribing to stream events for session:", sessionId);
|
||||
|
||||
const handleStream = (event: StreamEvent) => {
|
||||
// CRITICAL: Only process events for our specific session
|
||||
if (event.sessionId !== sessionId) {
|
||||
console.log("[useElectronAgent] Ignoring event for different session:", event.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[useElectronAgent] Stream event for", sessionId, ":", event.type);
|
||||
|
||||
switch (event.type) {
|
||||
case "message":
|
||||
// User message added
|
||||
setMessages((prev) => [...prev, event.message]);
|
||||
break;
|
||||
|
||||
case "stream":
|
||||
// Assistant message streaming
|
||||
if (event.isComplete) {
|
||||
// Final update
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
currentMessageRef.current = null;
|
||||
} else {
|
||||
// Streaming update
|
||||
setMessages((prev) => {
|
||||
const existingIndex = prev.findIndex((m) => m.id === event.messageId);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing message
|
||||
return prev.map((msg) =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
);
|
||||
} else {
|
||||
// Create new message
|
||||
const newMessage: Message = {
|
||||
id: event.messageId,
|
||||
role: "assistant",
|
||||
content: event.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
currentMessageRef.current = newMessage;
|
||||
return [...prev, newMessage];
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_use":
|
||||
// Tool being used
|
||||
console.log("[useElectronAgent] Tool use:", event.tool.name);
|
||||
onToolUse?.(event.tool.name, event.tool.input);
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
// Agent finished processing for THIS session
|
||||
console.log("[useElectronAgent] Processing complete for session:", sessionId);
|
||||
setIsProcessing(false);
|
||||
if (event.messageId) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: event.content }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "error":
|
||||
// Error occurred for THIS session
|
||||
console.error("[useElectronAgent] Agent error for session:", sessionId, event.error);
|
||||
setIsProcessing(false);
|
||||
setError(event.error);
|
||||
if (event.message) {
|
||||
const errorMessage = event.message;
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
unsubscribeRef.current = api.agent!.onStream(handleStream as (data: unknown) => void);
|
||||
|
||||
return () => {
|
||||
if (unsubscribeRef.current) {
|
||||
console.log("[useElectronAgent] Unsubscribing from stream events for session:", sessionId);
|
||||
unsubscribeRef.current();
|
||||
unsubscribeRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, onToolUse]);
|
||||
|
||||
// Send a message to the agent
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, images?: ImageAttachment[]) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessing) {
|
||||
console.warn("[useElectronAgent] Already processing a message");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Sending message", {
|
||||
hasImages: images && images.length > 0,
|
||||
imageCount: images?.length || 0
|
||||
});
|
||||
|
||||
// Save images to .automaker/images and get paths
|
||||
let imagePaths: string[] | undefined;
|
||||
if (images && images.length > 0 && api.saveImageToTemp) {
|
||||
imagePaths = [];
|
||||
for (const image of images) {
|
||||
const result = await api.saveImageToTemp(
|
||||
image.data,
|
||||
image.filename,
|
||||
image.mimeType,
|
||||
workingDirectory // Pass workingDirectory as projectPath
|
||||
);
|
||||
if (result.success && result.path) {
|
||||
imagePaths.push(result.path);
|
||||
console.log("[useElectronAgent] Saved image to .automaker/images:", result.path);
|
||||
} else {
|
||||
console.error("[useElectronAgent] Failed to save image:", result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await api.agent!.send(
|
||||
sessionId,
|
||||
content,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
// Note: We don't set isProcessing to false here because
|
||||
// it will be set by the "complete" or "error" stream event
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to send message:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to send message");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[sessionId, workingDirectory, model, isProcessing]
|
||||
);
|
||||
|
||||
// Stop current execution
|
||||
const stopExecution = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Stopping execution");
|
||||
const result = await api.agent!.stop(sessionId);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Failed to stop execution");
|
||||
} else {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to stop:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to stop execution");
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Clear conversation history
|
||||
const clearHistory = useCallback(async () => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.agent) {
|
||||
setError("API not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("[useElectronAgent] Clearing history");
|
||||
const result = await api.agent!.clear(sessionId);
|
||||
|
||||
if (result.success) {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error || "Failed to clear history");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[useElectronAgent] Failed to clear:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to clear history");
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isProcessing,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
stopExecution,
|
||||
clearHistory,
|
||||
error,
|
||||
queuedMessages,
|
||||
isQueueProcessing: isProcessingQueue,
|
||||
clearMessageQueue: clearQueue,
|
||||
};
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAppStore, parseShortcut } from "@/store/app-store";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string; // Can be simple "K" or with modifiers "Shift+N", "Cmd+K"
|
||||
action: () => void;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the currently focused element is an input, textarea, or contenteditable element
|
||||
* or if an autocomplete/typeahead dropdown is open
|
||||
*/
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
// Check if it's a form input element
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a contenteditable element
|
||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it has a role of textbox or searchbox
|
||||
const role = activeElement.getAttribute("role");
|
||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if focus is inside an xterm terminal (they use a hidden textarea)
|
||||
const xtermContainer = activeElement.closest(".xterm");
|
||||
if (xtermContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if any parent has data-terminal-container attribute
|
||||
const terminalContainer = activeElement.closest("[data-terminal-container]");
|
||||
if (terminalContainer) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for autocomplete/typeahead dropdowns being open
|
||||
const autocompleteList = document.querySelector(
|
||||
'[data-testid="category-autocomplete-list"]'
|
||||
);
|
||||
if (autocompleteList) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for any open dialogs
|
||||
const dialog = document.querySelector('[role="dialog"][data-state="open"]');
|
||||
if (dialog) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for project picker dropdown being open
|
||||
const projectPickerDropdown = document.querySelector(
|
||||
'[data-testid="project-picker-dropdown"]'
|
||||
);
|
||||
if (projectPickerDropdown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for any open dropdown menus (Radix UI uses role="menu")
|
||||
// This prevents shortcuts from firing when user is typing in dropdown filters
|
||||
const dropdownMenu = document.querySelector('[role="menu"]');
|
||||
if (dropdownMenu) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches a shortcut definition
|
||||
*/
|
||||
function matchesShortcut(event: KeyboardEvent, shortcutStr: string): boolean {
|
||||
const shortcut = parseShortcut(shortcutStr);
|
||||
|
||||
// Check if the key matches (case-insensitive)
|
||||
if (event.key.toLowerCase() !== shortcut.key.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifier keys
|
||||
const cmdCtrlPressed = event.metaKey || event.ctrlKey;
|
||||
const shiftPressed = event.shiftKey;
|
||||
const altPressed = event.altKey;
|
||||
|
||||
// If shortcut requires cmdCtrl, it must be pressed
|
||||
if (shortcut.cmdCtrl && !cmdCtrlPressed) return false;
|
||||
// If shortcut doesn't require cmdCtrl, it shouldn't be pressed
|
||||
if (!shortcut.cmdCtrl && cmdCtrlPressed) return false;
|
||||
|
||||
// If shortcut requires shift, it must be pressed
|
||||
if (shortcut.shift && !shiftPressed) return false;
|
||||
// If shortcut doesn't require shift, it shouldn't be pressed
|
||||
if (!shortcut.shift && shiftPressed) return false;
|
||||
|
||||
// If shortcut requires alt, it must be pressed
|
||||
if (shortcut.alt && !altPressed) return false;
|
||||
// If shortcut doesn't require alt, it shouldn't be pressed
|
||||
if (!shortcut.alt && altPressed) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage keyboard shortcuts
|
||||
* Shortcuts won't fire when user is typing in inputs, textareas, or when dialogs are open
|
||||
* Supports modifier keys: Shift, Cmd/Ctrl, Alt/Option
|
||||
*/
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching shortcut
|
||||
const matchingShortcut = shortcuts.find(
|
||||
(shortcut) => matchesShortcut(event, shortcut.key)
|
||||
);
|
||||
|
||||
if (matchingShortcut) {
|
||||
event.preventDefault();
|
||||
matchingShortcut.action();
|
||||
}
|
||||
},
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current keyboard shortcuts from store
|
||||
* This replaces the static constants and allows customization
|
||||
*/
|
||||
export function useKeyboardShortcutsConfig() {
|
||||
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
|
||||
return keyboardShortcuts;
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* Dependency Resolution Utility
|
||||
*
|
||||
* Provides topological sorting and dependency analysis for features.
|
||||
* Uses a modified Kahn's algorithm that respects both dependencies and priorities.
|
||||
*/
|
||||
|
||||
import type { Feature } from "@/store/app-store";
|
||||
|
||||
export interface DependencyResolutionResult {
|
||||
orderedFeatures: Feature[]; // Features in dependency-aware order
|
||||
circularDependencies: string[][]; // Groups of IDs forming cycles
|
||||
missingDependencies: Map<string, string[]>; // featureId -> missing dep IDs
|
||||
blockedFeatures: Map<string, string[]>; // featureId -> blocking dep IDs (incomplete dependencies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves feature dependencies using topological sort with priority-aware ordering.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Build dependency graph and detect missing/blocked dependencies
|
||||
* 2. Apply Kahn's algorithm for topological sort
|
||||
* 3. Within same dependency level, sort by priority (1=high, 2=medium, 3=low)
|
||||
* 4. Detect circular dependencies for features that can't be ordered
|
||||
*
|
||||
* @param features - Array of features to order
|
||||
* @returns Resolution result with ordered features and dependency metadata
|
||||
*/
|
||||
export function resolveDependencies(features: Feature[]): DependencyResolutionResult {
|
||||
const featureMap = new Map<string, Feature>(features.map(f => [f.id, f]));
|
||||
const inDegree = new Map<string, number>();
|
||||
const adjacencyList = new Map<string, string[]>(); // dependencyId -> [dependentIds]
|
||||
const missingDependencies = new Map<string, string[]>();
|
||||
const blockedFeatures = new Map<string, string[]>();
|
||||
|
||||
// Initialize graph structures
|
||||
for (const feature of features) {
|
||||
inDegree.set(feature.id, 0);
|
||||
adjacencyList.set(feature.id, []);
|
||||
}
|
||||
|
||||
// Build dependency graph and detect missing/blocked dependencies
|
||||
for (const feature of features) {
|
||||
const deps = feature.dependencies || [];
|
||||
for (const depId of deps) {
|
||||
if (!featureMap.has(depId)) {
|
||||
// Missing dependency - track it
|
||||
if (!missingDependencies.has(feature.id)) {
|
||||
missingDependencies.set(feature.id, []);
|
||||
}
|
||||
missingDependencies.get(feature.id)!.push(depId);
|
||||
} else {
|
||||
// Valid dependency - add edge to graph
|
||||
adjacencyList.get(depId)!.push(feature.id);
|
||||
inDegree.set(feature.id, (inDegree.get(feature.id) || 0) + 1);
|
||||
|
||||
// Check if dependency is incomplete (blocking)
|
||||
const depFeature = featureMap.get(depId)!;
|
||||
if (depFeature.status !== 'completed' && depFeature.status !== 'verified') {
|
||||
if (!blockedFeatures.has(feature.id)) {
|
||||
blockedFeatures.set(feature.id, []);
|
||||
}
|
||||
blockedFeatures.get(feature.id)!.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kahn's algorithm with priority-aware selection
|
||||
const queue: Feature[] = [];
|
||||
const orderedFeatures: Feature[] = [];
|
||||
|
||||
// Helper to sort features by priority (lower number = higher priority)
|
||||
const sortByPriority = (a: Feature, b: Feature) =>
|
||||
(a.priority ?? 2) - (b.priority ?? 2);
|
||||
|
||||
// Start with features that have no dependencies (in-degree 0)
|
||||
for (const [id, degree] of inDegree) {
|
||||
if (degree === 0) {
|
||||
queue.push(featureMap.get(id)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort initial queue by priority
|
||||
queue.sort(sortByPriority);
|
||||
|
||||
// Process features in topological order
|
||||
while (queue.length > 0) {
|
||||
// Take highest priority feature from queue
|
||||
const current = queue.shift()!;
|
||||
orderedFeatures.push(current);
|
||||
|
||||
// Process features that depend on this one
|
||||
for (const dependentId of adjacencyList.get(current.id) || []) {
|
||||
const currentDegree = inDegree.get(dependentId);
|
||||
if (currentDegree === undefined) {
|
||||
throw new Error(`In-degree not initialized for feature ${dependentId}`);
|
||||
}
|
||||
const newDegree = currentDegree - 1;
|
||||
inDegree.set(dependentId, newDegree);
|
||||
|
||||
if (newDegree === 0) {
|
||||
queue.push(featureMap.get(dependentId)!);
|
||||
// Re-sort queue to maintain priority order
|
||||
queue.sort(sortByPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect circular dependencies (features not in output = part of cycle)
|
||||
const circularDependencies: string[][] = [];
|
||||
const processedIds = new Set(orderedFeatures.map(f => f.id));
|
||||
|
||||
if (orderedFeatures.length < features.length) {
|
||||
// Find cycles using DFS
|
||||
const remaining = features.filter(f => !processedIds.has(f.id));
|
||||
const cycles = detectCycles(remaining, featureMap);
|
||||
circularDependencies.push(...cycles);
|
||||
|
||||
// Add remaining features at end (part of cycles)
|
||||
orderedFeatures.push(...remaining);
|
||||
}
|
||||
|
||||
return {
|
||||
orderedFeatures,
|
||||
circularDependencies,
|
||||
missingDependencies,
|
||||
blockedFeatures
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects circular dependencies using depth-first search
|
||||
*
|
||||
* @param features - Features that couldn't be topologically sorted (potential cycles)
|
||||
* @param featureMap - Map of all features by ID
|
||||
* @returns Array of cycles, where each cycle is an array of feature IDs
|
||||
*/
|
||||
function detectCycles(
|
||||
features: Feature[],
|
||||
featureMap: Map<string, Feature>
|
||||
): string[][] {
|
||||
const cycles: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
const currentPath: string[] = [];
|
||||
|
||||
function dfs(featureId: string): boolean {
|
||||
visited.add(featureId);
|
||||
recursionStack.add(featureId);
|
||||
currentPath.push(featureId);
|
||||
|
||||
const feature = featureMap.get(featureId);
|
||||
if (feature) {
|
||||
for (const depId of feature.dependencies || []) {
|
||||
if (!visited.has(depId)) {
|
||||
if (dfs(depId)) return true;
|
||||
} else if (recursionStack.has(depId)) {
|
||||
// Found cycle - extract it
|
||||
const cycleStart = currentPath.indexOf(depId);
|
||||
cycles.push(currentPath.slice(cycleStart));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPath.pop();
|
||||
recursionStack.delete(featureId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
if (!visited.has(feature.id)) {
|
||||
dfs(feature.id);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a feature's dependencies are satisfied (all complete or verified)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns true if all dependencies are satisfied, false otherwise
|
||||
*/
|
||||
export function areDependenciesSatisfied(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): boolean {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return true; // No dependencies = always ready
|
||||
}
|
||||
|
||||
return feature.dependencies.every(depId => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && (dep.status === 'completed' || dep.status === 'verified');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the blocking dependencies for a feature (dependencies that are incomplete)
|
||||
*
|
||||
* @param feature - Feature to check
|
||||
* @param allFeatures - All features in the project
|
||||
* @returns Array of feature IDs that are blocking this feature
|
||||
*/
|
||||
export function getBlockingDependencies(
|
||||
feature: Feature,
|
||||
allFeatures: Feature[]
|
||||
): string[] {
|
||||
if (!feature.dependencies || feature.dependencies.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return feature.dependencies.filter(depId => {
|
||||
const dep = allFeatures.find(f => f.id === depId);
|
||||
return dep && dep.status !== 'completed' && dep.status !== 'verified';
|
||||
});
|
||||
}
|
||||
@@ -1,895 +0,0 @@
|
||||
/**
|
||||
* HTTP API Client for web mode
|
||||
*
|
||||
* This client provides the same API as the Electron IPC bridge,
|
||||
* but communicates with the backend server via HTTP/WebSocket.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ElectronAPI,
|
||||
FileResult,
|
||||
WriteResult,
|
||||
ReaddirResult,
|
||||
StatResult,
|
||||
DialogResult,
|
||||
SaveImageResult,
|
||||
AutoModeAPI,
|
||||
FeaturesAPI,
|
||||
SuggestionsAPI,
|
||||
SpecRegenerationAPI,
|
||||
AutoModeEvent,
|
||||
SuggestionsEvent,
|
||||
SpecRegenerationEvent,
|
||||
FeatureSuggestion,
|
||||
SuggestionType,
|
||||
} from "./electron";
|
||||
import type { Message, SessionListItem } from "@/types/electron";
|
||||
import type { Feature } from "@/store/app-store";
|
||||
import type {
|
||||
WorktreeAPI,
|
||||
GitAPI,
|
||||
ModelDefinition,
|
||||
ProviderStatus,
|
||||
} from "@/types/electron";
|
||||
import { getGlobalFileBrowser } from "@/contexts/file-browser-context";
|
||||
|
||||
// Server URL - configurable via environment variable
|
||||
const getServerUrl = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
}
|
||||
return "http://localhost:3008";
|
||||
};
|
||||
|
||||
// Get API key from environment variable
|
||||
const getApiKey = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return process.env.NEXT_PUBLIC_AUTOMAKER_API_KEY || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EventType =
|
||||
| "agent:stream"
|
||||
| "auto-mode:event"
|
||||
| "suggestions:event"
|
||||
| "spec-regeneration:event";
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
interface EnhancePromptResult {
|
||||
success: boolean;
|
||||
enhancedText?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP API Client that implements ElectronAPI interface
|
||||
*/
|
||||
export class HttpApiClient implements ElectronAPI {
|
||||
private serverUrl: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private isConnecting = false;
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrl();
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (
|
||||
this.isConnecting ||
|
||||
(this.ws && this.ws.readyState === WebSocket.OPEN)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[HttpApiClient] WebSocket connected");
|
||||
this.isConnecting = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const callbacks = this.eventCallbacks.get(data.type);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb(data.payload));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[HttpApiClient] Failed to parse WebSocket message:",
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[HttpApiClient] WebSocket disconnected");
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
// Attempt to reconnect after 5 seconds
|
||||
if (!this.reconnectTimer) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connectWebSocket();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("[HttpApiClient] WebSocket error:", error);
|
||||
this.isConnecting = false;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[HttpApiClient] Failed to create WebSocket:", error);
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToEvent(
|
||||
type: EventType,
|
||||
callback: EventCallback
|
||||
): () => void {
|
||||
if (!this.eventCallbacks.has(type)) {
|
||||
this.eventCallbacks.set(type, new Set());
|
||||
}
|
||||
this.eventCallbacks.get(type)!.add(callback);
|
||||
|
||||
// Ensure WebSocket is connected
|
||||
this.connectWebSocket();
|
||||
|
||||
return () => {
|
||||
const callbacks = this.eventCallbacks.get(type);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers["X-API-Key"] = apiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async put<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "PUT",
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async httpDelete<T>(endpoint: string): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "DELETE",
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Basic operations
|
||||
async ping(): Promise<string> {
|
||||
const result = await this.get<{ status: string }>("/api/health");
|
||||
return result.status === "ok" ? "pong" : "error";
|
||||
}
|
||||
|
||||
async openExternalLink(
|
||||
url: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// Open in new tab
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// File picker - uses server-side file browser dialog
|
||||
async openDirectory(): Promise<DialogResult> {
|
||||
const fileBrowser = getGlobalFileBrowser();
|
||||
|
||||
if (!fileBrowser) {
|
||||
console.error("File browser not initialized");
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
const path = await fileBrowser();
|
||||
|
||||
if (!path) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
// Validate with server
|
||||
const result = await this.post<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>("/api/fs/validate-path", { filePath: path });
|
||||
|
||||
if (result.success && result.path) {
|
||||
return { canceled: false, filePaths: [result.path] };
|
||||
}
|
||||
|
||||
console.error("Invalid directory:", result.error);
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
async openFile(options?: object): Promise<DialogResult> {
|
||||
const fileBrowser = getGlobalFileBrowser();
|
||||
|
||||
if (!fileBrowser) {
|
||||
console.error("File browser not initialized");
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
// For now, use the same directory browser (could be enhanced for file selection)
|
||||
const path = await fileBrowser();
|
||||
|
||||
if (!path) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
||||
"/api/fs/exists",
|
||||
{ filePath: path }
|
||||
);
|
||||
|
||||
if (result.success && result.exists) {
|
||||
return { canceled: false, filePaths: [path] };
|
||||
}
|
||||
|
||||
console.error("File not found");
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
// File system operations
|
||||
async readFile(filePath: string): Promise<FileResult> {
|
||||
return this.post("/api/fs/read", { filePath });
|
||||
}
|
||||
|
||||
async writeFile(filePath: string, content: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/write", { filePath, content });
|
||||
}
|
||||
|
||||
async mkdir(dirPath: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/mkdir", { dirPath });
|
||||
}
|
||||
|
||||
async readdir(dirPath: string): Promise<ReaddirResult> {
|
||||
return this.post("/api/fs/readdir", { dirPath });
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
||||
"/api/fs/exists",
|
||||
{ filePath }
|
||||
);
|
||||
return result.exists;
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<StatResult> {
|
||||
return this.post("/api/fs/stat", { filePath });
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/delete", { filePath });
|
||||
}
|
||||
|
||||
async trashItem(filePath: string): Promise<WriteResult> {
|
||||
// In web mode, trash is just delete
|
||||
return this.deleteFile(filePath);
|
||||
}
|
||||
|
||||
async getPath(name: string): Promise<string> {
|
||||
// Server provides data directory
|
||||
if (name === "userData") {
|
||||
const result = await this.get<{ dataDir: string }>(
|
||||
"/api/health/detailed"
|
||||
);
|
||||
return result.dataDir || "/data";
|
||||
}
|
||||
return `/data/${name}`;
|
||||
}
|
||||
|
||||
async saveImageToTemp(
|
||||
data: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
projectPath?: string
|
||||
): Promise<SaveImageResult> {
|
||||
return this.post("/api/fs/save-image", {
|
||||
data,
|
||||
filename,
|
||||
mimeType,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
async saveBoardBackground(
|
||||
data: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
projectPath: string
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> {
|
||||
return this.post("/api/fs/save-board-background", {
|
||||
data,
|
||||
filename,
|
||||
mimeType,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBoardBackground(
|
||||
projectPath: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return this.post("/api/fs/delete-board-background", { projectPath });
|
||||
}
|
||||
|
||||
// CLI checks - server-side
|
||||
async checkClaudeCli(): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
return this.get("/api/setup/claude-status");
|
||||
}
|
||||
|
||||
// Model API
|
||||
model = {
|
||||
getAvailable: async (): Promise<{
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
error?: string;
|
||||
}> => {
|
||||
return this.get("/api/models/available");
|
||||
},
|
||||
checkProviders: async (): Promise<{
|
||||
success: boolean;
|
||||
providers?: Record<string, ProviderStatus>;
|
||||
error?: string;
|
||||
}> => {
|
||||
return this.get("/api/models/providers");
|
||||
},
|
||||
};
|
||||
|
||||
// Setup API
|
||||
setup = {
|
||||
getClaudeStatus: (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
installed?: boolean;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
hasCredentialsFile?: boolean;
|
||||
hasToken?: boolean;
|
||||
hasStoredOAuthToken?: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
hasEnvOAuthToken?: boolean;
|
||||
hasCliAuth?: boolean;
|
||||
hasRecentActivity?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/claude-status"),
|
||||
|
||||
installClaude: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/install-claude"),
|
||||
|
||||
authClaude: (): Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
requiresManualAuth?: boolean;
|
||||
terminalOpened?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}> => this.post("/api/setup/auth-claude"),
|
||||
|
||||
storeApiKey: (
|
||||
provider: string,
|
||||
apiKey: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
||||
|
||||
deleteApiKey: (
|
||||
provider: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}> => this.post("/api/setup/delete-api-key", { provider }),
|
||||
|
||||
getApiKeys: (): Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}> => this.get("/api/setup/api-keys"),
|
||||
|
||||
getPlatform: (): Promise<{
|
||||
success: boolean;
|
||||
platform: string;
|
||||
arch: string;
|
||||
homeDir: string;
|
||||
isWindows: boolean;
|
||||
isMac: boolean;
|
||||
isLinux: boolean;
|
||||
}> => this.get("/api/setup/platform"),
|
||||
|
||||
verifyClaudeAuth: (
|
||||
authMethod?: "cli" | "api_key"
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/verify-claude-auth", { authMethod }),
|
||||
|
||||
getGhStatus: (): Promise<{
|
||||
success: boolean;
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
version: string | null;
|
||||
path: string | null;
|
||||
user: string | null;
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/gh-status"),
|
||||
|
||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback);
|
||||
},
|
||||
|
||||
onAuthProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback);
|
||||
},
|
||||
};
|
||||
|
||||
// Features API
|
||||
features: FeaturesAPI = {
|
||||
getAll: (projectPath: string) =>
|
||||
this.post("/api/features/list", { projectPath }),
|
||||
get: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/get", { projectPath, featureId }),
|
||||
create: (projectPath: string, feature: Feature) =>
|
||||
this.post("/api/features/create", { projectPath, feature }),
|
||||
update: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
) => this.post("/api/features/update", { projectPath, featureId, updates }),
|
||||
delete: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/delete", { projectPath, featureId }),
|
||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/agent-output", { projectPath, featureId }),
|
||||
};
|
||||
|
||||
// Auto Mode API
|
||||
autoMode: AutoModeAPI = {
|
||||
start: (projectPath: string, maxConcurrency?: number) =>
|
||||
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
|
||||
stop: (projectPath: string) =>
|
||||
this.post("/api/auto-mode/stop", { projectPath }),
|
||||
stopFeature: (featureId: string) =>
|
||||
this.post("/api/auto-mode/stop-feature", { featureId }),
|
||||
status: (projectPath?: string) =>
|
||||
this.post("/api/auto-mode/status", { projectPath }),
|
||||
runFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean,
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/run-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
worktreePath,
|
||||
}),
|
||||
verifyFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
||||
resumeFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees?: boolean
|
||||
) =>
|
||||
this.post("/api/auto-mode/resume-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
useWorktrees,
|
||||
}),
|
||||
contextExists: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
|
||||
analyzeProject: (projectPath: string) =>
|
||||
this.post("/api/auto-mode/analyze-project", { projectPath }),
|
||||
followUpFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[],
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/follow-up-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
worktreePath,
|
||||
}),
|
||||
commitFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
worktreePath?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/commit-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
worktreePath,
|
||||
}),
|
||||
approvePlan: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
approved: boolean,
|
||||
editedPlan?: string,
|
||||
feedback?: string
|
||||
) =>
|
||||
this.post("/api/auto-mode/approve-plan", {
|
||||
projectPath,
|
||||
featureId,
|
||||
approved,
|
||||
editedPlan,
|
||||
feedback,
|
||||
}),
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"auto-mode:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Enhance Prompt API
|
||||
enhancePrompt = {
|
||||
enhance: (
|
||||
originalText: string,
|
||||
enhancementMode: string,
|
||||
model?: string
|
||||
): Promise<EnhancePromptResult> =>
|
||||
this.post("/api/enhance-prompt", {
|
||||
originalText,
|
||||
enhancementMode,
|
||||
model,
|
||||
}),
|
||||
};
|
||||
|
||||
// Worktree API
|
||||
worktree: WorktreeAPI = {
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
||||
this.post("/api/worktree/merge", { projectPath, featureId, options }),
|
||||
getInfo: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/info", { projectPath, featureId }),
|
||||
getStatus: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/status", { projectPath, featureId }),
|
||||
list: (projectPath: string) =>
|
||||
this.post("/api/worktree/list", { projectPath }),
|
||||
listAll: (projectPath: string, includeDetails?: boolean) =>
|
||||
this.post("/api/worktree/list", { projectPath, includeDetails }),
|
||||
create: (projectPath: string, branchName: string, baseBranch?: string) =>
|
||||
this.post("/api/worktree/create", {
|
||||
projectPath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
}),
|
||||
delete: (
|
||||
projectPath: string,
|
||||
worktreePath: string,
|
||||
deleteBranch?: boolean
|
||||
) =>
|
||||
this.post("/api/worktree/delete", {
|
||||
projectPath,
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
}),
|
||||
commit: (worktreePath: string, message: string) =>
|
||||
this.post("/api/worktree/commit", { worktreePath, message }),
|
||||
push: (worktreePath: string, force?: boolean) =>
|
||||
this.post("/api/worktree/push", { worktreePath, force }),
|
||||
createPR: (worktreePath: string, options?: any) =>
|
||||
this.post("/api/worktree/create-pr", { worktreePath, ...options }),
|
||||
getDiffs: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/diffs", { projectPath, featureId }),
|
||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
||||
this.post("/api/worktree/file-diff", {
|
||||
projectPath,
|
||||
featureId,
|
||||
filePath,
|
||||
}),
|
||||
pull: (worktreePath: string) =>
|
||||
this.post("/api/worktree/pull", { worktreePath }),
|
||||
checkoutBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post("/api/worktree/checkout-branch", { worktreePath, branchName }),
|
||||
listBranches: (worktreePath: string) =>
|
||||
this.post("/api/worktree/list-branches", { worktreePath }),
|
||||
switchBranch: (worktreePath: string, branchName: string) =>
|
||||
this.post("/api/worktree/switch-branch", { worktreePath, branchName }),
|
||||
openInEditor: (worktreePath: string) =>
|
||||
this.post("/api/worktree/open-in-editor", { worktreePath }),
|
||||
getDefaultEditor: () => this.get("/api/worktree/default-editor"),
|
||||
initGit: (projectPath: string) =>
|
||||
this.post("/api/worktree/init-git", { projectPath }),
|
||||
startDevServer: (projectPath: string, worktreePath: string) =>
|
||||
this.post("/api/worktree/start-dev", { projectPath, worktreePath }),
|
||||
stopDevServer: (worktreePath: string) =>
|
||||
this.post("/api/worktree/stop-dev", { worktreePath }),
|
||||
listDevServers: () => this.post("/api/worktree/list-dev-servers", {}),
|
||||
};
|
||||
|
||||
// Git API
|
||||
git: GitAPI = {
|
||||
getDiffs: (projectPath: string) =>
|
||||
this.post("/api/git/diffs", { projectPath }),
|
||||
getFileDiff: (projectPath: string, filePath: string) =>
|
||||
this.post("/api/git/file-diff", { projectPath, filePath }),
|
||||
};
|
||||
|
||||
// Suggestions API
|
||||
suggestions: SuggestionsAPI = {
|
||||
generate: (projectPath: string, suggestionType?: SuggestionType) =>
|
||||
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
|
||||
stop: () => this.post("/api/suggestions/stop"),
|
||||
status: () => this.get("/api/suggestions/status"),
|
||||
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"suggestions:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Spec Regeneration API
|
||||
specRegeneration: SpecRegenerationAPI = {
|
||||
create: (
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number
|
||||
) =>
|
||||
this.post("/api/spec-regeneration/create", {
|
||||
projectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
maxFeatures,
|
||||
}),
|
||||
generate: (
|
||||
projectPath: string,
|
||||
projectDefinition: string,
|
||||
generateFeatures?: boolean,
|
||||
analyzeProject?: boolean,
|
||||
maxFeatures?: number
|
||||
) =>
|
||||
this.post("/api/spec-regeneration/generate", {
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
generateFeatures,
|
||||
analyzeProject,
|
||||
maxFeatures,
|
||||
}),
|
||||
generateFeatures: (projectPath: string, maxFeatures?: number) =>
|
||||
this.post("/api/spec-regeneration/generate-features", {
|
||||
projectPath,
|
||||
maxFeatures,
|
||||
}),
|
||||
stop: () => this.post("/api/spec-regeneration/stop"),
|
||||
status: () => this.get("/api/spec-regeneration/status"),
|
||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"spec-regeneration:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Running Agents API
|
||||
runningAgents = {
|
||||
getAll: (): Promise<{
|
||||
success: boolean;
|
||||
runningAgents?: Array<{
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
}>;
|
||||
totalCount?: number;
|
||||
error?: string;
|
||||
}> => this.get("/api/running-agents"),
|
||||
};
|
||||
|
||||
// Workspace API
|
||||
workspace = {
|
||||
getConfig: (): Promise<{
|
||||
success: boolean;
|
||||
configured: boolean;
|
||||
workspaceDir?: string;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/config"),
|
||||
|
||||
getDirectories: (): Promise<{
|
||||
success: boolean;
|
||||
directories?: Array<{ name: string; path: string }>;
|
||||
error?: string;
|
||||
}> => this.get("/api/workspace/directories"),
|
||||
};
|
||||
|
||||
// Agent API
|
||||
agent = {
|
||||
start: (
|
||||
sessionId: string,
|
||||
workingDirectory?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
error?: string;
|
||||
}> => this.post("/api/agent/start", { sessionId, workingDirectory }),
|
||||
|
||||
send: (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
workingDirectory?: string,
|
||||
imagePaths?: string[],
|
||||
model?: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/send", {
|
||||
sessionId,
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
model,
|
||||
}),
|
||||
|
||||
getHistory: (
|
||||
sessionId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
isRunning?: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/agent/history", { sessionId }),
|
||||
|
||||
stop: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/stop", { sessionId }),
|
||||
|
||||
clear: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post("/api/agent/clear", { sessionId }),
|
||||
|
||||
onStream: (callback: (data: unknown) => void): (() => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// Templates API
|
||||
templates = {
|
||||
clone: (
|
||||
repoUrl: string,
|
||||
projectName: string,
|
||||
parentDir: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
}> =>
|
||||
this.post("/api/templates/clone", { repoUrl, projectName, parentDir }),
|
||||
};
|
||||
|
||||
// Sessions API
|
||||
sessions = {
|
||||
list: (
|
||||
includeArchived?: boolean
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
sessions?: SessionListItem[];
|
||||
error?: string;
|
||||
}> => this.get(`/api/sessions?includeArchived=${includeArchived || false}`),
|
||||
|
||||
create: (
|
||||
name: string,
|
||||
projectPath: string,
|
||||
workingDirectory?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
session?: {
|
||||
id: string;
|
||||
name: string;
|
||||
projectPath: string;
|
||||
workingDirectory?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.post("/api/sessions", { name, projectPath, workingDirectory }),
|
||||
|
||||
update: (
|
||||
sessionId: string,
|
||||
name?: string,
|
||||
tags?: string[]
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.put(`/api/sessions/${sessionId}`, { name, tags }),
|
||||
|
||||
archive: (
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post(`/api/sessions/${sessionId}/archive`, {}),
|
||||
|
||||
unarchive: (
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.post(`/api/sessions/${sessionId}/unarchive`, {}),
|
||||
|
||||
delete: (
|
||||
sessionId: string
|
||||
): Promise<{ success: boolean; error?: string }> =>
|
||||
this.httpDelete(`/api/sessions/${sessionId}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let httpApiClientInstance: HttpApiClient | null = null;
|
||||
|
||||
export function getHttpApiClient(): HttpApiClient {
|
||||
if (!httpApiClientInstance) {
|
||||
httpApiClientInstance = new HttpApiClient();
|
||||
}
|
||||
return httpApiClientInstance;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* Starter Kit Templates
|
||||
*
|
||||
* Define GitHub templates that users can clone when creating new projects.
|
||||
*/
|
||||
|
||||
export interface StarterTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
repoUrl: string;
|
||||
techStack: string[];
|
||||
features: string[];
|
||||
category: "fullstack" | "frontend" | "backend" | "ai" | "other";
|
||||
author: string;
|
||||
}
|
||||
|
||||
export const starterTemplates: StarterTemplate[] = [
|
||||
{
|
||||
id: "automaker-starter-kit",
|
||||
name: "Automaker Starter Kit",
|
||||
description: "An online community and training platform template for aspiring full stack engineers. Master frontend and backend development, build real-world projects, and launch your software engineering career.",
|
||||
repoUrl: "https://github.com/webdevcody/automaker-starter-kit",
|
||||
techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"],
|
||||
features: [
|
||||
"Community posts with comments and reactions",
|
||||
"User profiles and portfolios",
|
||||
"Calendar event management",
|
||||
"Direct messaging",
|
||||
"Member discovery directory",
|
||||
"Real-time notifications",
|
||||
"Classroom modules for learning",
|
||||
"Tiered subscriptions (free/basic/pro)",
|
||||
"File uploads with presigned URLs"
|
||||
],
|
||||
category: "fullstack",
|
||||
author: "webdevcody"
|
||||
},
|
||||
{
|
||||
id: "agentic-jumpstart",
|
||||
name: "Agentic Jumpstart",
|
||||
description: "A starter template for building agentic AI applications with a pre-configured development environment including database setup, Docker support, and TypeScript configuration.",
|
||||
repoUrl: "https://github.com/webdevcody/agentic-jumpstart-starter-kit",
|
||||
techStack: ["TypeScript", "Vite", "Drizzle ORM", "Docker", "PostCSS"],
|
||||
features: [
|
||||
"Pre-configured VS Code settings",
|
||||
"Docker Compose setup",
|
||||
"Database migrations with Drizzle",
|
||||
"Type-safe development",
|
||||
"Environment setup with .env.example"
|
||||
],
|
||||
category: "ai",
|
||||
author: "webdevcody"
|
||||
},
|
||||
{
|
||||
id: "full-stack-campus",
|
||||
name: "Full Stack Campus",
|
||||
description: "A feature-driven development template for building community platforms. Includes authentication, Stripe payments, file uploads, and real-time features using TanStack Start.",
|
||||
repoUrl: "https://github.com/webdevcody/full-stack-campus",
|
||||
techStack: ["TanStack Start", "PostgreSQL", "Drizzle ORM", "Better Auth", "Tailwind CSS", "Radix UI", "Stripe", "AWS S3/R2"],
|
||||
features: [
|
||||
"Community posts with comments and reactions",
|
||||
"User profiles and portfolios",
|
||||
"Calendar event management",
|
||||
"Direct messaging",
|
||||
"Member discovery directory",
|
||||
"Real-time notifications",
|
||||
"Tiered subscriptions (free/basic/pro)",
|
||||
"File uploads with presigned URLs"
|
||||
],
|
||||
category: "fullstack",
|
||||
author: "webdevcody"
|
||||
}
|
||||
];
|
||||
|
||||
export function getTemplateById(id: string): StarterTemplate | undefined {
|
||||
return starterTemplates.find(t => t.id === id);
|
||||
}
|
||||
|
||||
export function getTemplatesByCategory(category: StarterTemplate["category"]): StarterTemplate[] {
|
||||
return starterTemplates.filter(t => t.category === category);
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {
|
||||
resetContextDirectory,
|
||||
createContextFileOnDisk,
|
||||
contextFileExistsOnDisk,
|
||||
setupProjectWithFixture,
|
||||
getFixturePath,
|
||||
navigateToContext,
|
||||
waitForFileContentToLoad,
|
||||
switchToEditMode,
|
||||
waitForContextFile,
|
||||
selectContextFile,
|
||||
simulateFileDrop,
|
||||
setContextEditorContent,
|
||||
getContextEditorContent,
|
||||
clickElement,
|
||||
fillInput,
|
||||
getByTestId,
|
||||
waitForNetworkIdle,
|
||||
} from "./utils";
|
||||
|
||||
const WORKSPACE_ROOT = path.resolve(process.cwd(), "../..");
|
||||
const TEST_IMAGE_SRC = path.join(WORKSPACE_ROOT, "apps/app/public/logo.png");
|
||||
|
||||
// Configure all tests to run serially to prevent interference with shared context directory
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite 1: Context View - File Management
|
||||
// ============================================================================
|
||||
test.describe("Context View - File Management", () => {
|
||||
|
||||
test.beforeEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test("should create a new MD context file", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click Add File button
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Select text type (should be default)
|
||||
await clickElement(page, "add-text-type");
|
||||
|
||||
// Enter filename
|
||||
await fillInput(page, "new-file-name", "test-context.md");
|
||||
|
||||
// Enter content
|
||||
const testContent = "# Test Context\n\nThis is test content";
|
||||
await fillInput(page, "new-file-content", testContent);
|
||||
|
||||
// Click confirm
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Wait for file list to refresh (file should appear)
|
||||
await waitForContextFile(page, "test-context.md", 10000);
|
||||
|
||||
// Verify file appears in list
|
||||
const fileButton = await getByTestId(page, "context-file-test-context.md");
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Click on the file and wait for it to be selected
|
||||
await selectContextFile(page, "test-context.md");
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode if in preview mode (markdown files default to preview)
|
||||
await switchToEditMode(page);
|
||||
|
||||
// Wait for editor to be visible
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify content in editor
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe(testContent);
|
||||
});
|
||||
|
||||
test("should edit an existing MD context file", async ({ page }) => {
|
||||
// Create a test file on disk first
|
||||
const originalContent = "# Original Content\n\nThis will be edited.";
|
||||
createContextFileOnDisk("edit-test.md", originalContent);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click on the existing file and wait for it to be selected
|
||||
await selectContextFile(page, "edit-test.md");
|
||||
|
||||
// Wait for file content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode by default)
|
||||
await switchToEditMode(page);
|
||||
|
||||
// Wait for editor
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Modify content
|
||||
const newContent = "# Modified Content\n\nThis has been edited.";
|
||||
await setContextEditorContent(page, newContent);
|
||||
|
||||
// Click save
|
||||
await clickElement(page, "save-context-file");
|
||||
|
||||
// Wait for save to complete
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-testid="save-context-file"]')
|
||||
?.textContent?.includes("Saved"),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to context view
|
||||
await navigateToContext(page);
|
||||
|
||||
// Wait for file to appear after reload and select it
|
||||
await selectContextFile(page, "edit-test.md");
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify content persisted
|
||||
const persistedContent = await getContextEditorContent(page);
|
||||
expect(persistedContent).toBe(newContent);
|
||||
});
|
||||
|
||||
test("should remove an MD context file", async ({ page }) => {
|
||||
// Create a test file on disk first
|
||||
createContextFileOnDisk("delete-test.md", "# Delete Me");
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click on the file to select it
|
||||
const fileButton = await getByTestId(page, "context-file-delete-test.md");
|
||||
await fileButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await fileButton.click();
|
||||
|
||||
// Click delete button
|
||||
await clickElement(page, "delete-context-file");
|
||||
|
||||
// Wait for delete dialog
|
||||
await page.waitForSelector('[data-testid="delete-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Confirm deletion
|
||||
await clickElement(page, "confirm-delete-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="delete-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is removed from list
|
||||
const deletedFile = await getByTestId(page, "context-file-delete-test.md");
|
||||
await expect(deletedFile).not.toBeVisible();
|
||||
|
||||
// Verify file is removed from disk
|
||||
expect(contextFileExistsOnDisk("delete-test.md")).toBe(false);
|
||||
});
|
||||
|
||||
test("should upload an image context file", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click Add File button
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Select image type
|
||||
await clickElement(page, "add-image-type");
|
||||
|
||||
// Enter filename
|
||||
await fillInput(page, "new-file-name", "test-image.png");
|
||||
|
||||
// Upload image using file input
|
||||
await page.setInputFiles(
|
||||
'[data-testid="image-upload-input"]',
|
||||
TEST_IMAGE_SRC
|
||||
);
|
||||
|
||||
// Wait for image preview to appear (indicates upload success)
|
||||
const addDialog = await getByTestId(page, "add-context-dialog");
|
||||
await addDialog.locator("img").waitFor({ state: "visible" });
|
||||
|
||||
// Click confirm
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file appears in list
|
||||
const fileButton = await getByTestId(page, "context-file-test-image.png");
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Click on the image to view it
|
||||
await fileButton.click();
|
||||
|
||||
// Verify image preview is displayed
|
||||
await page.waitForSelector('[data-testid="image-preview"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
const imagePreview = await getByTestId(page, "image-preview");
|
||||
await expect(imagePreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("should remove an image context file", async ({ page }) => {
|
||||
// Create a test image file on disk as base64 data URL (matching app's storage format)
|
||||
const imageContent = fs.readFileSync(TEST_IMAGE_SRC);
|
||||
const base64DataUrl = `data:image/png;base64,${imageContent.toString("base64")}`;
|
||||
const contextPath = path.join(getFixturePath(), ".automaker/context");
|
||||
fs.writeFileSync(path.join(contextPath, "delete-image.png"), base64DataUrl);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Wait for the image file and select it
|
||||
await selectContextFile(page, "delete-image.png");
|
||||
|
||||
// Wait for file content (image preview) to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Click delete button
|
||||
await clickElement(page, "delete-context-file");
|
||||
|
||||
// Wait for delete dialog
|
||||
await page.waitForSelector('[data-testid="delete-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Confirm deletion
|
||||
await clickElement(page, "confirm-delete-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="delete-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is removed from list
|
||||
const deletedImageFile = await getByTestId(page, "context-file-delete-image.png");
|
||||
await expect(deletedImageFile).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should toggle markdown preview mode", async ({ page }) => {
|
||||
// Create a markdown file with content
|
||||
const mdContent =
|
||||
"# Heading\n\n**Bold text** and *italic text*\n\n- List item 1\n- List item 2";
|
||||
createContextFileOnDisk("preview-test.md", mdContent);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Click on the markdown file
|
||||
const fileButton = await getByTestId(page, "context-file-preview-test.md");
|
||||
await fileButton.waitFor({ state: "visible", timeout: 5000 });
|
||||
await fileButton.click();
|
||||
|
||||
// Wait for content to load (markdown files open in preview mode by default)
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Check if preview button is visible (indicates it's a markdown file)
|
||||
const previewToggle = await getByTestId(page, "toggle-preview-mode");
|
||||
await expect(previewToggle).toBeVisible();
|
||||
|
||||
// Markdown files always open in preview mode by default (see context-view.tsx:163)
|
||||
// Verify we're in preview mode
|
||||
const markdownPreview = await getByTestId(page, "markdown-preview");
|
||||
await expect(markdownPreview).toBeVisible();
|
||||
|
||||
// Click to switch to edit mode
|
||||
await previewToggle.click();
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify editor is shown
|
||||
const editor = await getByTestId(page, "context-editor");
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(markdownPreview).not.toBeVisible();
|
||||
|
||||
// Click to switch back to preview mode
|
||||
await previewToggle.click();
|
||||
await page.waitForSelector('[data-testid="markdown-preview"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Verify preview is shown
|
||||
await expect(markdownPreview).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite 2: Context View - Drag and Drop
|
||||
// ============================================================================
|
||||
test.describe("Context View - Drag and Drop", () => {
|
||||
test.beforeEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test("should handle drag and drop of MD file onto textarea in add dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Open add file dialog
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Ensure text type is selected
|
||||
await clickElement(page, "add-text-type");
|
||||
|
||||
// Simulate drag and drop of a .md file onto the textarea
|
||||
const droppedContent = "# Dropped Content\n\nThis was dragged and dropped.";
|
||||
await simulateFileDrop(
|
||||
page,
|
||||
'[data-testid="new-file-content"]',
|
||||
"dropped-file.md",
|
||||
droppedContent
|
||||
);
|
||||
|
||||
// Wait for content to be populated in textarea
|
||||
const textarea = await getByTestId(page, "new-file-content");
|
||||
await textarea.waitFor({ state: "visible" });
|
||||
await expect(textarea).toHaveValue(droppedContent);
|
||||
|
||||
// Verify content is populated in textarea
|
||||
const textareaContent = await textarea.inputValue();
|
||||
expect(textareaContent).toBe(droppedContent);
|
||||
|
||||
// Verify filename is auto-filled
|
||||
const filenameValue = await page
|
||||
.locator('[data-testid="new-file-name"]')
|
||||
.inputValue();
|
||||
expect(filenameValue).toBe("dropped-file.md");
|
||||
|
||||
// Confirm and create the file
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file was created
|
||||
const droppedFile = await getByTestId(page, "context-file-dropped-file.md");
|
||||
await expect(droppedFile).toBeVisible();
|
||||
});
|
||||
|
||||
test("should handle drag and drop of file onto main view", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Wait for the context view to be fully loaded
|
||||
await page.waitForSelector('[data-testid="context-file-list"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Simulate drag and drop onto the drop zone
|
||||
const droppedContent = "This is a text file dropped onto the main view.";
|
||||
await simulateFileDrop(
|
||||
page,
|
||||
'[data-testid="context-drop-zone"]',
|
||||
"main-drop.txt",
|
||||
droppedContent
|
||||
);
|
||||
|
||||
// Wait for file to appear in the list (drag-drop triggers file creation)
|
||||
await waitForContextFile(page, "main-drop.txt", 15000);
|
||||
|
||||
// Verify file appears in the file list
|
||||
const fileButton = await getByTestId(page, "context-file-main-drop.txt");
|
||||
await expect(fileButton).toBeVisible();
|
||||
|
||||
// Select file and verify content
|
||||
await fileButton.click();
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe(droppedContent);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Test Suite 3: Context View - Edge Cases
|
||||
// ============================================================================
|
||||
test.describe("Context View - Edge Cases", () => {
|
||||
test.beforeEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
resetContextDirectory();
|
||||
});
|
||||
|
||||
test("should handle duplicate filename (overwrite behavior)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create an existing file
|
||||
createContextFileOnDisk("test.md", "# Original Content");
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Verify the original file exists
|
||||
const originalFile = await getByTestId(page, "context-file-test.md");
|
||||
await expect(originalFile).toBeVisible();
|
||||
|
||||
// Try to create another file with the same name
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, "add-text-type");
|
||||
await fillInput(page, "new-file-name", "test.md");
|
||||
await fillInput(page, "new-file-content", "# New Content - Overwritten");
|
||||
|
||||
await clickElement(page, "confirm-add-file");
|
||||
|
||||
// Wait for dialog to close
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// File should still exist (was overwritten)
|
||||
await expect(originalFile).toBeVisible();
|
||||
|
||||
// Select the file and verify the new content
|
||||
await originalFile.click();
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe("# New Content - Overwritten");
|
||||
});
|
||||
|
||||
test("should handle special characters in filename", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Test file with parentheses
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, "add-text-type");
|
||||
await fillInput(page, "new-file-name", "context (1).md");
|
||||
await fillInput(page, "new-file-content", "Content with parentheses in filename");
|
||||
|
||||
await clickElement(page, "confirm-add-file");
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is created - use CSS escape for special characters
|
||||
const fileWithParens = await getByTestId(page, "context-file-context (1).md");
|
||||
await expect(fileWithParens).toBeVisible();
|
||||
|
||||
// Test file with hyphens and underscores
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, "add-text-type");
|
||||
await fillInput(page, "new-file-name", "test-file_v2.md");
|
||||
await fillInput(page, "new-file-content", "Content with hyphens and underscores");
|
||||
|
||||
await clickElement(page, "confirm-add-file");
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is created
|
||||
const fileWithHyphens = await getByTestId(page, "context-file-test-file_v2.md");
|
||||
await expect(fileWithHyphens).toBeVisible();
|
||||
|
||||
// Verify both files are accessible
|
||||
await fileWithHyphens.click();
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const content = await getContextEditorContent(page);
|
||||
expect(content).toBe("Content with hyphens and underscores");
|
||||
});
|
||||
|
||||
test("should handle empty content", async ({ page }) => {
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Create file with empty content
|
||||
await clickElement(page, "add-context-file");
|
||||
await page.waitForSelector('[data-testid="add-context-dialog"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await clickElement(page, "add-text-type");
|
||||
await fillInput(page, "new-file-name", "empty-file.md");
|
||||
// Don't fill any content - leave it empty
|
||||
|
||||
await clickElement(page, "confirm-add-file");
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[data-testid="add-context-dialog"]'),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
// Verify file is created
|
||||
const emptyFile = await getByTestId(page, "context-file-empty-file.md");
|
||||
await expect(emptyFile).toBeVisible();
|
||||
|
||||
// Select file and verify editor shows empty content
|
||||
await emptyFile.click();
|
||||
|
||||
// Wait for content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const editorContent = await getContextEditorContent(page);
|
||||
expect(editorContent).toBe("");
|
||||
|
||||
// Verify save works with empty content
|
||||
// The save button should be disabled when there are no changes
|
||||
// Let's add some content first, then clear it and save
|
||||
await setContextEditorContent(page, "temporary");
|
||||
await setContextEditorContent(page, "");
|
||||
|
||||
// Save should work
|
||||
await clickElement(page, "save-context-file");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document
|
||||
.querySelector('[data-testid="save-context-file"]')
|
||||
?.textContent?.includes("Saved"),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
|
||||
test("should verify persistence across page refresh", async ({ page }) => {
|
||||
// Create a file directly on disk to ensure it persists across refreshes
|
||||
const testContent = "# Persistence Test\n\nThis content should persist.";
|
||||
createContextFileOnDisk("persist-test.md", testContent);
|
||||
|
||||
await setupProjectWithFixture(page, getFixturePath());
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
await navigateToContext(page);
|
||||
|
||||
// Verify file exists before refresh
|
||||
await waitForContextFile(page, "persist-test.md", 10000);
|
||||
|
||||
// Refresh the page
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to context view
|
||||
await navigateToContext(page);
|
||||
|
||||
// Select the file after refresh (uses robust clicking mechanism)
|
||||
await selectContextFile(page, "persist-test.md");
|
||||
|
||||
// Wait for file content to load
|
||||
await waitForFileContentToLoad(page);
|
||||
|
||||
// Switch to edit mode (markdown files open in preview mode)
|
||||
await switchToEditMode(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="context-editor"]', {
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const persistedContent = await getContextEditorContent(page);
|
||||
expect(persistedContent).toBe(testContent);
|
||||
});
|
||||
});
|
||||
@@ -1,536 +0,0 @@
|
||||
/**
|
||||
* Feature Lifecycle End-to-End Tests
|
||||
*
|
||||
* Tests the complete feature lifecycle flow:
|
||||
* 1. Create a feature in backlog
|
||||
* 2. Drag to in_progress and wait for agent to finish
|
||||
* 3. Verify it moves to waiting_approval (manual review)
|
||||
* 4. Click commit and verify git status shows committed changes
|
||||
* 5. Drag to verified column
|
||||
* 6. Archive (complete) the feature
|
||||
* 7. Open archive modal and restore the feature
|
||||
* 8. Delete the feature
|
||||
*
|
||||
* NOTE: This test uses AUTOMAKER_MOCK_AGENT=true to mock the agent
|
||||
* so it doesn't make real API calls during CI/CD runs.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
import {
|
||||
waitForNetworkIdle,
|
||||
createTestGitRepo,
|
||||
cleanupTempDir,
|
||||
createTempDirPath,
|
||||
setupProjectWithPathNoWorktrees,
|
||||
waitForBoardView,
|
||||
clickAddFeature,
|
||||
fillAddFeatureDialog,
|
||||
confirmAddFeature,
|
||||
dragAndDropWithDndKit,
|
||||
} from "./utils";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Create unique temp dir for this test run
|
||||
const TEST_TEMP_DIR = createTempDirPath("feature-lifecycle-tests");
|
||||
|
||||
interface TestRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Configure all tests to run serially
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("Feature Lifecycle Tests", () => {
|
||||
let testRepo: TestRepo;
|
||||
let featureId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create test temp directory
|
||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Create a fresh test repo for each test
|
||||
testRepo = await createTestGitRepo(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Cleanup test repo after each test
|
||||
if (testRepo) {
|
||||
await testRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
cleanupTempDir(TEST_TEMP_DIR);
|
||||
});
|
||||
|
||||
// this one fails in github actions for some reason
|
||||
test.skip("complete feature lifecycle: create -> in_progress -> waiting_approval -> commit -> verified -> archive -> restore -> delete", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Increase timeout for this comprehensive test
|
||||
test.setTimeout(120000);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
// Use no-worktrees setup to avoid worktree-related filtering/initialization issues
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait a bit for the UI to fully load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details - requesting a file with "yellow" content
|
||||
const featureDescription =
|
||||
"Create a file named yellow.txt that contains the text yellow";
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Debug: Check the filesystem to see if feature was created
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
await expect(async () => {
|
||||
const dirs = fs.readdirSync(featuresDir);
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Reload to force features to load from filesystem
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the feature card to appear on the board
|
||||
const featureCard = page.getByText(featureDescription).first();
|
||||
await expect(featureCard).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Get the feature ID from the filesystem
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
featureId = featureDirs[0];
|
||||
|
||||
// Now get the actual card element by testid
|
||||
const featureCardByTestId = page.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(featureCardByTestId).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress and wait for agent to finish
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||
const inProgressColumn = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
|
||||
// Perform the drag and drop using dnd-kit compatible method
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
|
||||
// First verify that the drag succeeded by checking for in_progress status
|
||||
// This helps diagnose if the drag-drop is working or not
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// The mock agent should complete quickly (about 1.3 seconds based on the sleep times)
|
||||
// Wait for the feature to move to waiting_approval (manual review)
|
||||
// The status changes are: in_progress -> waiting_approval after agent completes
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Refresh page to ensure UI reflects the status change
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 3: Verify feature is in waiting_approval (manual review) column
|
||||
// ==========================================================================
|
||||
const waitingApprovalColumn = page.locator(
|
||||
'[data-testid="kanban-column-waiting_approval"]'
|
||||
);
|
||||
const cardInWaitingApproval = waitingApprovalColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(cardInWaitingApproval).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify the mock agent created the yellow.txt file
|
||||
const yellowFilePath = path.join(testRepo.path, "yellow.txt");
|
||||
expect(fs.existsSync(yellowFilePath)).toBe(true);
|
||||
const yellowContent = fs.readFileSync(yellowFilePath, "utf-8");
|
||||
expect(yellowContent).toBe("yellow");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 4: Click commit and verify git status shows committed changes
|
||||
// ==========================================================================
|
||||
// The commit button should be visible on the card in waiting_approval
|
||||
const commitButton = page.locator(`[data-testid="commit-${featureId}"]`);
|
||||
await expect(commitButton).toBeVisible({ timeout: 5000 });
|
||||
await commitButton.click();
|
||||
|
||||
// Wait for the commit to process
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify git status shows clean (changes committed)
|
||||
const { stdout: gitStatus } = await execAsync("git status --porcelain", {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
// After commit, the yellow.txt file should be committed, so git status should be clean
|
||||
// (only .automaker directory might have changes)
|
||||
expect(gitStatus.includes("yellow.txt")).toBe(false);
|
||||
|
||||
// Verify the commit exists in git log
|
||||
const { stdout: gitLog } = await execAsync("git log --oneline -1", {
|
||||
cwd: testRepo.path,
|
||||
});
|
||||
expect(gitLog.toLowerCase()).toContain("yellow");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 5: Verify feature moved to verified column after commit
|
||||
// ==========================================================================
|
||||
// Feature should automatically move to verified after commit
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
const verifiedColumn = page.locator(
|
||||
'[data-testid="kanban-column-verified"]'
|
||||
);
|
||||
const cardInVerified = verifiedColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(cardInVerified).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 6: Archive (complete) the feature
|
||||
// ==========================================================================
|
||||
// Click the Complete button on the verified card
|
||||
const completeButton = page.locator(
|
||||
`[data-testid="complete-${featureId}"]`
|
||||
);
|
||||
await expect(completeButton).toBeVisible({ timeout: 5000 });
|
||||
await completeButton.click();
|
||||
|
||||
// Wait for the archive action to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the feature is no longer visible on the board (it's archived)
|
||||
await expect(cardInVerified).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify feature status is completed in filesystem
|
||||
const featureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
expect(featureData.status).toBe("completed");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 7: Open archive modal and restore the feature
|
||||
// ==========================================================================
|
||||
// Click the completed features button to open the archive modal
|
||||
const completedFeaturesButton = page.locator(
|
||||
'[data-testid="completed-features-button"]'
|
||||
);
|
||||
await expect(completedFeaturesButton).toBeVisible({ timeout: 5000 });
|
||||
await completedFeaturesButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
const completedModal = page.locator(
|
||||
'[data-testid="completed-features-modal"]'
|
||||
);
|
||||
await expect(completedModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the archived feature is shown in the modal
|
||||
const archivedCard = completedModal.locator(
|
||||
`[data-testid="completed-card-${featureId}"]`
|
||||
);
|
||||
await expect(archivedCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the restore button
|
||||
const restoreButton = page.locator(
|
||||
`[data-testid="unarchive-${featureId}"]`
|
||||
);
|
||||
await expect(restoreButton).toBeVisible({ timeout: 5000 });
|
||||
await restoreButton.click();
|
||||
|
||||
// Wait for the restore action to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Close the modal - use first() to select the footer Close button, not the X button
|
||||
const closeButton = completedModal
|
||||
.locator('button:has-text("Close")')
|
||||
.first();
|
||||
await closeButton.click();
|
||||
await expect(completedModal).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the feature is back in the verified column
|
||||
const restoredCard = verifiedColumn.locator(
|
||||
`[data-testid="kanban-card-${featureId}"]`
|
||||
);
|
||||
await expect(restoredCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify feature status is verified in filesystem
|
||||
const restoredFeatureData = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(featuresDir, featureId, "feature.json"),
|
||||
"utf-8"
|
||||
)
|
||||
);
|
||||
expect(restoredFeatureData.status).toBe("verified");
|
||||
|
||||
// ==========================================================================
|
||||
// Step 8: Delete the feature and verify it's removed
|
||||
// ==========================================================================
|
||||
// Click the delete button on the verified card
|
||||
const deleteButton = page.locator(
|
||||
`[data-testid="delete-verified-${featureId}"]`
|
||||
);
|
||||
await expect(deleteButton).toBeVisible({ timeout: 5000 });
|
||||
await deleteButton.click();
|
||||
|
||||
// Wait for the confirmation dialog
|
||||
const confirmDialog = page.locator(
|
||||
'[data-testid="delete-confirmation-dialog"]'
|
||||
);
|
||||
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the confirm delete button
|
||||
const confirmDeleteButton = page.locator(
|
||||
'[data-testid="confirm-delete-button"]'
|
||||
);
|
||||
await confirmDeleteButton.click();
|
||||
|
||||
// Wait for the delete action to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the feature is no longer visible on the board
|
||||
await expect(restoredCard).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the feature directory is deleted from filesystem
|
||||
const featureDirExists = fs.existsSync(path.join(featuresDir, featureId));
|
||||
expect(featureDirExists).toBe(false);
|
||||
});
|
||||
|
||||
// this one fails in github actions for some reason
|
||||
test.skip("stop and restart feature: create -> in_progress -> stop -> restart should work without 'Feature not found' error", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test verifies that stopping a feature and restarting it works correctly
|
||||
// Bug: Previously, stopping a feature and immediately restarting could cause
|
||||
// "Feature not found" error due to race conditions
|
||||
test.setTimeout(120000);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 1: Setup and create a feature in backlog
|
||||
// ==========================================================================
|
||||
await setupProjectWithPathNoWorktrees(page, testRepo.path);
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click add feature button
|
||||
await clickAddFeature(page);
|
||||
|
||||
// Fill in the feature details
|
||||
const featureDescription = "Create a file named test-restart.txt";
|
||||
const descriptionInput = page
|
||||
.locator('[data-testid="add-feature-dialog"] textarea')
|
||||
.first();
|
||||
await descriptionInput.fill(featureDescription);
|
||||
|
||||
// Confirm the feature creation
|
||||
await confirmAddFeature(page);
|
||||
|
||||
// Wait for the feature to be created in the filesystem
|
||||
const featuresDir = path.join(testRepo.path, ".automaker", "features");
|
||||
await expect(async () => {
|
||||
const dirs = fs.readdirSync(featuresDir);
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Get the feature ID
|
||||
const featureDirs = fs.readdirSync(featuresDir);
|
||||
const testFeatureId = featureDirs[0];
|
||||
|
||||
// Reload to ensure features are loaded
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// Wait for the feature card to appear
|
||||
const featureCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
await expect(featureCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 2: Drag feature to in_progress (first start)
|
||||
// ==========================================================================
|
||||
const dragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const inProgressColumn = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
|
||||
await dragAndDropWithDndKit(page, dragHandle, inProgressColumn);
|
||||
|
||||
// Verify feature file still exists and is readable
|
||||
const featureFilePath = path.join(
|
||||
featuresDir,
|
||||
testFeatureId,
|
||||
"feature.json"
|
||||
);
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// First verify that the drag succeeded by checking for in_progress status
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(featureData.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// ==========================================================================
|
||||
// Step 3: Wait for the mock agent to complete (it's fast in mock mode)
|
||||
// ==========================================================================
|
||||
// The mock agent completes quickly, so we wait for it to finish
|
||||
await expect(async () => {
|
||||
const featureData = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(featureData.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
// Verify feature file still exists after completion
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
const featureDataAfterComplete = JSON.parse(
|
||||
fs.readFileSync(featureFilePath, "utf-8")
|
||||
);
|
||||
console.log(
|
||||
"Feature status after first run:",
|
||||
featureDataAfterComplete.status
|
||||
);
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 4: Move feature back to backlog to simulate stop scenario
|
||||
// ==========================================================================
|
||||
// Feature is in waiting_approval, drag it back to backlog
|
||||
const backlogColumn = page.locator('[data-testid="kanban-column-backlog"]');
|
||||
const currentCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
const currentDragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
|
||||
await expect(currentCard).toBeVisible({ timeout: 10000 });
|
||||
await dragAndDropWithDndKit(page, currentDragHandle, backlogColumn);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify feature is in backlog
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("backlog");
|
||||
}).toPass({ timeout: 10000 });
|
||||
|
||||
// Reload to ensure clean state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
await waitForBoardView(page);
|
||||
|
||||
// ==========================================================================
|
||||
// Step 5: Restart the feature (drag to in_progress again)
|
||||
// ==========================================================================
|
||||
const restartCard = page.locator(
|
||||
`[data-testid="kanban-card-${testFeatureId}"]`
|
||||
);
|
||||
await expect(restartCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const restartDragHandle = page.locator(
|
||||
`[data-testid="drag-handle-${testFeatureId}"]`
|
||||
);
|
||||
const inProgressColumnRestart = page.locator(
|
||||
'[data-testid="kanban-column-in_progress"]'
|
||||
);
|
||||
|
||||
// Listen for console errors to catch "Feature not found"
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Drag to in_progress to restart
|
||||
await dragAndDropWithDndKit(
|
||||
page,
|
||||
restartDragHandle,
|
||||
inProgressColumnRestart
|
||||
);
|
||||
|
||||
// Verify the feature file still exists
|
||||
expect(fs.existsSync(featureFilePath)).toBe(true);
|
||||
|
||||
// First verify that the restart drag succeeded by checking for in_progress status
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
// Feature should be either in_progress (agent running) or waiting_approval (agent done)
|
||||
expect(["in_progress", "waiting_approval"]).toContain(data.status);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// Verify no "Feature not found" errors in console
|
||||
const featureNotFoundErrors = consoleErrors.filter(
|
||||
(err) => err.includes("not found") || err.includes("Feature")
|
||||
);
|
||||
expect(featureNotFoundErrors).toEqual([]);
|
||||
|
||||
// Wait for the mock agent to complete and move to waiting_approval
|
||||
await expect(async () => {
|
||||
const data = JSON.parse(fs.readFileSync(featureFilePath, "utf-8"));
|
||||
expect(data.status).toBe("waiting_approval");
|
||||
}).toPass({ timeout: 30000 });
|
||||
|
||||
console.log("Feature successfully restarted after stop!");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,324 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
resetFixtureSpec,
|
||||
setupProjectWithFixture,
|
||||
getFixturePath,
|
||||
navigateToSpecEditor,
|
||||
getEditorContent,
|
||||
setEditorContent,
|
||||
clickSaveButton,
|
||||
getByTestId,
|
||||
clickElement,
|
||||
fillInput,
|
||||
waitForNetworkIdle,
|
||||
waitForElement,
|
||||
} from "./utils";
|
||||
|
||||
test.describe("Spec Editor Persistence", () => {
|
||||
test.beforeEach(async () => {
|
||||
// Reset the fixture spec file to original content before each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up - reset the spec file after each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test("should open project, edit spec, save, and persist changes after refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Use the resolved fixture path
|
||||
const fixturePath = getFixturePath();
|
||||
|
||||
// Step 1: Set up the project in localStorage pointing to our fixture
|
||||
await setupProjectWithFixture(page, fixturePath);
|
||||
|
||||
// Step 2: Navigate to the app
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Step 3: Verify we're on the dashboard with the project loaded
|
||||
// The sidebar should show the project selector
|
||||
const sidebar = await getByTestId(page, "sidebar");
|
||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Step 4: Click on the Spec Editor in the sidebar
|
||||
await navigateToSpecEditor(page);
|
||||
|
||||
// Step 5: Wait for the spec editor to load
|
||||
const specEditor = await getByTestId(page, "spec-editor");
|
||||
await specEditor.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Step 6: Wait for CodeMirror to initialize (it has a .cm-content element)
|
||||
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Small delay to ensure editor is fully initialized
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 7: Modify the editor content to "hello world"
|
||||
await setEditorContent(page, "hello world");
|
||||
|
||||
// Step 8: Click the save button
|
||||
await clickSaveButton(page);
|
||||
|
||||
// Step 9: Refresh the page
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Step 10: Navigate back to the spec editor
|
||||
// After reload, we need to wait for the app to initialize
|
||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||
|
||||
// Navigate to spec editor again
|
||||
await navigateToSpecEditor(page);
|
||||
|
||||
// Wait for CodeMirror to be ready
|
||||
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Small delay to ensure editor content is loaded
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 11: Verify the content was persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
expect(persistedContent.trim()).toBe("hello world");
|
||||
});
|
||||
|
||||
test("should handle opening project via Open Project button and file browser", async ({
|
||||
page,
|
||||
}) => {
|
||||
// This test covers the flow of:
|
||||
// 1. Clicking Open Project button
|
||||
// 2. Using the file browser to navigate to the fixture directory
|
||||
// 3. Opening the project
|
||||
// 4. Editing the spec
|
||||
|
||||
// Set up without a current project to test the open project flow
|
||||
await page.addInitScript(() => {
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
});
|
||||
|
||||
// Navigate to the app
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Wait for the sidebar to be visible
|
||||
const sidebar = await getByTestId(page, "sidebar");
|
||||
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Click the Open Project button
|
||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||
|
||||
// Check if the button is visible (it might not be in collapsed sidebar)
|
||||
const isButtonVisible = await openProjectButton
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (isButtonVisible) {
|
||||
await clickElement(page, "open-project-button");
|
||||
|
||||
// The file browser dialog should open
|
||||
// Note: In web mode, this might use the FileBrowserDialog component
|
||||
// which makes requests to the backend server at /api/fs/browse
|
||||
|
||||
// Wait a bit to see if a dialog appears
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if a dialog is visible
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
const dialogVisible = await dialog.isVisible().catch(() => false);
|
||||
|
||||
if (dialogVisible) {
|
||||
// If file browser dialog is open, we need to navigate to the fixture path
|
||||
// This depends on the current directory structure
|
||||
|
||||
// For now, let's verify the dialog appeared and close it
|
||||
// A full test would navigate through directories
|
||||
console.log("File browser dialog opened successfully");
|
||||
|
||||
// Press Escape to close the dialog
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
}
|
||||
|
||||
// For a complete e2e test with file browsing, we'd need to:
|
||||
// 1. Navigate through the directory tree
|
||||
// 2. Select the projectA directory
|
||||
// 3. Click "Select Current Folder"
|
||||
|
||||
// Since this involves actual file system navigation,
|
||||
// and depends on the backend server being properly configured,
|
||||
// we'll verify the basic UI elements are present
|
||||
|
||||
expect(sidebar).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Spec Editor - Full Open Project Flow", () => {
|
||||
test.beforeEach(async () => {
|
||||
// Reset the fixture spec file to original content before each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up - reset the spec file after each test
|
||||
resetFixtureSpec();
|
||||
});
|
||||
|
||||
// Skip in CI - file browser navigation is flaky in headless environments
|
||||
test.skip("should open project via file browser, edit spec, and persist", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to app first
|
||||
await page.goto("/");
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Set up localStorage state (without a current project, but mark setup complete)
|
||||
// Using evaluate instead of addInitScript so it only runs once
|
||||
// Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
|
||||
await page.evaluate(() => {
|
||||
const mockState = {
|
||||
state: {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
currentView: "welcome",
|
||||
theme: "dark",
|
||||
sidebarOpen: true,
|
||||
apiKeys: { anthropic: "", google: "" },
|
||||
chatSessions: [],
|
||||
chatHistoryOpen: false,
|
||||
maxConcurrency: 3,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||
|
||||
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
|
||||
const setupState = {
|
||||
state: {
|
||||
isFirstRun: false,
|
||||
setupComplete: true,
|
||||
currentStep: "complete",
|
||||
skipClaudeSetup: false,
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
||||
});
|
||||
|
||||
// Reload to apply the localStorage state
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Wait for sidebar
|
||||
await waitForElement(page, "sidebar", { timeout: 10000 });
|
||||
|
||||
// Click the Open Project button
|
||||
const openProjectButton = await getByTestId(page, "open-project-button");
|
||||
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "open-project-button");
|
||||
|
||||
// Wait for the file browser dialog to open
|
||||
const dialogTitle = page.locator('text="Select Project Directory"');
|
||||
await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
|
||||
|
||||
// Wait for the dialog to fully load (loading to complete)
|
||||
await page.waitForFunction(
|
||||
() => !document.body.textContent?.includes("Loading directories..."),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Use the path input to directly navigate to the fixture directory
|
||||
const pathInput = await getByTestId(page, "path-input");
|
||||
await pathInput.waitFor({ state: "visible", timeout: 5000 });
|
||||
|
||||
// Clear the input and type the full path to the fixture
|
||||
await fillInput(page, "path-input", getFixturePath());
|
||||
|
||||
// Click the Go button to navigate to the path
|
||||
await clickElement(page, "go-to-path-button");
|
||||
|
||||
// Wait for loading to complete
|
||||
await page.waitForFunction(
|
||||
() => !document.body.textContent?.includes("Loading directories..."),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Verify we're in the right directory by checking the path display
|
||||
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
|
||||
await expect(pathDisplay).toContainText("projectA");
|
||||
|
||||
// Click "Select Current Folder" button
|
||||
const selectFolderButton = page.locator(
|
||||
'button:has-text("Select Current Folder")'
|
||||
);
|
||||
await selectFolderButton.click();
|
||||
|
||||
// Wait for dialog to close and project to load
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('[role="dialog"]'),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate to spec editor
|
||||
const specNav = await getByTestId(page, "nav-spec");
|
||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "nav-spec");
|
||||
|
||||
// Wait for spec view with the editor (not the empty state)
|
||||
await waitForElement(page, "spec-view", { timeout: 10000 });
|
||||
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
|
||||
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Edit the content
|
||||
await setEditorContent(page, "hello world");
|
||||
|
||||
// Click save button
|
||||
await clickSaveButton(page);
|
||||
|
||||
// Refresh and verify persistence
|
||||
await page.reload();
|
||||
await waitForNetworkIdle(page);
|
||||
|
||||
// Navigate back to spec editor
|
||||
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
||||
await clickElement(page, "nav-spec");
|
||||
|
||||
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
|
||||
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify the content persisted
|
||||
const persistedContent = await getEditorContent(page);
|
||||
expect(persistedContent.trim()).toBe("hello world");
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Page, Locator } from "@playwright/test";
|
||||
import { waitForElement, waitForElementHidden } from "../core/waiting";
|
||||
|
||||
/**
|
||||
* Check if the category autocomplete dropdown is visible
|
||||
*/
|
||||
export async function isCategoryAutocompleteListVisible(
|
||||
page: Page
|
||||
): Promise<boolean> {
|
||||
const list = page.locator('[data-testid="category-autocomplete-list"]');
|
||||
return await list.isVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the category autocomplete dropdown to be visible
|
||||
*/
|
||||
export async function waitForCategoryAutocompleteList(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<Locator> {
|
||||
return await waitForElement(page, "category-autocomplete-list", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the category autocomplete dropdown to be hidden
|
||||
*/
|
||||
export async function waitForCategoryAutocompleteListHidden(
|
||||
page: Page,
|
||||
options?: { timeout?: number }
|
||||
): Promise<void> {
|
||||
await waitForElementHidden(page, "category-autocomplete-list", options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a category option in the autocomplete dropdown
|
||||
*/
|
||||
export async function clickCategoryOption(
|
||||
page: Page,
|
||||
categoryName: string
|
||||
): Promise<void> {
|
||||
const optionTestId = `category-option-${categoryName
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")}`;
|
||||
const option = page.locator(`[data-testid="${optionTestId}"]`);
|
||||
await option.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a category option element by name
|
||||
*/
|
||||
export async function getCategoryOption(
|
||||
page: Page,
|
||||
categoryName: string
|
||||
): Promise<Locator> {
|
||||
const optionTestId = `category-option-${categoryName
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")}`;
|
||||
return page.locator(`[data-testid="${optionTestId}"]`);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Page } from "@playwright/test";
|
||||
import { getByTestId, getButtonByText } from "./elements";
|
||||
|
||||
/**
|
||||
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
|
||||
* This is used for keyboard shortcuts like Cmd+Enter or Ctrl+Enter
|
||||
*/
|
||||
export function getPlatformModifier(): "Meta" | "Control" {
|
||||
return process.platform === "darwin" ? "Meta" : "Control";
|
||||
}
|
||||
|
||||
/**
|
||||
* Press the platform-specific modifier + a key (e.g., Cmd+Enter or Ctrl+Enter)
|
||||
*/
|
||||
export async function pressModifierEnter(page: Page): Promise<void> {
|
||||
const modifier = getPlatformModifier();
|
||||
await page.keyboard.press(`${modifier}+Enter`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an element by its data-testid attribute
|
||||
*/
|
||||
export async function clickElement(page: Page, testId: string): Promise<void> {
|
||||
const element = await getByTestId(page, testId);
|
||||
await element.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a button by its text content
|
||||
*/
|
||||
export async function clickButtonByText(
|
||||
page: Page,
|
||||
text: string
|
||||
): Promise<void> {
|
||||
const button = await getButtonByText(page, text);
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an input field by its data-testid attribute
|
||||
*/
|
||||
export async function fillInput(
|
||||
page: Page,
|
||||
testId: string,
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const input = await getByTestId(page, testId);
|
||||
await input.fill(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a keyboard shortcut key
|
||||
*/
|
||||
export async function pressShortcut(page: Page, key: string): Promise<void> {
|
||||
await page.keyboard.press(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press a number key (0-9) on the keyboard
|
||||
*/
|
||||
export async function pressNumberKey(page: Page, num: number): Promise<void> {
|
||||
await page.keyboard.press(num.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on an input element to test that shortcuts don't fire when typing
|
||||
*/
|
||||
export async function focusOnInput(page: Page, testId: string): Promise<void> {
|
||||
const input = page.locator(`[data-testid="${testId}"]`);
|
||||
await input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any open dialog by pressing Escape
|
||||
* Waits for dialog to be removed from DOM rather than using arbitrary timeout
|
||||
*/
|
||||
export async function closeDialogWithEscape(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
// Wait for any dialog overlay to disappear
|
||||
await page
|
||||
.locator('[data-radix-dialog-overlay], [role="dialog"]')
|
||||
.waitFor({ state: "hidden", timeout: 5000 })
|
||||
.catch(() => {
|
||||
// Dialog may have already closed or not exist
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user