mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 22:32:04 +00:00
Compare commits
715 Commits
v0.3.4
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f164a1b4 | ||
|
|
4a708aa305 | ||
|
|
3a1781eb39 | ||
|
|
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 | ||
|
|
46c3dd252f | ||
|
|
96b0e74794 | ||
|
|
18a2ed2a44 | ||
|
|
7d6ed0cb37 | ||
|
|
396100686c | ||
|
|
35ecb0dd2d | ||
|
|
8d6dae7495 | ||
|
|
340e76c3ed | ||
|
|
275037c73d | ||
|
|
a14ef30c69 | ||
|
|
18fa0f3066 | ||
|
|
3e015591d3 | ||
|
|
81d631ea72 | ||
|
|
2a1ab218ec | ||
|
|
6c31f725ff | ||
|
|
f0bea76141 | ||
|
|
46933a2a81 | ||
|
|
dccb5faa4b | ||
|
|
15ae1fe147 | ||
|
|
6f82f64195 | ||
|
|
1656b4fb7a | ||
|
|
419e954230 | ||
|
|
1a83c9b256 | ||
|
|
a0efa5d351 | ||
|
|
afcda98dc4 | ||
|
|
e508f9c1d1 | ||
|
|
8c2c54b0a4 | ||
|
|
65edddbc36 | ||
|
|
0dfe5a91e7 | ||
|
|
2f75c0bec5 | ||
|
|
516f26edae | ||
|
|
dd8862ce21 | ||
|
|
0c2192d039 | ||
|
|
a85390b289 | ||
|
|
c4a90d7f29 | ||
|
|
8794156f28 | ||
|
|
7603c827f6 | ||
|
|
7ad70a3923 | ||
|
|
95c6a69610 | ||
|
|
157dd71efa | ||
|
|
06ed965a3b | ||
|
|
ea7e273fb4 | ||
|
|
dcf05e4f1c | ||
|
|
50fb7ece4f | ||
|
|
f9db4fffa7 | ||
|
|
1cb6daaa07 | ||
|
|
adf9307796 | ||
|
|
7fdc2b2fab | ||
|
|
0d8043f1f2 | ||
|
|
2c079623a8 | ||
|
|
899c45fc1b | ||
|
|
c763f2a545 | ||
|
|
45dd1d45a1 | ||
|
|
a860b3cf45 | ||
|
|
9fcdd899b2 | ||
|
|
bacafd129f | ||
|
|
20d7fb1949 | ||
|
|
a192eaa20f | ||
|
|
99fe6f6497 | ||
|
|
35c6beca37 | ||
|
|
f7cb92fa9d | ||
|
|
b403d0d570 | ||
|
|
c80ae3367a | ||
|
|
c7312af3c8 | ||
|
|
f3dbc996d4 | ||
|
|
0549b8085a | ||
|
|
760f254f78 | ||
|
|
91bff6c572 | ||
|
|
019ac56ceb | ||
|
|
ecf34b178e | ||
|
|
3cd6e8c13b | ||
|
|
ca8341bf39 | ||
|
|
160bd8bfc7 | ||
|
|
0d1138dfcf | ||
|
|
b112747073 | ||
|
|
e1c3b7528f | ||
|
|
e78bfc80ec | ||
|
|
3eac848d4f | ||
|
|
76cb72812f | ||
|
|
bfc8f9bc26 | ||
|
|
8f2e06bc32 | ||
|
|
2c9f77356f | ||
|
|
ea1b10fea6 | ||
|
|
e6a63ccae1 | ||
|
|
784188cf52 | ||
|
|
1d945f4f75 | ||
|
|
df50cccb7b | ||
|
|
b0a9c89157 | ||
|
|
45eaf91cb3 | ||
|
|
c20b224f30 | ||
|
|
96b941b008 | ||
|
|
ad4da23743 | ||
|
|
5136c32b68 | ||
|
|
cffdec91f1 | ||
|
|
d9c87f8116 | ||
|
|
9954feafd8 | ||
|
|
acfb8d2255 | ||
|
|
fe6106e807 | ||
|
|
2af43d7c2d | ||
|
|
a7a6ff2e6c | ||
|
|
8f598d7ce3 | ||
|
|
7f8092264a | ||
|
|
e0471fef09 | ||
|
|
043edde63b | ||
|
|
4b9a211c49 | ||
|
|
b59bbd93ba | ||
|
|
2a782392bc | ||
|
|
fe56ba133e | ||
|
|
40a3046a3b | ||
|
|
1aa8b5b56b | ||
|
|
266e0c54b9 | ||
|
|
31550ab4e7 | ||
|
|
cce8d1569a | ||
|
|
ad051eb8f0 | ||
|
|
01098545cf | ||
|
|
d58bd782ef | ||
|
|
c11cb6a6cd | ||
|
|
64549f824c | ||
|
|
83fab5321e | ||
|
|
bb47f22d6c | ||
|
|
4996a63bcc | ||
|
|
f302234b0e | ||
|
|
58d6ae02a5 | ||
|
|
f9ec7222f2 | ||
|
|
360b7ebe08 | ||
|
|
ebc99d06eb | ||
|
|
f2600821d6 | ||
|
|
6e08126875 | ||
|
|
176eeca096 | ||
|
|
f6a9ae6335 | ||
|
|
30db67f89c | ||
|
|
da90bafde8 | ||
|
|
04d263b1ed | ||
|
|
e8e79d8446 | ||
|
|
8c24381759 | ||
|
|
8482cdab87 | ||
|
|
064a395c4c | ||
|
|
d103d0aa45 | ||
|
|
9509c8ea00 | ||
|
|
26b73fdaa9 | ||
|
|
a3c9c9cee5 | ||
|
|
0d088962a0 | ||
|
|
2ce4e02ada | ||
|
|
fad3ed1aae | ||
|
|
81444d5603 | ||
|
|
e8b65dbd0b | ||
|
|
f86bb3eab8 | ||
|
|
54a102f029 | ||
|
|
2ee4ec65b4 | ||
|
|
166679cd36 | ||
|
|
b95c54a539 | ||
|
|
e71be53459 | ||
|
|
8c5759d74e | ||
|
|
bd1c4e0690 | ||
|
|
9a428eefe0 | ||
|
|
8774d28bc4 | ||
|
|
9eb9c070cd | ||
|
|
7110a690e1 | ||
|
|
1194e7d51e | ||
|
|
1641f9da5e | ||
|
|
ff4887773e | ||
|
|
15a580ece9 | ||
|
|
b37d258698 | ||
|
|
e0e7bb9190 | ||
|
|
7131c70186 | ||
|
|
be98a59023 | ||
|
|
7e517101a0 | ||
|
|
92f60cceb5 | ||
|
|
b1dcb8a9d7 | ||
|
|
ec6ec7d569 | ||
|
|
31bb069e75 | ||
|
|
363be54303 | ||
|
|
ca0f6661d3 | ||
|
|
cd803cd9bc | ||
|
|
cbdc88c5d0 | ||
|
|
44b548c5c8 | ||
|
|
cc2ac3542d | ||
|
|
25044d40b9 | ||
|
|
676169b189 | ||
|
|
c8c05efb8d | ||
|
|
23cef5fd82 | ||
|
|
c0b0b30541 | ||
|
|
7eeba5f17c | ||
|
|
fcb2457e17 | ||
|
|
02e378905e | ||
|
|
87c0ab6daa | ||
|
|
60f9da9208 | ||
|
|
ff318d6ef5 | ||
|
|
93d9e08de1 | ||
|
|
7edca6b823 | ||
|
|
73eebe7c9e | ||
|
|
049f9a9e37 | ||
|
|
658cbb8bd6 | ||
|
|
19f1c32805 | ||
|
|
ece8ff8cbc | ||
|
|
a3a648aef1 | ||
|
|
3bc2b74d30 | ||
|
|
9d17cd7d9c | ||
|
|
091c6b2737 | ||
|
|
2880314931 | ||
|
|
0102719067 | ||
|
|
123b471b68 | ||
|
|
b66d228460 | ||
|
|
770d67d8c4 | ||
|
|
d42857ec26 | ||
|
|
54b977ee1b | ||
|
|
e8999ba908 | ||
|
|
96c4383b29 | ||
|
|
93d1d2c41a | ||
|
|
b075af5bc9 | ||
|
|
07ca7fccb8 | ||
|
|
797643ffdc | ||
|
|
7d4052be95 | ||
|
|
1036719f2a | ||
|
|
1ab520eda3 | ||
|
|
658f7d816e | ||
|
|
835ab516a6 | ||
|
|
00e098e57d | ||
|
|
f04cac8e2f | ||
|
|
c1b9f1cb28 | ||
|
|
7d8670ff1f | ||
|
|
9f9bcaff65 | ||
|
|
25b1789b0a | ||
|
|
2c8add3b54 | ||
|
|
f25d62fe25 | ||
|
|
7a9f55e1bd | ||
|
|
fbdf1689b3 | ||
|
|
29ba2c5936 | ||
|
|
493050fba1 | ||
|
|
455f6fa95b | ||
|
|
e14957900e | ||
|
|
c0e0f8d214 | ||
|
|
caae869501 | ||
|
|
b998d253bb | ||
|
|
0b1123e3ce | ||
|
|
a412f5d0fb | ||
|
|
919e08689a | ||
|
|
72e803b56d | ||
|
|
e378704c63 | ||
|
|
f6c50ce336 | ||
|
|
063224966c | ||
|
|
5d40e694a5 | ||
|
|
4405a97d9b | ||
|
|
6733de9e0d | ||
|
|
01bae7d43e | ||
|
|
6b30271441 | ||
|
|
cdc8334d82 | ||
|
|
4a3a98b562 | ||
|
|
c280225a4e | ||
|
|
b3ea506a73 | ||
|
|
590437c78b | ||
|
|
a5c61b0546 | ||
|
|
790a1b8e20 | ||
|
|
fa47264c76 | ||
|
|
a4075fb637 | ||
|
|
20a7c8b5a8 | ||
|
|
202494156b | ||
|
|
7558fed4e4 | ||
|
|
480589510e | ||
|
|
999ed5b51b | ||
|
|
589155fa1c | ||
|
|
ae13551033 | ||
|
|
038caeb2a0 | ||
|
|
7b34c9a108 | ||
|
|
5a1fe23ddb | ||
|
|
9bb843f82f | ||
|
|
ebc4f1422a | ||
|
|
96bfa8f131 | ||
|
|
406ba14af5 | ||
|
|
13841b1af6 | ||
|
|
7b3be213e4 | ||
|
|
b52b9ba236 | ||
|
|
58f466b443 | ||
|
|
13e3f05a7a | ||
|
|
7f5cdc0345 | ||
|
|
c21a298e07 | ||
|
|
3d940e21d5 | ||
|
|
6446dd5d3a | ||
|
|
38cff827b3 | ||
|
|
cc4310b368 | ||
|
|
d248e74492 | ||
|
|
a3d74fbe6e | ||
|
|
54dad0135f | ||
|
|
d9440e86a2 | ||
|
|
1f716142af | ||
|
|
f2eb6c3745 | ||
|
|
a08641a59b | ||
|
|
8dc3bdde67 | ||
|
|
223fff9ef9 | ||
|
|
986f6c034f | ||
|
|
10e647570b | ||
|
|
6ac888c5ce | ||
|
|
1bda0259db | ||
|
|
bea115d1e4 | ||
|
|
bc46a18372 | ||
|
|
e2c238f4f8 | ||
|
|
574680fc11 | ||
|
|
673dcd1113 | ||
|
|
41ae35bcdb | ||
|
|
ae02b30aba | ||
|
|
a2ad1d9420 | ||
|
|
1f4e801c58 | ||
|
|
ed65b096e3 | ||
|
|
ff06821fcd | ||
|
|
25edfecbd4 | ||
|
|
aa83583ee9 | ||
|
|
7fe3dff655 | ||
|
|
7c6d9d3723 | ||
|
|
a1ff498585 | ||
|
|
37f45ee89b | ||
|
|
83fbf55781 | ||
|
|
1db24ab887 | ||
|
|
e27e0b2343 | ||
|
|
54311a887c | ||
|
|
89216c01e5 | ||
|
|
9e9cffde6b | ||
|
|
c24cd9721c | ||
|
|
8c33b1c751 | ||
|
|
4d0d15d1d5 | ||
|
|
4ba82e131a | ||
|
|
23ff99d2e2 | ||
|
|
0473b35db3 | ||
|
|
f71533ab17 | ||
|
|
8709b5d34b | ||
|
|
88a059ca52 | ||
|
|
f3ffb22487 | ||
|
|
b915a43eb0 | ||
|
|
8b2b7662ee | ||
|
|
e6d3e8e5a5 | ||
|
|
26e01c930f | ||
|
|
8621a3095d | ||
|
|
6e7352e67e | ||
|
|
7e3f77cb38 | ||
|
|
75b73c55e0 | ||
|
|
ebd928e3b6 | ||
|
|
80cbabeeb0 | ||
|
|
05910905ee | ||
|
|
66fe3392ad | ||
|
|
3a553c892d | ||
|
|
ca506a208e | ||
|
|
cbca6fa6e4 | ||
|
|
951010b64d | ||
|
|
08221c6660 | ||
|
|
ffd8752cde | ||
|
|
deae01712a | ||
|
|
14d1562903 | ||
|
|
8c100230ab | ||
|
|
8eb374d77c | ||
|
|
998ad354d2 | ||
|
|
a2bd1b593b | ||
|
|
2ebb650609 | ||
|
|
11ddcfaf90 | ||
|
|
be4a0b292c | ||
|
|
18494547bc | ||
|
|
272905b884 | ||
|
|
21cbdba530 | ||
|
|
04ccd6f81c | ||
|
|
25f5f7d6b2 | ||
|
|
2f2eab6e02 | ||
|
|
6726050969 | ||
|
|
d08eba2331 | ||
|
|
7cbdb3db73 | ||
|
|
0519aba820 | ||
|
|
a65b16cbae | ||
|
|
55603cb5c7 |
@@ -21,4 +21,4 @@
|
|||||||
"mcp__puppeteer__puppeteer_evaluate"
|
"mcp__puppeteer__puppeteer_evaluate"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
|
||||||
S3Client,
|
const fs = require('fs');
|
||||||
PutObjectCommand,
|
const path = require('path');
|
||||||
GetObjectCommand,
|
const https = require('https');
|
||||||
} = require("@aws-sdk/client-s3");
|
const { pipeline } = require('stream/promises');
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const https = require("https");
|
|
||||||
const { pipeline } = require("stream/promises");
|
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: "auto",
|
region: 'auto',
|
||||||
endpoint: process.env.R2_ENDPOINT,
|
endpoint: process.env.R2_ENDPOINT,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||||
@@ -28,14 +24,14 @@ async function fetchExistingReleases() {
|
|||||||
const response = await s3Client.send(
|
const response = await s3Client.send(
|
||||||
new GetObjectCommand({
|
new GetObjectCommand({
|
||||||
Bucket: BUCKET,
|
Bucket: BUCKET,
|
||||||
Key: "releases.json",
|
Key: 'releases.json',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const body = await response.Body.transformToString();
|
const body = await response.Body.transformToString();
|
||||||
return JSON.parse(body);
|
return JSON.parse(body);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
|
if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) {
|
||||||
console.log("No existing releases.json found, creating new one");
|
console.log('No existing releases.json found, creating new one');
|
||||||
return { latestVersion: null, releases: [] };
|
return { latestVersion: null, releases: [] };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -85,7 +81,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
|||||||
resolve({
|
resolve({
|
||||||
accessible: false,
|
accessible: false,
|
||||||
statusCode,
|
statusCode,
|
||||||
error: "Redirect without location header",
|
error: 'Redirect without location header',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -93,18 +89,16 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
|||||||
return https
|
return https
|
||||||
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
|
.get(redirectUrl, { timeout: 10000 }, (redirectResponse) => {
|
||||||
const redirectStatus = redirectResponse.statusCode;
|
const redirectStatus = redirectResponse.statusCode;
|
||||||
const contentType =
|
const contentType = redirectResponse.headers['content-type'] || '';
|
||||||
redirectResponse.headers["content-type"] || "";
|
|
||||||
// Check if it's actually a file (zip/tar.gz) and not HTML
|
// Check if it's actually a file (zip/tar.gz) and not HTML
|
||||||
const isFile =
|
const isFile =
|
||||||
contentType.includes("application/zip") ||
|
contentType.includes('application/zip') ||
|
||||||
contentType.includes("application/gzip") ||
|
contentType.includes('application/gzip') ||
|
||||||
contentType.includes("application/x-gzip") ||
|
contentType.includes('application/x-gzip') ||
|
||||||
contentType.includes("application/x-tar") ||
|
contentType.includes('application/x-tar') ||
|
||||||
redirectUrl.includes(".zip") ||
|
redirectUrl.includes('.zip') ||
|
||||||
redirectUrl.includes(".tar.gz");
|
redirectUrl.includes('.tar.gz');
|
||||||
const isGood =
|
const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile;
|
||||||
redirectStatus >= 200 && redirectStatus < 300 && isFile;
|
|
||||||
redirectResponse.destroy();
|
redirectResponse.destroy();
|
||||||
resolve({
|
resolve({
|
||||||
accessible: isGood,
|
accessible: isGood,
|
||||||
@@ -113,38 +107,38 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
|||||||
contentType,
|
contentType,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on("error", (error) => {
|
.on('error', (error) => {
|
||||||
resolve({
|
resolve({
|
||||||
accessible: false,
|
accessible: false,
|
||||||
statusCode,
|
statusCode,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on("timeout", function () {
|
.on('timeout', function () {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
resolve({
|
resolve({
|
||||||
accessible: false,
|
accessible: false,
|
||||||
statusCode,
|
statusCode,
|
||||||
error: "Timeout following redirect",
|
error: 'Timeout following redirect',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if status is good (200-299 range) and it's actually a file
|
// Check if status is good (200-299 range) and it's actually a file
|
||||||
const contentType = response.headers["content-type"] || "";
|
const contentType = response.headers['content-type'] || '';
|
||||||
const isFile =
|
const isFile =
|
||||||
contentType.includes("application/zip") ||
|
contentType.includes('application/zip') ||
|
||||||
contentType.includes("application/gzip") ||
|
contentType.includes('application/gzip') ||
|
||||||
contentType.includes("application/x-gzip") ||
|
contentType.includes('application/x-gzip') ||
|
||||||
contentType.includes("application/x-tar") ||
|
contentType.includes('application/x-tar') ||
|
||||||
url.includes(".zip") ||
|
url.includes('.zip') ||
|
||||||
url.includes(".tar.gz");
|
url.includes('.tar.gz');
|
||||||
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
|
const isGood = statusCode >= 200 && statusCode < 300 && isFile;
|
||||||
response.destroy();
|
response.destroy();
|
||||||
resolve({ accessible: isGood, statusCode, contentType });
|
resolve({ accessible: isGood, statusCode, contentType });
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on("error", (error) => {
|
request.on('error', (error) => {
|
||||||
resolve({
|
resolve({
|
||||||
accessible: false,
|
accessible: false,
|
||||||
statusCode: null,
|
statusCode: null,
|
||||||
@@ -152,12 +146,12 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on("timeout", () => {
|
request.on('timeout', () => {
|
||||||
request.destroy();
|
request.destroy();
|
||||||
resolve({
|
resolve({
|
||||||
accessible: false,
|
accessible: false,
|
||||||
statusCode: null,
|
statusCode: null,
|
||||||
error: "Request timeout",
|
error: 'Request timeout',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -168,22 +162,14 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
|||||||
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
|
`✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`);
|
||||||
`✓ URL ${url} is accessible (status: ${result.statusCode})`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return result.finalUrl || url; // Return the final URL (after redirects) if available
|
return result.finalUrl || url; // Return the final URL (after redirects) if available
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error ? ` - ${result.error}` : "";
|
const errorMsg = result.error ? ` - ${result.error}` : '';
|
||||||
const statusMsg = result.statusCode
|
const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : '';
|
||||||
? ` (status: ${result.statusCode})`
|
const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : '';
|
||||||
: "";
|
console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`);
|
||||||
const contentTypeMsg = result.contentType
|
|
||||||
? ` [content-type: ${result.contentType}]`
|
|
||||||
: "";
|
|
||||||
console.log(
|
|
||||||
`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`✗ URL ${url} check failed: ${error.message}`);
|
console.log(`✗ URL ${url} check failed: ${error.message}`);
|
||||||
@@ -191,9 +177,7 @@ async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) {
|
|||||||
|
|
||||||
if (attempt < maxRetries - 1) {
|
if (attempt < maxRetries - 1) {
|
||||||
const delay = initialDelay * Math.pow(2, attempt);
|
const delay = initialDelay * Math.pow(2, attempt);
|
||||||
console.log(
|
console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`);
|
||||||
` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,12 +191,7 @@ async function downloadFromGitHub(url, outputPath) {
|
|||||||
const statusCode = response.statusCode;
|
const statusCode = response.statusCode;
|
||||||
|
|
||||||
// Follow redirects (all redirect types)
|
// Follow redirects (all redirect types)
|
||||||
if (
|
if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) {
|
||||||
statusCode === 301 ||
|
|
||||||
statusCode === 302 ||
|
|
||||||
statusCode === 307 ||
|
|
||||||
statusCode === 308
|
|
||||||
) {
|
|
||||||
const redirectUrl = response.headers.location;
|
const redirectUrl = response.headers.location;
|
||||||
response.destroy();
|
response.destroy();
|
||||||
if (!redirectUrl) {
|
if (!redirectUrl) {
|
||||||
@@ -220,39 +199,33 @@ async function downloadFromGitHub(url, outputPath) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Resolve relative redirects
|
// Resolve relative redirects
|
||||||
const finalRedirectUrl = redirectUrl.startsWith("http")
|
const finalRedirectUrl = redirectUrl.startsWith('http')
|
||||||
? redirectUrl
|
? redirectUrl
|
||||||
: new URL(redirectUrl, url).href;
|
: new URL(redirectUrl, url).href;
|
||||||
console.log(` Following redirect: ${finalRedirectUrl}`);
|
console.log(` Following redirect: ${finalRedirectUrl}`);
|
||||||
return downloadFromGitHub(finalRedirectUrl, outputPath)
|
return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject);
|
||||||
.then(resolve)
|
|
||||||
.catch(reject);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusCode !== 200) {
|
if (statusCode !== 200) {
|
||||||
response.destroy();
|
response.destroy();
|
||||||
reject(
|
reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`));
|
||||||
new Error(
|
|
||||||
`Failed to download ${url}: ${statusCode} ${response.statusMessage}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = fs.createWriteStream(outputPath);
|
const fileStream = fs.createWriteStream(outputPath);
|
||||||
response.pipe(fileStream);
|
response.pipe(fileStream);
|
||||||
fileStream.on("finish", () => {
|
fileStream.on('finish', () => {
|
||||||
fileStream.close();
|
fileStream.close();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
fileStream.on("error", (error) => {
|
fileStream.on('error', (error) => {
|
||||||
response.destroy();
|
response.destroy();
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on("error", reject);
|
request.on('error', reject);
|
||||||
request.on("timeout", () => {
|
request.on('timeout', () => {
|
||||||
request.destroy();
|
request.destroy();
|
||||||
reject(new Error(`Request timeout for ${url}`));
|
reject(new Error(`Request timeout for ${url}`));
|
||||||
});
|
});
|
||||||
@@ -260,8 +233,8 @@ async function downloadFromGitHub(url, outputPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const artifactsDir = "artifacts";
|
const artifactsDir = 'artifacts';
|
||||||
const tempDir = path.join(artifactsDir, "temp");
|
const tempDir = path.join(artifactsDir, 'temp');
|
||||||
|
|
||||||
// Create temp directory for downloaded GitHub archives
|
// Create temp directory for downloaded GitHub archives
|
||||||
if (!fs.existsSync(tempDir)) {
|
if (!fs.existsSync(tempDir)) {
|
||||||
@@ -292,40 +265,30 @@ async function main() {
|
|||||||
|
|
||||||
// Find all artifacts
|
// Find all artifacts
|
||||||
const artifacts = {
|
const artifacts = {
|
||||||
windows: findArtifacts(path.join(artifactsDir, "windows-builds"), /\.exe$/),
|
windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/),
|
||||||
macos: findArtifacts(path.join(artifactsDir, "macos-builds"), /-x64\.dmg$/),
|
macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/),
|
||||||
macosArm: findArtifacts(
|
macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/),
|
||||||
path.join(artifactsDir, "macos-builds"),
|
linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/),
|
||||||
/-arm64\.dmg$/
|
|
||||||
),
|
|
||||||
linux: findArtifacts(
|
|
||||||
path.join(artifactsDir, "linux-builds"),
|
|
||||||
/\.AppImage$/
|
|
||||||
),
|
|
||||||
sourceZip: [sourceZipPath],
|
sourceZip: [sourceZipPath],
|
||||||
sourceTarGz: [sourceTarGzPath],
|
sourceTarGz: [sourceTarGzPath],
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Found artifacts:");
|
console.log('Found artifacts:');
|
||||||
for (const [platform, files] of Object.entries(artifacts)) {
|
for (const [platform, files] of Object.entries(artifacts)) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${platform}: ${
|
` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}`
|
||||||
files.length > 0
|
|
||||||
? files.map((f) => path.basename(f)).join(", ")
|
|
||||||
: "none"
|
|
||||||
}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload each artifact to R2
|
// Upload each artifact to R2
|
||||||
const assets = {};
|
const assets = {};
|
||||||
const contentTypes = {
|
const contentTypes = {
|
||||||
windows: "application/x-msdownload",
|
windows: 'application/x-msdownload',
|
||||||
macos: "application/x-apple-diskimage",
|
macos: 'application/x-apple-diskimage',
|
||||||
macosArm: "application/x-apple-diskimage",
|
macosArm: 'application/x-apple-diskimage',
|
||||||
linux: "application/x-executable",
|
linux: 'application/x-executable',
|
||||||
sourceZip: "application/zip",
|
sourceZip: 'application/zip',
|
||||||
sourceTarGz: "application/gzip",
|
sourceTarGz: 'application/gzip',
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [platform, files] of Object.entries(artifacts)) {
|
for (const [platform, files] of Object.entries(artifacts)) {
|
||||||
@@ -345,11 +308,11 @@ async function main() {
|
|||||||
filename,
|
filename,
|
||||||
size,
|
size,
|
||||||
arch:
|
arch:
|
||||||
platform === "macosArm"
|
platform === 'macosArm'
|
||||||
? "arm64"
|
? 'arm64'
|
||||||
: platform === "sourceZip" || platform === "sourceTarGz"
|
: platform === 'sourceZip' || platform === 'sourceTarGz'
|
||||||
? "source"
|
? 'source'
|
||||||
: "x64",
|
: 'x64',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,9 +327,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Remove existing entry for this version if re-running
|
// Remove existing entry for this version if re-running
|
||||||
releasesData.releases = releasesData.releases.filter(
|
releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION);
|
||||||
(r) => r.version !== VERSION
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prepend new release
|
// Prepend new release
|
||||||
releasesData.releases.unshift(newRelease);
|
releasesData.releases.unshift(newRelease);
|
||||||
@@ -376,19 +337,19 @@ async function main() {
|
|||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
Bucket: BUCKET,
|
Bucket: BUCKET,
|
||||||
Key: "releases.json",
|
Key: 'releases.json',
|
||||||
Body: JSON.stringify(releasesData, null, 2),
|
Body: JSON.stringify(releasesData, null, 2),
|
||||||
ContentType: "application/json",
|
ContentType: 'application/json',
|
||||||
CacheControl: "public, max-age=60",
|
CacheControl: 'public, max-age=60',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("Successfully updated releases.json");
|
console.log('Successfully updated releases.json');
|
||||||
console.log(`Latest version: ${VERSION}`);
|
console.log(`Latest version: ${VERSION}`);
|
||||||
console.log(`Total releases: ${releasesData.releases.length}`);
|
console.log(`Total releases: ${releasesData.releases.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("Failed to upload to R2:", err);
|
console.error('Failed to upload to R2:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
49
.github/workflows/claude.yml
vendored
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:*)'
|
||||||
77
.github/workflows/e2e-tests.yml
vendored
Normal file
77
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup project
|
||||||
|
uses: ./.github/actions/setup-project
|
||||||
|
with:
|
||||||
|
check-lockfile: 'true'
|
||||||
|
rebuild-node-pty-path: 'apps/server'
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
working-directory: apps/ui
|
||||||
|
|
||||||
|
- name: Build server
|
||||||
|
run: npm run build --workspace=apps/server
|
||||||
|
|
||||||
|
- name: Start backend server
|
||||||
|
run: npm run start --workspace=apps/server &
|
||||||
|
env:
|
||||||
|
PORT: 3008
|
||||||
|
NODE_ENV: test
|
||||||
|
|
||||||
|
- name: Wait for backend server
|
||||||
|
run: |
|
||||||
|
echo "Waiting for backend server to be ready..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -s http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||||
|
echo "Backend server is ready!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Waiting... ($i/30)"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Backend server failed to start!"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
# Playwright automatically starts the Vite frontend via webServer config
|
||||||
|
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
||||||
|
run: npm run test --workspace=apps/ui
|
||||||
|
env:
|
||||||
|
CI: 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/ui/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: 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
|
||||||
19
.github/workflows/pr-check.yml
vendored
19
.github/workflows/pr-check.yml
vendored
@@ -3,7 +3,7 @@ name: PR Build Check
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- "*"
|
- '*'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -17,17 +17,10 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup project
|
||||||
uses: actions/setup-node@v4
|
uses: ./.github/actions/setup-project
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
check-lockfile: 'true'
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Run build:electron (dir only - faster CI)
|
||||||
# Use npm install instead of npm ci to correctly resolve platform-specific
|
run: npm run build:electron:dir
|
||||||
# optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries)
|
|
||||||
run: npm install
|
|
||||||
|
|
||||||
- name: Run build:electron
|
|
||||||
run: npm run build:electron
|
|
||||||
|
|||||||
197
.github/workflows/release.yml
vendored
197
.github/workflows/release.yml
vendored
@@ -1,166 +1,111 @@
|
|||||||
name: Build and Release Electron App
|
name: Release Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
tags:
|
types: [published]
|
||||||
- "v*.*.*" # Triggers on version tags like v1.0.0
|
|
||||||
workflow_dispatch: # Allows manual triggering
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to release (e.g., v1.0.0)"
|
|
||||||
required: true
|
|
||||||
default: "v0.1.0"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
- os: macos-latest
|
|
||||||
name: macOS
|
|
||||||
artifact-name: macos-builds
|
|
||||||
- os: windows-latest
|
|
||||||
name: Windows
|
|
||||||
artifact-name: windows-builds
|
|
||||||
- os: ubuntu-latest
|
|
||||||
name: Linux
|
|
||||||
artifact-name: linux-builds
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Extract version from tag
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: package-lock.json
|
|
||||||
|
|
||||||
- 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: Extract and set version
|
|
||||||
id: version
|
id: version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
# Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3")
|
||||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
VERSION="${{ github.event.release.tag_name }}"
|
||||||
VERSION="${VERSION_TAG#v}"
|
VERSION="${VERSION#v}"
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
echo "Extracted version: ${VERSION}"
|
||||||
# Update the app's package.json version
|
|
||||||
cd apps/app
|
|
||||||
npm version $VERSION --no-git-tag-version
|
|
||||||
cd ../..
|
|
||||||
echo "Updated apps/app/package.json to version $VERSION"
|
|
||||||
|
|
||||||
- name: Build Electron App (macOS)
|
- name: Update package.json version
|
||||||
if: matrix.os == 'macos-latest'
|
shell: bash
|
||||||
env:
|
run: |
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}"
|
||||||
run: npm run build:electron -- --mac --x64 --arm64
|
|
||||||
|
|
||||||
- name: Build Electron App (Windows)
|
- name: Setup project
|
||||||
if: matrix.os == 'windows-latest'
|
uses: ./.github/actions/setup-project
|
||||||
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
|
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.event.inputs.version || github.ref_name }}
|
check-lockfile: 'true'
|
||||||
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 }}
|
|
||||||
|
|
||||||
- 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'
|
if: matrix.os == 'macos-latest'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact-name }}
|
name: macos-builds
|
||||||
path: apps/app/dist/*.dmg
|
path: apps/ui/release/*.{dmg,zip}
|
||||||
retention-days: 1
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload Windows artifacts for R2
|
- name: Upload Windows artifacts
|
||||||
if: matrix.os == 'windows-latest'
|
if: matrix.os == 'windows-latest'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact-name }}
|
name: windows-builds
|
||||||
path: apps/app/dist/*.exe
|
path: apps/ui/release/*.exe
|
||||||
retention-days: 1
|
retention-days: 30
|
||||||
|
|
||||||
- name: Upload Linux artifacts for R2
|
- name: Upload Linux artifacts
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact-name }}
|
name: linux-builds
|
||||||
path: apps/app/dist/*.AppImage
|
path: apps/ui/release/*.{AppImage,deb}
|
||||||
retention-days: 1
|
retention-days: 30
|
||||||
|
|
||||||
upload-to-r2:
|
upload:
|
||||||
needs: build-and-release
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.release.draft == false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Download macOS artifacts
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
name: macos-builds
|
||||||
|
path: artifacts/macos-builds
|
||||||
|
|
||||||
- name: Install AWS SDK
|
- name: Download Windows artifacts
|
||||||
run: npm install @aws-sdk/client-s3
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-builds
|
||||||
|
path: artifacts/windows-builds
|
||||||
|
|
||||||
- name: Extract version
|
- name: Download Linux artifacts
|
||||||
id: version
|
uses: actions/download-artifact@v4
|
||||||
shell: bash
|
with:
|
||||||
run: |
|
name: linux-builds
|
||||||
VERSION_TAG="${{ github.event.inputs.version || github.ref_name }}"
|
path: artifacts/linux-builds
|
||||||
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
|
|
||||||
VERSION="${VERSION_TAG#v}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "version_tag=$VERSION_TAG" >> $GITHUB_OUTPUT
|
|
||||||
echo "Extracted version: $VERSION from tag: $VERSION_TAG"
|
|
||||||
|
|
||||||
- name: Upload to R2 and update releases.json
|
- name: Upload to GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
artifacts/macos-builds/*
|
||||||
|
artifacts/windows-builds/*
|
||||||
|
artifacts/linux-builds/*
|
||||||
env:
|
env:
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
||||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
|
||||||
R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }}
|
|
||||||
RELEASE_VERSION: ${{ steps.version.outputs.version }}
|
|
||||||
RELEASE_TAG: ${{ steps.version.outputs.version_tag }}
|
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
|
||||||
run: node .github/scripts/upload-to-r2.js
|
|
||||||
|
|||||||
30
.github/workflows/security-audit.yml
vendored
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
|
||||||
44
.github/workflows/test.yml
vendored
Normal file
44
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Test Suite
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup project
|
||||||
|
uses: ./.github/actions/setup-project
|
||||||
|
with:
|
||||||
|
check-lockfile: 'true'
|
||||||
|
rebuild-node-pty-path: 'apps/server'
|
||||||
|
|
||||||
|
- 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
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
|
||||||
|
# - name: Upload coverage reports
|
||||||
|
# uses: codecov/codecov-action@v4
|
||||||
|
# if: always()
|
||||||
|
# with:
|
||||||
|
# files: ./apps/server/coverage/coverage-final.json
|
||||||
|
# flags: server
|
||||||
|
# name: server-coverage
|
||||||
|
# env:
|
||||||
|
# CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
70
.gitignore
vendored
70
.gitignore
vendored
@@ -6,8 +6,78 @@ node_modules/
|
|||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
.next/
|
.next/
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Automaker
|
||||||
.automaker/images/
|
.automaker/images/
|
||||||
.automaker/
|
.automaker/
|
||||||
/.automaker/*
|
/.automaker/*
|
||||||
/.automaker/
|
/.automaker/
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
/logs
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# OS-specific files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# IDE/Editor configs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# Editor backup/temp files
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
*.orig
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Local settings (user-specific)
|
||||||
|
*.local.json
|
||||||
|
|
||||||
|
# Application state/backup
|
||||||
|
backup.json
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
test-results/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
|
# Environment files (keep .example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
docker-compose.override.yml
|
||||||
|
.claude/
|
||||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
6
.npmrc
6
.npmrc
@@ -8,3 +8,9 @@
|
|||||||
#
|
#
|
||||||
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
|
# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve
|
||||||
# the correct platform-specific binaries at install time.
|
# the correct platform-specific binaries at install time.
|
||||||
|
|
||||||
|
# Include bindings for all platforms in package-lock.json to support CI/CD
|
||||||
|
# This ensures Linux, macOS, and Windows bindings are all present
|
||||||
|
# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast
|
||||||
|
# supportedArchitectures.os=linux,darwin,win32
|
||||||
|
# supportedArchitectures.cpu=x64,arm64
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
@@ -19,30 +19,57 @@ While we have made efforts to review this codebase for security vulnerabilities
|
|||||||
## Recommendations
|
## Recommendations
|
||||||
|
|
||||||
### 1. Review the Code First
|
### 1. Review the Code First
|
||||||
|
|
||||||
Before running Automaker, we strongly recommend reviewing the source code yourself to understand what operations it performs and ensure you are comfortable with its behavior.
|
Before running Automaker, we strongly recommend reviewing the source code yourself to understand what operations it performs and ensure you are comfortable with its behavior.
|
||||||
|
|
||||||
### 2. Use Sandboxing (Highly Recommended)
|
### 2. Use Sandboxing (Highly Recommended)
|
||||||
|
|
||||||
**We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Instead, consider:
|
**We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Instead, consider:
|
||||||
|
|
||||||
- **Docker**: Run Automaker in a Docker container to isolate it from your host system
|
- **Docker**: Run Automaker in a Docker container to isolate it from your host system
|
||||||
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
|
- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment
|
||||||
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
|
- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation
|
||||||
|
|
||||||
|
#### Running in Isolated Docker Container
|
||||||
|
|
||||||
|
For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file)
|
||||||
|
echo "ANTHROPIC_API_KEY=your-api-key-here" > .env
|
||||||
|
|
||||||
|
# On Windows PowerShell, use instead:
|
||||||
|
Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8
|
||||||
|
|
||||||
|
# 2. Build and run isolated container
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 3. Access the UI at http://localhost:3007
|
||||||
|
# API at http://localhost:3008/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation.
|
||||||
|
|
||||||
### 3. Limit Access
|
### 3. Limit Access
|
||||||
|
|
||||||
If you must run locally:
|
If you must run locally:
|
||||||
|
|
||||||
- Create a dedicated user account with limited permissions
|
- Create a dedicated user account with limited permissions
|
||||||
- Only grant access to specific project directories
|
- Only grant access to specific project directories
|
||||||
- Avoid running with administrator/root privileges
|
- Avoid running with administrator/root privileges
|
||||||
- Keep sensitive files and credentials outside of project directories
|
- Keep sensitive files and credentials outside of project directories
|
||||||
|
|
||||||
### 4. Monitor Activity
|
### 4. Monitor Activity
|
||||||
|
|
||||||
- Review the agent's actions in the output logs
|
- Review the agent's actions in the output logs
|
||||||
- Pay attention to file modifications and command executions
|
- Pay attention to file modifications and command executions
|
||||||
- Stop the agent immediately if you notice unexpected behavior
|
- Stop the agent immediately if you notice unexpected behavior
|
||||||
|
|
||||||
## No Warranty
|
## No Warranty & Limitation of Liability
|
||||||
|
|
||||||
This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
|
THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
|
||||||
|
|
||||||
|
This software is provided "as is", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, including but not limited to hardware damage, data loss, financial loss, or business interruption, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software.
|
||||||
|
|
||||||
## Acknowledgment
|
## Acknowledgment
|
||||||
|
|
||||||
|
|||||||
241
LICENSE
241
LICENSE
@@ -1,208 +1,141 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
AUTOMAKER LICENSE AGREEMENT
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
This License Agreement ("Agreement") is entered into between you ("Licensee") and the copyright holders of Automaker ("Licensor"). By using, copying, modifying, downloading, cloning, or distributing the Software (as defined below), you agree to be bound by the terms of this Agreement.
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
1. DEFINITIONS
|
||||||
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
|
"Software" means the Automaker software, including all source code, object code, documentation, and related materials.
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
"Generated Files" means files created by the Software during normal operation to store internal state, configuration, or working data, including but not limited to app_spec.txt, feature.json, and similar files generated by the Software. Generated Files are not considered part of the Software for the purposes of this license and are not subject to the restrictions herein.
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
|
"Derivative Work" means any work that is based on, derived from, or incorporates the Software or any substantial portion of it, including but not limited to modifications, forks, adaptations, translations, or any altered version of the Software.
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
|
"Monetization" means any activity that generates revenue, income, or commercial benefit from the Software itself or any Derivative Work, including but not limited to:
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
|
- Reselling, redistributing, or sublicensing the Software, any Derivative Work, or any substantial portion thereof
|
||||||
|
- Including the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||||
|
- Offering the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||||
|
- Hosting the Software or any Derivative Work as a service (whether free or paid) for use by others, including cloud hosting, Software-as-a-Service (SaaS), or any other form of hosted access for third parties
|
||||||
|
- Extracting, reselling, redistributing, or sublicensing any prompts, context, or other instructional content bundled within the Software
|
||||||
|
- Creating, distributing, or selling modified versions, forks, or Derivative Works of the Software
|
||||||
|
|
||||||
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
|
Monetization does NOT include:
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and modification follow.
|
- Using the Software internally within your organization, regardless of whether your organization is for-profit
|
||||||
|
- Using the Software to build products or services that generate revenue, as long as you are not reselling or redistributing the Software itself
|
||||||
|
- Using the Software to provide services for which fees are charged, as long as the Software itself is not being resold or redistributed
|
||||||
|
- Hosting the Software anywhere for personal use by a single developer, as long as the Software is not made accessible to others
|
||||||
|
|
||||||
TERMS AND CONDITIONS 0. Definitions.
|
"Core Contributors" means the following individuals who are granted perpetual, royalty-free licenses:
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
- Cody Seibert (webdevcody)
|
||||||
|
- SuperComboGamer (SCG)
|
||||||
|
- Kacper Lachowicz (Shironex, Shirone)
|
||||||
|
- Ben Scott (trueheads)
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
|
2. GRANT OF LICENSE
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
|
Subject to the terms and conditions of this Agreement, Licensor hereby grants to Licensee a non-exclusive, non-transferable license to use, copy, modify, and distribute the Software, provided that:
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based on the Program.
|
a) Licensee may freely clone, install, and use the Software locally or within an organization for the purpose of building, developing, and maintaining other products, software, or services. There are no restrictions on the products you build _using_ the Software.
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
b) Licensee may run the Software on personal or organizational infrastructure for internal use.
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
c) Core Contributors are each individually granted a perpetual, worldwide, royalty-free, non-exclusive license to use, copy, modify, distribute, and sublicense the Software for any purpose, including Monetization, without payment of any fees or royalties. Each Core Contributor may exercise these rights independently and does not require permission, consent, or approval from any other Core Contributor to Monetize the Software in any way they see fit.
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
d) Commercial licenses for the Software may be discussed and issued to external parties or companies seeking to use the Software for financial gain or Monetization purposes. Core Contributors already have full rights under section 2(c) and do not require commercial licenses. Any commercial license issued to external parties shall require a unanimous vote by all Core Contributors and shall be granted in writing and signed by all Core Contributors.
|
||||||
|
|
||||||
1. Source Code.
|
e) The list of individuals defined as "Core Contributors" in Section 1 shall be amended to reflect any revocation or reinstatement of status made under this section.
|
||||||
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
3. RESTRICTIONS
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
Licensee may NOT:
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
- Engage in any Monetization of the Software or any Derivative Work without explicit written permission from all Core Contributors
|
||||||
|
- Resell, redistribute, or sublicense the Software, any Derivative Work, or any substantial portion thereof
|
||||||
|
- Create, distribute, or sell modified versions, forks, or Derivative Works of the Software for any commercial purpose
|
||||||
|
- Include the Software, any Derivative Work, or substantial portions thereof in a product or service that you sell or distribute
|
||||||
|
- Offer the Software, any Derivative Work, or substantial portions thereof as a standalone product or service for sale
|
||||||
|
- Extract, resell, redistribute, or sublicense any prompts, context, or other instructional content bundled within the Software
|
||||||
|
- Host the Software or any Derivative Work as a service (whether free or paid) for use by others (except Core Contributors)
|
||||||
|
- Remove or alter any copyright notices or license terms
|
||||||
|
- Use the Software in any manner that violates applicable laws or regulations
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
Licensee MAY:
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same work.
|
- Use the Software internally within their organization (commercial or non-profit)
|
||||||
|
- Use the Software to build other commercial products (products that do NOT contain the Software or Derivative Works)
|
||||||
|
- Modify the Software for internal use within their organization (commercial or non-profit)
|
||||||
|
|
||||||
2. Basic Permissions.
|
4. CORE CONTRIBUTOR STATUS MANAGEMENT
|
||||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
a) Core Contributor status may be revoked indefinitely by the remaining Core Contributors if:
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
- A Core Contributor cannot be reached for a period of one (1) month through reasonable means of communication (including but not limited to email, Discord, GitHub, or other project communication channels)
|
||||||
|
- AND the Core Contributor has not contributed to the project during that one-month period. For purposes of this section, "contributed" means at least one of the following activities:
|
||||||
|
- Discussing the Software through project communication channels
|
||||||
|
- Committing code changes to the project repository
|
||||||
|
- Submitting bug fixes or patches
|
||||||
|
- Participating in project-related discussions or decision-making
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
b) Revocation of Core Contributor status requires a unanimous vote by all other Core Contributors (excluding the Core Contributor whose status is being considered for revocation).
|
||||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
c) Upon revocation of Core Contributor status, the individual shall no longer be considered a Core Contributor and shall lose the rights granted under section 2(c) of this Agreement. However, any Contributions made prior to revocation shall remain subject to the terms of section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT).
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
d) A revoked Core Contributor may be reinstated to Core Contributor status with a unanimous vote by all current Core Contributors. Upon reinstatement, the individual shall regain all rights granted under section 2(c) of this Agreement.
|
||||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
5. CONTRIBUTIONS AND RIGHTS ASSIGNMENT
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials ("Contributions") to the Automaker project, you agree to the following terms without reservation:
|
||||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
a) **Full Ownership Transfer & Rights Grant:** You hereby assign to the Core Contributors all right, title, and interest in and to your Contributions, including all copyrights, patents, and other intellectual property rights. If such assignment is not effective under applicable law, you grant the Core Contributors an unrestricted, perpetual, worldwide, non-exclusive, royalty-free, fully paid-up, irrevocable, sublicensable, and transferable license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute, perform, display, and otherwise exploit your Contributions in any manner they see fit, including for any commercial purpose or Monetization.
|
||||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
|
|
||||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
|
||||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
|
||||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
b) **No Take-Backs:** You understand and agree that this grant of rights is irrevocable ("no take-backs"). You cannot revoke, rescind, or terminate this grant of rights once your Contribution has been submitted.
|
||||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
c) **Waiver of Moral Rights:** You waive any "moral rights" or other rights with respect to attribution of authorship or integrity of materials regarding your Contributions that you may have under any applicable law.
|
||||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
|
||||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
|
||||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
|
||||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
d) **Right to Contribute:** You represent and warrant that you are the original author of the Contributions, or that you have sufficient rights to grant the rights conveyed by this section, and that your Contributions do not infringe upon the rights of any third party.
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
6. TERMINATION
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
This license will terminate automatically if Licensee breaches any term of this Agreement. Upon termination, Licensee must immediately cease all use of the Software and destroy all copies in their possession.
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
7. HIGH RISK DISCLAIMER AND LIMITATION OF LIABILITY
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
a) **AI RISKS:** THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE.
|
||||||
|
|
||||||
7. Additional Terms.
|
b) **USE AT YOUR OWN RISK:** YOU AGREE THAT YOUR USE OF THE SOFTWARE IS SOLELY AT YOUR OWN RISK. THE CORE CONTRIBUTORS AND LICENSOR DO NOT GUARANTEE THAT THE SOFTWARE OR ANY CODE GENERATED BY IT WILL BE SAFE, BUG-FREE, OR FUNCTIONAL.
|
||||||
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
c) **NO WARRANTY:** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
d) **LIMITATION OF LIABILITY:** IN NO EVENT SHALL THE CORE CONTRIBUTORS, LICENSORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE, INCLUDING BUT NOT LIMITED TO:
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
- DAMAGE TO HARDWARE OR COMPUTER SYSTEMS
|
||||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
- DATA LOSS OR CORRUPTION
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
- GENERATION OF BAD, VULNERABLE, OR MALICIOUS CODE
|
||||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
- FINANCIAL LOSSES
|
||||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
- BUSINESS INTERRUPTION
|
||||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
|
||||||
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
8. LICENSE AMENDMENTS
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
Any amendment, modification, or update to this License Agreement must be agreed upon unanimously by all Core Contributors. No changes to this Agreement shall be effective unless all Core Contributors have provided their written consent or approval through a unanimous vote.
|
||||||
|
|
||||||
8. Termination.
|
9. CONTACT
|
||||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
For inquiries regarding this license or permissions for Monetization, please contact the Core Contributors through the official project channels:
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
- Agentic Jumpstart Discord: https://discord.gg/JUDWZDN3VT
|
||||||
|
- Website: https://automaker.app
|
||||||
|
- Email: automakerapp@gmail.com
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
Any permission for Monetization requires the unanimous written consent of all Core Contributors.
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
10. GOVERNING LAW
|
||||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
This Agreement shall be governed by and construed in accordance with the laws of the State of Tennessee, USA, without regard to conflict of law principles.
|
||||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
By using the Software, you acknowledge that you have read this Agreement, understand it, and agree to be bound by its terms and conditions.
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
---
|
||||||
|
|
||||||
11. Patents.
|
Copyright (c) 2025 Automaker Core Contributors
|
||||||
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
521
README.md
521
README.md
@@ -1,6 +1,85 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="apps/ui/public/readme_logo.png" alt="Automaker Logo" height="80" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> **[!TIP]**
|
||||||
|
>
|
||||||
|
> **Learn more about Agentic Coding!**
|
||||||
|
>
|
||||||
|
> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks.
|
||||||
|
>
|
||||||
|
> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh).
|
||||||
|
|
||||||
# Automaker
|
# Automaker
|
||||||
|
|
||||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
**Stop typing code. Start directing AI agents.**
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><h2>Table of Contents</h2></summary>
|
||||||
|
|
||||||
|
- [What Makes Automaker Different?](#what-makes-automaker-different)
|
||||||
|
- [The Workflow](#the-workflow)
|
||||||
|
- [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk)
|
||||||
|
- [Why This Matters](#why-this-matters)
|
||||||
|
- [Security Disclaimer](#security-disclaimer)
|
||||||
|
- [Community & Support](#community--support)
|
||||||
|
- [Getting Started](#getting-started)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [How to Run](#how-to-run)
|
||||||
|
- [Development Mode](#development-mode)
|
||||||
|
- [Building for Production](#building-for-production)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Linting](#linting)
|
||||||
|
- [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 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What Makes Automaker Different?
|
||||||
|
|
||||||
|
Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation.
|
||||||
|
|
||||||
|
### The Workflow
|
||||||
|
|
||||||
|
1. **Add Features** - Describe features you want built (with text, images, or screenshots)
|
||||||
|
2. **Move to "In Progress"** - Automaker automatically assigns an AI agent to implement the feature
|
||||||
|
3. **Watch It Build** - See real-time progress as the agent writes code, runs tests, and makes changes
|
||||||
|
4. **Review & Verify** - Review the changes, run tests, and approve when ready
|
||||||
|
5. **Ship Faster** - Build entire applications in days, not weeks
|
||||||
|
|
||||||
|
### Powered by Claude Agent SDK
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,7 +93,23 @@ Automaker is an autonomous AI development studio that helps you build software f
|
|||||||
>
|
>
|
||||||
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine.
|
||||||
>
|
>
|
||||||
> **[Read the full disclaimer](../DISCLAIMER.md)**
|
> **[Read the full disclaimer](./DISCLAIMER.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community & Support
|
||||||
|
|
||||||
|
Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows.
|
||||||
|
|
||||||
|
In the Discord, you can:
|
||||||
|
|
||||||
|
- 💬 Discuss agentic coding patterns and best practices
|
||||||
|
- 🧠 Share ideas for AI-driven development workflows
|
||||||
|
- 🛠️ Get help setting up or extending Automaker
|
||||||
|
- 🚀 Show off projects built with AI agents
|
||||||
|
- 🤝 Collaborate with other developers and contributors
|
||||||
|
|
||||||
|
👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,34 +117,60 @@ Automaker is an autonomous AI development studio that helps you build software f
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- **Node.js 18+** (tested with Node.js 22)
|
||||||
- npm
|
- **npm** (comes with Node.js)
|
||||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
|
- **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
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repo
|
# 1. Clone the repository
|
||||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
git clone https://github.com/AutoMaker-Org/automaker.git
|
||||||
cd automaker
|
cd automaker
|
||||||
|
|
||||||
# 2. Install dependencies
|
# 2. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 3. Get your Claude Code OAuth token
|
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||||
claude setup-token
|
npm run build:packages
|
||||||
# ⚠️ This prints your token - don't share your screen!
|
|
||||||
|
|
||||||
# 4. Set the token and run
|
# 4. Set up authentication (skip if using Claude Code CLI)
|
||||||
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..."
|
# If using Claude Code CLI: credentials are detected automatically
|
||||||
npm run dev:electron
|
# 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
|
||||||
|
# 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
|
## How to Run
|
||||||
|
|
||||||
### Development Modes
|
### Development Mode
|
||||||
|
|
||||||
Automaker can be run in several modes:
|
Start Automaker in development mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This will prompt you to choose your run mode, or you can specify a mode directly:
|
||||||
|
|
||||||
#### Electron Desktop App (Recommended)
|
#### Electron Desktop App (Recommended)
|
||||||
|
|
||||||
@@ -72,37 +193,69 @@ npm run dev:electron:wsl:gpu
|
|||||||
```bash
|
```bash
|
||||||
# Run in web browser (http://localhost:3007)
|
# Run in web browser (http://localhost:3007)
|
||||||
npm run dev:web
|
npm run dev:web
|
||||||
# or
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building for Production
|
### Building for Production
|
||||||
|
|
||||||
|
#### Web Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build Next.js app
|
# Build for web deployment (uses Vite)
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Build Electron app for distribution
|
# Run production build
|
||||||
npm run build:electron
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Production Build
|
#### Desktop Application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start production Next.js server
|
# Build for current platform (macOS/Windows/Linux)
|
||||||
npm run start
|
npm run build:electron
|
||||||
|
|
||||||
|
# Platform-specific builds
|
||||||
|
npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64)
|
||||||
|
npm run build:electron:win # Windows (NSIS installer, x64)
|
||||||
|
npm run build:electron:linux # Linux (AppImage + DEB, x64)
|
||||||
|
|
||||||
|
# Output directory: apps/ui/release/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run with Docker Compose (recommended for security)
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Access at http://localhost:3007
|
||||||
|
# API at http://localhost:3008
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
```bash
|
#### End-to-End Tests (Playwright)
|
||||||
# Run tests headless
|
|
||||||
npm run test
|
|
||||||
|
|
||||||
# Run tests with browser visible
|
```bash
|
||||||
npm run test:headed
|
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
|
### Linting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -110,64 +263,304 @@ npm run test:headed
|
|||||||
npm run lint
|
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 |
|
- `ANTHROPIC_API_KEY` - Your Anthropic API key for Claude Agent SDK (not needed if using Claude Code CLI)
|
||||||
| -------------------- | ------------------------- | --------------------------------------------------------- |
|
|
||||||
| OAuth Token (env) | `CLAUDE_CODE_OAUTH_TOKEN` | From `claude setup-token` - uses your Claude subscription |
|
|
||||||
| OAuth Token (stored) | — | Stored in app credentials file |
|
|
||||||
| API Key (stored) | — | Anthropic API key stored in app |
|
|
||||||
| API Key (env) | `ANTHROPIC_API_KEY` | Pay-per-use API key |
|
|
||||||
|
|
||||||
**Recommended:** Use `CLAUDE_CODE_OAUTH_TOKEN` if you have a Claude subscription.
|
#### Optional - Server
|
||||||
|
|
||||||
### Persistent Setup (Optional)
|
- `PORT` - Server port (default: 3008)
|
||||||
|
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||||
|
- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true)
|
||||||
|
|
||||||
|
#### Optional - Security
|
||||||
|
|
||||||
|
- `AUTOMAKER_API_KEY` - Optional API authentication for the server
|
||||||
|
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||||
|
- `CORS_ORIGIN` - CORS policy (default: \*)
|
||||||
|
|
||||||
|
#### Optional - Development
|
||||||
|
|
||||||
|
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||||
|
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||||
|
|
||||||
|
### Authentication Setup
|
||||||
|
|
||||||
|
#### Option 1: Claude Code CLI (Recommended)
|
||||||
|
|
||||||
|
Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart).
|
||||||
|
|
||||||
|
Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed!
|
||||||
|
|
||||||
|
#### Option 2: Direct API Key
|
||||||
|
|
||||||
|
If you prefer not to use the CLI, you can provide an Anthropic API key directly using one of these methods:
|
||||||
|
|
||||||
|
##### 2a. Shell Configuration
|
||||||
|
|
||||||
Add to your `~/.bashrc` or `~/.zshrc`:
|
Add to your `~/.bashrc` or `~/.zshrc`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export CLAUDE_CODE_OAUTH_TOKEN="YOUR_TOKEN_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
|
## Features
|
||||||
|
|
||||||
|
### Core Workflow
|
||||||
|
|
||||||
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages
|
||||||
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress"
|
||||||
- 🧠 **Multi-Model Support** - Choose from multiple AI models including Claude Opus, Sonnet, and more
|
- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch
|
||||||
- 💭 **Extended Thinking** - Enable extended thinking modes for complex problem-solving
|
- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion
|
||||||
- 📡 **Real-time Agent Output** - View live agent output, logs, and file diffs as features are being implemented
|
- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them
|
||||||
- 🔍 **Project Analysis** - AI-powered project structure analysis to understand your codebase
|
|
||||||
- 📁 **Context Management** - Add context files to help AI agents understand your project better
|
### AI & Planning
|
||||||
- 💡 **Feature Suggestions** - AI-generated feature suggestions based on your project
|
|
||||||
- 🖼️ **Image Support** - Attach images and screenshots to feature descriptions
|
- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature
|
||||||
- ⚡ **Concurrent Processing** - Configure concurrency to process multiple features simultaneously
|
- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving
|
||||||
- 🧪 **Test Integration** - Automatic test running and verification for implemented features
|
- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution)
|
||||||
- 🔀 **Git Integration** - View git diffs and track changes made by AI agents
|
- ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins
|
||||||
- 👤 **AI Profiles** - Create and manage different AI agent profiles for various tasks
|
- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation
|
||||||
- 💬 **Chat History** - Keep track of conversations and interactions with AI agents
|
|
||||||
- ⌨️ **Keyboard Shortcuts** - Efficient navigation and actions via keyboard shortcuts
|
### Project Management
|
||||||
- 🎨 **Dark/Light Theme** - Beautiful UI with theme support
|
|
||||||
- 🖥️ **Cross-Platform** - Desktop application built with Electron for Windows, macOS, and Linux
|
- 🔍 **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
|
## Tech Stack
|
||||||
|
|
||||||
- [Next.js](https://nextjs.org) - React framework
|
### Frontend
|
||||||
- [Electron](https://www.electronjs.org/) - Desktop application framework
|
|
||||||
- [Tailwind CSS](https://tailwindcss.com/) - Styling
|
- **React 19** - UI framework
|
||||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
- **Vite 7** - Build tool and development server
|
||||||
- [dnd-kit](https://dndkit.com/) - Drag and drop functionality
|
- **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
|
||||||
|
```
|
||||||
|
|
||||||
## Learn More
|
## 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.
|
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
- [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
|
## License
|
||||||
|
|
||||||
See [LICENSE](../LICENSE) for details.
|
This project is licensed under the **Automaker License Agreement**. See [LICENSE](LICENSE) for the full text.
|
||||||
|
|
||||||
|
**Summary of Terms:**
|
||||||
|
|
||||||
|
- **Allowed:**
|
||||||
|
- **Build Anything:** You can clone and use Automaker locally or in your organization to build ANY product (commercial or free).
|
||||||
|
- **Internal Use:** You can use it internally within your company (commercial or non-profit) without restriction.
|
||||||
|
- **Modify:** You can modify the code for internal use within your organization (commercial or non-profit).
|
||||||
|
|
||||||
|
- **Restricted (The "No Monetization of the Tool" Rule):**
|
||||||
|
- **No Resale:** You cannot resell Automaker itself.
|
||||||
|
- **No SaaS:** You cannot host Automaker as a service for others.
|
||||||
|
- **No Monetizing Mods:** You cannot distribute modified versions of Automaker for money.
|
||||||
|
|
||||||
|
- **Liability:**
|
||||||
|
- **Use at Own Risk:** This tool uses AI. We are **NOT** responsible if it breaks your computer, deletes your files, or generates bad code. You assume all risk.
|
||||||
|
|
||||||
|
- **Contributing:**
|
||||||
|
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).
|
||||||
|
|
||||||
|
**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
# Automaker
|
|
||||||
|
|
||||||
Automaker is an autonomous AI development studio that helps you build software faster using AI-powered agents. It provides a visual Kanban board interface to manage features, automatically assigns AI agents to implement them, and tracks progress through an intuitive workflow from backlog to verified completion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **[!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)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
**Step 1:** Clone this repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone git@github.com:AutoMaker-Org/automaker.git
|
|
||||||
cd automaker
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2:** Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows notes (in-app Claude auth)
|
|
||||||
|
|
||||||
- Node.js 22.x
|
|
||||||
- Prebuilt PTY is bundled; Visual Studio build tools are not required for Claude auth.
|
|
||||||
- If you prefer the external terminal flow, set `CLAUDE_AUTH_DISABLE_PTY=1`.
|
|
||||||
- If you later add native modules beyond the prebuilt PTY, you may still need VS Build Tools + Python to rebuild those.
|
|
||||||
|
|
||||||
**Step 3:** Run the Claude Code setup token command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude setup-token
|
|
||||||
```
|
|
||||||
|
|
||||||
> **⚠️ Warning:** This command will print your token to your terminal. Be careful if you're streaming or sharing your screen, as the token will be visible to anyone watching.
|
|
||||||
|
|
||||||
**Step 4:** Export the Claude Code OAuth token in your shell:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export CLAUDE_CODE_OAUTH_TOKEN="your-token-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5:** Start the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev:electron
|
|
||||||
```
|
|
||||||
|
|
||||||
This will start both the Next.js development server and the Electron application.
|
|
||||||
|
|
||||||
### Auth smoke test (Windows)
|
|
||||||
|
|
||||||
1. Ensure dependencies are installed (prebuilt pty is included).
|
|
||||||
2. Run `npm run dev:electron` and open the Setup modal.
|
|
||||||
3. Click Start on Claude auth; watch the embedded terminal stream logs.
|
|
||||||
4. Successful runs show “Token captured automatically.”; otherwise copy/paste the token from the log.
|
|
||||||
5. Optional: `node --test tests/claude-cli-detector.test.js` to verify token parsing.
|
|
||||||
|
|
||||||
**Step 6:** MOST IMPORTANT: Run the Following after all is setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
echo "W"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 📋 **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
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
See [LICENSE](../LICENSE) for details.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,247 +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");
|
|
||||||
|
|
||||||
// Load environment variables from .env file
|
|
||||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
|
||||||
|
|
||||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
|
||||||
|
|
||||||
let mainWindow = null;
|
|
||||||
let serverProcess = null;
|
|
||||||
const SERVER_PORT = 3008;
|
|
||||||
|
|
||||||
// Get icon path - works in both dev and production
|
|
||||||
function getIconPath() {
|
|
||||||
return app.isPackaged
|
|
||||||
? path.join(process.resourcesPath, "app", "public", "logo.png")
|
|
||||||
: path.join(__dirname, "../public/logo.png");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the backend server
|
|
||||||
*/
|
|
||||||
async function startServer() {
|
|
||||||
const isDev = !app.isPackaged;
|
|
||||||
|
|
||||||
// Server entry point
|
|
||||||
const serverPath = isDev
|
|
||||||
? path.join(__dirname, "../../server/dist/index.js")
|
|
||||||
: path.join(process.resourcesPath, "server", "index.js");
|
|
||||||
|
|
||||||
// Set environment variables for server
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
PORT: SERVER_PORT.toString(),
|
|
||||||
DATA_DIR: app.getPath("userData"),
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("[Electron] Starting backend server...");
|
|
||||||
|
|
||||||
serverProcess = spawn("node", [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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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() {
|
|
||||||
mainWindow = new BrowserWindow({
|
|
||||||
width: 1400,
|
|
||||||
height: 900,
|
|
||||||
minWidth: 1024,
|
|
||||||
minHeight: 700,
|
|
||||||
icon: getIconPath(),
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, "preload.js"),
|
|
||||||
contextIsolation: true,
|
|
||||||
nodeIntegration: false,
|
|
||||||
},
|
|
||||||
titleBarStyle: "hiddenInset",
|
|
||||||
backgroundColor: "#0a0a0a",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load Next.js dev server in development or production build
|
|
||||||
const isDev = !app.isPackaged;
|
|
||||||
if (isDev) {
|
|
||||||
mainWindow.loadURL("http://localhost:3007");
|
|
||||||
if (process.env.OPEN_DEVTOOLS === "true") {
|
|
||||||
mainWindow.webContents.openDevTools();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
app.dock.setIcon(getIconPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 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,9 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
env: {
|
|
||||||
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN || "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,167 +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": "next build && electron-builder",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "eslint",
|
|
||||||
"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": {
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@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-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@tanstack/react-query": "^5.90.12",
|
|
||||||
"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.7",
|
|
||||||
"react": "19.2.0",
|
|
||||||
"react-dom": "19.2.0",
|
|
||||||
"react-markdown": "^10.1.0",
|
|
||||||
"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.6",
|
|
||||||
"electron-builder": "^26.0.12",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "16.0.7",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "^5",
|
|
||||||
"wait-on": "^9.0.3"
|
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"appId": "com.automaker.app",
|
|
||||||
"productName": "Automaker",
|
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
|
||||||
"directories": {
|
|
||||||
"output": "dist"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"electron/**/*",
|
|
||||||
".next/**/*",
|
|
||||||
"public/**/*",
|
|
||||||
"!node_modules/**/*"
|
|
||||||
],
|
|
||||||
"extraResources": [
|
|
||||||
{
|
|
||||||
"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/logo_larger.png"
|
|
||||||
},
|
|
||||||
"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,35 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
const port = process.env.TEST_PORT || 3007;
|
|
||||||
const reuseServer = process.env.TEST_REUSE_SERVER === "true";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: "html",
|
|
||||||
timeout: 30000,
|
|
||||||
use: {
|
|
||||||
baseURL: `http://localhost:${port}`,
|
|
||||||
trace: "on-first-retry",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: { ...devices["Desktop Chrome"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...(reuseServer
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
webServer: {
|
|
||||||
command: `npx next dev -p ${port}`,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 120000,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
|
||||||
|
|
||||||
const port = process.env.TEST_PORT || 3007;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
reporter: "html",
|
|
||||||
timeout: 10000,
|
|
||||||
use: {
|
|
||||||
baseURL: `http://localhost:${port}`,
|
|
||||||
trace: "on-first-retry",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium",
|
|
||||||
use: { ...devices["Desktop Chrome"] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
webServer: {
|
|
||||||
command: `npx next dev -p ${port}`,
|
|
||||||
url: `http://localhost:${port}`,
|
|
||||||
reuseExistingServer: true,
|
|
||||||
timeout: 60000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 262 KiB |
Binary file not shown.
@@ -1,172 +0,0 @@
|
|||||||
import {
|
|
||||||
query,
|
|
||||||
Options,
|
|
||||||
SDKAssistantMessage,
|
|
||||||
} from "@anthropic-ai/claude-agent-sdk";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const systemPrompt = `You are an AI assistant helping users build software. You are part of the Automaker application,
|
|
||||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
|
||||||
|
|
||||||
Your role is to:
|
|
||||||
- Help users define their project requirements and specifications
|
|
||||||
- Ask clarifying questions to better understand their needs
|
|
||||||
- Suggest technical approaches and architectures
|
|
||||||
- Guide them through the development process
|
|
||||||
- Be conversational and helpful
|
|
||||||
- Write, edit, and modify code files as requested
|
|
||||||
- Execute commands and tests
|
|
||||||
- Search and analyze the codebase
|
|
||||||
|
|
||||||
When discussing projects, help users think through:
|
|
||||||
- Core functionality and features
|
|
||||||
- Technical stack choices
|
|
||||||
- Data models and architecture
|
|
||||||
- User experience considerations
|
|
||||||
- Testing strategies
|
|
||||||
|
|
||||||
You have full access to the codebase and can:
|
|
||||||
- Read files to understand existing code
|
|
||||||
- Write new files
|
|
||||||
- Edit existing files
|
|
||||||
- Run bash commands
|
|
||||||
- Search for code patterns
|
|
||||||
- Execute tests and builds`;
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { messages, workingDirectory } = await request.json();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[API] CLAUDE_CODE_OAUTH_TOKEN present:",
|
|
||||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "CLAUDE_CODE_OAUTH_TOKEN not configured" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last user message
|
|
||||||
const lastMessage = messages[messages.length - 1];
|
|
||||||
|
|
||||||
// Determine working directory - default to parent of app directory
|
|
||||||
const cwd = workingDirectory || path.resolve(process.cwd(), "..");
|
|
||||||
|
|
||||||
console.log("[API] Working directory:", cwd);
|
|
||||||
|
|
||||||
// Create query with options that enable code modification
|
|
||||||
const options: Options = {
|
|
||||||
// model: "claude-sonnet-4-20250514",
|
|
||||||
model: "claude-opus-4-5-20251101",
|
|
||||||
systemPrompt,
|
|
||||||
maxTurns: 20,
|
|
||||||
cwd,
|
|
||||||
// Enable all core tools for code modification
|
|
||||||
allowedTools: [
|
|
||||||
"Read",
|
|
||||||
"Write",
|
|
||||||
"Edit",
|
|
||||||
"Glob",
|
|
||||||
"Grep",
|
|
||||||
"Bash",
|
|
||||||
"WebSearch",
|
|
||||||
"WebFetch",
|
|
||||||
],
|
|
||||||
// Auto-accept file edits within the working directory
|
|
||||||
permissionMode: "acceptEdits",
|
|
||||||
// Enable sandbox for safer bash execution
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
autoAllowBashIfSandboxed: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert message history to SDK format to preserve conversation context
|
|
||||||
// Include both user and assistant messages for full context
|
|
||||||
const sessionId = `api-session-${Date.now()}`;
|
|
||||||
const conversationMessages = messages.map(
|
|
||||||
(msg: { role: string; content: string }) => {
|
|
||||||
if (msg.role === "user") {
|
|
||||||
return {
|
|
||||||
type: "user" as const,
|
|
||||||
message: {
|
|
||||||
role: "user" as const,
|
|
||||||
content: msg.content,
|
|
||||||
},
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
session_id: sessionId,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Assistant message
|
|
||||||
return {
|
|
||||||
type: "assistant" as const,
|
|
||||||
message: {
|
|
||||||
role: "assistant" as const,
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: msg.content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
session_id: sessionId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute query with full conversation context
|
|
||||||
const queryResult = query({
|
|
||||||
prompt:
|
|
||||||
conversationMessages.length > 0
|
|
||||||
? conversationMessages
|
|
||||||
: lastMessage.content,
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
|
|
||||||
let responseText = "";
|
|
||||||
const toolUses: Array<{ name: string; input: unknown }> = [];
|
|
||||||
|
|
||||||
// Collect the response from the async generator
|
|
||||||
for await (const msg of queryResult) {
|
|
||||||
if (msg.type === "assistant") {
|
|
||||||
const assistantMsg = msg as SDKAssistantMessage;
|
|
||||||
if (assistantMsg.message.content) {
|
|
||||||
for (const block of assistantMsg.message.content) {
|
|
||||||
if (block.type === "text") {
|
|
||||||
responseText += block.text;
|
|
||||||
} else if (block.type === "tool_use") {
|
|
||||||
// Track tool usage for transparency
|
|
||||||
toolUses.push({
|
|
||||||
name: block.name,
|
|
||||||
input: block.input,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === "result") {
|
|
||||||
if (msg.subtype === "success") {
|
|
||||||
if (msg.result) {
|
|
||||||
responseText = msg.result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
content: responseText || "Sorry, I couldn't generate a response.",
|
|
||||||
toolUses: toolUses.length > 0 ? toolUses : undefined,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error("Claude API error:", error);
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to get response from Claude";
|
|
||||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
||||||
|
|
||||||
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,262 +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 { useAppStore } from "@/store/app-store";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { getElectronAPI, isElectron } from "@/lib/electron";
|
|
||||||
import {
|
|
||||||
FileBrowserProvider,
|
|
||||||
useFileBrowser,
|
|
||||||
setGlobalFileBrowser,
|
|
||||||
} from "@/contexts/file-browser-context";
|
|
||||||
|
|
||||||
function HomeContent() {
|
|
||||||
const {
|
|
||||||
currentView,
|
|
||||||
setCurrentView,
|
|
||||||
setIpcConnected,
|
|
||||||
theme,
|
|
||||||
currentProject,
|
|
||||||
previewTheme,
|
|
||||||
getEffectiveTheme,
|
|
||||||
} = useAppStore();
|
|
||||||
const { isFirstRun, setupComplete } = useSetupStore();
|
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
|
||||||
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
|
|
||||||
const { openFileBrowser } = useFileBrowser();
|
|
||||||
|
|
||||||
// Hidden streamer panel - opens with "\" key
|
|
||||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
|
||||||
// Don't trigger when typing in inputs
|
|
||||||
const activeElement = document.activeElement;
|
|
||||||
if (activeElement) {
|
|
||||||
const tagName = activeElement.tagName.toLowerCase();
|
|
||||||
if (
|
|
||||||
tagName === "input" ||
|
|
||||||
tagName === "textarea" ||
|
|
||||||
tagName === "select"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (activeElement.getAttribute("contenteditable") === "true") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const role = activeElement.getAttribute("role");
|
|
||||||
if (role === "textbox" || role === "searchbox" || role === "combobox") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't trigger with modifier keys
|
|
||||||
if (event.ctrlKey || event.altKey || event.metaKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for "\" key (backslash)
|
|
||||||
if (event.key === "\\") {
|
|
||||||
event.preventDefault();
|
|
||||||
setStreamerPanelOpen((prev) => !prev);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Register the "\" shortcut for streamer panel
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener("keydown", handleStreamerPanelShortcut);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleStreamerPanelShortcut);
|
|
||||||
};
|
|
||||||
}, [handleStreamerPanelShortcut]);
|
|
||||||
|
|
||||||
// Compute the effective theme: previewTheme takes priority, then project theme, then global theme
|
|
||||||
// This is reactive because it depends on previewTheme, currentProject, and theme from the store
|
|
||||||
const effectiveTheme = getEffectiveTheme();
|
|
||||||
|
|
||||||
// Prevent hydration issues
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initialize global file browser for HttpApiClient
|
|
||||||
useEffect(() => {
|
|
||||||
setGlobalFileBrowser(openFileBrowser);
|
|
||||||
}, [openFileBrowser]);
|
|
||||||
|
|
||||||
// Check if this is first run and redirect to setup if needed
|
|
||||||
useEffect(() => {
|
|
||||||
console.log("[Setup Flow] Checking setup state:", {
|
|
||||||
isMounted,
|
|
||||||
isFirstRun,
|
|
||||||
setupComplete,
|
|
||||||
currentView,
|
|
||||||
shouldShowSetup: isMounted && isFirstRun && !setupComplete,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isMounted && isFirstRun && !setupComplete) {
|
|
||||||
console.log(
|
|
||||||
"[Setup Flow] Redirecting to setup wizard (first run, not complete)"
|
|
||||||
);
|
|
||||||
setCurrentView("setup");
|
|
||||||
} else if (isMounted && setupComplete) {
|
|
||||||
console.log("[Setup Flow] Setup already complete, showing normal view");
|
|
||||||
}
|
|
||||||
}, [isMounted, isFirstRun, setupComplete, setCurrentView, currentView]);
|
|
||||||
|
|
||||||
// Test IPC connection on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const testConnection = async () => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.ping();
|
|
||||||
setIpcConnected(result === "pong");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("IPC connection failed:", error);
|
|
||||||
setIpcConnected(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
testConnection();
|
|
||||||
}, [setIpcConnected]);
|
|
||||||
|
|
||||||
// Apply theme class to document (uses effective theme - preview, project-specific, or global)
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.classList.remove(
|
|
||||||
"dark",
|
|
||||||
"retro",
|
|
||||||
"light",
|
|
||||||
"dracula",
|
|
||||||
"nord",
|
|
||||||
"monokai",
|
|
||||||
"tokyonight",
|
|
||||||
"solarized",
|
|
||||||
"gruvbox",
|
|
||||||
"catppuccin",
|
|
||||||
"onedark",
|
|
||||||
"synthwave",
|
|
||||||
"red"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (effectiveTheme === "dark") {
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else if (effectiveTheme === "retro") {
|
|
||||||
root.classList.add("retro");
|
|
||||||
} else if (effectiveTheme === "dracula") {
|
|
||||||
root.classList.add("dracula");
|
|
||||||
} else if (effectiveTheme === "nord") {
|
|
||||||
root.classList.add("nord");
|
|
||||||
} else if (effectiveTheme === "monokai") {
|
|
||||||
root.classList.add("monokai");
|
|
||||||
} else if (effectiveTheme === "tokyonight") {
|
|
||||||
root.classList.add("tokyonight");
|
|
||||||
} else if (effectiveTheme === "solarized") {
|
|
||||||
root.classList.add("solarized");
|
|
||||||
} else if (effectiveTheme === "gruvbox") {
|
|
||||||
root.classList.add("gruvbox");
|
|
||||||
} else if (effectiveTheme === "catppuccin") {
|
|
||||||
root.classList.add("catppuccin");
|
|
||||||
} else if (effectiveTheme === "onedark") {
|
|
||||||
root.classList.add("onedark");
|
|
||||||
} else if (effectiveTheme === "synthwave") {
|
|
||||||
root.classList.add("synthwave");
|
|
||||||
} else if (effectiveTheme === "red") {
|
|
||||||
root.classList.add("red");
|
|
||||||
} else if (effectiveTheme === "light") {
|
|
||||||
root.classList.add("light");
|
|
||||||
} else if (effectiveTheme === "system") {
|
|
||||||
// System theme
|
|
||||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
||||||
if (isDark) {
|
|
||||||
root.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
root.classList.add("light");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [effectiveTheme, previewTheme, currentProject, theme]);
|
|
||||||
|
|
||||||
const renderView = () => {
|
|
||||||
switch (currentView) {
|
|
||||||
case "welcome":
|
|
||||||
return <WelcomeView />;
|
|
||||||
case "setup":
|
|
||||||
return <SetupView />;
|
|
||||||
case "board":
|
|
||||||
return <BoardView />;
|
|
||||||
case "spec":
|
|
||||||
return <SpecView />;
|
|
||||||
case "agent":
|
|
||||||
return <AgentView />;
|
|
||||||
case "settings":
|
|
||||||
return <SettingsView />;
|
|
||||||
case "interview":
|
|
||||||
return <InterviewView />;
|
|
||||||
case "context":
|
|
||||||
return <ContextView />;
|
|
||||||
case "profiles":
|
|
||||||
return <ProfilesView />;
|
|
||||||
case "running-agents":
|
|
||||||
return <RunningAgentsView />;
|
|
||||||
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 />
|
|
||||||
{/* Environment indicator */}
|
|
||||||
{isMounted && !isElectron() && (
|
|
||||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
|
|
||||||
Web Mode
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Environment indicator - only show after mount to prevent hydration issues */}
|
|
||||||
{isMounted && !isElectron() && (
|
|
||||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
|
|
||||||
Web Mode
|
|
||||||
</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,231 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface DirectoryEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrowseResult {
|
|
||||||
success: boolean;
|
|
||||||
currentPath: string;
|
|
||||||
parentPath: string | null;
|
|
||||||
directories: DirectoryEntry[];
|
|
||||||
drives?: string[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileBrowserDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSelect: (path: string) => void;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FileBrowserDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSelect,
|
|
||||||
title = "Select Project Directory",
|
|
||||||
description = "Navigate to your project folder",
|
|
||||||
}: FileBrowserDialogProps) {
|
|
||||||
const [currentPath, setCurrentPath] = useState<string>("");
|
|
||||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
|
||||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
|
||||||
const [drives, setDrives] = useState<string[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const browseDirectory = async (dirPath?: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get server URL from environment or default
|
|
||||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/fs/browse`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ dirPath }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result: BrowseResult = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setCurrentPath(result.currentPath);
|
|
||||||
setParentPath(result.parentPath);
|
|
||||||
setDirectories(result.directories);
|
|
||||||
setDrives(result.drives || []);
|
|
||||||
} else {
|
|
||||||
setError(result.error || "Failed to browse directory");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to load directories");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load home directory on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && !currentPath) {
|
|
||||||
browseDirectory();
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSelectDirectory = (dir: DirectoryEntry) => {
|
|
||||||
browseDirectory(dir.path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoToParent = () => {
|
|
||||||
if (parentPath) {
|
|
||||||
browseDirectory(parentPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoHome = () => {
|
|
||||||
browseDirectory();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectDrive = (drivePath: string) => {
|
|
||||||
browseDirectory(drivePath);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = () => {
|
|
||||||
if (currentPath) {
|
|
||||||
onSelect(currentPath);
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
|
||||||
{title}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 min-h-[400px]">
|
|
||||||
{/* Drives selector (Windows only) */}
|
|
||||||
{drives.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
|
||||||
<HardDrive className="w-3 h-3" />
|
|
||||||
<span>Drives:</span>
|
|
||||||
</div>
|
|
||||||
{drives.map((drive) => (
|
|
||||||
<Button
|
|
||||||
key={drive}
|
|
||||||
variant={currentPath.startsWith(drive) ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectDrive(drive)}
|
|
||||||
className="h-7 px-3 text-xs"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{drive.replace("\\", "")}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current path breadcrumb */}
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGoHome}
|
|
||||||
className="h-7 px-2"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<Home className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
{parentPath && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGoToParent}
|
|
||||||
className="h-7 px-2"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
|
|
||||||
{currentPath || "Loading..."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Directory list */}
|
|
||||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
|
||||||
<div className="text-sm text-muted-foreground">Loading directories...</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
|
||||||
<div className="text-sm text-destructive">{error}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && directories.length === 0 && (
|
|
||||||
<div className="flex items-center justify-center h-full p-8">
|
|
||||||
<div className="text-sm text-muted-foreground">No subdirectories found</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && directories.length > 0 && (
|
|
||||||
<div className="divide-y divide-sidebar-border">
|
|
||||||
{directories.map((dir) => (
|
|
||||||
<button
|
|
||||||
key={dir.path}
|
|
||||||
onClick={() => handleSelectDirectory(dir)}
|
|
||||||
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
|
||||||
>
|
|
||||||
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
|
||||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
|
||||||
<FolderOpen className="w-4 h-4 mr-2" />
|
|
||||||
Select Current Folder
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +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-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
||||||
secondary:
|
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
destructive:
|
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
||||||
outline: "text-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface BadgeProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
VariantProps<typeof badgeVariants> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
"animated-outline":
|
|
||||||
"relative overflow-hidden rounded-xl hover:bg-transparent shadow-none",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
||||||
icon: "size-9",
|
|
||||||
"icon-sm": "size-8",
|
|
||||||
"icon-lg": "size-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean;
|
|
||||||
}) {
|
|
||||||
// Special handling for animated-outline variant
|
|
||||||
if (variant === "animated-outline" && !asChild) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant, size }),
|
|
||||||
"p-[1px]", // Force 1px padding for the gradient border
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
data-slot="button"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{/* Animated rotating gradient border */}
|
|
||||||
<span className="absolute inset-[-1000%] animate-[spin_2s_linear_infinite] animated-outline-gradient" />
|
|
||||||
|
|
||||||
{/* Inner content container */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"animated-outline-inner inline-flex h-full w-full cursor-pointer items-center justify-center gap-2 rounded-[10px] px-4 py-1 text-sm font-medium backdrop-blur-3xl transition-all",
|
|
||||||
size === "sm" && "px-3 text-xs gap-1.5",
|
|
||||||
size === "lg" && "px-8",
|
|
||||||
size === "icon" && "p-0 gap-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Comp = asChild ? Slot : "button";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Comp>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
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-sm py-6 shadow-sm",
|
|
||||||
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-2 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", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", 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 px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
|
|
||||||
interface CategoryAutocompleteProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
suggestions: string[];
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryAutocomplete({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
suggestions,
|
|
||||||
placeholder = "Select or type a category...",
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
"data-testid": testId,
|
|
||||||
}: CategoryAutocompleteProps) {
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn("w-full justify-between", className)}
|
|
||||||
data-testid={testId}
|
|
||||||
>
|
|
||||||
{value
|
|
||||||
? suggestions.find((s) => s === value) ?? value
|
|
||||||
: placeholder}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[200px] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search category..." className="h-9" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No category found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{suggestions.map((suggestion) => (
|
|
||||||
<CommandItem
|
|
||||||
key={suggestion}
|
|
||||||
value={suggestion}
|
|
||||||
onSelect={(currentValue) => {
|
|
||||||
onChange(currentValue === value ? "" : currentValue);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"ml-auto",
|
|
||||||
value === suggestion ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground hover:border-primary/80",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
));
|
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
export { Checkbox };
|
|
||||||
@@ -1,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,422 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ImageIcon, X, Loader2 } from "lucide-react";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
|
|
||||||
export interface FeatureImagePath {
|
|
||||||
id: string;
|
|
||||||
path: string; // Path to the temp file
|
|
||||||
filename: string;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map to store preview data by image ID (persisted across component re-mounts)
|
|
||||||
export type ImagePreviewMap = Map<string, string>;
|
|
||||||
|
|
||||||
interface DescriptionImageDropZoneProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
images: FeatureImagePath[];
|
|
||||||
onImagesChange: (images: FeatureImagePath[]) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
maxFiles?: number;
|
|
||||||
maxFileSize?: number; // in bytes, default 10MB
|
|
||||||
// Optional: pass preview map from parent to persist across tab switches
|
|
||||||
previewMap?: ImagePreviewMap;
|
|
||||||
onPreviewMapChange?: (map: ImagePreviewMap) => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
error?: boolean; // Show error state with red border
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
];
|
|
||||||
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
||||||
|
|
||||||
export function DescriptionImageDropZone({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
images,
|
|
||||||
onImagesChange,
|
|
||||||
placeholder = "Describe the feature...",
|
|
||||||
className,
|
|
||||||
disabled = false,
|
|
||||||
maxFiles = 5,
|
|
||||||
maxFileSize = DEFAULT_MAX_FILE_SIZE,
|
|
||||||
previewMap,
|
|
||||||
onPreviewMapChange,
|
|
||||||
autoFocus = false,
|
|
||||||
error = false,
|
|
||||||
}: DescriptionImageDropZoneProps) {
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
// Use parent-provided preview map if available, otherwise use local state
|
|
||||||
const [localPreviewImages, setLocalPreviewImages] = useState<Map<string, string>>(
|
|
||||||
() => new Map()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine which preview map to use - prefer parent-controlled state
|
|
||||||
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
|
|
||||||
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
|
|
||||||
if (onPreviewMapChange) {
|
|
||||||
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
|
|
||||||
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
|
|
||||||
onPreviewMapChange(newMap);
|
|
||||||
} else {
|
|
||||||
setLocalPreviewImages((prev) => {
|
|
||||||
const newMap = typeof updater === 'function' ? updater(prev) : updater;
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [onPreviewMapChange, previewMap, localPreviewImages]);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const currentProject = useAppStore((state) => state.currentProject);
|
|
||||||
|
|
||||||
// Construct server URL for loading saved images
|
|
||||||
const getImageServerUrl = useCallback((imagePath: string): string => {
|
|
||||||
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
|
||||||
const projectPath = currentProject?.path || "";
|
|
||||||
return `${serverUrl}/api/fs/image?path=${encodeURIComponent(imagePath)}&projectPath=${encodeURIComponent(projectPath)}`;
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
const fileToBase64 = (file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
resolve(reader.result);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to read file as base64"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveImageToTemp = useCallback(async (
|
|
||||||
base64Data: string,
|
|
||||||
filename: string,
|
|
||||||
mimeType: string
|
|
||||||
): Promise<string | null> => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
// Check if saveImageToTemp method exists
|
|
||||||
if (!api.saveImageToTemp) {
|
|
||||||
// Fallback path when saveImageToTemp is not available
|
|
||||||
console.log("[DescriptionImageDropZone] Using fallback path for image");
|
|
||||||
return `.automaker/images/${Date.now()}_${filename}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get projectPath from the store if available
|
|
||||||
const projectPath = currentProject?.path;
|
|
||||||
const result = await api.saveImageToTemp(base64Data, filename, mimeType, projectPath);
|
|
||||||
if (result.success && result.path) {
|
|
||||||
return result.path;
|
|
||||||
}
|
|
||||||
console.error("[DescriptionImageDropZone] Failed to save image:", result.error);
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[DescriptionImageDropZone] Error saving image:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
const processFiles = useCallback(
|
|
||||||
async (files: FileList) => {
|
|
||||||
if (disabled || isProcessing) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
const newImages: FeatureImagePath[] = [];
|
|
||||||
const newPreviews = new Map(previewImages);
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
for (const file of Array.from(files)) {
|
|
||||||
// Validate file type
|
|
||||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
|
||||||
errors.push(
|
|
||||||
`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > maxFileSize) {
|
|
||||||
const maxSizeMB = maxFileSize / (1024 * 1024);
|
|
||||||
errors.push(
|
|
||||||
`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've reached max files
|
|
||||||
if (newImages.length + images.length >= maxFiles) {
|
|
||||||
errors.push(`Maximum ${maxFiles} images allowed.`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const base64 = await fileToBase64(file);
|
|
||||||
const tempPath = await saveImageToTemp(base64, file.name, file.type);
|
|
||||||
|
|
||||||
if (tempPath) {
|
|
||||||
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
|
||||||
const imagePathRef: FeatureImagePath = {
|
|
||||||
id: imageId,
|
|
||||||
path: tempPath,
|
|
||||||
filename: file.name,
|
|
||||||
mimeType: file.type,
|
|
||||||
};
|
|
||||||
newImages.push(imagePathRef);
|
|
||||||
// Store preview for display
|
|
||||||
newPreviews.set(imageId, base64);
|
|
||||||
} else {
|
|
||||||
errors.push(`${file.name}: Failed to save image.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errors.push(`${file.name}: Failed to process image.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
console.warn("Image upload errors:", errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
|
||||||
onImagesChange([...images, ...newImages]);
|
|
||||||
setPreviewImages(newPreviews);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(false);
|
|
||||||
},
|
|
||||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files.length > 0) {
|
|
||||||
processFiles(files);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled, processFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback(
|
|
||||||
(e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!disabled) {
|
|
||||||
setIsDragOver(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFileSelect = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
processFiles(files);
|
|
||||||
}
|
|
||||||
// Reset the input so the same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[processFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBrowseClick = useCallback(() => {
|
|
||||||
if (!disabled && fileInputRef.current) {
|
|
||||||
fileInputRef.current.click();
|
|
||||||
}
|
|
||||||
}, [disabled]);
|
|
||||||
|
|
||||||
const removeImage = useCallback(
|
|
||||||
(imageId: string) => {
|
|
||||||
onImagesChange(images.filter((img) => img.id !== imageId));
|
|
||||||
setPreviewImages((prev) => {
|
|
||||||
const newMap = new Map(prev);
|
|
||||||
newMap.delete(imageId);
|
|
||||||
return newMap;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[images, onImagesChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("relative", className)}>
|
|
||||||
{/* Hidden file input */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={ACCEPTED_IMAGE_TYPES.join(",")}
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
disabled={disabled}
|
|
||||||
data-testid="description-image-input"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Drop zone wrapper */}
|
|
||||||
<div
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
className={cn(
|
|
||||||
"relative rounded-md transition-all duration-200",
|
|
||||||
{
|
|
||||||
"ring-2 ring-blue-400 ring-offset-2 ring-offset-background":
|
|
||||||
isDragOver && !disabled,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Drag overlay */}
|
|
||||||
{isDragOver && !disabled && (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-blue-500/20 border-2 border-dashed border-blue-400 pointer-events-none"
|
|
||||||
data-testid="drop-overlay"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-2 text-blue-400">
|
|
||||||
<ImageIcon className="w-8 h-8" />
|
|
||||||
<span className="text-sm font-medium">Drop images here</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Textarea */}
|
|
||||||
<Textarea
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
aria-invalid={error}
|
|
||||||
className={cn(
|
|
||||||
"min-h-[120px]",
|
|
||||||
isProcessing && "opacity-50 pointer-events-none"
|
|
||||||
)}
|
|
||||||
data-testid="feature-description-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hint text */}
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Drag and drop images here or{" "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleBrowseClick}
|
|
||||||
className="text-primary hover:text-primary/80 underline"
|
|
||||||
disabled={disabled || isProcessing}
|
|
||||||
>
|
|
||||||
browse
|
|
||||||
</button>{" "}
|
|
||||||
to attach context images
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Processing indicator */}
|
|
||||||
{isProcessing && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Saving images...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Image previews */}
|
|
||||||
{images.length > 0 && (
|
|
||||||
<div className="mt-3 space-y-2" data-testid="description-image-previews">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-medium text-foreground">
|
|
||||||
{images.length} image{images.length > 1 ? "s" : ""} attached
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onImagesChange([]);
|
|
||||||
setPreviewImages(new Map());
|
|
||||||
}}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{images.map((image) => (
|
|
||||||
<div
|
|
||||||
key={image.id}
|
|
||||||
className="relative group rounded-md border border-muted bg-muted/50 overflow-hidden"
|
|
||||||
data-testid={`description-image-preview-${image.id}`}
|
|
||||||
>
|
|
||||||
{/* Image thumbnail or placeholder */}
|
|
||||||
<div className="w-16 h-16 flex items-center justify-center bg-zinc-800">
|
|
||||||
{previewImages.has(image.id) ? (
|
|
||||||
<img
|
|
||||||
src={previewImages.get(image.id)}
|
|
||||||
alt={image.filename}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={getImageServerUrl(image.path)}
|
|
||||||
alt={image.filename}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
onError={(e) => {
|
|
||||||
// If image fails to load, hide it
|
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Remove button */}
|
|
||||||
{!disabled && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeImage(image.id);
|
|
||||||
}}
|
|
||||||
className="absolute top-0.5 right-0.5 p-0.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
data-testid={`remove-description-image-${image.id}`}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Filename tooltip on hover */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<p className="text-[10px] text-white truncate">
|
|
||||||
{image.filename}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +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";
|
|
||||||
|
|
||||||
function Dialog({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
|
||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
||||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
|
||||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Overlay
|
|
||||||
data-slot="dialog-overlay"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
showCloseButton = true,
|
|
||||||
compact = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
}) {
|
|
||||||
// Check if className contains a custom max-width
|
|
||||||
const hasCustomMaxWidth =
|
|
||||||
typeof className === "string" && className.includes("max-w-");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogPortal data-slot="dialog-portal">
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-4rem)]",
|
|
||||||
compact
|
|
||||||
? "max-w-4xl p-4"
|
|
||||||
: !hasCustomMaxWidth
|
|
||||||
? "sm:max-w-2xl p-6"
|
|
||||||
: "p-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-close"
|
|
||||||
className={cn(
|
|
||||||
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity cursor-pointer hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
compact ? "top-2 right-3" : "top-3 right-5"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
)}
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Title
|
|
||||||
data-slot="dialog-title"
|
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DialogPrimitive.Description
|
|
||||||
data-slot="dialog-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogPortal,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
};
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent hover:bg-accent",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
))
|
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
))
|
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
))
|
|
||||||
DropdownMenuCheckboxItem.displayName =
|
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed hover:bg-accent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
))
|
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("ml-auto text-xs tracking-widest text-brand-400/70", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
}
|
|
||||||
@@ -1,290 +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>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatFileSize(image.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* Remove button */}
|
|
||||||
{!disabled && (
|
|
||||||
<button
|
|
||||||
onClick={() => removeImage(image.id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-destructive hover:text-destructive-foreground text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileToBase64(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === 'string') {
|
|
||||||
resolve(reader.result);
|
|
||||||
} else {
|
|
||||||
reject(new Error('Failed to read file as base64'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={type}
|
|
||||||
data-slot="input"
|
|
||||||
className={cn(
|
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-input border-input h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
||||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Input }
|
|
||||||
@@ -1,637 +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",
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
toggleSidebar: "ui",
|
|
||||||
addFeature: "action",
|
|
||||||
addContextFile: "action",
|
|
||||||
startNext: "action",
|
|
||||||
newSession: "action",
|
|
||||||
openProject: "action",
|
|
||||||
projectPicker: "action",
|
|
||||||
cyclePrevProject: "action",
|
|
||||||
cycleNextProject: "action",
|
|
||||||
addProfile: "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();
|
|
||||||
|
|
||||||
// Create a reverse map: base key -> list of shortcut names (including info about modifiers)
|
|
||||||
const keyToShortcuts = React.useMemo(() => {
|
|
||||||
const map: Record<string, Array<{ name: keyof KeyboardShortcuts; hasModifiers: boolean }>> = {};
|
|
||||||
(Object.entries(keyboardShortcuts) as [keyof KeyboardShortcuts, string][]).forEach(
|
|
||||||
([shortcutName, shortcutStr]) => {
|
|
||||||
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;
|
|
||||||
}, [keyboardShortcuts]);
|
|
||||||
|
|
||||||
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) => keyboardShortcuts[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.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 = keyboardShortcuts[shortcut];
|
|
||||||
const displayShortcut = formatShortcut(shortcutStr, true);
|
|
||||||
return (
|
|
||||||
<div key={shortcut} className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"w-2 h-2 rounded-full",
|
|
||||||
CATEGORY_COLORS[SHORTCUT_CATEGORIES[shortcut]].bg.replace("/20", "")
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{SHORTCUT_LABELS[shortcut]}</span>
|
|
||||||
<kbd className="text-xs font-mono bg-sidebar-accent/30 px-1 rounded">
|
|
||||||
{displayShortcut}
|
|
||||||
</kbd>
|
|
||||||
{keyboardShortcuts[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);
|
|
||||||
|
|
||||||
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],
|
|
||||||
value: keyboardShortcuts[shortcut],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [keyboardShortcuts]);
|
|
||||||
|
|
||||||
// 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(keyboardShortcuts).find(
|
|
||||||
([k, v]) => k !== currentKey && v.toUpperCase() === shortcutStr.toUpperCase()
|
|
||||||
);
|
|
||||||
return conflict ? SHORTCUT_LABELS[conflict[0] as keyof KeyboardShortcuts] : null;
|
|
||||||
}, [keyboardShortcuts]);
|
|
||||||
|
|
||||||
const handleStartEdit = (key: keyof KeyboardShortcuts) => {
|
|
||||||
const currentValue = keyboardShortcuts[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 = keyboardShortcuts[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,284 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
MessageSquare,
|
|
||||||
Wrench,
|
|
||||||
Zap,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertTriangle,
|
|
||||||
Bug,
|
|
||||||
Info,
|
|
||||||
FileOutput,
|
|
||||||
Brain,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
parseLogOutput,
|
|
||||||
getLogTypeColors,
|
|
||||||
type LogEntry,
|
|
||||||
type LogEntryType,
|
|
||||||
} from "@/lib/log-parser";
|
|
||||||
|
|
||||||
interface LogViewerProps {
|
|
||||||
output: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLogIcon = (type: LogEntryType) => {
|
|
||||||
switch (type) {
|
|
||||||
case "prompt":
|
|
||||||
return <MessageSquare className="w-4 h-4" />;
|
|
||||||
case "tool_call":
|
|
||||||
return <Wrench className="w-4 h-4" />;
|
|
||||||
case "tool_result":
|
|
||||||
return <FileOutput className="w-4 h-4" />;
|
|
||||||
case "phase":
|
|
||||||
return <Zap className="w-4 h-4" />;
|
|
||||||
case "error":
|
|
||||||
return <AlertCircle className="w-4 h-4" />;
|
|
||||||
case "success":
|
|
||||||
return <CheckCircle2 className="w-4 h-4" />;
|
|
||||||
case "warning":
|
|
||||||
return <AlertTriangle className="w-4 h-4" />;
|
|
||||||
case "thinking":
|
|
||||||
return <Brain className="w-4 h-4" />;
|
|
||||||
case "debug":
|
|
||||||
return <Bug className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <Info className="w-4 h-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LogEntryItemProps {
|
|
||||||
entry: LogEntry;
|
|
||||||
isExpanded: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
|
|
||||||
const colors = getLogTypeColors(entry.type);
|
|
||||||
const hasContent = entry.content.length > 100;
|
|
||||||
|
|
||||||
// Format content - detect and highlight JSON
|
|
||||||
const formattedContent = useMemo(() => {
|
|
||||||
const content = entry.content;
|
|
||||||
|
|
||||||
// Try to find and format JSON blocks
|
|
||||||
const jsonRegex = /(\{[\s\S]*?\}|\[[\s\S]*?\])/g;
|
|
||||||
let lastIndex = 0;
|
|
||||||
const parts: { type: "text" | "json"; content: string }[] = [];
|
|
||||||
|
|
||||||
let match;
|
|
||||||
while ((match = jsonRegex.exec(content)) !== null) {
|
|
||||||
// Add text before JSON
|
|
||||||
if (match.index > lastIndex) {
|
|
||||||
parts.push({
|
|
||||||
type: "text",
|
|
||||||
content: content.slice(lastIndex, match.index),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse and format JSON
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(match[1]);
|
|
||||||
parts.push({
|
|
||||||
type: "json",
|
|
||||||
content: JSON.stringify(parsed, null, 2),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Not valid JSON, treat as text
|
|
||||||
parts.push({ type: "text", content: match[1] });
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndex = match.index + match[1].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining text
|
|
||||||
if (lastIndex < content.length) {
|
|
||||||
parts.push({ type: "text", content: content.slice(lastIndex) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts : [{ type: "text" as const, content }];
|
|
||||||
}, [entry.content]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-lg border-l-4 transition-all duration-200",
|
|
||||||
colors.bg,
|
|
||||||
colors.border,
|
|
||||||
"hover:brightness-110"
|
|
||||||
)}
|
|
||||||
data-testid={`log-entry-${entry.type}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="w-full px-3 py-2 flex items-center gap-2 text-left"
|
|
||||||
data-testid={`log-entry-toggle-${entry.id}`}
|
|
||||||
>
|
|
||||||
{hasContent ? (
|
|
||||||
isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span className="w-4 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className={cn("flex-shrink-0", colors.icon)}>
|
|
||||||
{getLogIcon(entry.type)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0",
|
|
||||||
colors.badge
|
|
||||||
)}
|
|
||||||
data-testid="log-entry-badge"
|
|
||||||
>
|
|
||||||
{entry.title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">
|
|
||||||
{!isExpanded &&
|
|
||||||
entry.content.slice(0, 80) +
|
|
||||||
(entry.content.length > 80 ? "..." : "")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(isExpanded || !hasContent) && (
|
|
||||||
<div
|
|
||||||
className="px-4 pb-3 pt-1"
|
|
||||||
data-testid={`log-entry-content-${entry.id}`}
|
|
||||||
>
|
|
||||||
<div className="font-mono text-xs space-y-1">
|
|
||||||
{formattedContent.map((part, index) => (
|
|
||||||
<div key={index}>
|
|
||||||
{part.type === "json" ? (
|
|
||||||
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto text-xs text-primary">
|
|
||||||
{part.content}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<pre
|
|
||||||
className={cn(
|
|
||||||
"whitespace-pre-wrap break-words",
|
|
||||||
colors.text
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{part.content}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LogViewer({ output, className }: LogViewerProps) {
|
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const entries = useMemo(() => parseLogOutput(output), [output]);
|
|
||||||
|
|
||||||
const toggleEntry = (id: string) => {
|
|
||||||
setExpandedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id);
|
|
||||||
} else {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const expandAll = () => {
|
|
||||||
setExpandedIds(new Set(entries.map((e) => e.id)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapseAll = () => {
|
|
||||||
setExpandedIds(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
|
||||||
<div className="text-center">
|
|
||||||
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
||||||
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
|
|
||||||
{output && output.trim() && (
|
|
||||||
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto">
|
|
||||||
<pre className="whitespace-pre-wrap">{output}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count entries by type
|
|
||||||
const typeCounts = entries.reduce((acc, entry) => {
|
|
||||||
acc[entry.type] = (acc[entry.type] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-col gap-2", className)}>
|
|
||||||
{/* Header with controls */}
|
|
||||||
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{Object.entries(typeCounts).map(([type, count]) => {
|
|
||||||
const colors = getLogTypeColors(type as LogEntryType);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={type}
|
|
||||||
className={cn(
|
|
||||||
"text-xs px-2 py-0.5 rounded-full",
|
|
||||||
colors.badge
|
|
||||||
)}
|
|
||||||
data-testid={`log-type-count-${type}`}
|
|
||||||
>
|
|
||||||
{type}: {count}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={expandAll}
|
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
|
||||||
data-testid="log-expand-all"
|
|
||||||
>
|
|
||||||
Expand All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={collapseAll}
|
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
|
|
||||||
data-testid="log-collapse-all"
|
|
||||||
>
|
|
||||||
Collapse All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Log entries */}
|
|
||||||
<div className="space-y-2" data-testid="log-entries-container">
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<LogEntryItem
|
|
||||||
key={entry.id}
|
|
||||||
entry={entry}
|
|
||||||
isExpanded={expandedIds.has(entry.id)}
|
|
||||||
onToggle={() => toggleEntry(entry.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Popover({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
|
||||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverContent({
|
|
||||||
className,
|
|
||||||
align = "center",
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<PopoverPrimitive.Portal>
|
|
||||||
<PopoverPrimitive.Content
|
|
||||||
data-slot="popover-content"
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</PopoverPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PopoverAnchor({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
|
||||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Overlay
|
|
||||||
data-slot="sheet-overlay"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
side = "right",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
data-slot="sheet-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
|
||||||
side === "right" &&
|
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
|
||||||
side === "left" &&
|
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
|
||||||
side === "top" &&
|
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
|
||||||
side === "bottom" &&
|
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="sheet-header"
|
|
||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="sheet-footer"
|
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Title
|
|
||||||
data-slot="sheet-title"
|
|
||||||
className={cn("text-foreground font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Description
|
|
||||||
data-slot="sheet-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sheet,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetFooter,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
|
||||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SliderPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full touch-none select-none items-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Track className="slider-track relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted cursor-pointer">
|
|
||||||
<SliderPrimitive.Range className="slider-range absolute h-full bg-primary" />
|
|
||||||
</SliderPrimitive.Track>
|
|
||||||
<SliderPrimitive.Thumb className="slider-thumb block h-4 w-4 rounded-full border border-border bg-card shadow transition-colors cursor-grab active:cursor-grabbing focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent" />
|
|
||||||
</SliderPrimitive.Root>
|
|
||||||
));
|
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
|
||||||
|
|
||||||
export { Slider };
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Tabs({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Root
|
|
||||||
data-slot="tabs"
|
|
||||||
className={cn("flex flex-col gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
data-slot="tabs-list"
|
|
||||||
className={cn(
|
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px] border border-border",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
data-slot="tabs-trigger"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200 cursor-pointer",
|
|
||||||
"text-foreground/70 hover:text-foreground hover:bg-accent",
|
|
||||||
"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-md data-[state=active]:border-primary/50",
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1",
|
|
||||||
"disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed",
|
|
||||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
data-slot="tabs-content"
|
|
||||||
className={cn("flex-1 outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
data-slot="textarea"
|
|
||||||
className={cn(
|
|
||||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input min-h-[80px] w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm resize-none",
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const TooltipProvider = TooltipPrimitive.Provider
|
|
||||||
|
|
||||||
const Tooltip = TooltipPrimitive.Root
|
|
||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
))
|
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useCallback, useMemo } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface XmlSyntaxEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
"data-testid"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tokenize XML content into parts for highlighting
|
|
||||||
interface Token {
|
|
||||||
type:
|
|
||||||
| "tag-bracket"
|
|
||||||
| "tag-name"
|
|
||||||
| "attribute-name"
|
|
||||||
| "attribute-equals"
|
|
||||||
| "attribute-value"
|
|
||||||
| "text"
|
|
||||||
| "comment"
|
|
||||||
| "cdata"
|
|
||||||
| "doctype";
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeXml(text: string): Token[] {
|
|
||||||
const tokens: Token[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < text.length) {
|
|
||||||
// Comment: <!-- ... -->
|
|
||||||
if (text.slice(i, i + 4) === "<!--") {
|
|
||||||
const end = text.indexOf("-->", i + 4);
|
|
||||||
if (end !== -1) {
|
|
||||||
tokens.push({ type: "comment", value: text.slice(i, end + 3) });
|
|
||||||
i = end + 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CDATA: <![CDATA[ ... ]]>
|
|
||||||
if (text.slice(i, i + 9) === "<![CDATA[") {
|
|
||||||
const end = text.indexOf("]]>", i + 9);
|
|
||||||
if (end !== -1) {
|
|
||||||
tokens.push({ type: "cdata", value: text.slice(i, end + 3) });
|
|
||||||
i = end + 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DOCTYPE: <!DOCTYPE ... >
|
|
||||||
if (text.slice(i, i + 9).toUpperCase() === "<!DOCTYPE") {
|
|
||||||
const end = text.indexOf(">", i + 9);
|
|
||||||
if (end !== -1) {
|
|
||||||
tokens.push({ type: "doctype", value: text.slice(i, end + 1) });
|
|
||||||
i = end + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag: < ... >
|
|
||||||
if (text[i] === "<") {
|
|
||||||
// Find the end of the tag
|
|
||||||
let tagEnd = i + 1;
|
|
||||||
let inString: string | null = null;
|
|
||||||
|
|
||||||
while (tagEnd < text.length) {
|
|
||||||
const char = text[tagEnd];
|
|
||||||
|
|
||||||
if (inString) {
|
|
||||||
if (char === inString && text[tagEnd - 1] !== "\\") {
|
|
||||||
inString = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (char === '"' || char === "'") {
|
|
||||||
inString = char;
|
|
||||||
} else if (char === ">") {
|
|
||||||
tagEnd++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tagEnd++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagContent = text.slice(i, tagEnd);
|
|
||||||
const tagTokens = tokenizeTag(tagContent);
|
|
||||||
tokens.push(...tagTokens);
|
|
||||||
i = tagEnd;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text content between tags
|
|
||||||
const nextTag = text.indexOf("<", i);
|
|
||||||
if (nextTag === -1) {
|
|
||||||
tokens.push({ type: "text", value: text.slice(i) });
|
|
||||||
break;
|
|
||||||
} else if (nextTag > i) {
|
|
||||||
tokens.push({ type: "text", value: text.slice(i, nextTag) });
|
|
||||||
i = nextTag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeTag(tag: string): Token[] {
|
|
||||||
const tokens: Token[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
// Opening bracket (< or </ or <?)
|
|
||||||
if (tag.startsWith("</")) {
|
|
||||||
tokens.push({ type: "tag-bracket", value: "</" });
|
|
||||||
i = 2;
|
|
||||||
} else if (tag.startsWith("<?")) {
|
|
||||||
tokens.push({ type: "tag-bracket", value: "<?" });
|
|
||||||
i = 2;
|
|
||||||
} else {
|
|
||||||
tokens.push({ type: "tag-bracket", value: "<" });
|
|
||||||
i = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip whitespace
|
|
||||||
while (i < tag.length && /\s/.test(tag[i])) {
|
|
||||||
tokens.push({ type: "text", value: tag[i] });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag name
|
|
||||||
let tagName = "";
|
|
||||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
|
||||||
tagName += tag[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (tagName) {
|
|
||||||
tokens.push({ type: "tag-name", value: tagName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attributes and closing
|
|
||||||
while (i < tag.length) {
|
|
||||||
// Skip whitespace
|
|
||||||
if (/\s/.test(tag[i])) {
|
|
||||||
let ws = "";
|
|
||||||
while (i < tag.length && /\s/.test(tag[i])) {
|
|
||||||
ws += tag[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
tokens.push({ type: "text", value: ws });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Closing bracket
|
|
||||||
if (tag[i] === ">" || tag.slice(i, i + 2) === "/>" || tag.slice(i, i + 2) === "?>") {
|
|
||||||
tokens.push({ type: "tag-bracket", value: tag.slice(i) });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attribute name
|
|
||||||
let attrName = "";
|
|
||||||
while (i < tag.length && /[a-zA-Z0-9_:-]/.test(tag[i])) {
|
|
||||||
attrName += tag[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (attrName) {
|
|
||||||
tokens.push({ type: "attribute-name", value: attrName });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip whitespace around =
|
|
||||||
while (i < tag.length && /\s/.test(tag[i])) {
|
|
||||||
tokens.push({ type: "text", value: tag[i] });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Equals sign
|
|
||||||
if (tag[i] === "=") {
|
|
||||||
tokens.push({ type: "attribute-equals", value: "=" });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip whitespace after =
|
|
||||||
while (i < tag.length && /\s/.test(tag[i])) {
|
|
||||||
tokens.push({ type: "text", value: tag[i] });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attribute value
|
|
||||||
if (tag[i] === '"' || tag[i] === "'") {
|
|
||||||
const quote = tag[i];
|
|
||||||
let value = quote;
|
|
||||||
i++;
|
|
||||||
while (i < tag.length && tag[i] !== quote) {
|
|
||||||
value += tag[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (i < tag.length) {
|
|
||||||
value += tag[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
tokens.push({ type: "attribute-value", value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XmlSyntaxEditor({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
className,
|
|
||||||
"data-testid": testId,
|
|
||||||
}: XmlSyntaxEditorProps) {
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const highlightRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Sync scroll between textarea and highlight layer
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (textareaRef.current && highlightRef.current) {
|
|
||||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
|
||||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle tab key for indentation
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
const textarea = e.currentTarget;
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const newValue =
|
|
||||||
value.substring(0, start) + " " + value.substring(end);
|
|
||||||
onChange(newValue);
|
|
||||||
// Reset cursor position after state update
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[value, onChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize the highlighted content
|
|
||||||
const highlightedContent = useMemo(() => {
|
|
||||||
const tokens = tokenizeXml(value);
|
|
||||||
|
|
||||||
return tokens.map((token, index) => {
|
|
||||||
const className = `xml-${token.type}`;
|
|
||||||
// React handles escaping automatically, just render the raw value
|
|
||||||
return (
|
|
||||||
<span key={index} className={className}>
|
|
||||||
{token.value}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("relative w-full h-full xml-editor", className)}>
|
|
||||||
{/* Syntax highlighted layer (read-only, behind textarea) */}
|
|
||||||
<div
|
|
||||||
ref={highlightRef}
|
|
||||||
className="absolute inset-0 overflow-auto pointer-events-none font-mono text-sm p-4 whitespace-pre-wrap break-words"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{value ? (
|
|
||||||
<code className="xml-highlight">{highlightedContent}</code>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground opacity-50">{placeholder}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actual textarea (transparent text, handles input) */}
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder=""
|
|
||||||
spellCheck={false}
|
|
||||||
className="absolute inset-0 w-full h-full font-mono text-sm p-4 bg-transparent resize-none focus:outline-none text-transparent caret-foreground selection:bg-primary/30"
|
|
||||||
data-testid={testId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,804 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ImageDropZone } from "@/components/ui/image-drop-zone";
|
|
||||||
import {
|
|
||||||
Bot,
|
|
||||||
Send,
|
|
||||||
User,
|
|
||||||
Loader2,
|
|
||||||
Sparkles,
|
|
||||||
Wrench,
|
|
||||||
Trash2,
|
|
||||||
PanelLeftClose,
|
|
||||||
PanelLeft,
|
|
||||||
Paperclip,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useElectronAgent } from "@/hooks/use-electron-agent";
|
|
||||||
import { SessionManager } from "@/components/session-manager";
|
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
|
||||||
import type { ImageAttachment } from "@/store/app-store";
|
|
||||||
import {
|
|
||||||
useKeyboardShortcuts,
|
|
||||||
useKeyboardShortcutsConfig,
|
|
||||||
KeyboardShortcut,
|
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
|
||||||
|
|
||||||
export function AgentView() {
|
|
||||||
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
|
||||||
const [showImageDropZone, setShowImageDropZone] = useState(false);
|
|
||||||
const [currentTool, setCurrentTool] = useState<string | null>(null);
|
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
|
||||||
const [showSessionManager, setShowSessionManager] = useState(true);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
|
|
||||||
// Track if initial session has been loaded
|
|
||||||
const initialSessionLoadedRef = useRef(false);
|
|
||||||
|
|
||||||
// Scroll management for auto-scroll
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
|
|
||||||
|
|
||||||
// Input ref for auto-focus
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Ref for quick create session function from SessionManager
|
|
||||||
const quickCreateSessionRef = useRef<(() => Promise<void>) | null>(null);
|
|
||||||
|
|
||||||
// Use the Electron agent hook (only if we have a session)
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
isProcessing,
|
|
||||||
isConnected,
|
|
||||||
sendMessage,
|
|
||||||
clearHistory,
|
|
||||||
error: agentError,
|
|
||||||
} = useElectronAgent({
|
|
||||||
sessionId: currentSessionId || "",
|
|
||||||
workingDirectory: currentProject?.path,
|
|
||||||
onToolUse: (toolName) => {
|
|
||||||
setCurrentTool(toolName);
|
|
||||||
setTimeout(() => setCurrentTool(null), 2000);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle session selection with persistence
|
|
||||||
const handleSelectSession = useCallback((sessionId: string | null) => {
|
|
||||||
setCurrentSessionId(sessionId);
|
|
||||||
// Persist the selection for this project
|
|
||||||
if (currentProject?.path) {
|
|
||||||
setLastSelectedSession(currentProject.path, sessionId);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path, setLastSelectedSession]);
|
|
||||||
|
|
||||||
// Restore last selected session when switching to Agent view or when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject?.path) {
|
|
||||||
// No project, reset
|
|
||||||
setCurrentSessionId(null);
|
|
||||||
initialSessionLoadedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only restore once per project
|
|
||||||
if (initialSessionLoadedRef.current) return;
|
|
||||||
initialSessionLoadedRef.current = true;
|
|
||||||
|
|
||||||
const lastSessionId = getLastSelectedSession(currentProject.path);
|
|
||||||
if (lastSessionId) {
|
|
||||||
console.log("[AgentView] Restoring last selected session:", lastSessionId);
|
|
||||||
setCurrentSessionId(lastSessionId);
|
|
||||||
}
|
|
||||||
}, [currentProject?.path, getLastSelectedSession]);
|
|
||||||
|
|
||||||
// Reset initialSessionLoadedRef when project changes
|
|
||||||
useEffect(() => {
|
|
||||||
initialSessionLoadedRef.current = false;
|
|
||||||
}, [currentProject?.path]);
|
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
|
||||||
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
|
|
||||||
|
|
||||||
const messageContent = input;
|
|
||||||
const messageImages = selectedImages;
|
|
||||||
|
|
||||||
setInput("");
|
|
||||||
setSelectedImages([]);
|
|
||||||
setShowImageDropZone(false);
|
|
||||||
|
|
||||||
await sendMessage(messageContent, messageImages);
|
|
||||||
}, [input, selectedImages, isProcessing, sendMessage]);
|
|
||||||
|
|
||||||
const handleImagesSelected = useCallback((images: ImageAttachment[]) => {
|
|
||||||
setSelectedImages(images);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleImageDropZone = useCallback(() => {
|
|
||||||
setShowImageDropZone(!showImageDropZone);
|
|
||||||
}, [showImageDropZone]);
|
|
||||||
|
|
||||||
// Helper function to convert file to base64
|
|
||||||
const fileToBase64 = useCallback((file: File): Promise<string> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === "string") {
|
|
||||||
resolve(reader.result);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Failed to read file as base64"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Process dropped files
|
|
||||||
const processDroppedFiles = useCallback(
|
|
||||||
async (files: FileList) => {
|
|
||||||
if (isProcessing) return;
|
|
||||||
|
|
||||||
const ACCEPTED_IMAGE_TYPES = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
];
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
||||||
const MAX_FILES = 5;
|
|
||||||
|
|
||||||
const newImages: ImageAttachment[] = [];
|
|
||||||
const 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 > MAX_FILE_SIZE) {
|
|
||||||
const maxSizeMB = MAX_FILE_SIZE / (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 >= MAX_FILES) {
|
|
||||||
errors.push(`Maximum ${MAX_FILES} 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
|
||||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isProcessing, selectedImages, fileToBase64]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Remove individual image
|
|
||||||
const removeImage = useCallback((imageId: string) => {
|
|
||||||
setSelectedImages((prev) => prev.filter((img) => img.id !== imageId));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Drag and drop handlers for the input area
|
|
||||||
const handleDragEnter = useCallback(
|
|
||||||
(e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isProcessing || !isConnected) return;
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[agent-view] Drag enter types:",
|
|
||||||
Array.from(e.dataTransfer.types)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if dragged items contain files
|
|
||||||
if (e.dataTransfer.types.includes("Files")) {
|
|
||||||
setIsDragOver(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isProcessing, isConnected]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Only set dragOver to false if we're leaving the input container
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const x = e.clientX;
|
|
||||||
const y = e.clientY;
|
|
||||||
|
|
||||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
|
||||||
setIsDragOver(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
async (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDragOver(false);
|
|
||||||
|
|
||||||
if (isProcessing || !isConnected) return;
|
|
||||||
|
|
||||||
console.log("[agent-view] Drop event:", {
|
|
||||||
filesCount: e.dataTransfer.files.length,
|
|
||||||
itemsCount: e.dataTransfer.items.length,
|
|
||||||
types: Array.from(e.dataTransfer.types),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we have files
|
|
||||||
const files = e.dataTransfer.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
console.log("[agent-view] Processing files from dataTransfer.files");
|
|
||||||
processDroppedFiles(files);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle file paths (from screenshots or other sources)
|
|
||||||
// This is common on macOS when dragging screenshots
|
|
||||||
const items = e.dataTransfer.items;
|
|
||||||
if (items && items.length > 0) {
|
|
||||||
console.log("[agent-view] Processing items");
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
console.log(`[agent-view] Item ${i}:`, {
|
|
||||||
kind: item.kind,
|
|
||||||
type: item.type,
|
|
||||||
});
|
|
||||||
if (item.kind === "file") {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) {
|
|
||||||
console.log("[agent-view] Got file from item:", {
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
dataTransfer.items.add(file);
|
|
||||||
processDroppedFiles(dataTransfer.files);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isProcessing, isConnected, processDroppedFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
|
||||||
async (e: React.ClipboardEvent) => {
|
|
||||||
// Check if clipboard contains files
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (items) {
|
|
||||||
const files: File[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i];
|
|
||||||
console.log("[agent-view] Paste item:", {
|
|
||||||
kind: item.kind,
|
|
||||||
type: item.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item.kind === "file") {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file && file.type.startsWith("image/")) {
|
|
||||||
e.preventDefault(); // Prevent default paste of file path
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length > 0) {
|
|
||||||
console.log(
|
|
||||||
"[agent-view] Processing pasted image files:",
|
|
||||||
files.length
|
|
||||||
);
|
|
||||||
const dataTransfer = new DataTransfer();
|
|
||||||
files.forEach((file) => dataTransfer.items.add(file));
|
|
||||||
await processDroppedFiles(dataTransfer.files);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[processDroppedFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearChat = async () => {
|
|
||||||
if (!confirm("Are you sure you want to clear this conversation?")) return;
|
|
||||||
await clearHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scroll position detection
|
|
||||||
const checkIfUserIsAtBottom = useCallback(() => {
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const threshold = 50; // 50px threshold for "near bottom"
|
|
||||||
const isAtBottom =
|
|
||||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
|
||||||
threshold;
|
|
||||||
|
|
||||||
setIsUserAtBottom(isAtBottom);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Scroll to bottom function
|
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
|
||||||
const container = messagesContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.scrollTo({
|
|
||||||
top: container.scrollHeight,
|
|
||||||
behavior: behavior,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle scroll events
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
checkIfUserIsAtBottom();
|
|
||||||
}, [checkIfUserIsAtBottom]);
|
|
||||||
|
|
||||||
// Auto-scroll effect when messages change
|
|
||||||
useEffect(() => {
|
|
||||||
// Only auto-scroll if user was already at bottom
|
|
||||||
if (isUserAtBottom && messages.length > 0) {
|
|
||||||
// Use a small delay to ensure DOM is updated
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollToBottom("smooth");
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [messages, isUserAtBottom, scrollToBottom]);
|
|
||||||
|
|
||||||
// Initial scroll to bottom when session changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSessionId && messages.length > 0) {
|
|
||||||
// Scroll immediately without animation when switching sessions
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollToBottom("auto");
|
|
||||||
setIsUserAtBottom(true);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}, [currentSessionId, scrollToBottom]);
|
|
||||||
|
|
||||||
// Auto-focus input when session is selected/changed
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentSessionId && inputRef.current) {
|
|
||||||
// Small delay to ensure UI has updated
|
|
||||||
setTimeout(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
}, [currentSessionId]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts for agent view
|
|
||||||
const agentShortcuts: KeyboardShortcut[] = useMemo(() => {
|
|
||||||
const shortcutsList: KeyboardShortcut[] = [];
|
|
||||||
|
|
||||||
// New session shortcut - only when in agent view with a project
|
|
||||||
if (currentProject) {
|
|
||||||
shortcutsList.push({
|
|
||||||
key: shortcuts.newSession,
|
|
||||||
action: () => {
|
|
||||||
if (quickCreateSessionRef.current) {
|
|
||||||
quickCreateSessionRef.current();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
description: "Create new session",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return shortcutsList;
|
|
||||||
}, [currentProject, shortcuts]);
|
|
||||||
|
|
||||||
// Register keyboard shortcuts
|
|
||||||
useKeyboardShortcuts(agentShortcuts);
|
|
||||||
|
|
||||||
if (!currentProject) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="agent-view-no-project"
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<Sparkles className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
|
||||||
<h2 className="text-xl font-semibold mb-2">No Project Selected</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Open or create a project to start working with the AI agent.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show welcome message if no messages yet
|
|
||||||
const displayMessages =
|
|
||||||
messages.length === 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: "welcome",
|
|
||||||
role: "assistant" as const,
|
|
||||||
content:
|
|
||||||
"Hello! I'm the Automaker Agent. I can help you build software autonomously. I can read and modify files in this project, run commands, and execute tests. What would you like to create today?",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: messages;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex overflow-hidden content-bg"
|
|
||||||
data-testid="agent-view"
|
|
||||||
>
|
|
||||||
{/* Session Manager Sidebar */}
|
|
||||||
{showSessionManager && currentProject && (
|
|
||||||
<div className="w-80 border-r flex-shrink-0">
|
|
||||||
<SessionManager
|
|
||||||
currentSessionId={currentSessionId}
|
|
||||||
onSelectSession={handleSelectSession}
|
|
||||||
projectPath={currentProject.path}
|
|
||||||
isCurrentSessionThinking={isProcessing}
|
|
||||||
onQuickCreateRef={quickCreateSessionRef}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat Area */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{/* 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">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowSessionManager(!showSessionManager)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
{showSessionManager ? (
|
|
||||||
<PanelLeftClose className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<PanelLeft className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Bot className="w-5 h-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">AI Agent</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{currentProject.name}
|
|
||||||
{currentSessionId && !isConnected && " · Connecting..."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status indicators & actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{currentTool && (
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
|
|
||||||
<Wrench className="w-3 h-3" />
|
|
||||||
<span>{currentTool}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{agentError && (
|
|
||||||
<span className="text-xs text-destructive">{agentError}</span>
|
|
||||||
)}
|
|
||||||
{currentSessionId && messages.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearChat}
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{!currentSessionId ? (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="no-session-placeholder"
|
|
||||||
>
|
|
||||||
<div className="text-center">
|
|
||||||
<Bot className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
|
||||||
<h2 className="text-lg font-semibold mb-2">
|
|
||||||
No Session Selected
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Create or select a session to start chatting
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowSessionManager(true)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<PanelLeft className="w-4 h-4 mr-2" />
|
|
||||||
{showSessionManager ? "View" : "Show"} Sessions
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
ref={messagesContainerRef}
|
|
||||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
|
||||||
data-testid="message-list"
|
|
||||||
onScroll={handleScroll}
|
|
||||||
>
|
|
||||||
{displayMessages.map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className={cn(
|
|
||||||
"flex gap-3",
|
|
||||||
message.role === "user" && "flex-row-reverse"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-8 h-8 rounded-full flex items-center justify-center shrink-0",
|
|
||||||
message.role === "assistant" ? "bg-primary/10" : "bg-muted"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<Bot className="w-4 h-4 text-primary" />
|
|
||||||
) : (
|
|
||||||
<User className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"max-w-[80%] py-0",
|
|
||||||
message.role === "user"
|
|
||||||
? "bg-transparent border border-primary text-foreground"
|
|
||||||
: "border-l-4 border-primary bg-card"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="px-3 py-2">
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<Markdown className="text-sm text-primary prose-headings:text-primary prose-strong:text-primary prose-code:text-primary">
|
|
||||||
{message.content}
|
|
||||||
</Markdown>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm whitespace-pre-wrap">
|
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs mt-1",
|
|
||||||
message.role === "user"
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-primary/70"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{new Date(message.timestamp).toLocaleTimeString()}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{isProcessing && (
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
|
||||||
<Bot className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<Card className="border-l-4 border-primary bg-card py-0">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
|
||||||
<span className="text-sm text-primary">
|
|
||||||
Thinking...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input */}
|
|
||||||
{currentSessionId && (
|
|
||||||
<div className="border-t border-border p-4 space-y-3 bg-background">
|
|
||||||
{/* Image Drop Zone (when visible) */}
|
|
||||||
{showImageDropZone && (
|
|
||||||
<ImageDropZone
|
|
||||||
onImagesSelected={handleImagesSelected}
|
|
||||||
images={selectedImages}
|
|
||||||
maxFiles={5}
|
|
||||||
className="mb-3"
|
|
||||||
disabled={isProcessing || !isConnected}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Text Input and Controls - with drag and drop support */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex gap-2 transition-all duration-200 rounded-lg",
|
|
||||||
isDragOver &&
|
|
||||||
"bg-primary/10 ring-2 ring-primary ring-offset-2 ring-offset-background"
|
|
||||||
)}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={
|
|
||||||
isDragOver
|
|
||||||
? "Drop your images here..."
|
|
||||||
: "Describe what you want to build..."
|
|
||||||
}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyPress={handleKeyPress}
|
|
||||||
onPaste={handlePaste}
|
|
||||||
disabled={isProcessing || !isConnected}
|
|
||||||
data-testid="agent-input"
|
|
||||||
className={cn(
|
|
||||||
"bg-input border-border",
|
|
||||||
selectedImages.length > 0 &&
|
|
||||||
"border-primary/50 bg-primary/5",
|
|
||||||
isDragOver &&
|
|
||||||
"border-primary bg-primary/10"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{selectedImages.length > 0 && !isDragOver && (
|
|
||||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded">
|
|
||||||
{selectedImages.length} image
|
|
||||||
{selectedImages.length > 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isDragOver && (
|
|
||||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 text-xs text-primary-foreground bg-primary px-2 py-1 rounded flex items-center gap-1">
|
|
||||||
<Paperclip className="w-3 h-3" />
|
|
||||||
Drop here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Attachment Button */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
onClick={toggleImageDropZone}
|
|
||||||
disabled={isProcessing || !isConnected}
|
|
||||||
className={cn(
|
|
||||||
showImageDropZone &&
|
|
||||||
"bg-primary/20 text-primary border-primary",
|
|
||||||
selectedImages.length > 0 && "border-primary"
|
|
||||||
)}
|
|
||||||
title="Attach images"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Send Button */}
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={
|
|
||||||
(!input.trim() && selectedImages.length === 0) ||
|
|
||||||
isProcessing ||
|
|
||||||
!isConnected
|
|
||||||
}
|
|
||||||
data-testid="send-message"
|
|
||||||
>
|
|
||||||
<Send className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected Images Preview */}
|
|
||||||
{selectedImages.length > 0 && (
|
|
||||||
<div className="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" : ""} attached
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedImages([])}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Clear all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedImages.map((image) => (
|
|
||||||
<div
|
|
||||||
key={image.id}
|
|
||||||
className="relative group rounded-md border border-muted bg-muted/50 p-2 flex items-center space-x-2"
|
|
||||||
>
|
|
||||||
{/* Image thumbnail */}
|
|
||||||
<div className="w-8 h-8 rounded overflow-hidden bg-muted flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={image.data}
|
|
||||||
alt={image.filename}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Image info */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-xs font-medium text-foreground truncate">
|
|
||||||
{image.filename}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatFileSize(image.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* Remove button */}
|
|
||||||
<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"
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format file size
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return "0 B";
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ["B", "KB", "MB", "GB"];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,734 +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,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
useKeyboardShortcuts,
|
|
||||||
useKeyboardShortcutsConfig,
|
|
||||||
KeyboardShortcut,
|
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Markdown } from "../ui/markdown";
|
|
||||||
|
|
||||||
interface ContextFile {
|
|
||||||
name: string;
|
|
||||||
type: "text" | "image";
|
|
||||||
content?: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContextView() {
|
|
||||||
const { currentProject } = useAppStore();
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
|
||||||
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
|
||||||
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
const [editedContent, setEditedContent] = useState("");
|
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [newFileName, setNewFileName] = useState("");
|
|
||||||
const [newFileType, setNewFileType] = useState<"text" | "image">("text");
|
|
||||||
const [uploadedImageData, setUploadedImageData] = useState<string | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [newFileContent, setNewFileContent] = useState("");
|
|
||||||
const [isDropHovering, setIsDropHovering] = useState(false);
|
|
||||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
|
||||||
|
|
||||||
// Keyboard shortcuts for this view
|
|
||||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: shortcuts.addContextFile,
|
|
||||||
action: () => setIsAddDialogOpen(true),
|
|
||||||
description: "Add new context file",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[shortcuts]
|
|
||||||
);
|
|
||||||
useKeyboardShortcuts(contextShortcuts);
|
|
||||||
|
|
||||||
// Get context directory path for user-added context files
|
|
||||||
const getContextPath = useCallback(() => {
|
|
||||||
if (!currentProject) return null;
|
|
||||||
return `${currentProject.path}/.automaker/context`;
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
const isMarkdownFile = (filename: string): boolean => {
|
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
|
||||||
return ext === ".md" || ext === ".markdown";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine if a file is an image based on extension
|
|
||||||
const isImageFile = (filename: string): boolean => {
|
|
||||||
const imageExtensions = [
|
|
||||||
".png",
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".gif",
|
|
||||||
".webp",
|
|
||||||
".svg",
|
|
||||||
".bmp",
|
|
||||||
];
|
|
||||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf("."));
|
|
||||||
return imageExtensions.includes(ext);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load context files
|
|
||||||
const loadContextFiles = useCallback(async () => {
|
|
||||||
const contextPath = getContextPath();
|
|
||||||
if (!contextPath) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
|
|
||||||
// Ensure context directory exists
|
|
||||||
await api.mkdir(contextPath);
|
|
||||||
|
|
||||||
// Read directory contents
|
|
||||||
const result = await api.readdir(contextPath);
|
|
||||||
if (result.success && result.entries) {
|
|
||||||
const files: ContextFile[] = result.entries
|
|
||||||
.filter((entry) => entry.isFile)
|
|
||||||
.map((entry) => ({
|
|
||||||
name: entry.name,
|
|
||||||
type: isImageFile(entry.name) ? "image" : "text",
|
|
||||||
path: `${contextPath}/${entry.name}`,
|
|
||||||
}));
|
|
||||||
setContextFiles(files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load context files:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [getContextPath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadContextFiles();
|
|
||||||
}, [loadContextFiles]);
|
|
||||||
|
|
||||||
// Load selected file content
|
|
||||||
const loadFileContent = useCallback(async (file: ContextFile) => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.readFile(file.path);
|
|
||||||
if (result.success && result.content !== undefined) {
|
|
||||||
setEditedContent(result.content);
|
|
||||||
setSelectedFile({ ...file, content: result.content });
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load file content:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Select a file
|
|
||||||
const handleSelectFile = (file: ContextFile) => {
|
|
||||||
if (hasChanges) {
|
|
||||||
// Could add a confirmation dialog here
|
|
||||||
}
|
|
||||||
loadFileContent(file);
|
|
||||||
setIsPreviewMode(isMarkdownFile(file.name));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save current file
|
|
||||||
const saveFile = async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
await api.writeFile(selectedFile.path, editedContent);
|
|
||||||
setSelectedFile({ ...selectedFile, content: editedContent });
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save file:", error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle content change
|
|
||||||
const handleContentChange = (value: string) => {
|
|
||||||
setEditedContent(value);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add new context file
|
|
||||||
const handleAddFile = async () => {
|
|
||||||
const contextPath = getContextPath();
|
|
||||||
if (!contextPath || !newFileName.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
let filename = newFileName.trim();
|
|
||||||
|
|
||||||
// Add default extension if not provided
|
|
||||||
if (newFileType === "text" && !filename.includes(".")) {
|
|
||||||
filename += ".md";
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = `${contextPath}/${filename}`;
|
|
||||||
|
|
||||||
if (newFileType === "image" && uploadedImageData) {
|
|
||||||
// Write image data
|
|
||||||
await api.writeFile(filePath, uploadedImageData);
|
|
||||||
} else {
|
|
||||||
// Write text file with content (or empty if no content)
|
|
||||||
await api.writeFile(filePath, newFileContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsAddDialogOpen(false);
|
|
||||||
setNewFileName("");
|
|
||||||
setNewFileType("text");
|
|
||||||
setUploadedImageData(null);
|
|
||||||
setNewFileContent("");
|
|
||||||
setIsDropHovering(false);
|
|
||||||
await loadContextFiles();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to add file:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete selected file
|
|
||||||
const handleDeleteFile = async () => {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
await api.deleteFile(selectedFile.path);
|
|
||||||
|
|
||||||
setIsDeleteDialogOpen(false);
|
|
||||||
setSelectedFile(null);
|
|
||||||
setEditedContent("");
|
|
||||||
setHasChanges(false);
|
|
||||||
await loadContextFiles();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete file:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle image upload
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const base64 = event.target?.result as string;
|
|
||||||
setUploadedImageData(base64);
|
|
||||||
if (!newFileName) {
|
|
||||||
setNewFileName(file.name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle drag and drop for file upload
|
|
||||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
const contextPath = getContextPath();
|
|
||||||
if (!contextPath) return;
|
|
||||||
|
|
||||||
const api = getElectronAPI();
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
const content = event.target?.result as string;
|
|
||||||
const filePath = `${contextPath}/${file.name}`;
|
|
||||||
await api.writeFile(filePath, content);
|
|
||||||
await loadContextFiles();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isImageFile(file.name)) {
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
} else {
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle drag and drop for .txt and .md files in the add context dialog textarea
|
|
||||||
const handleTextAreaDrop = async (
|
|
||||||
e: React.DragEvent<HTMLTextAreaElement>
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropHovering(false);
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
const file = files[0]; // Only handle the first file
|
|
||||||
const fileName = file.name.toLowerCase();
|
|
||||||
|
|
||||||
// Only accept .txt and .md files
|
|
||||||
if (!fileName.endsWith(".txt") && !fileName.endsWith(".md")) {
|
|
||||||
console.warn("Only .txt and .md files are supported for drag and drop");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const content = event.target?.result as string;
|
|
||||||
setNewFileContent(content);
|
|
||||||
|
|
||||||
// Auto-fill filename if empty
|
|
||||||
if (!newFileName) {
|
|
||||||
setNewFileName(file.name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTextAreaDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropHovering(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTextAreaDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropHovering(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentProject) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="context-view-no-project"
|
|
||||||
>
|
|
||||||
<p className="text-muted-foreground">No project selected</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="context-view-loading"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
||||||
data-testid="context-view"
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<BookOpen className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">Context Files</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Add context files to include in AI prompts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<HotkeyButton
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsAddDialogOpen(true)}
|
|
||||||
hotkey={shortcuts.addContextFile}
|
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="add-context-file"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Add File
|
|
||||||
</HotkeyButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content area with file list and editor */}
|
|
||||||
<div
|
|
||||||
className="flex-1 flex overflow-hidden"
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
>
|
|
||||||
{/* Left Panel - File List */}
|
|
||||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
|
||||||
<div className="p-3 border-b border-border">
|
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
|
||||||
Context Files ({contextFiles.length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex-1 overflow-y-auto p-2"
|
|
||||||
data-testid="context-file-list"
|
|
||||||
>
|
|
||||||
{contextFiles.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
|
||||||
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No context files yet.
|
|
||||||
<br />
|
|
||||||
Drop files here or click Add File.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{contextFiles.map((file) => (
|
|
||||||
<button
|
|
||||||
key={file.path}
|
|
||||||
onClick={() => handleSelectFile(file)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors",
|
|
||||||
selectedFile?.path === file.path
|
|
||||||
? "bg-primary/20 text-foreground border border-primary/30"
|
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
)}
|
|
||||||
data-testid={`context-file-${file.name}`}
|
|
||||||
>
|
|
||||||
{file.type === "image" ? (
|
|
||||||
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<FileText className="w-4 h-4 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate text-sm">{file.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Panel - Editor/Preview */}
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
|
||||||
{selectedFile ? (
|
|
||||||
<>
|
|
||||||
{/* File toolbar */}
|
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectedFile.type === "image" ? (
|
|
||||||
<ImageIcon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<FileText className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{selectedFile.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{selectedFile.type === "text" &&
|
|
||||||
isMarkdownFile(selectedFile.name) && (
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
|
||||||
data-testid="toggle-preview-mode"
|
|
||||||
>
|
|
||||||
{isPreviewMode ? (
|
|
||||||
<>
|
|
||||||
<EditIcon className="w-4 h-4 mr-2" />
|
|
||||||
Edit
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Eye className="w-4 h-4 mr-2" />
|
|
||||||
Preview
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{selectedFile.type === "text" && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={saveFile}
|
|
||||||
disabled={!hasChanges || isSaving}
|
|
||||||
data-testid="save-context-file"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{isSaving ? "Saving..." : hasChanges ? "Save" : "Saved"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsDeleteDialogOpen(true)}
|
|
||||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
|
||||||
data-testid="delete-context-file"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<div className="flex-1 overflow-hidden p-4">
|
|
||||||
{selectedFile.type === "image" ? (
|
|
||||||
<div
|
|
||||||
className="h-full flex items-center justify-center bg-card rounded-lg"
|
|
||||||
data-testid="image-preview"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={editedContent}
|
|
||||||
alt={selectedFile.name}
|
|
||||||
className="max-w-full max-h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : isPreviewMode ? (
|
|
||||||
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
|
|
||||||
<Markdown>{editedContent}</Markdown>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card className="h-full overflow-hidden">
|
|
||||||
<textarea
|
|
||||||
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
|
|
||||||
value={editedContent}
|
|
||||||
onChange={(e) => handleContentChange(e.target.value)}
|
|
||||||
placeholder="Enter context content here..."
|
|
||||||
spellCheck={false}
|
|
||||||
data-testid="context-editor"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
|
||||||
<p className="text-foreground-secondary">
|
|
||||||
Select a file to view or edit
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
|
||||||
Or drop files here to add them
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add File Dialog */}
|
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
|
||||||
<DialogContent
|
|
||||||
data-testid="add-context-dialog"
|
|
||||||
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add Context File</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Add a new text or image file to the context.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant={newFileType === "text" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setNewFileType("text")}
|
|
||||||
data-testid="add-text-type"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
Text
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={newFileType === "image" ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setNewFileType("image")}
|
|
||||||
data-testid="add-image-type"
|
|
||||||
>
|
|
||||||
<ImageIcon className="w-4 h-4 mr-2" />
|
|
||||||
Image
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="filename">File Name</Label>
|
|
||||||
<Input
|
|
||||||
id="filename"
|
|
||||||
value={newFileName}
|
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
newFileType === "text" ? "context.md" : "image.png"
|
|
||||||
}
|
|
||||||
data-testid="new-file-name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{newFileType === "text" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="context-content">Context Content</Label>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative rounded-lg transition-colors",
|
|
||||||
isDropHovering && "ring-2 ring-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
id="context-content"
|
|
||||||
value={newFileContent}
|
|
||||||
onChange={(e) => setNewFileContent(e.target.value)}
|
|
||||||
onDrop={handleTextAreaDrop}
|
|
||||||
onDragOver={handleTextAreaDragOver}
|
|
||||||
onDragLeave={handleTextAreaDragLeave}
|
|
||||||
placeholder="Enter context content here or drag & drop a .txt or .md file..."
|
|
||||||
className={cn(
|
|
||||||
"w-full h-40 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent",
|
|
||||||
isDropHovering && "border-primary bg-primary/10"
|
|
||||||
)}
|
|
||||||
spellCheck={false}
|
|
||||||
data-testid="new-file-content"
|
|
||||||
/>
|
|
||||||
{isDropHovering && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/20 rounded-lg pointer-events-none">
|
|
||||||
<div className="flex flex-col items-center text-primary">
|
|
||||||
<Upload className="w-8 h-8 mb-2" />
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Drop .txt or .md file here
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Drag & drop .txt or .md files to import their content
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{newFileType === "image" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Upload Image</Label>
|
|
||||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
className="hidden"
|
|
||||||
id="image-upload"
|
|
||||||
data-testid="image-upload-input"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="image-upload"
|
|
||||||
className="cursor-pointer flex flex-col items-center"
|
|
||||||
>
|
|
||||||
{uploadedImageData ? (
|
|
||||||
<img
|
|
||||||
src={uploadedImageData}
|
|
||||||
alt="Preview"
|
|
||||||
className="max-w-32 max-h-32 object-contain mb-2"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{uploadedImageData
|
|
||||||
? "Click to change"
|
|
||||||
: "Click to upload"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddDialogOpen(false);
|
|
||||||
setNewFileName("");
|
|
||||||
setUploadedImageData(null);
|
|
||||||
setNewFileContent("");
|
|
||||||
setIsDropHovering(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleAddFile}
|
|
||||||
disabled={
|
|
||||||
!newFileName.trim() ||
|
|
||||||
(newFileType === "image" && !uploadedImageData)
|
|
||||||
}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={isAddDialogOpen}
|
|
||||||
data-testid="confirm-add-file"
|
|
||||||
>
|
|
||||||
Add File
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
||||||
<DialogContent data-testid="delete-context-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Context File</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete "{selectedFile?.name}"? This
|
|
||||||
action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsDeleteDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteFile}
|
|
||||||
className="bg-red-600 hover:bg-red-700"
|
|
||||||
data-testid="confirm-delete-file"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
color: string;
|
|
||||||
count: number;
|
|
||||||
children: ReactNode;
|
|
||||||
headerAction?: ReactNode;
|
|
||||||
opacity?: number; // Opacity percentage (0-100) - only affects background
|
|
||||||
showBorder?: boolean; // Whether to show column border
|
|
||||||
hideScrollbar?: boolean; // Whether to hide the column scrollbar
|
|
||||||
}
|
|
||||||
|
|
||||||
export const KanbanColumn = memo(function KanbanColumn({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
color,
|
|
||||||
count,
|
|
||||||
children,
|
|
||||||
headerAction,
|
|
||||||
opacity = 100,
|
|
||||||
showBorder = true,
|
|
||||||
hideScrollbar = false,
|
|
||||||
}: KanbanColumnProps) {
|
|
||||||
const { setNodeRef, isOver } = useDroppable({ id });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col h-full rounded-lg transition-colors w-72",
|
|
||||||
showBorder && "border border-border"
|
|
||||||
)}
|
|
||||||
data-testid={`kanban-column-${id}`}
|
|
||||||
>
|
|
||||||
{/* Background layer with opacity - only this layer is affected by opacity */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 rounded-lg backdrop-blur-sm transition-colors",
|
|
||||||
isOver ? "bg-accent" : "bg-card"
|
|
||||||
)}
|
|
||||||
style={{ opacity: opacity / 100 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Column Header - positioned above the background */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex items-center gap-2 p-3",
|
|
||||||
showBorder && "border-b border-border"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={cn("w-3 h-3 rounded-full", color)} />
|
|
||||||
<h3 className="font-medium text-sm flex-1">{title}</h3>
|
|
||||||
{headerAction}
|
|
||||||
<span className="text-xs text-muted-foreground bg-background px-2 py-0.5 rounded-full">
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column Content - positioned above the background */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex-1 overflow-y-auto p-2 space-y-2",
|
|
||||||
hideScrollbar &&
|
|
||||||
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
useAppStore,
|
|
||||||
AIProfile,
|
|
||||||
AgentModel,
|
|
||||||
ThinkingLevel,
|
|
||||||
ModelProvider,
|
|
||||||
} from "@/store/app-store";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { cn, modelSupportsThinking } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
useKeyboardShortcuts,
|
|
||||||
useKeyboardShortcutsConfig,
|
|
||||||
KeyboardShortcut,
|
|
||||||
} from "@/hooks/use-keyboard-shortcuts";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
UserCircle,
|
|
||||||
Plus,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
GripVertical,
|
|
||||||
Lock,
|
|
||||||
Check,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragEndEvent,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
closestCenter,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
|
|
||||||
// Icon mapping for profiles
|
|
||||||
const PROFILE_ICONS: Record<
|
|
||||||
string,
|
|
||||||
React.ComponentType<{ className?: string }>
|
|
||||||
> = {
|
|
||||||
Brain,
|
|
||||||
Zap,
|
|
||||||
Scale,
|
|
||||||
Cpu,
|
|
||||||
Rocket,
|
|
||||||
Sparkles,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Available icons for selection
|
|
||||||
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
|
|
||||||
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
|
|
||||||
{ id: "haiku", label: "Claude Haiku" },
|
|
||||||
{ id: "sonnet", label: "Claude Sonnet" },
|
|
||||||
{ id: "opus", label: "Claude Opus" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
|
|
||||||
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
|
||||||
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
|
||||||
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
|
|
||||||
{ id: "gpt-5.1", label: "GPT-5.1" },
|
|
||||||
];
|
|
||||||
|
|
||||||
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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Helper to determine provider from model
|
|
||||||
function getProviderFromModel(model: AgentModel): ModelProvider {
|
|
||||||
if (model.startsWith("gpt")) {
|
|
||||||
return "codex";
|
|
||||||
}
|
|
||||||
return "claude";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sortable Profile Card Component
|
|
||||||
function SortableProfileCard({
|
|
||||||
profile,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
profile: AIProfile;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: profile.id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
|
|
||||||
const isCodex = profile.provider === "codex";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
|
|
||||||
isDragging && "shadow-lg",
|
|
||||||
profile.isBuiltIn
|
|
||||||
? "border-border/50"
|
|
||||||
: "border-border hover:border-primary/50 hover:shadow-sm"
|
|
||||||
)}
|
|
||||||
data-testid={`profile-card-${profile.id}`}
|
|
||||||
>
|
|
||||||
{/* Drag Handle */}
|
|
||||||
<button
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
|
|
||||||
data-testid={`profile-drag-handle-${profile.id}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
|
||||||
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{IconComponent && (
|
|
||||||
<IconComponent
|
|
||||||
className={cn(
|
|
||||||
"w-5 h-5",
|
|
||||||
isCodex ? "text-emerald-500" : "text-primary"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-foreground">{profile.name}</h3>
|
|
||||||
{profile.isBuiltIn && (
|
|
||||||
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
||||||
<Lock className="w-2.5 h-2.5" />
|
|
||||||
Built-in
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
|
|
||||||
{profile.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-xs px-2 py-0.5 rounded-full border",
|
|
||||||
isCodex
|
|
||||||
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
|
||||||
: "border-primary/30 text-primary bg-primary/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{profile.model}
|
|
||||||
</span>
|
|
||||||
{profile.thinkingLevel !== "none" && (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
|
|
||||||
{profile.thinkingLevel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{!profile.isBuiltIn && (
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
data-testid={`edit-profile-${profile.id}`}
|
|
||||||
>
|
|
||||||
<Pencil className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onDelete}
|
|
||||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
||||||
data-testid={`delete-profile-${profile.id}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile Form Component
|
|
||||||
function ProfileForm({
|
|
||||||
profile,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
isEditing,
|
|
||||||
hotkeyActive,
|
|
||||||
}: {
|
|
||||||
profile: Partial<AIProfile>;
|
|
||||||
onSave: (profile: Omit<AIProfile, "id">) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
isEditing: boolean;
|
|
||||||
hotkeyActive: boolean;
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: profile.name || "",
|
|
||||||
description: profile.description || "",
|
|
||||||
model: profile.model || ("opus" as AgentModel),
|
|
||||||
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
|
|
||||||
icon: profile.icon || "Brain",
|
|
||||||
});
|
|
||||||
|
|
||||||
const provider = getProviderFromModel(formData.model);
|
|
||||||
const supportsThinking = modelSupportsThinking(formData.model);
|
|
||||||
|
|
||||||
const handleModelChange = (model: AgentModel) => {
|
|
||||||
const newProvider = getProviderFromModel(model);
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
model,
|
|
||||||
// Reset thinking level when switching to Codex (doesn't support thinking)
|
|
||||||
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
toast.error("Please enter a profile name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onSave({
|
|
||||||
name: formData.name.trim(),
|
|
||||||
description: formData.description.trim(),
|
|
||||||
model: formData.model,
|
|
||||||
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
|
|
||||||
provider,
|
|
||||||
isBuiltIn: false,
|
|
||||||
icon: formData.icon,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="profile-name">Profile Name</Label>
|
|
||||||
<Input
|
|
||||||
id="profile-name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="e.g., Heavy Task, Quick Fix"
|
|
||||||
data-testid="profile-name-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="profile-description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="profile-description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, description: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Describe when to use this profile..."
|
|
||||||
rows={2}
|
|
||||||
data-testid="profile-description-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Icon Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Icon</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({ ...formData, icon: name })}
|
|
||||||
className={cn(
|
|
||||||
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
|
|
||||||
formData.icon === name
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`icon-select-${name}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Selection - Claude */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-primary" />
|
|
||||||
Claude Models
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{CLAUDE_MODELS.map(({ id, label }) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleModelChange(id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
|
||||||
formData.model === id
|
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`model-select-${id}`}
|
|
||||||
>
|
|
||||||
{label.replace("Claude ", "")}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Selection - Codex */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Zap className="w-4 h-4 text-emerald-500" />
|
|
||||||
Codex Models
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{CODEX_MODELS.map(({ id, label }) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleModelChange(id)}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
|
||||||
formData.model === id
|
|
||||||
? "bg-emerald-600 text-white border-emerald-500"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`model-select-${id}`}
|
|
||||||
>
|
|
||||||
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thinking Level - Only for Claude models */}
|
|
||||||
{supportsThinking && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="flex items-center gap-2">
|
|
||||||
<Brain className="w-4 h-4 text-amber-500" />
|
|
||||||
Thinking Level
|
|
||||||
</Label>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{THINKING_LEVELS.map(({ id, label }) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFormData({ ...formData, thinkingLevel: id });
|
|
||||||
if (id === "ultrathink") {
|
|
||||||
toast.warning("Ultrathink uses extensive reasoning", {
|
|
||||||
description:
|
|
||||||
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
|
|
||||||
duration: 4000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
|
|
||||||
formData.thinkingLevel === id
|
|
||||||
? "bg-amber-500 text-white border-amber-400"
|
|
||||||
: "bg-background hover:bg-accent border-input"
|
|
||||||
)}
|
|
||||||
data-testid={`thinking-select-${id}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Higher levels give more time to reason through complex problems.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<DialogFooter className="pt-4">
|
|
||||||
<Button variant="ghost" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleSubmit}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={hotkeyActive}
|
|
||||||
data-testid="save-profile-button"
|
|
||||||
>
|
|
||||||
{isEditing ? "Save Changes" : "Create Profile"}
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfilesView() {
|
|
||||||
const {
|
|
||||||
aiProfiles,
|
|
||||||
addAIProfile,
|
|
||||||
updateAIProfile,
|
|
||||||
removeAIProfile,
|
|
||||||
reorderAIProfiles,
|
|
||||||
} = useAppStore();
|
|
||||||
const shortcuts = useKeyboardShortcutsConfig();
|
|
||||||
|
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
||||||
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
|
|
||||||
|
|
||||||
// Sensors for drag-and-drop
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 5,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Separate built-in and custom profiles
|
|
||||||
const builtInProfiles = useMemo(
|
|
||||||
() => aiProfiles.filter((p) => p.isBuiltIn),
|
|
||||||
[aiProfiles]
|
|
||||||
);
|
|
||||||
const customProfiles = useMemo(
|
|
||||||
() => aiProfiles.filter((p) => !p.isBuiltIn),
|
|
||||||
[aiProfiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
|
|
||||||
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
|
|
||||||
|
|
||||||
if (oldIndex !== -1 && newIndex !== -1) {
|
|
||||||
reorderAIProfiles(oldIndex, newIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[aiProfiles, reorderAIProfiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
|
|
||||||
addAIProfile(profile);
|
|
||||||
setShowAddDialog(false);
|
|
||||||
toast.success("Profile created", {
|
|
||||||
description: `Created "${profile.name}" profile`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
|
|
||||||
if (editingProfile) {
|
|
||||||
updateAIProfile(editingProfile.id, profile);
|
|
||||||
setEditingProfile(null);
|
|
||||||
toast.success("Profile updated", {
|
|
||||||
description: `Updated "${profile.name}" profile`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProfile = (profile: AIProfile) => {
|
|
||||||
if (profile.isBuiltIn) return;
|
|
||||||
|
|
||||||
removeAIProfile(profile.id);
|
|
||||||
toast.success("Profile deleted", {
|
|
||||||
description: `Deleted "${profile.name}" profile`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build keyboard shortcuts for profiles view
|
|
||||||
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
|
|
||||||
const shortcutsList: KeyboardShortcut[] = [];
|
|
||||||
|
|
||||||
// Add profile shortcut - when in profiles view
|
|
||||||
shortcutsList.push({
|
|
||||||
key: shortcuts.addProfile,
|
|
||||||
action: () => setShowAddDialog(true),
|
|
||||||
description: "Create new profile",
|
|
||||||
});
|
|
||||||
|
|
||||||
return shortcutsList;
|
|
||||||
}, [shortcuts]);
|
|
||||||
|
|
||||||
// Register keyboard shortcuts for profiles view
|
|
||||||
useKeyboardShortcuts(profilesShortcuts);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
||||||
data-testid="profiles-view"
|
|
||||||
>
|
|
||||||
{/* Header Section */}
|
|
||||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
|
||||||
<div className="px-8 py-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
|
||||||
<UserCircle className="w-5 h-5 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">
|
|
||||||
AI Profiles
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Create and manage model configuration presets
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={() => setShowAddDialog(true)}
|
|
||||||
hotkey={shortcuts.addProfile}
|
|
||||||
hotkeyActive={false}
|
|
||||||
data-testid="add-profile-button"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Profile
|
|
||||||
</HotkeyButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
|
||||||
{/* Custom Profiles Section */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Custom Profiles
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{customProfiles.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{customProfiles.length === 0 ? (
|
|
||||||
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
|
||||||
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
No custom profiles yet. Create one to get started!
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="mt-4"
|
|
||||||
onClick={() => setShowAddDialog(true)}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Profile
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={customProfiles.map((p) => p.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{customProfiles.map((profile) => (
|
|
||||||
<SortableProfileCard
|
|
||||||
key={profile.id}
|
|
||||||
profile={profile}
|
|
||||||
onEdit={() => setEditingProfile(profile)}
|
|
||||||
onDelete={() => handleDeleteProfile(profile)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Built-in Profiles Section */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Built-in Profiles
|
|
||||||
</h2>
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
||||||
{builtInProfiles.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Pre-configured profiles for common use cases. These cannot be
|
|
||||||
edited or deleted.
|
|
||||||
</p>
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={builtInProfiles.map((p) => p.id)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{builtInProfiles.map((profile) => (
|
|
||||||
<SortableProfileCard
|
|
||||||
key={profile.id}
|
|
||||||
profile={profile}
|
|
||||||
onEdit={() => {}}
|
|
||||||
onDelete={() => {}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Profile Dialog */}
|
|
||||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
|
||||||
<DialogContent data-testid="add-profile-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New Profile</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Define a reusable model configuration preset.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<ProfileForm
|
|
||||||
profile={{}}
|
|
||||||
onSave={handleAddProfile}
|
|
||||||
onCancel={() => setShowAddDialog(false)}
|
|
||||||
isEditing={false}
|
|
||||||
hotkeyActive={showAddDialog}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Edit Profile Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={!!editingProfile}
|
|
||||||
onOpenChange={() => setEditingProfile(null)}
|
|
||||||
>
|
|
||||||
<DialogContent data-testid="edit-profile-dialog">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Edit Profile</DialogTitle>
|
|
||||||
<DialogDescription>Modify your profile settings.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{editingProfile && (
|
|
||||||
<ProfileForm
|
|
||||||
profile={editingProfile}
|
|
||||||
onSave={handleUpdateProfile}
|
|
||||||
onCancel={() => setEditingProfile(null)}
|
|
||||||
isEditing={true}
|
|
||||||
hotkeyActive={!!editingProfile}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Key,
|
|
||||||
Palette,
|
|
||||||
Terminal,
|
|
||||||
Atom,
|
|
||||||
FlaskConical,
|
|
||||||
Trash2,
|
|
||||||
Settings2,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
|
|
||||||
import { useCliStatus } from "./settings-view/hooks/use-cli-status";
|
|
||||||
import { useScrollTracking } from "@/hooks/use-scroll-tracking";
|
|
||||||
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 { CodexCliStatus } from "./settings-view/cli-status/codex-cli-status";
|
|
||||||
import { AppearanceSection } from "./settings-view/appearance/appearance-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";
|
|
||||||
|
|
||||||
// Navigation items for the side panel
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
|
||||||
{ id: "claude", label: "Claude", icon: Terminal },
|
|
||||||
{ id: "codex", label: "Codex", icon: Atom },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
|
||||||
{ id: "audio", label: "Audio", icon: Volume2 },
|
|
||||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
|
||||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
|
||||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SettingsView() {
|
|
||||||
const {
|
|
||||||
theme,
|
|
||||||
setTheme,
|
|
||||||
setProjectTheme,
|
|
||||||
defaultSkipTests,
|
|
||||||
setDefaultSkipTests,
|
|
||||||
useWorktrees,
|
|
||||||
setUseWorktrees,
|
|
||||||
showProfilesOnly,
|
|
||||||
setShowProfilesOnly,
|
|
||||||
muteDoneSound,
|
|
||||||
setMuteDoneSound,
|
|
||||||
currentProject,
|
|
||||||
moveProjectToTrash,
|
|
||||||
} = 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,
|
|
||||||
codexCliStatus,
|
|
||||||
isCheckingClaudeCli,
|
|
||||||
isCheckingCodexCli,
|
|
||||||
handleRefreshClaudeCli,
|
|
||||||
handleRefreshCodexCli,
|
|
||||||
} = useCliStatus();
|
|
||||||
|
|
||||||
// Use scroll tracking hook
|
|
||||||
const { activeSection, scrollToSection, scrollContainerRef } =
|
|
||||||
useScrollTracking({
|
|
||||||
items: NAV_ITEMS,
|
|
||||||
filterFn: (item) => item.id !== "danger" || !!currentProject,
|
|
||||||
initialSection: "api-keys",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
|
|
||||||
|
|
||||||
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">
|
|
||||||
{/* Sticky Side Navigation */}
|
|
||||||
<SettingsNavigation
|
|
||||||
navItems={NAV_ITEMS}
|
|
||||||
activeSection={activeSection}
|
|
||||||
currentProject={currentProject}
|
|
||||||
onNavigate={scrollToSection}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-8">
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6 pb-96">
|
|
||||||
{/* API Keys Section */}
|
|
||||||
<ApiKeysSection />
|
|
||||||
|
|
||||||
{/* Claude CLI Status Section */}
|
|
||||||
{claudeCliStatus && (
|
|
||||||
<ClaudeCliStatus
|
|
||||||
status={claudeCliStatus}
|
|
||||||
isChecking={isCheckingClaudeCli}
|
|
||||||
onRefresh={handleRefreshClaudeCli}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Codex CLI Status Section */}
|
|
||||||
{codexCliStatus && (
|
|
||||||
<CodexCliStatus
|
|
||||||
status={codexCliStatus}
|
|
||||||
isChecking={isCheckingCodexCli}
|
|
||||||
onRefresh={handleRefreshCodexCli}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Appearance Section */}
|
|
||||||
<AppearanceSection
|
|
||||||
effectiveTheme={effectiveTheme}
|
|
||||||
currentProject={settingsProject}
|
|
||||||
onThemeChange={handleSetTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Section */}
|
|
||||||
<KeyboardShortcutsSection
|
|
||||||
onOpenKeyboardMap={() => setShowKeyboardMapDialog(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Audio Section */}
|
|
||||||
<div
|
|
||||||
id="audio"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Volume2 className="w-5 h-5 text-brand-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Audio
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure audio and notification settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{/* Mute Done Sound Setting */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id="mute-done-sound"
|
|
||||||
checked={muteDoneSound}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setMuteDoneSound(checked === true)
|
|
||||||
}
|
|
||||||
className="mt-0.5"
|
|
||||||
data-testid="mute-done-sound-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label
|
|
||||||
htmlFor="mute-done-sound"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<VolumeX className="w-4 h-4 text-brand-500" />
|
|
||||||
Mute notification sound when agents complete
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, disables the "ding" sound that
|
|
||||||
plays when an agent completes a feature. The feature
|
|
||||||
will still move to the completed column, but without
|
|
||||||
audio notification.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature Defaults Section */}
|
|
||||||
<FeatureDefaultsSection
|
|
||||||
showProfilesOnly={showProfilesOnly}
|
|
||||||
defaultSkipTests={defaultSkipTests}
|
|
||||||
useWorktrees={useWorktrees}
|
|
||||||
onShowProfilesOnlyChange={setShowProfilesOnly}
|
|
||||||
onDefaultSkipTestsChange={setDefaultSkipTests}
|
|
||||||
onUseWorktreesChange={setUseWorktrees}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Danger Zone Section - Only show when a project is selected */}
|
|
||||||
<DangerZoneSection
|
|
||||||
project={settingsProject}
|
|
||||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
|
||||||
/>
|
|
||||||
</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,72 +0,0 @@
|
|||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Key, CheckCircle2 } from "lucide-react";
|
|
||||||
import { ApiKeyField } from "./api-key-field";
|
|
||||||
import { buildProviderConfigs } from "@/config/api-providers";
|
|
||||||
import { AuthenticationStatusDisplay } from "./authentication-status-display";
|
|
||||||
import { SecurityNotice } from "./security-notice";
|
|
||||||
import { useApiKeyManagement } from "./hooks/use-api-key-management";
|
|
||||||
|
|
||||||
export function ApiKeysSection() {
|
|
||||||
const { apiKeys } = useAppStore();
|
|
||||||
const { claudeAuthStatus, codexAuthStatus } = useSetupStore();
|
|
||||||
|
|
||||||
const { providerConfigParams, apiKeyStatus, handleSave, saved } =
|
|
||||||
useApiKeyManagement();
|
|
||||||
|
|
||||||
const providerConfigs = buildProviderConfigs(providerConfigParams);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="api-keys"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Key className="w-5 h-5 text-brand-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">API Keys</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure your AI provider API keys. Keys are stored locally in your
|
|
||||||
browser.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{/* API Key Fields */}
|
|
||||||
{providerConfigs.map((provider) => (
|
|
||||||
<ApiKeyField key={provider.key} config={provider} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Authentication Status Display */}
|
|
||||||
<AuthenticationStatusDisplay
|
|
||||||
claudeAuthStatus={claudeAuthStatus}
|
|
||||||
codexAuthStatus={codexAuthStatus}
|
|
||||||
apiKeyStatus={apiKeyStatus}
|
|
||||||
apiKeys={apiKeys}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Security Notice */}
|
|
||||||
<SecurityNotice />
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex items-center gap-4 pt-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
data-testid="save-settings"
|
|
||||||
className="min-w-[120px] bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-600 text-primary-foreground border-0"
|
|
||||||
>
|
|
||||||
{saved ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
||||||
Saved!
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Save API Keys"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
Info,
|
|
||||||
Terminal,
|
|
||||||
Atom,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ClaudeAuthStatus, CodexAuthStatus } from "@/store/setup-store";
|
|
||||||
|
|
||||||
interface AuthenticationStatusDisplayProps {
|
|
||||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
|
||||||
codexAuthStatus: CodexAuthStatus | null;
|
|
||||||
apiKeyStatus: {
|
|
||||||
hasAnthropicKey: boolean;
|
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
|
||||||
} | null;
|
|
||||||
apiKeys: {
|
|
||||||
anthropic: string;
|
|
||||||
google: string;
|
|
||||||
openai: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthenticationStatusDisplay({
|
|
||||||
claudeAuthStatus,
|
|
||||||
codexAuthStatus,
|
|
||||||
apiKeyStatus,
|
|
||||||
apiKeys,
|
|
||||||
}: AuthenticationStatusDisplayProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 pt-4 border-t border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Info className="w-4 h-4 text-brand-500" />
|
|
||||||
<Label className="text-foreground font-semibold">
|
|
||||||
Current Authentication Configuration
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{/* Claude Authentication Status */}
|
|
||||||
<div className="p-3 rounded-lg bg-card border border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<Terminal className="w-4 h-4 text-brand-500" />
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
Claude (Anthropic)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 text-xs min-h-12">
|
|
||||||
{claudeAuthStatus?.authenticated ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
|
||||||
<span className="text-green-400 font-medium">Authenticated</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>
|
|
||||||
{claudeAuthStatus.method === "oauth_token_env"
|
|
||||||
? "Using CLAUDE_CODE_OAUTH_TOKEN"
|
|
||||||
: claudeAuthStatus.method === "oauth_token"
|
|
||||||
? "Using stored OAuth token (subscription)"
|
|
||||||
: claudeAuthStatus.method === "api_key_env"
|
|
||||||
? "Using ANTHROPIC_API_KEY"
|
|
||||||
: claudeAuthStatus.method === "api_key"
|
|
||||||
? "Using stored API key"
|
|
||||||
: claudeAuthStatus.method === "credentials_file"
|
|
||||||
? "Using credentials file"
|
|
||||||
: claudeAuthStatus.method === "cli_authenticated"
|
|
||||||
? "Using Claude CLI authentication"
|
|
||||||
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : apiKeyStatus?.hasAnthropicKey ? (
|
|
||||||
<div className="flex items-center gap-2 text-blue-400">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>Using environment variable (ANTHROPIC_API_KEY)</span>
|
|
||||||
</div>
|
|
||||||
) : apiKeys.anthropic ? (
|
|
||||||
<div className="flex items-center gap-2 text-blue-400">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>Using manual API key from settings</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
|
||||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
|
||||||
<span className="text-xs">Not configured</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Codex/OpenAI Authentication Status */}
|
|
||||||
<div className="p-3 rounded-lg bg-card border border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<Atom className="w-4 h-4 text-green-500" />
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
Codex (OpenAI)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 text-xs min-h-12">
|
|
||||||
{codexAuthStatus?.authenticated ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
|
||||||
<span className="text-green-400 font-medium">Authenticated</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>
|
|
||||||
{codexAuthStatus.method === "subscription"
|
|
||||||
? "Using Codex subscription (Plus/Team)"
|
|
||||||
: codexAuthStatus.method === "cli_verified" ||
|
|
||||||
codexAuthStatus.method === "cli_tokens"
|
|
||||||
? "Using CLI login (OpenAI account)"
|
|
||||||
: codexAuthStatus.method === "api_key"
|
|
||||||
? "Using stored API key"
|
|
||||||
: codexAuthStatus.method === "env"
|
|
||||||
? "Using OPENAI_API_KEY"
|
|
||||||
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : apiKeyStatus?.hasOpenAIKey ? (
|
|
||||||
<div className="flex items-center gap-2 text-blue-400">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>Using environment variable (OPENAI_API_KEY)</span>
|
|
||||||
</div>
|
|
||||||
) : apiKeys.openai ? (
|
|
||||||
<div className="flex items-center gap-2 text-blue-400">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>Using manual API key from settings</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
|
||||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
|
||||||
<span className="text-xs">Not configured</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Google/Gemini Authentication Status */}
|
|
||||||
<div className="p-3 rounded-lg bg-card border border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
|
||||||
<Sparkles className="w-4 h-4 text-purple-500" />
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
Gemini (Google)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 text-xs min-h-12">
|
|
||||||
{apiKeyStatus?.hasGoogleKey ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
|
||||||
<span className="text-green-400 font-medium">Authenticated</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>Using GOOGLE_API_KEY</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : apiKeys.google ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500 shrink-0" />
|
|
||||||
<span className="text-green-400 font-medium">Authenticated</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
|
||||||
<Info className="w-3 h-3 shrink-0" />
|
|
||||||
<span>Using stored API key</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 text-yellow-500 py-0.5">
|
|
||||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
|
||||||
<span className="text-xs">Not configured</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import type { ProviderConfigParams } from "@/config/api-providers";
|
|
||||||
|
|
||||||
interface TestResult {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiKeyStatus {
|
|
||||||
hasAnthropicKey: boolean;
|
|
||||||
hasOpenAIKey: boolean;
|
|
||||||
hasGoogleKey: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing API key state and operations
|
|
||||||
* Handles input values, visibility toggles, connection testing, and saving
|
|
||||||
*/
|
|
||||||
export function useApiKeyManagement() {
|
|
||||||
const { apiKeys, setApiKeys } = useAppStore();
|
|
||||||
|
|
||||||
// API key values
|
|
||||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
|
||||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
|
||||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
|
||||||
|
|
||||||
// Visibility toggles
|
|
||||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
|
||||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
|
||||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
|
||||||
|
|
||||||
// Test connection states
|
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
|
||||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
|
||||||
const [testingGeminiConnection, setTestingGeminiConnection] = useState(false);
|
|
||||||
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
|
||||||
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// API key status from environment
|
|
||||||
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
|
||||||
|
|
||||||
// Save state
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
|
|
||||||
// Sync local state with store
|
|
||||||
useEffect(() => {
|
|
||||||
setAnthropicKey(apiKeys.anthropic);
|
|
||||||
setGoogleKey(apiKeys.google);
|
|
||||||
setOpenaiKey(apiKeys.openai);
|
|
||||||
}, [apiKeys]);
|
|
||||||
|
|
||||||
// Check API key status from environment on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const checkApiKeyStatus = async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api?.setup?.getApiKeys) {
|
|
||||||
try {
|
|
||||||
const status = await api.setup.getApiKeys();
|
|
||||||
if (status.success) {
|
|
||||||
setApiKeyStatus({
|
|
||||||
hasAnthropicKey: status.hasAnthropicKey,
|
|
||||||
hasOpenAIKey: status.hasOpenAIKey,
|
|
||||||
hasGoogleKey: status.hasGoogleKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check API key status:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkApiKeyStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Test Anthropic/Claude connection
|
|
||||||
const handleTestAnthropicConnection = async () => {
|
|
||||||
setTestingConnection(true);
|
|
||||||
setTestResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/claude/test", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ apiKey: anthropicKey }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
setTestResult({
|
|
||||||
success: true,
|
|
||||||
message: data.message || "Connection successful! Claude responded.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTestResult({
|
|
||||||
success: false,
|
|
||||||
message: data.error || "Failed to connect to Claude API.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setTestResult({
|
|
||||||
success: false,
|
|
||||||
message: "Network error. Please check your connection.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTestingConnection(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test Google/Gemini connection
|
|
||||||
const handleTestGeminiConnection = async () => {
|
|
||||||
setTestingGeminiConnection(true);
|
|
||||||
setGeminiTestResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/gemini/test", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ apiKey: googleKey }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
setGeminiTestResult({
|
|
||||||
success: true,
|
|
||||||
message: data.message || "Connection successful! Gemini responded.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setGeminiTestResult({
|
|
||||||
success: false,
|
|
||||||
message: data.error || "Failed to connect to Gemini API.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setGeminiTestResult({
|
|
||||||
success: false,
|
|
||||||
message: "Network error. Please check your connection.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTestingGeminiConnection(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test OpenAI connection
|
|
||||||
const handleTestOpenaiConnection = async () => {
|
|
||||||
setTestingOpenaiConnection(true);
|
|
||||||
setOpenaiTestResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api?.testOpenAIConnection) {
|
|
||||||
const result = await api.testOpenAIConnection(openaiKey);
|
|
||||||
if (result.success) {
|
|
||||||
setOpenaiTestResult({
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
result.message || "Connection successful! OpenAI API responded.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setOpenaiTestResult({
|
|
||||||
success: false,
|
|
||||||
message: result.error || "Failed to connect to OpenAI API.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to web API test
|
|
||||||
const response = await fetch("/api/openai/test", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ apiKey: openaiKey }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
setOpenaiTestResult({
|
|
||||||
success: true,
|
|
||||||
message:
|
|
||||||
data.message || "Connection successful! OpenAI API responded.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setOpenaiTestResult({
|
|
||||||
success: false,
|
|
||||||
message: data.error || "Failed to connect to OpenAI API.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setOpenaiTestResult({
|
|
||||||
success: false,
|
|
||||||
message: "Network error. Please check your connection.",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setTestingOpenaiConnection(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save API keys
|
|
||||||
const handleSave = () => {
|
|
||||||
setApiKeys({
|
|
||||||
anthropic: anthropicKey,
|
|
||||||
google: googleKey,
|
|
||||||
openai: openaiKey,
|
|
||||||
});
|
|
||||||
setSaved(true);
|
|
||||||
setTimeout(() => setSaved(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build provider config params for buildProviderConfigs
|
|
||||||
const providerConfigParams: ProviderConfigParams = {
|
|
||||||
apiKeys,
|
|
||||||
anthropic: {
|
|
||||||
value: anthropicKey,
|
|
||||||
setValue: setAnthropicKey,
|
|
||||||
show: showAnthropicKey,
|
|
||||||
setShow: setShowAnthropicKey,
|
|
||||||
testing: testingConnection,
|
|
||||||
onTest: handleTestAnthropicConnection,
|
|
||||||
result: testResult,
|
|
||||||
},
|
|
||||||
google: {
|
|
||||||
value: googleKey,
|
|
||||||
setValue: setGoogleKey,
|
|
||||||
show: showGoogleKey,
|
|
||||||
setShow: setShowGoogleKey,
|
|
||||||
testing: testingGeminiConnection,
|
|
||||||
onTest: handleTestGeminiConnection,
|
|
||||||
result: geminiTestResult,
|
|
||||||
},
|
|
||||||
openai: {
|
|
||||||
value: openaiKey,
|
|
||||||
setValue: setOpenaiKey,
|
|
||||||
show: showOpenaiKey,
|
|
||||||
setShow: setShowOpenaiKey,
|
|
||||||
testing: testingOpenaiConnection,
|
|
||||||
onTest: handleTestOpenaiConnection,
|
|
||||||
result: openaiTestResult,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Provider config params for buildProviderConfigs
|
|
||||||
providerConfigParams,
|
|
||||||
|
|
||||||
// API key status from environment
|
|
||||||
apiKeyStatus,
|
|
||||||
|
|
||||||
// Save handler and state
|
|
||||||
handleSave,
|
|
||||||
saved,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,61 +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 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
|
|
||||||
id="appearance"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Palette className="w-5 h-5 text-brand-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Appearance</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Customize the look and feel of your application.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-foreground">
|
|
||||||
Theme{" "}
|
|
||||||
{currentProject ? `(for ${currentProject.name})` : "(Global)"}
|
|
||||||
</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}
|
|
||||||
variant={isActive ? "secondary" : "outline"}
|
|
||||||
onClick={() => onThemeChange(value)}
|
|
||||||
className={`flex items-center justify-center gap-2 px-3 py-3 h-auto ${
|
|
||||||
isActive ? "border-brand-500 ring-1 ring-brand-500/50" : ""
|
|
||||||
}`}
|
|
||||||
data-testid={testId}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
<span className="font-medium text-sm">{label}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Terminal,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { CliStatus } from "../shared/types";
|
|
||||||
|
|
||||||
interface CliStatusProps {
|
|
||||||
status: CliStatus | null;
|
|
||||||
isChecking: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClaudeCliStatus({
|
|
||||||
status,
|
|
||||||
isChecking,
|
|
||||||
onRefresh,
|
|
||||||
}: CliStatusProps) {
|
|
||||||
if (!status) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="claude"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5 text-brand-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Claude Code CLI
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={isChecking}
|
|
||||||
data-testid="refresh-claude-cli"
|
|
||||||
title="Refresh Claude CLI detection"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Claude Code CLI provides better performance for long-running tasks,
|
|
||||||
especially with ultrathink.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{status.success && status.status === "installed" ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-green-400">
|
|
||||||
Claude Code CLI Installed
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
|
||||||
{status.method && (
|
|
||||||
<p>
|
|
||||||
Method: <span className="font-mono">{status.method}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.version && (
|
|
||||||
<p>
|
|
||||||
Version:{" "}
|
|
||||||
<span className="font-mono">{status.version}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.path && (
|
|
||||||
<p className="truncate" title={status.path}>
|
|
||||||
Path:{" "}
|
|
||||||
<span className="font-mono text-[10px]">
|
|
||||||
{status.path}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.recommendation && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{status.recommendation}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-yellow-400">
|
|
||||||
Claude Code CLI Not Detected
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-400/80 mt-1">
|
|
||||||
{status.recommendation ||
|
|
||||||
"Consider installing Claude Code CLI for optimal performance with ultrathink."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.installCommands && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-foreground-secondary">
|
|
||||||
Installation Commands:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{status.installCommands.npm && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.npm}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status.installCommands.macos && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
|
||||||
macOS/Linux:
|
|
||||||
</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.macos}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status.installCommands.windows && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
|
||||||
Windows (PowerShell):
|
|
||||||
</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.windows}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Terminal,
|
|
||||||
CheckCircle2,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { CliStatus } from "../shared/types";
|
|
||||||
|
|
||||||
interface CliStatusProps {
|
|
||||||
status: CliStatus | null;
|
|
||||||
isChecking: boolean;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexCliStatus({
|
|
||||||
status,
|
|
||||||
isChecking,
|
|
||||||
onRefresh,
|
|
||||||
}: CliStatusProps) {
|
|
||||||
if (!status) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="codex"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5 text-green-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
OpenAI Codex CLI
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={isChecking}
|
|
||||||
data-testid="refresh-codex-cli"
|
|
||||||
title="Refresh Codex CLI detection"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{status.success && status.status === "installed" ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-green-400">
|
|
||||||
Codex CLI Installed
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-green-400/80 mt-1 space-y-1">
|
|
||||||
{status.method && (
|
|
||||||
<p>
|
|
||||||
Method: <span className="font-mono">{status.method}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.version && (
|
|
||||||
<p>
|
|
||||||
Version:{" "}
|
|
||||||
<span className="font-mono">{status.version}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{status.path && (
|
|
||||||
<p className="truncate" title={status.path}>
|
|
||||||
Path:{" "}
|
|
||||||
<span className="font-mono text-[10px]">
|
|
||||||
{status.path}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.recommendation && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{status.recommendation}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : status.status === "api_key_only" ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
|
||||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-blue-400">
|
|
||||||
API Key Detected - CLI Not Installed
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-400/80 mt-1">
|
|
||||||
{status.recommendation ||
|
|
||||||
"OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.installCommands && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-foreground-secondary">
|
|
||||||
Installation Commands:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{status.installCommands.npm && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.npm}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-yellow-400">
|
|
||||||
Codex CLI Not Detected
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-yellow-400/80 mt-1">
|
|
||||||
{status.recommendation ||
|
|
||||||
"Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{status.installCommands && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-foreground-secondary">
|
|
||||||
Installation Commands:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{status.installCommands.npm && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">npm:</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.npm}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{status.installCommands.macos && (
|
|
||||||
<div className="p-2 rounded bg-background border border-border-glass">
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">
|
|
||||||
macOS (Homebrew):
|
|
||||||
</p>
|
|
||||||
<code className="text-xs text-foreground-secondary font-mono break-all">
|
|
||||||
{status.installCommands.macos}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Settings } from "lucide-react";
|
|
||||||
|
|
||||||
interface SettingsHeaderProps {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsHeader({
|
|
||||||
title = "Settings",
|
|
||||||
description = "Configure your API keys and preferences",
|
|
||||||
}: SettingsHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
|
||||||
<div className="px-8 py-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
|
|
||||||
<Settings className="w-5 h-5 text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">{title}</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { Project } from "@/lib/electron";
|
|
||||||
import type { NavigationItem } from "../config/navigation";
|
|
||||||
|
|
||||||
interface SettingsNavigationProps {
|
|
||||||
navItems: NavigationItem[];
|
|
||||||
activeSection: string;
|
|
||||||
currentProject: Project | null;
|
|
||||||
onNavigate: (sectionId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsNavigation({
|
|
||||||
navItems,
|
|
||||||
activeSection,
|
|
||||||
currentProject,
|
|
||||||
onNavigate,
|
|
||||||
}: SettingsNavigationProps) {
|
|
||||||
return (
|
|
||||||
<nav className="hidden lg:block w-48 shrink-0 border-r border-border bg-card/50 backdrop-blur-sm">
|
|
||||||
<div className="sticky top-0 p-4 space-y-1">
|
|
||||||
{navItems
|
|
||||||
.filter((item) => item.id !== "danger" || currentProject)
|
|
||||||
.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = activeSection === item.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => onNavigate(item.id)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all text-left",
|
|
||||||
isActive
|
|
||||||
? "bg-brand-500/10 text-brand-500 border border-brand-500/20"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn(
|
|
||||||
"w-4 h-4 shrink-0",
|
|
||||||
isActive ? "text-brand-500" : ""
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Key,
|
|
||||||
Terminal,
|
|
||||||
Atom,
|
|
||||||
Palette,
|
|
||||||
LayoutGrid,
|
|
||||||
Settings2,
|
|
||||||
FlaskConical,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export interface NavigationItem {
|
|
||||||
id: string;
|
|
||||||
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: "codex", label: "Codex", icon: Atom },
|
|
||||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
|
||||||
{ id: "kanban", label: "Kanban Display", icon: LayoutGrid },
|
|
||||||
{ id: "keyboard", label: "Keyboard Shortcuts", icon: Settings2 },
|
|
||||||
{ id: "defaults", label: "Feature Defaults", icon: FlaskConical },
|
|
||||||
{ id: "danger", label: "Danger Zone", icon: Trash2 },
|
|
||||||
];
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Trash2, Folder } from "lucide-react";
|
|
||||||
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
|
|
||||||
id="danger"
|
|
||||||
className="rounded-xl border border-destructive/30 bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-destructive/30">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Trash2 className="w-5 h-5 text-destructive" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Permanently remove this project from Automaker.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border 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 truncate">
|
|
||||||
{project.path}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={onDeleteClick}
|
|
||||||
data-testid="delete-project-button"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
Delete Project
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { FlaskConical, Settings2, TestTube, GitBranch } from "lucide-react";
|
|
||||||
|
|
||||||
interface FeatureDefaultsSectionProps {
|
|
||||||
showProfilesOnly: boolean;
|
|
||||||
defaultSkipTests: boolean;
|
|
||||||
useWorktrees: boolean;
|
|
||||||
onShowProfilesOnlyChange: (value: boolean) => void;
|
|
||||||
onDefaultSkipTestsChange: (value: boolean) => void;
|
|
||||||
onUseWorktreesChange: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeatureDefaultsSection({
|
|
||||||
showProfilesOnly,
|
|
||||||
defaultSkipTests,
|
|
||||||
useWorktrees,
|
|
||||||
onShowProfilesOnlyChange,
|
|
||||||
onDefaultSkipTestsChange,
|
|
||||||
onUseWorktreesChange,
|
|
||||||
}: FeatureDefaultsSectionProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="defaults"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<FlaskConical className="w-5 h-5 text-brand-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Feature Defaults
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure default settings for new features.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
{/* Profiles Only Setting */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id="show-profiles-only"
|
|
||||||
checked={showProfilesOnly}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onShowProfilesOnlyChange(checked === true)
|
|
||||||
}
|
|
||||||
className="mt-0.5"
|
|
||||||
data-testid="show-profiles-only-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label
|
|
||||||
htmlFor="show-profiles-only"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Settings2 className="w-4 h-4 text-brand-500" />
|
|
||||||
Show profiles only by default
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, the Add Feature dialog will show only AI profiles
|
|
||||||
and hide advanced model tweaking options (Claude SDK, thinking
|
|
||||||
levels, and OpenAI Codex CLI). This creates a cleaner, less
|
|
||||||
overwhelming UI. You can always disable this to access advanced
|
|
||||||
settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
|
|
||||||
{/* Automated Testing Setting */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id="default-skip-tests"
|
|
||||||
checked={!defaultSkipTests}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onDefaultSkipTestsChange(checked !== true)
|
|
||||||
}
|
|
||||||
className="mt-0.5"
|
|
||||||
data-testid="default-skip-tests-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label
|
|
||||||
htmlFor="default-skip-tests"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<TestTube className="w-4 h-4 text-brand-500" />
|
|
||||||
Enable automated testing by default
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
When enabled, new features will use TDD (test-driven
|
|
||||||
development) with automated tests. When disabled, features will
|
|
||||||
require manual verification. You can still override this for
|
|
||||||
individual features.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Worktree Isolation Setting */}
|
|
||||||
<div className="space-y-3 pt-2 border-t border-border">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id="use-worktrees"
|
|
||||||
checked={useWorktrees}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onUseWorktreesChange(checked === true)
|
|
||||||
}
|
|
||||||
className="mt-0.5"
|
|
||||||
data-testid="use-worktrees-checkbox"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label
|
|
||||||
htmlFor="use-worktrees"
|
|
||||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<GitBranch className="w-4 h-4 text-brand-500" />
|
|
||||||
Enable Git Worktree Isolation (experimental)
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Creates isolated git branches for each feature. When disabled,
|
|
||||||
agents work directly in the main project directory. This feature
|
|
||||||
is experimental and may require additional setup like branch
|
|
||||||
selection and merge configuration.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
|
|
||||||
interface CliStatusResult {
|
|
||||||
success: boolean;
|
|
||||||
status?: string;
|
|
||||||
method?: string;
|
|
||||||
version?: string;
|
|
||||||
path?: string;
|
|
||||||
recommendation?: string;
|
|
||||||
installCommands?: {
|
|
||||||
macos?: string;
|
|
||||||
windows?: string;
|
|
||||||
linux?: string;
|
|
||||||
npm?: string;
|
|
||||||
};
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CodexCliStatusResult extends CliStatusResult {
|
|
||||||
hasApiKey?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing Claude and Codex CLI status
|
|
||||||
* Handles checking CLI installation, authentication, and refresh functionality
|
|
||||||
*/
|
|
||||||
export function useCliStatus() {
|
|
||||||
const { setClaudeAuthStatus, setCodexAuthStatus } = useSetupStore();
|
|
||||||
|
|
||||||
const [claudeCliStatus, setClaudeCliStatus] =
|
|
||||||
useState<CliStatusResult | null>(null);
|
|
||||||
|
|
||||||
const [codexCliStatus, setCodexCliStatus] =
|
|
||||||
useState<CodexCliStatusResult | null>(null);
|
|
||||||
|
|
||||||
const [isCheckingClaudeCli, setIsCheckingClaudeCli] = useState(false);
|
|
||||||
const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false);
|
|
||||||
|
|
||||||
// Check CLI status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const checkCliStatus = async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
|
|
||||||
// Check Claude CLI
|
|
||||||
if (api?.checkClaudeCli) {
|
|
||||||
try {
|
|
||||||
const status = await api.checkClaudeCli();
|
|
||||||
setClaudeCliStatus(status);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check Claude CLI status:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Codex CLI
|
|
||||||
if (api?.checkCodexCli) {
|
|
||||||
try {
|
|
||||||
const status = await api.checkCodexCli();
|
|
||||||
setCodexCliStatus(status);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check Codex CLI status:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Claude auth status (re-fetch on mount to ensure persistence)
|
|
||||||
if (api?.setup?.getClaudeStatus) {
|
|
||||||
try {
|
|
||||||
const result = await api.setup.getClaudeStatus();
|
|
||||||
if (result.success && result.auth) {
|
|
||||||
// Cast to extended type that includes server-added fields
|
|
||||||
const auth = result.auth as typeof result.auth & {
|
|
||||||
oauthTokenValid?: boolean;
|
|
||||||
apiKeyValid?: boolean;
|
|
||||||
};
|
|
||||||
// Map server method names to client method types
|
|
||||||
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, cli_authenticated, none
|
|
||||||
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "cli_authenticated", "none"] as const;
|
|
||||||
type AuthMethod = typeof validMethods[number];
|
|
||||||
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
|
|
||||||
? (auth.method as AuthMethod)
|
|
||||||
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none
|
|
||||||
const authStatus = {
|
|
||||||
authenticated: auth.authenticated,
|
|
||||||
method,
|
|
||||||
hasCredentialsFile: auth.hasCredentialsFile ?? false,
|
|
||||||
oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
|
|
||||||
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
|
|
||||||
hasEnvOAuthToken: auth.hasEnvOAuthToken,
|
|
||||||
hasEnvApiKey: auth.hasEnvApiKey,
|
|
||||||
};
|
|
||||||
setClaudeAuthStatus(authStatus);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check Claude auth status:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Codex auth status (re-fetch on mount to ensure persistence)
|
|
||||||
if (api?.setup?.getCodexStatus) {
|
|
||||||
try {
|
|
||||||
const result = await api.setup.getCodexStatus();
|
|
||||||
if (result.success && result.auth) {
|
|
||||||
// Cast to extended type that includes server-added fields
|
|
||||||
const auth = result.auth as typeof result.auth & {
|
|
||||||
hasSubscription?: boolean;
|
|
||||||
cliLoggedIn?: boolean;
|
|
||||||
hasEnvApiKey?: boolean;
|
|
||||||
};
|
|
||||||
// Map server method names to client method types
|
|
||||||
// Server returns: subscription, cli_verified, cli_tokens, api_key, env, none
|
|
||||||
const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const;
|
|
||||||
type CodexMethod = typeof validMethods[number];
|
|
||||||
const method: CodexMethod = validMethods.includes(auth.method as CodexMethod)
|
|
||||||
? (auth.method as CodexMethod)
|
|
||||||
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key
|
|
||||||
|
|
||||||
const authStatus = {
|
|
||||||
authenticated: auth.authenticated,
|
|
||||||
method,
|
|
||||||
// Only set apiKeyValid for actual API key methods, not CLI login or subscription
|
|
||||||
apiKeyValid:
|
|
||||||
method === "cli_verified" || method === "cli_tokens" || method === "subscription"
|
|
||||||
? undefined
|
|
||||||
: auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
|
|
||||||
hasSubscription: auth.hasSubscription,
|
|
||||||
cliLoggedIn: auth.cliLoggedIn,
|
|
||||||
};
|
|
||||||
setCodexAuthStatus(authStatus);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to check Codex auth status:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkCliStatus();
|
|
||||||
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
|
||||||
|
|
||||||
// Refresh Claude CLI status
|
|
||||||
const handleRefreshClaudeCli = useCallback(async () => {
|
|
||||||
setIsCheckingClaudeCli(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api?.checkClaudeCli) {
|
|
||||||
const status = await api.checkClaudeCli();
|
|
||||||
setClaudeCliStatus(status);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to refresh Claude CLI status:", error);
|
|
||||||
} finally {
|
|
||||||
setIsCheckingClaudeCli(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refresh Codex CLI status
|
|
||||||
const handleRefreshCodexCli = useCallback(async () => {
|
|
||||||
setIsCheckingCodexCli(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (api?.checkCodexCli) {
|
|
||||||
const status = await api.checkCodexCli();
|
|
||||||
setCodexCliStatus(status);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to refresh Codex CLI status:", error);
|
|
||||||
} finally {
|
|
||||||
setIsCheckingCodexCli(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
claudeCliStatus,
|
|
||||||
codexCliStatus,
|
|
||||||
isCheckingClaudeCli,
|
|
||||||
isCheckingCodexCli,
|
|
||||||
handleRefreshClaudeCli,
|
|
||||||
handleRefreshCodexCli,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Settings2, Keyboard } from "lucide-react";
|
|
||||||
|
|
||||||
interface KeyboardShortcutsSectionProps {
|
|
||||||
onOpenKeyboardMap: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeyboardShortcutsSection({
|
|
||||||
onOpenKeyboardMap,
|
|
||||||
}: KeyboardShortcutsSectionProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="keyboard"
|
|
||||||
className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden scroll-mt-6"
|
|
||||||
>
|
|
||||||
<div className="p-6 border-b border-border">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Settings2 className="w-5 h-5 text-brand-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
Keyboard Shortcuts
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Customize keyboard shortcuts for navigation and actions using the
|
|
||||||
visual keyboard map.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
{/* Centered message directing to keyboard map */}
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Keyboard className="w-16 h-16 text-brand-500/30" />
|
|
||||||
<div className="absolute inset-0 bg-brand-500/10 blur-xl rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-w-md">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground">
|
|
||||||
Use the Visual Keyboard Map
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Click the "View Keyboard Map" button above to customize
|
|
||||||
your keyboard shortcuts. The visual interface shows all available
|
|
||||||
keys and lets you easily edit shortcuts with single-modifier
|
|
||||||
restrictions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
onClick={onOpenKeyboardMap}
|
|
||||||
className="gap-2 mt-4"
|
|
||||||
>
|
|
||||||
<Keyboard className="w-5 h-5" />
|
|
||||||
Open Keyboard Map
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,148 +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,
|
|
||||||
CodexSetupStep,
|
|
||||||
} from "./setup-view/steps";
|
|
||||||
|
|
||||||
// Main Setup View
|
|
||||||
export function SetupView() {
|
|
||||||
const {
|
|
||||||
currentStep,
|
|
||||||
setCurrentStep,
|
|
||||||
completeSetup,
|
|
||||||
setSkipClaudeSetup,
|
|
||||||
setSkipCodexSetup,
|
|
||||||
} = useSetupStore();
|
|
||||||
const { setCurrentView } = useAppStore();
|
|
||||||
|
|
||||||
const steps = ["welcome", "claude", "codex", "complete"] as const;
|
|
||||||
type StepName = (typeof steps)[number];
|
|
||||||
const getStepName = (): StepName => {
|
|
||||||
if (currentStep === "claude_detect" || currentStep === "claude_auth")
|
|
||||||
return "claude";
|
|
||||||
if (currentStep === "codex_detect" || currentStep === "codex_auth")
|
|
||||||
return "codex";
|
|
||||||
if (currentStep === "welcome") return "welcome";
|
|
||||||
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 codex_detect step");
|
|
||||||
setCurrentStep("codex_detect");
|
|
||||||
break;
|
|
||||||
case "codex":
|
|
||||||
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 "codex":
|
|
||||||
setCurrentStep("claude_detect");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipClaude = () => {
|
|
||||||
console.log("[Setup Flow] Skipping Claude setup");
|
|
||||||
setSkipClaudeSetup(true);
|
|
||||||
setCurrentStep("codex_detect");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipCodex = () => {
|
|
||||||
console.log("[Setup Flow] Skipping Codex setup");
|
|
||||||
setSkipCodexSetup(true);
|
|
||||||
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 === "codex_detect" ||
|
|
||||||
currentStep === "codex_auth") && (
|
|
||||||
<CodexSetupStep
|
|
||||||
onNext={() => handleNext("codex")}
|
|
||||||
onBack={() => handleBack("codex")}
|
|
||||||
onSkip={handleSkipCodex}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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,46 +0,0 @@
|
|||||||
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
interface StatusBadgeProps {
|
|
||||||
status:
|
|
||||||
| "installed"
|
|
||||||
| "not_installed"
|
|
||||||
| "checking"
|
|
||||||
| "authenticated"
|
|
||||||
| "not_authenticated";
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
|
||||||
const getStatusConfig = () => {
|
|
||||||
switch (status) {
|
|
||||||
case "installed":
|
|
||||||
case "authenticated":
|
|
||||||
return {
|
|
||||||
icon: <CheckCircle2 className="w-4 h-4" />,
|
|
||||||
className: "bg-green-500/10 text-green-500 border-green-500/20",
|
|
||||||
};
|
|
||||||
case "not_installed":
|
|
||||||
case "not_authenticated":
|
|
||||||
return {
|
|
||||||
icon: <XCircle className="w-4 h-4" />,
|
|
||||||
className: "bg-red-500/10 text-red-500 border-red-500/20",
|
|
||||||
};
|
|
||||||
case "checking":
|
|
||||||
return {
|
|
||||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
|
||||||
className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = getStatusConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${config.className}`}
|
|
||||||
>
|
|
||||||
{config.icon}
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,2 +0,0 @@
|
|||||||
// Re-export all setup dialog components for easier imports
|
|
||||||
export { SetupTokenModal } from "./setup-token-modal";
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Loader2,
|
|
||||||
Terminal,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
Copy,
|
|
||||||
RotateCcw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useOAuthAuthentication } from "../hooks";
|
|
||||||
|
|
||||||
interface SetupTokenModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onTokenObtained: (token: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetupTokenModal({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onTokenObtained,
|
|
||||||
}: SetupTokenModalProps) {
|
|
||||||
// Use the OAuth authentication hook
|
|
||||||
const { authState, output, token, error, startAuth, reset } =
|
|
||||||
useOAuthAuthentication({ cliType: "claude" });
|
|
||||||
|
|
||||||
const [manualToken, setManualToken] = useState("");
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when output changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [output]);
|
|
||||||
|
|
||||||
// Reset state when modal opens/closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
reset();
|
|
||||||
setManualToken("");
|
|
||||||
}
|
|
||||||
}, [open, reset]);
|
|
||||||
|
|
||||||
const handleUseToken = useCallback(() => {
|
|
||||||
const tokenToUse = token || manualToken;
|
|
||||||
if (tokenToUse.trim()) {
|
|
||||||
onTokenObtained(tokenToUse.trim());
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [token, manualToken, onTokenObtained, onClose]);
|
|
||||||
|
|
||||||
const copyCommand = useCallback(() => {
|
|
||||||
navigator.clipboard.writeText("claude setup-token");
|
|
||||||
toast.success("Command copied to clipboard");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRetry = useCallback(() => {
|
|
||||||
reset();
|
|
||||||
setManualToken("");
|
|
||||||
}, [reset]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
|
||||||
<DialogContent
|
|
||||||
className="max-w-2xl bg-card border-border"
|
|
||||||
data-testid="setup-token-modal"
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
|
||||||
<Terminal className="w-5 h-5 text-brand-500" />
|
|
||||||
Claude Subscription Authentication
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
|
||||||
{authState === "idle" &&
|
|
||||||
"Click Start to begin the authentication process."}
|
|
||||||
{authState === "running" &&
|
|
||||||
"Complete the sign-in in your browser..."}
|
|
||||||
{authState === "success" &&
|
|
||||||
"Authentication successful! Your token has been captured."}
|
|
||||||
{authState === "error" &&
|
|
||||||
"Authentication failed. Please try again or enter the token manually."}
|
|
||||||
{authState === "manual" &&
|
|
||||||
"Copy the token from your terminal and paste it below."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Terminal Output */}
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="bg-zinc-900 rounded-lg p-4 font-mono text-sm max-h-48 overflow-y-auto border border-border mt-3"
|
|
||||||
>
|
|
||||||
{output.map((line, index) => (
|
|
||||||
<div key={index} className="text-zinc-300 whitespace-pre-wrap">
|
|
||||||
{line.startsWith("Error") || line.startsWith("⚠") ? (
|
|
||||||
<span className="text-yellow-400">{line}</span>
|
|
||||||
) : line.startsWith("✓") ? (
|
|
||||||
<span className="text-green-400">{line}</span>
|
|
||||||
) : (
|
|
||||||
line
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{output.length === 0 && (
|
|
||||||
<div className="text-zinc-500 italic">Waiting to start...</div>
|
|
||||||
)}
|
|
||||||
{authState === "running" && (
|
|
||||||
<div className="flex items-center gap-2 text-brand-400 mt-2">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Waiting for authentication...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Manual Token Input (for fallback) */}
|
|
||||||
{(authState === "manual" || authState === "error") && (
|
|
||||||
<div className="space-y-3 pt-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>Run this command in your terminal:</span>
|
|
||||||
<code className="bg-muted px-2 py-1 rounded font-mono text-foreground">
|
|
||||||
claude setup-token
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={copyCommand}
|
|
||||||
className="h-7 w-7"
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="manual-token" className="text-foreground">
|
|
||||||
Paste your token:
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="manual-token"
|
|
||||||
type="password"
|
|
||||||
placeholder="Paste token here..."
|
|
||||||
value={manualToken}
|
|
||||||
onChange={(e) => setManualToken(e.target.value)}
|
|
||||||
className="bg-input border-border text-foreground"
|
|
||||||
data-testid="manual-token-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success State */}
|
|
||||||
{authState === "success" && (
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Token captured successfully!
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Click "Use Token" to save and continue.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{error && authState === "error" && (
|
|
||||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
|
||||||
<XCircle className="w-6 h-6 text-red-500 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">Error</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="mt-5 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{authState === "idle" && (
|
|
||||||
<Button
|
|
||||||
onClick={startAuth}
|
|
||||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
|
||||||
data-testid="start-auth-button"
|
|
||||||
>
|
|
||||||
<Terminal className="w-4 h-4 mr-2" />
|
|
||||||
Start Authentication
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authState === "running" && (
|
|
||||||
<Button disabled className="bg-brand-500">
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Authenticating...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authState === "success" && (
|
|
||||||
<Button
|
|
||||||
onClick={handleUseToken}
|
|
||||||
className="bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="use-token-button"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
||||||
Use Token
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authState === "manual" && (
|
|
||||||
<Button
|
|
||||||
onClick={handleUseToken}
|
|
||||||
disabled={!manualToken.trim()}
|
|
||||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50"
|
|
||||||
data-testid="use-manual-token-button"
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
||||||
Use Token
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{authState === "error" && (
|
|
||||||
<>
|
|
||||||
{manualToken.trim() && (
|
|
||||||
<Button
|
|
||||||
onClick={handleUseToken}
|
|
||||||
className="bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
>
|
|
||||||
Use Manual Token
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Re-export all hooks for easier imports
|
|
||||||
export { useCliStatus } from "./use-cli-status";
|
|
||||||
export { useCliInstallation } from "./use-cli-installation";
|
|
||||||
export { useOAuthAuthentication } from "./use-oauth-authentication";
|
|
||||||
export { useTokenSave } from "./use-token-save";
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
|
|
||||||
interface UseCliStatusOptions {
|
|
||||||
cliType: "claude" | "codex";
|
|
||||||
statusApi: () => Promise<any>;
|
|
||||||
setCliStatus: (status: any) => void;
|
|
||||||
setAuthStatus: (status: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCliStatus({
|
|
||||||
cliType,
|
|
||||||
statusApi,
|
|
||||||
setCliStatus,
|
|
||||||
setAuthStatus,
|
|
||||||
}: UseCliStatusOptions) {
|
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
|
||||||
|
|
||||||
const checkStatus = useCallback(async () => {
|
|
||||||
console.log(`[${cliType} Setup] Starting status check...`);
|
|
||||||
setIsChecking(true);
|
|
||||||
try {
|
|
||||||
const result = await statusApi();
|
|
||||||
console.log(`[${cliType} Setup] Raw status result:`, result);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const cliStatus = {
|
|
||||||
installed: result.status === "installed",
|
|
||||||
path: result.path || null,
|
|
||||||
version: result.version || null,
|
|
||||||
method: result.method || "none",
|
|
||||||
};
|
|
||||||
console.log(`[${cliType} Setup] CLI Status:`, cliStatus);
|
|
||||||
setCliStatus(cliStatus);
|
|
||||||
|
|
||||||
if (result.auth) {
|
|
||||||
if (cliType === "claude") {
|
|
||||||
// Validate method is one of the expected values, default to "none"
|
|
||||||
const validMethods = [
|
|
||||||
"oauth_token_env",
|
|
||||||
"oauth_token",
|
|
||||||
"api_key",
|
|
||||||
"api_key_env",
|
|
||||||
"credentials_file",
|
|
||||||
"cli_authenticated",
|
|
||||||
"none",
|
|
||||||
] as const;
|
|
||||||
type AuthMethod = (typeof validMethods)[number];
|
|
||||||
const method: AuthMethod = validMethods.includes(
|
|
||||||
result.auth.method as AuthMethod
|
|
||||||
)
|
|
||||||
? (result.auth.method as AuthMethod)
|
|
||||||
: "none";
|
|
||||||
const authStatus = {
|
|
||||||
authenticated: result.auth.authenticated,
|
|
||||||
method,
|
|
||||||
hasCredentialsFile: false,
|
|
||||||
oauthTokenValid:
|
|
||||||
result.auth.hasStoredOAuthToken ||
|
|
||||||
result.auth.hasEnvOAuthToken,
|
|
||||||
apiKeyValid:
|
|
||||||
result.auth.hasStoredApiKey || result.auth.hasEnvApiKey,
|
|
||||||
hasEnvOAuthToken: result.auth.hasEnvOAuthToken,
|
|
||||||
hasEnvApiKey: result.auth.hasEnvApiKey,
|
|
||||||
};
|
|
||||||
setAuthStatus(authStatus);
|
|
||||||
} else {
|
|
||||||
// Codex auth status mapping
|
|
||||||
const mapAuthMethod = (method?: string): any => {
|
|
||||||
switch (method) {
|
|
||||||
case "cli_verified":
|
|
||||||
return "cli_verified";
|
|
||||||
case "cli_tokens":
|
|
||||||
return "cli_tokens";
|
|
||||||
case "auth_file":
|
|
||||||
return "api_key";
|
|
||||||
case "env_var":
|
|
||||||
return "env";
|
|
||||||
default:
|
|
||||||
return "none";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const method = mapAuthMethod(result.auth.method);
|
|
||||||
const authStatus = {
|
|
||||||
authenticated: result.auth.authenticated,
|
|
||||||
method,
|
|
||||||
apiKeyValid:
|
|
||||||
method === "cli_verified" || method === "cli_tokens"
|
|
||||||
? undefined
|
|
||||||
: result.auth.authenticated,
|
|
||||||
};
|
|
||||||
console.log(`[${cliType} Setup] Auth Status:`, authStatus);
|
|
||||||
setAuthStatus(authStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[${cliType} Setup] Failed to check status:`, error);
|
|
||||||
} finally {
|
|
||||||
setIsChecking(false);
|
|
||||||
}
|
|
||||||
}, [cliType, statusApi, setCliStatus, setAuthStatus]);
|
|
||||||
|
|
||||||
return { isChecking, checkStatus };
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
|
|
||||||
type AuthState = "idle" | "running" | "success" | "error" | "manual";
|
|
||||||
|
|
||||||
interface UseOAuthAuthenticationOptions {
|
|
||||||
cliType: "claude" | "codex";
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOAuthAuthentication({
|
|
||||||
cliType,
|
|
||||||
enabled = true,
|
|
||||||
}: UseOAuthAuthenticationOptions) {
|
|
||||||
const [authState, setAuthState] = useState<AuthState>("idle");
|
|
||||||
const [output, setOutput] = useState<string[]>([]);
|
|
||||||
const [token, setToken] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
// Reset state when disabled
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
setAuthState("idle");
|
|
||||||
setOutput([]);
|
|
||||||
setToken("");
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Cleanup subscription
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
const startAuth = useCallback(async () => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.setup) {
|
|
||||||
setError("Setup API not available");
|
|
||||||
setAuthState("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthState("running");
|
|
||||||
setOutput([
|
|
||||||
"Starting authentication...",
|
|
||||||
`Running ${cliType} CLI in an embedded terminal so you don't need to copy/paste.`,
|
|
||||||
"When your browser opens, complete sign-in and return here.",
|
|
||||||
"",
|
|
||||||
]);
|
|
||||||
setError(null);
|
|
||||||
setToken("");
|
|
||||||
|
|
||||||
// Subscribe to progress events
|
|
||||||
if (api.setup.onAuthProgress) {
|
|
||||||
unsubscribeRef.current = api.setup.onAuthProgress((progress) => {
|
|
||||||
if (progress.cli === cliType && progress.data) {
|
|
||||||
// Split by newlines and add each line
|
|
||||||
const normalized = progress.data.replace(/\r/g, "\n");
|
|
||||||
const lines = normalized
|
|
||||||
.split("\n")
|
|
||||||
.map((line: string) => line.trimEnd())
|
|
||||||
.filter((line: string) => line.length > 0);
|
|
||||||
if (lines.length > 0) {
|
|
||||||
setOutput((prev) => [...prev, ...lines]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call the appropriate auth API based on cliType
|
|
||||||
const result =
|
|
||||||
cliType === "claude"
|
|
||||||
? await api.setup.authClaude()
|
|
||||||
: await api.setup.authCodex?.();
|
|
||||||
|
|
||||||
// Cleanup subscription
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
setError("Authentication API not available");
|
|
||||||
setAuthState("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for token (only available for Claude)
|
|
||||||
const resultToken =
|
|
||||||
cliType === "claude" && "token" in result ? result.token : undefined;
|
|
||||||
const resultTerminalOpened =
|
|
||||||
cliType === "claude" && "terminalOpened" in result
|
|
||||||
? result.terminalOpened
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (result.success && resultToken && typeof resultToken === "string") {
|
|
||||||
setToken(resultToken);
|
|
||||||
setAuthState("success");
|
|
||||||
setOutput((prev) => [
|
|
||||||
...prev,
|
|
||||||
"",
|
|
||||||
"✓ Authentication successful!",
|
|
||||||
"✓ Token captured automatically.",
|
|
||||||
]);
|
|
||||||
} else if (result.requiresManualAuth) {
|
|
||||||
// Terminal was opened - user needs to copy token manually
|
|
||||||
setAuthState("manual");
|
|
||||||
// Don't add extra messages if terminalOpened - the progress messages already explain
|
|
||||||
if (!resultTerminalOpened) {
|
|
||||||
const extraMessages = [
|
|
||||||
"",
|
|
||||||
"⚠ Could not capture token automatically.",
|
|
||||||
];
|
|
||||||
if (result.error) {
|
|
||||||
extraMessages.push(result.error);
|
|
||||||
}
|
|
||||||
setOutput((prev) => [
|
|
||||||
...prev,
|
|
||||||
...extraMessages,
|
|
||||||
"Please copy the token from above and paste it below.",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(result.error || "Authentication failed");
|
|
||||||
setAuthState("error");
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Cleanup subscription
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage =
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: typeof err === "object" && err !== null && "error" in err
|
|
||||||
? String((err as { error: unknown }).error)
|
|
||||||
: "Authentication failed";
|
|
||||||
|
|
||||||
// Check if we should fall back to manual mode
|
|
||||||
if (
|
|
||||||
typeof err === "object" &&
|
|
||||||
err !== null &&
|
|
||||||
"requiresManualAuth" in err &&
|
|
||||||
(err as { requiresManualAuth: boolean }).requiresManualAuth
|
|
||||||
) {
|
|
||||||
setAuthState("manual");
|
|
||||||
setOutput((prev) => [
|
|
||||||
...prev,
|
|
||||||
"",
|
|
||||||
"⚠ " + errorMessage,
|
|
||||||
"Please copy the token manually and paste it below.",
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setError(errorMessage);
|
|
||||||
setAuthState("error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [cliType]);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setAuthState("idle");
|
|
||||||
setOutput([]);
|
|
||||||
setToken("");
|
|
||||||
setError(null);
|
|
||||||
if (unsubscribeRef.current) {
|
|
||||||
unsubscribeRef.current();
|
|
||||||
unsubscribeRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { authState, output, token, error, startAuth, reset };
|
|
||||||
}
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Terminal,
|
|
||||||
Key,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowLeft,
|
|
||||||
ExternalLink,
|
|
||||||
Copy,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Download,
|
|
||||||
Shield,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { SetupTokenModal } from "../dialogs";
|
|
||||||
import { StatusBadge, TerminalOutput } from "../components";
|
|
||||||
import {
|
|
||||||
useCliStatus,
|
|
||||||
useCliInstallation,
|
|
||||||
useTokenSave,
|
|
||||||
} from "../hooks";
|
|
||||||
|
|
||||||
interface ClaudeSetupStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude Setup Step - 2 Authentication Options:
|
|
||||||
// 1. OAuth Token (Subscription): User runs `claude setup-token` and provides the token
|
|
||||||
// 2. API Key (Pay-per-use): User provides their Anthropic API key directly
|
|
||||||
export function ClaudeSetupStep({
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
onSkip,
|
|
||||||
}: ClaudeSetupStepProps) {
|
|
||||||
const {
|
|
||||||
claudeCliStatus,
|
|
||||||
claudeAuthStatus,
|
|
||||||
setClaudeCliStatus,
|
|
||||||
setClaudeAuthStatus,
|
|
||||||
setClaudeInstallProgress,
|
|
||||||
} = useSetupStore();
|
|
||||||
const { setApiKeys, apiKeys } = useAppStore();
|
|
||||||
|
|
||||||
const [authMethod, setAuthMethod] = useState<"token" | "api_key" | null>(null);
|
|
||||||
const [oauthToken, setOAuthToken] = useState("");
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
|
||||||
|
|
||||||
// Memoize API functions to prevent infinite loops
|
|
||||||
const statusApi = useCallback(
|
|
||||||
() => getElectronAPI().setup?.getClaudeStatus() || Promise.reject(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const installApi = useCallback(
|
|
||||||
() => getElectronAPI().setup?.installClaude() || Promise.reject(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getStoreState = useCallback(
|
|
||||||
() => useSetupStore.getState().claudeCliStatus,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hooks
|
|
||||||
const { isChecking, checkStatus } = useCliStatus({
|
|
||||||
cliType: "claude",
|
|
||||||
statusApi,
|
|
||||||
setCliStatus: setClaudeCliStatus,
|
|
||||||
setAuthStatus: setClaudeAuthStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onInstallSuccess = useCallback(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
|
||||||
cliType: "claude",
|
|
||||||
installApi,
|
|
||||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
|
||||||
onSuccess: onInstallSuccess,
|
|
||||||
getStoreState,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSaving: isSavingOAuth, saveToken: saveOAuthToken } = useTokenSave({
|
|
||||||
provider: "anthropic_oauth_token",
|
|
||||||
onSuccess: () => {
|
|
||||||
setClaudeAuthStatus({
|
|
||||||
authenticated: true,
|
|
||||||
method: "oauth_token",
|
|
||||||
hasCredentialsFile: false,
|
|
||||||
oauthTokenValid: true,
|
|
||||||
});
|
|
||||||
setAuthMethod(null);
|
|
||||||
checkStatus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({
|
|
||||||
provider: "anthropic",
|
|
||||||
onSuccess: () => {
|
|
||||||
setClaudeAuthStatus({
|
|
||||||
authenticated: true,
|
|
||||||
method: "api_key",
|
|
||||||
hasCredentialsFile: false,
|
|
||||||
apiKeyValid: true,
|
|
||||||
});
|
|
||||||
setApiKeys({ ...apiKeys, anthropic: apiKey });
|
|
||||||
setAuthMethod(null);
|
|
||||||
checkStatus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync install progress to store
|
|
||||||
useEffect(() => {
|
|
||||||
setClaudeInstallProgress({
|
|
||||||
isInstalling,
|
|
||||||
output: installProgress.output,
|
|
||||||
});
|
|
||||||
}, [isInstalling, installProgress, setClaudeInstallProgress]);
|
|
||||||
|
|
||||||
// Check status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const copyCommand = (command: string) => {
|
|
||||||
navigator.clipboard.writeText(command);
|
|
||||||
toast.success("Command copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle token obtained from the OAuth modal
|
|
||||||
const handleTokenFromModal = useCallback(
|
|
||||||
async (token: string) => {
|
|
||||||
setOAuthToken(token);
|
|
||||||
setShowTokenModal(false);
|
|
||||||
await saveOAuthToken(token);
|
|
||||||
},
|
|
||||||
[saveOAuthToken]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isAuthenticated = claudeAuthStatus?.authenticated || apiKeys.anthropic;
|
|
||||||
|
|
||||||
const getAuthMethodLabel = () => {
|
|
||||||
if (!isAuthenticated) return null;
|
|
||||||
if (
|
|
||||||
claudeAuthStatus?.method === "oauth_token_env" ||
|
|
||||||
claudeAuthStatus?.method === "oauth_token"
|
|
||||||
)
|
|
||||||
return "Subscription Token";
|
|
||||||
if (
|
|
||||||
apiKeys.anthropic ||
|
|
||||||
claudeAuthStatus?.method === "api_key" ||
|
|
||||||
claudeAuthStatus?.method === "api_key_env"
|
|
||||||
)
|
|
||||||
return "API Key";
|
|
||||||
return "Authenticated";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Terminal className="w-8 h-8 text-brand-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
|
||||||
Claude Setup
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Configure Claude for code generation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Card */}
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">Status</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={checkStatus}
|
|
||||||
disabled={isChecking}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-foreground">CLI Installation</span>
|
|
||||||
{isChecking ? (
|
|
||||||
<StatusBadge status="checking" label="Checking..." />
|
|
||||||
) : claudeCliStatus?.installed ? (
|
|
||||||
<StatusBadge status="installed" label="Installed" />
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="not_installed" label="Not Installed" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{claudeCliStatus?.version && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Version</span>
|
|
||||||
<span className="text-sm font-mono text-foreground">
|
|
||||||
{claudeCliStatus.version}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-foreground">Authentication</span>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status="authenticated" label="Authenticated" />
|
|
||||||
{getAuthMethodLabel() && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
({getAuthMethodLabel()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StatusBadge
|
|
||||||
status="not_authenticated"
|
|
||||||
label="Not Authenticated"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Installation Section */}
|
|
||||||
{!claudeCliStatus?.installed && (
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
Install Claude CLI
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Required for subscription-based authentication
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm text-muted-foreground">
|
|
||||||
macOS / Linux
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
|
||||||
curl -fsSL https://claude.ai/install.sh | bash
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
copyCommand(
|
|
||||||
"curl -fsSL https://claude.ai/install.sh | bash"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm text-muted-foreground">Windows</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
|
||||||
irm https://claude.ai/install.ps1 | iex
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
copyCommand("irm https://claude.ai/install.ps1 | iex")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isInstalling && (
|
|
||||||
<TerminalOutput lines={installProgress.output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={install}
|
|
||||||
disabled={isInstalling}
|
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
|
||||||
data-testid="install-claude-button"
|
|
||||||
>
|
|
||||||
{isInstalling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Installing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Auto Install
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Authentication Section */}
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Key className="w-5 h-5" />
|
|
||||||
Authentication
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Choose your authentication method</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* Option 1: Subscription Token */}
|
|
||||||
{authMethod === "token" ? (
|
|
||||||
<div className="p-4 rounded-lg bg-brand-500/5 border border-brand-500/20 space-y-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Subscription Token
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
Use your Claude subscription (no API charges)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{claudeCliStatus?.installed ? (
|
|
||||||
<>
|
|
||||||
{/* Primary: Automated OAuth setup */}
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowTokenModal(true)}
|
|
||||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white mb-4"
|
|
||||||
data-testid="setup-oauth-button"
|
|
||||||
>
|
|
||||||
<Terminal className="w-4 h-4 mr-2" />
|
|
||||||
Setup with OAuth
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="relative my-4">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-brand-500/5 px-2 text-muted-foreground">
|
|
||||||
or paste manually
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fallback: Manual token entry */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-foreground text-sm">
|
|
||||||
Paste token from{" "}
|
|
||||||
<code className="bg-muted px-1 py-0.5 rounded text-xs">
|
|
||||||
claude setup-token
|
|
||||||
</code>
|
|
||||||
:
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Paste token here..."
|
|
||||||
value={oauthToken}
|
|
||||||
onChange={(e) => setOAuthToken(e.target.value)}
|
|
||||||
className="bg-input border-border text-foreground"
|
|
||||||
data-testid="oauth-token-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setAuthMethod(null)}
|
|
||||||
className="border-border"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveOAuthToken(oauthToken)}
|
|
||||||
disabled={isSavingOAuth || !oauthToken.trim()}
|
|
||||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
|
||||||
data-testid="save-oauth-token-button"
|
|
||||||
>
|
|
||||||
{isSavingOAuth ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save Token"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="p-3 rounded bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<p className="text-sm text-yellow-600">
|
|
||||||
<AlertCircle className="w-4 h-4 inline mr-1" />
|
|
||||||
Install Claude CLI first to use subscription
|
|
||||||
authentication
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : authMethod === "api_key" ? (
|
|
||||||
/* Option 2: API Key */
|
|
||||||
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/20 space-y-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Key className="w-5 h-5 text-green-500 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-medium text-foreground">API Key</p>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
|
||||||
Pay-per-use with your Anthropic API key
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="anthropic-key"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
Anthropic API Key
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="anthropic-key"
|
|
||||||
type="password"
|
|
||||||
placeholder="sk-ant-..."
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
className="bg-input border-border text-foreground"
|
|
||||||
data-testid="anthropic-api-key-input"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Get your API key from{" "}
|
|
||||||
<a
|
|
||||||
href="https://console.anthropic.com/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-brand-500 hover:underline"
|
|
||||||
>
|
|
||||||
console.anthropic.com
|
|
||||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setAuthMethod(null)}
|
|
||||||
className="border-border"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveApiKeyToken(apiKey)}
|
|
||||||
disabled={isSavingApiKey || !apiKey.trim()}
|
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="save-anthropic-key-button"
|
|
||||||
>
|
|
||||||
{isSavingApiKey ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save API Key"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Auth Method Selection */
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setAuthMethod("token")}
|
|
||||||
className="p-4 rounded-lg border border-border hover:border-brand-500/50 bg-card hover:bg-brand-500/5 transition-all text-left"
|
|
||||||
data-testid="select-subscription-auth"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Shield className="w-6 h-6 text-brand-500" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Subscription
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Use your Claude subscription
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-brand-500 mt-2">
|
|
||||||
No API charges
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setAuthMethod("api_key")}
|
|
||||||
className="p-4 rounded-lg border border-border hover:border-green-500/50 bg-card hover:bg-green-500/5 transition-all text-left"
|
|
||||||
data-testid="select-api-key-auth"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Key className="w-6 h-6 text-green-500" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">API Key</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Use Anthropic API key
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-green-500 mt-2">Pay-per-use</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success State */}
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Card className="bg-green-500/5 border-green-500/20">
|
|
||||||
<CardContent className="py-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Claude is ready to use!
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{getAuthMethodLabel() && `Using ${getAuthMethodLabel()}. `}You
|
|
||||||
can proceed to the next step
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onBack}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onSkip}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
className="bg-brand-500 hover:bg-brand-600 text-white"
|
|
||||||
data-testid="claude-next-button"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OAuth Setup Modal */}
|
|
||||||
<SetupTokenModal
|
|
||||||
open={showTokenModal}
|
|
||||||
onClose={() => setShowTokenModal(false)}
|
|
||||||
onTokenObtained={handleTokenFromModal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { useSetupStore } from "@/store/setup-store";
|
|
||||||
import { useAppStore } from "@/store/app-store";
|
|
||||||
import { getElectronAPI } from "@/lib/electron";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Terminal,
|
|
||||||
Key,
|
|
||||||
ArrowRight,
|
|
||||||
ArrowLeft,
|
|
||||||
ExternalLink,
|
|
||||||
Copy,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Download,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { StatusBadge, TerminalOutput } from "../components";
|
|
||||||
import {
|
|
||||||
useCliStatus,
|
|
||||||
useCliInstallation,
|
|
||||||
useTokenSave,
|
|
||||||
} from "../hooks";
|
|
||||||
|
|
||||||
interface CodexSetupStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
onBack: () => void;
|
|
||||||
onSkip: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexSetupStep({
|
|
||||||
onNext,
|
|
||||||
onBack,
|
|
||||||
onSkip,
|
|
||||||
}: CodexSetupStepProps) {
|
|
||||||
const {
|
|
||||||
codexCliStatus,
|
|
||||||
codexAuthStatus,
|
|
||||||
setCodexCliStatus,
|
|
||||||
setCodexAuthStatus,
|
|
||||||
setCodexInstallProgress,
|
|
||||||
} = useSetupStore();
|
|
||||||
const { setApiKeys, apiKeys } = useAppStore();
|
|
||||||
|
|
||||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
|
|
||||||
// Memoize API functions to prevent infinite loops
|
|
||||||
const statusApi = useCallback(
|
|
||||||
() => getElectronAPI().setup?.getCodexStatus() || Promise.reject(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const installApi = useCallback(
|
|
||||||
() => getElectronAPI().setup?.installCodex() || Promise.reject(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use custom hooks
|
|
||||||
const { isChecking, checkStatus } = useCliStatus({
|
|
||||||
cliType: "codex",
|
|
||||||
statusApi,
|
|
||||||
setCliStatus: setCodexCliStatus,
|
|
||||||
setAuthStatus: setCodexAuthStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onInstallSuccess = useCallback(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const { isInstalling, installProgress, install } = useCliInstallation({
|
|
||||||
cliType: "codex",
|
|
||||||
installApi,
|
|
||||||
onProgressEvent: getElectronAPI().setup?.onInstallProgress,
|
|
||||||
onSuccess: onInstallSuccess,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSaving: isSavingKey, saveToken: saveApiKeyToken } = useTokenSave({
|
|
||||||
provider: "openai",
|
|
||||||
onSuccess: () => {
|
|
||||||
setCodexAuthStatus({
|
|
||||||
authenticated: true,
|
|
||||||
method: "api_key",
|
|
||||||
apiKeyValid: true,
|
|
||||||
});
|
|
||||||
setApiKeys({ ...apiKeys, openai: apiKey });
|
|
||||||
setShowApiKeyInput(false);
|
|
||||||
checkStatus();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync install progress to store
|
|
||||||
useEffect(() => {
|
|
||||||
setCodexInstallProgress({
|
|
||||||
isInstalling,
|
|
||||||
output: installProgress.output,
|
|
||||||
});
|
|
||||||
}, [isInstalling, installProgress, setCodexInstallProgress]);
|
|
||||||
|
|
||||||
// Check status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
checkStatus();
|
|
||||||
}, [checkStatus]);
|
|
||||||
|
|
||||||
const copyCommand = (command: string) => {
|
|
||||||
navigator.clipboard.writeText(command);
|
|
||||||
toast.success("Command copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAuthenticated = codexAuthStatus?.authenticated || apiKeys.openai;
|
|
||||||
|
|
||||||
const getAuthMethodLabel = () => {
|
|
||||||
if (!isAuthenticated) return null;
|
|
||||||
if (apiKeys.openai) return "API Key (Manual)";
|
|
||||||
if (codexAuthStatus?.method === "api_key") return "API Key (Auth File)";
|
|
||||||
if (codexAuthStatus?.method === "env") return "API Key (Environment)";
|
|
||||||
if (codexAuthStatus?.method === "cli_verified")
|
|
||||||
return "CLI Login (ChatGPT)";
|
|
||||||
return "Authenticated";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Terminal className="w-8 h-8 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
|
||||||
Codex CLI Setup
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
OpenAI's GPT-5.1 Codex for advanced code generation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Card */}
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">Installation Status</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={checkStatus}
|
|
||||||
disabled={isChecking}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`w-4 h-4 ${isChecking ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-foreground">CLI Installation</span>
|
|
||||||
{isChecking ? (
|
|
||||||
<StatusBadge status="checking" label="Checking..." />
|
|
||||||
) : codexCliStatus?.installed ? (
|
|
||||||
<StatusBadge status="installed" label="Installed" />
|
|
||||||
) : (
|
|
||||||
<StatusBadge status="not_installed" label="Not Installed" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{codexCliStatus?.version && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">Version</span>
|
|
||||||
<span className="text-sm font-mono text-foreground">
|
|
||||||
{codexCliStatus.version}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-foreground">Authentication</span>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status="authenticated" label="Authenticated" />
|
|
||||||
{getAuthMethodLabel() && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
({getAuthMethodLabel()})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<StatusBadge
|
|
||||||
status="not_authenticated"
|
|
||||||
label="Not Authenticated"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Installation Section */}
|
|
||||||
{!codexCliStatus?.installed && (
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Download className="w-5 h-5" />
|
|
||||||
Install Codex CLI
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Install via npm (Node.js required)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm text-muted-foreground">
|
|
||||||
npm (Global installation)
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
|
||||||
npm install -g @openai/codex
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => copyCommand("npm install -g @openai/codex")}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isInstalling && (
|
|
||||||
<TerminalOutput lines={installProgress.output} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={install}
|
|
||||||
disabled={isInstalling}
|
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="install-codex-button"
|
|
||||||
>
|
|
||||||
{isInstalling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Installing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Auto Install
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-yellow-500 mt-0.5" />
|
|
||||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
|
||||||
Requires Node.js to be installed. If the auto-install fails,
|
|
||||||
try running the command manually in your terminal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Authentication Section */}
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<Key className="w-5 h-5" />
|
|
||||||
Authentication
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Codex requires an OpenAI API key</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{codexCliStatus?.installed && (
|
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Terminal className="w-5 h-5 text-green-500 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Authenticate via CLI
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
|
||||||
Run this command in your terminal:
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="bg-muted px-3 py-1 rounded text-sm font-mono text-foreground">
|
|
||||||
codex auth login
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => copyCommand("codex auth login")}
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-card px-2 text-muted-foreground">
|
|
||||||
or enter API key
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showApiKeyInput ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="openai-key" className="text-foreground">
|
|
||||||
OpenAI API Key
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="openai-key"
|
|
||||||
type="password"
|
|
||||||
placeholder="sk-..."
|
|
||||||
value={apiKey}
|
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
|
||||||
className="bg-input border-border text-foreground"
|
|
||||||
data-testid="openai-api-key-input"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Get your API key from{" "}
|
|
||||||
<a
|
|
||||||
href="https://platform.openai.com/api-keys"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-green-500 hover:underline"
|
|
||||||
>
|
|
||||||
platform.openai.com
|
|
||||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowApiKeyInput(false)}
|
|
||||||
className="border-border"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => saveApiKeyToken(apiKey)}
|
|
||||||
disabled={isSavingKey || !apiKey.trim()}
|
|
||||||
className="flex-1 bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="save-openai-key-button"
|
|
||||||
>
|
|
||||||
{isSavingKey ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Save API Key"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowApiKeyInput(true)}
|
|
||||||
className="w-full border-border"
|
|
||||||
data-testid="use-openai-key-button"
|
|
||||||
>
|
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
Enter OpenAI API Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Success State */}
|
|
||||||
{isAuthenticated && (
|
|
||||||
<Card className="bg-green-500/5 border-green-500/20">
|
|
||||||
<CardContent className="py-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
Codex is ready to use!
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{getAuthMethodLabel() &&
|
|
||||||
`Authenticated via ${getAuthMethodLabel()}. `}
|
|
||||||
You can proceed to complete setup
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onBack}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={onSkip}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
className="bg-green-500 hover:bg-green-600 text-white"
|
|
||||||
data-testid="codex-next-button"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +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, codexCliStatus, codexAuthStatus } =
|
|
||||||
useSetupStore();
|
|
||||||
const { apiKeys } = useAppStore();
|
|
||||||
|
|
||||||
const claudeReady =
|
|
||||||
(claudeCliStatus?.installed && claudeAuthStatus?.authenticated) ||
|
|
||||||
apiKeys.anthropic;
|
|
||||||
const codexReady =
|
|
||||||
(codexCliStatus?.installed && codexAuthStatus?.authenticated) ||
|
|
||||||
apiKeys.openai;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
|
||||||
<Card
|
|
||||||
className={`bg-card/50 border ${
|
|
||||||
claudeReady ? "border-green-500/50" : "border-yellow-500/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{claudeReady ? (
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="font-medium text-foreground">Claude</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{claudeReady ? "Ready to use" : "Configure later in settings"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card
|
|
||||||
className={`bg-card/50 border ${
|
|
||||||
codexReady ? "border-green-500/50" : "border-yellow-500/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{codexReady ? (
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-6 h-6 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="font-medium text-foreground">Codex</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{codexReady ? "Ready to use" : "Configure later in settings"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border max-w-md mx-auto">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Shield className="w-5 h-5 text-brand-500 mt-0.5" />
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
Your credentials are secure
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
API keys are stored locally and never sent to our servers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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 { CodexSetupStep } from "./codex-setup-step";
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Terminal, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface WelcomeStepProps {
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WelcomeStep({ onNext }: WelcomeStepProps) {
|
|
||||||
return (
|
|
||||||
<div className="text-center space-y-6">
|
|
||||||
<div className="flex items-center justify-center mx-auto">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src="/logo.png" alt="Automaker Logo" className="w-24 h-24" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold text-foreground mb-3">
|
|
||||||
Welcome to Automaker
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground max-w-md mx-auto">
|
|
||||||
Let's set up your development environment. We'll check for
|
|
||||||
required CLI tools and help you configure them.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto">
|
|
||||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5 text-brand-500" />
|
|
||||||
Claude CLI
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Anthropic's powerful AI assistant for code generation and
|
|
||||||
analysis
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border hover:border-brand-500/50 transition-colors">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5 text-green-500" />
|
|
||||||
Codex CLI
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
OpenAI's GPT-5.1 Codex for advanced code generation tasks
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</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={onNext}
|
|
||||||
data-testid="setup-start-button"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,971 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } 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 {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Save, RefreshCw, FileText, Sparkles, Loader2, FilePlus2, AlertCircle, ListPlus, CheckCircle2 } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { XmlSyntaxEditor } from "@/components/ui/xml-syntax-editor";
|
|
||||||
import type { SpecRegenerationEvent } from "@/types/electron";
|
|
||||||
|
|
||||||
// Delay before reloading spec file to ensure it's written to disk
|
|
||||||
const SPEC_FILE_WRITE_DELAY = 500;
|
|
||||||
|
|
||||||
// Interval for polling backend status during generation
|
|
||||||
const STATUS_CHECK_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
export function SpecView() {
|
|
||||||
const { currentProject, appSpec, setAppSpec } = useAppStore();
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
const [specExists, setSpecExists] = useState(true);
|
|
||||||
|
|
||||||
// Regeneration state
|
|
||||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
|
||||||
const [projectDefinition, setProjectDefinition] = useState("");
|
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
|
||||||
|
|
||||||
// Create spec state
|
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
|
||||||
const [projectOverview, setProjectOverview] = useState("");
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [generateFeatures, setGenerateFeatures] = useState(true);
|
|
||||||
|
|
||||||
// Generate features only state
|
|
||||||
const [isGeneratingFeatures, setIsGeneratingFeatures] = useState(false);
|
|
||||||
|
|
||||||
// Logs state (kept for internal tracking, but UI removed)
|
|
||||||
const [logs, setLogs] = useState<string>("");
|
|
||||||
const logsRef = useRef<string>("");
|
|
||||||
|
|
||||||
// Phase tracking and status
|
|
||||||
const [currentPhase, setCurrentPhase] = useState<string>("");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
|
||||||
const statusCheckRef = useRef<boolean>(false);
|
|
||||||
const stateRestoredRef = useRef<boolean>(false);
|
|
||||||
|
|
||||||
// Load spec from file
|
|
||||||
const loadSpec = useCallback(async () => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
const result = await api.readFile(
|
|
||||||
`${currentProject.path}/.automaker/app_spec.txt`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && result.content) {
|
|
||||||
setAppSpec(result.content);
|
|
||||||
setSpecExists(true);
|
|
||||||
setHasChanges(false);
|
|
||||||
} else {
|
|
||||||
// File doesn't exist
|
|
||||||
setAppSpec("");
|
|
||||||
setSpecExists(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load spec:", error);
|
|
||||||
setSpecExists(false);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentProject, setAppSpec]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSpec();
|
|
||||||
}, [loadSpec]);
|
|
||||||
|
|
||||||
// Check if spec regeneration is running when component mounts or project changes
|
|
||||||
useEffect(() => {
|
|
||||||
const checkStatus = async () => {
|
|
||||||
if (!currentProject || statusCheckRef.current) return;
|
|
||||||
statusCheckRef.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
statusCheckRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await api.specRegeneration.status();
|
|
||||||
console.log("[SpecView] Status check on mount:", status);
|
|
||||||
|
|
||||||
if (status.success && status.isRunning) {
|
|
||||||
// Something is running - restore state using backend's authoritative phase
|
|
||||||
console.log("[SpecView] Spec generation is running - restoring state", { phase: status.currentPhase });
|
|
||||||
|
|
||||||
if (!stateRestoredRef.current) {
|
|
||||||
setIsCreating(true);
|
|
||||||
setIsRegenerating(true);
|
|
||||||
stateRestoredRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the backend's currentPhase directly - single source of truth
|
|
||||||
if (status.currentPhase) {
|
|
||||||
setCurrentPhase(status.currentPhase);
|
|
||||||
} else {
|
|
||||||
setCurrentPhase("in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add resume message to logs if needed
|
|
||||||
if (!logsRef.current) {
|
|
||||||
const resumeMessage = "[Status] Resumed monitoring existing spec generation process...\n";
|
|
||||||
logsRef.current = resumeMessage;
|
|
||||||
setLogs(resumeMessage);
|
|
||||||
} else if (!logsRef.current.includes("Resumed monitoring")) {
|
|
||||||
const resumeMessage = "\n[Status] Resumed monitoring existing spec generation process...\n";
|
|
||||||
logsRef.current = logsRef.current + resumeMessage;
|
|
||||||
setLogs(logsRef.current);
|
|
||||||
}
|
|
||||||
} else if (status.success && !status.isRunning) {
|
|
||||||
// Not running - clear all state
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setCurrentPhase("");
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[SpecView] Failed to check status:", error);
|
|
||||||
} finally {
|
|
||||||
statusCheckRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset restoration flag when project changes
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
checkStatus();
|
|
||||||
}, [currentProject]);
|
|
||||||
|
|
||||||
// Sync state when tab becomes visible (user returns to spec editor)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibilityChange = async () => {
|
|
||||||
if (!document.hidden && currentProject && (isCreating || isRegenerating || isGeneratingFeatures)) {
|
|
||||||
// Tab became visible and we think we're still generating - verify status from backend
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) return;
|
|
||||||
|
|
||||||
const status = await api.specRegeneration.status();
|
|
||||||
console.log("[SpecView] Visibility change - status check:", status);
|
|
||||||
|
|
||||||
if (!status.isRunning) {
|
|
||||||
// Backend says not running - clear state
|
|
||||||
console.log("[SpecView] Visibility change: Backend indicates generation complete - clearing state");
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase("");
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
loadSpec();
|
|
||||||
} else if (status.currentPhase) {
|
|
||||||
// Still running - update phase from backend
|
|
||||||
setCurrentPhase(status.currentPhase);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[SpecView] Failed to check status on visibility change:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, loadSpec]);
|
|
||||||
|
|
||||||
// Periodic status check to ensure state stays in sync (only when we think we're running)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!currentProject || (!isCreating && !isRegenerating && !isGeneratingFeatures)) return;
|
|
||||||
|
|
||||||
const intervalId = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) return;
|
|
||||||
|
|
||||||
const status = await api.specRegeneration.status();
|
|
||||||
|
|
||||||
if (!status.isRunning) {
|
|
||||||
// Backend says not running - clear state
|
|
||||||
console.log("[SpecView] Periodic check: Backend indicates generation complete - clearing state");
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase("");
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
loadSpec();
|
|
||||||
} else if (status.currentPhase && status.currentPhase !== currentPhase) {
|
|
||||||
// Still running but phase changed - update from backend
|
|
||||||
console.log("[SpecView] Periodic check: Phase updated from backend", {
|
|
||||||
old: currentPhase,
|
|
||||||
new: status.currentPhase
|
|
||||||
});
|
|
||||||
setCurrentPhase(status.currentPhase);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[SpecView] Periodic status check error:", error);
|
|
||||||
}
|
|
||||||
}, STATUS_CHECK_INTERVAL_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [currentProject, isCreating, isRegenerating, isGeneratingFeatures, currentPhase, loadSpec]);
|
|
||||||
|
|
||||||
// Subscribe to spec regeneration events
|
|
||||||
useEffect(() => {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) return;
|
|
||||||
|
|
||||||
const unsubscribe = api.specRegeneration.onEvent((event: SpecRegenerationEvent) => {
|
|
||||||
console.log("[SpecView] Regeneration event:", event.type);
|
|
||||||
|
|
||||||
if (event.type === "spec_regeneration_progress") {
|
|
||||||
// Extract phase from content if present
|
|
||||||
const phaseMatch = event.content.match(/\[Phase:\s*([^\]]+)\]/);
|
|
||||||
if (phaseMatch) {
|
|
||||||
const phase = phaseMatch[1];
|
|
||||||
setCurrentPhase(phase);
|
|
||||||
console.log(`[SpecView] Phase updated: ${phase}`);
|
|
||||||
|
|
||||||
// If phase is "complete", clear running state immediately
|
|
||||||
if (phase === "complete") {
|
|
||||||
console.log("[SpecView] Phase is complete - clearing state");
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
// Small delay to ensure spec file is written
|
|
||||||
setTimeout(() => {
|
|
||||||
loadSpec();
|
|
||||||
}, SPEC_FILE_WRITE_DELAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for completion indicators in content
|
|
||||||
if (event.content.includes("All tasks completed") ||
|
|
||||||
event.content.includes("✓ All tasks completed")) {
|
|
||||||
// This indicates everything is done - clear state immediately
|
|
||||||
console.log("[SpecView] Detected completion in progress message - clearing state");
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setCurrentPhase("");
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
loadSpec();
|
|
||||||
}, SPEC_FILE_WRITE_DELAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append progress to logs
|
|
||||||
const newLog = logsRef.current + event.content;
|
|
||||||
logsRef.current = newLog;
|
|
||||||
setLogs(newLog);
|
|
||||||
console.log("[SpecView] Progress:", event.content.substring(0, 100));
|
|
||||||
|
|
||||||
// Clear error message when we get new progress
|
|
||||||
if (errorMessage) {
|
|
||||||
setErrorMessage("");
|
|
||||||
}
|
|
||||||
} else if (event.type === "spec_regeneration_tool") {
|
|
||||||
// Check if this is a feature creation tool
|
|
||||||
const isFeatureTool = event.tool === "mcp__automaker-tools__UpdateFeatureStatus" ||
|
|
||||||
event.tool === "UpdateFeatureStatus" ||
|
|
||||||
event.tool?.includes("Feature");
|
|
||||||
|
|
||||||
if (isFeatureTool) {
|
|
||||||
// Ensure we're in feature generation phase
|
|
||||||
if (currentPhase !== "feature_generation") {
|
|
||||||
setCurrentPhase("feature_generation");
|
|
||||||
setIsCreating(true);
|
|
||||||
setIsRegenerating(true);
|
|
||||||
console.log("[SpecView] Detected feature creation tool - setting phase to feature_generation");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log tool usage with details
|
|
||||||
const toolInput = event.input ? ` (${JSON.stringify(event.input).substring(0, 100)}...)` : "";
|
|
||||||
const toolLog = `\n[Tool] ${event.tool}${toolInput}\n`;
|
|
||||||
const newLog = logsRef.current + toolLog;
|
|
||||||
logsRef.current = newLog;
|
|
||||||
setLogs(newLog);
|
|
||||||
console.log("[SpecView] Tool:", event.tool, event.input);
|
|
||||||
} else if (event.type === "spec_regeneration_complete") {
|
|
||||||
// Add completion message to logs first
|
|
||||||
const completionLog = logsRef.current + `\n[Complete] ${event.message}\n`;
|
|
||||||
logsRef.current = completionLog;
|
|
||||||
setLogs(completionLog);
|
|
||||||
|
|
||||||
// --- Completion Detection Logic ---
|
|
||||||
// The backend sends explicit signals for completion:
|
|
||||||
// 1. "All tasks completed" in the message
|
|
||||||
// 2. [Phase: complete] marker in logs
|
|
||||||
// 3. "Spec regeneration complete!" for regeneration
|
|
||||||
// 4. "Initial spec creation complete!" for creation without features
|
|
||||||
const isFinalCompletionMessage = event.message?.includes("All tasks completed") ||
|
|
||||||
event.message === "All tasks completed!" ||
|
|
||||||
event.message === "All tasks completed" ||
|
|
||||||
event.message === "Spec regeneration complete!" ||
|
|
||||||
event.message === "Initial spec creation complete!";
|
|
||||||
|
|
||||||
const hasCompletePhase = logsRef.current.includes("[Phase: complete]");
|
|
||||||
|
|
||||||
// Intermediate completion means features are being generated after spec creation
|
|
||||||
const isIntermediateCompletion = event.message?.includes("Features are being generated") ||
|
|
||||||
event.message?.includes("features are being generated");
|
|
||||||
|
|
||||||
// Rely solely on explicit backend signals
|
|
||||||
const shouldComplete = (isFinalCompletionMessage || hasCompletePhase) && !isIntermediateCompletion;
|
|
||||||
|
|
||||||
if (shouldComplete) {
|
|
||||||
// Fully complete - clear all states immediately
|
|
||||||
console.log("[SpecView] Final completion detected - clearing state", {
|
|
||||||
isFinalCompletionMessage,
|
|
||||||
hasCompletePhase,
|
|
||||||
message: event.message
|
|
||||||
});
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase("");
|
|
||||||
setShowRegenerateDialog(false);
|
|
||||||
setShowCreateDialog(false);
|
|
||||||
setProjectDefinition("");
|
|
||||||
setProjectOverview("");
|
|
||||||
setErrorMessage("");
|
|
||||||
stateRestoredRef.current = false;
|
|
||||||
|
|
||||||
// Reload the spec with delay to ensure file is written to disk
|
|
||||||
setTimeout(() => {
|
|
||||||
loadSpec();
|
|
||||||
}, SPEC_FILE_WRITE_DELAY);
|
|
||||||
|
|
||||||
// Show success toast notification
|
|
||||||
const isRegeneration = event.message?.includes("regeneration");
|
|
||||||
const isFeatureGeneration = event.message?.includes("Feature generation");
|
|
||||||
toast.success(
|
|
||||||
isFeatureGeneration
|
|
||||||
? "Feature Generation Complete"
|
|
||||||
: isRegeneration
|
|
||||||
? "Spec Regeneration Complete"
|
|
||||||
: "Spec Creation Complete",
|
|
||||||
{
|
|
||||||
description: isFeatureGeneration
|
|
||||||
? "Features have been created from the app specification."
|
|
||||||
: "Your app specification has been saved.",
|
|
||||||
icon: <CheckCircle2 className="w-4 h-4" />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (isIntermediateCompletion) {
|
|
||||||
// Intermediate completion - keep state active for feature generation
|
|
||||||
setIsCreating(true);
|
|
||||||
setIsRegenerating(true);
|
|
||||||
setCurrentPhase("feature_generation");
|
|
||||||
console.log("[SpecView] Intermediate completion, continuing with feature generation");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[SpecView] Spec generation event:", event.message);
|
|
||||||
} else if (event.type === "spec_regeneration_error") {
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setIsCreating(false);
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(event.error);
|
|
||||||
stateRestoredRef.current = false; // Reset restoration flag
|
|
||||||
// Add error to logs
|
|
||||||
const errorLog = logsRef.current + `\n\n[ERROR] ${event.error}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
console.error("[SpecView] Regeneration error:", event.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [loadSpec]);
|
|
||||||
|
|
||||||
// Save spec to file
|
|
||||||
const saveSpec = async () => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
await api.writeFile(
|
|
||||||
`${currentProject.path}/.automaker/app_spec.txt`,
|
|
||||||
appSpec
|
|
||||||
);
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to save spec:", error);
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (value: string) => {
|
|
||||||
setAppSpec(value);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegenerate = async () => {
|
|
||||||
if (!currentProject || !projectDefinition.trim()) return;
|
|
||||||
|
|
||||||
setIsRegenerating(true);
|
|
||||||
setCurrentPhase("initialization");
|
|
||||||
setErrorMessage("");
|
|
||||||
// Reset logs when starting new regeneration
|
|
||||||
logsRef.current = "";
|
|
||||||
setLogs("");
|
|
||||||
console.log("[SpecView] Starting spec regeneration");
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
console.error("[SpecView] Spec regeneration not available");
|
|
||||||
setIsRegenerating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.generate(
|
|
||||||
currentProject.path,
|
|
||||||
projectDefinition.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const errorMsg = result.error || "Unknown error";
|
|
||||||
console.error("[SpecView] Failed to start regeneration:", errorMsg);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to start regeneration: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
// If successful, we'll wait for the events to update the state
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error("[SpecView] Failed to regenerate spec:", errorMsg);
|
|
||||||
setIsRegenerating(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to regenerate spec: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateSpec = async () => {
|
|
||||||
if (!currentProject || !projectOverview.trim()) return;
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
setShowCreateDialog(false);
|
|
||||||
setCurrentPhase("initialization");
|
|
||||||
setErrorMessage("");
|
|
||||||
// Reset logs when starting new generation
|
|
||||||
logsRef.current = "";
|
|
||||||
setLogs("");
|
|
||||||
console.log("[SpecView] Starting spec creation, generateFeatures:", generateFeatures);
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
console.error("[SpecView] Spec regeneration not available");
|
|
||||||
setIsCreating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.create(
|
|
||||||
currentProject.path,
|
|
||||||
projectOverview.trim(),
|
|
||||||
generateFeatures
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const errorMsg = result.error || "Unknown error";
|
|
||||||
console.error("[SpecView] Failed to start spec creation:", errorMsg);
|
|
||||||
setIsCreating(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to start spec creation: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
// If successful, we'll wait for the events to update the state
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error("[SpecView] Failed to create spec:", errorMsg);
|
|
||||||
setIsCreating(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to create spec: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateFeatures = async () => {
|
|
||||||
if (!currentProject) return;
|
|
||||||
|
|
||||||
setIsGeneratingFeatures(true);
|
|
||||||
setShowRegenerateDialog(false);
|
|
||||||
setCurrentPhase("initialization");
|
|
||||||
setErrorMessage("");
|
|
||||||
// Reset logs when starting feature generation
|
|
||||||
logsRef.current = "";
|
|
||||||
setLogs("");
|
|
||||||
console.log("[SpecView] Starting feature generation from existing spec");
|
|
||||||
try {
|
|
||||||
const api = getElectronAPI();
|
|
||||||
if (!api.specRegeneration) {
|
|
||||||
console.error("[SpecView] Spec regeneration not available");
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await api.specRegeneration.generateFeatures(
|
|
||||||
currentProject.path
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
const errorMsg = result.error || "Unknown error";
|
|
||||||
console.error("[SpecView] Failed to start feature generation:", errorMsg);
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to start feature generation: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
// If successful, we'll wait for the events to update the state
|
|
||||||
} catch (error) {
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
console.error("[SpecView] Failed to generate features:", errorMsg);
|
|
||||||
setIsGeneratingFeatures(false);
|
|
||||||
setCurrentPhase("error");
|
|
||||||
setErrorMessage(errorMsg);
|
|
||||||
const errorLog = `[Error] Failed to generate features: ${errorMsg}\n`;
|
|
||||||
logsRef.current = errorLog;
|
|
||||||
setLogs(errorLog);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!currentProject) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex items-center justify-center"
|
|
||||||
data-testid="spec-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="spec-view-loading"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show empty state when no spec exists (isCreating is handled by bottom-right indicator in sidebar)
|
|
||||||
if (!specExists) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
||||||
data-testid="spec-view-empty"
|
|
||||||
>
|
|
||||||
{/* 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">
|
|
||||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">App Specification</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{currentProject.path}/.automaker/app_spec.txt
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(isCreating || isRegenerating) && (
|
|
||||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
|
|
||||||
<div className="relative">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
|
|
||||||
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
|
||||||
{isCreating ? "Generating Specification" : "Regenerating Specification"}
|
|
||||||
</span>
|
|
||||||
{currentPhase && (
|
|
||||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
|
||||||
{currentPhase === "initialization" && "Initializing..."}
|
|
||||||
{currentPhase === "setup" && "Setting up tools..."}
|
|
||||||
{currentPhase === "analysis" && "Analyzing project structure..."}
|
|
||||||
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
|
|
||||||
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
|
|
||||||
{currentPhase === "complete" && "Complete!"}
|
|
||||||
{currentPhase === "error" && "Error occurred"}
|
|
||||||
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="flex items-center gap-2 text-destructive">
|
|
||||||
<span className="text-sm font-medium">Error: {errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
<div className="flex-1 flex items-center justify-center p-8">
|
|
||||||
<div className="text-center max-w-md">
|
|
||||||
<div className="mb-6 flex justify-center">
|
|
||||||
<div className="p-4 rounded-full bg-primary/10">
|
|
||||||
{isCreating ? (
|
|
||||||
<Loader2 className="w-12 h-12 text-primary animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FilePlus2 className="w-12 h-12 text-primary" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold mb-4">
|
|
||||||
{isCreating ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-4">
|
|
||||||
<span>Generating App Specification</span>
|
|
||||||
</div>
|
|
||||||
{currentPhase && (
|
|
||||||
<div className="px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md inline-flex items-center justify-center">
|
|
||||||
<span className="text-sm font-semibold text-primary text-center tracking-tight">
|
|
||||||
{currentPhase === "initialization" && "Initializing..."}
|
|
||||||
{currentPhase === "setup" && "Setting up tools..."}
|
|
||||||
{currentPhase === "analysis" && "Analyzing project structure..."}
|
|
||||||
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
|
|
||||||
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
|
|
||||||
{currentPhase === "complete" && "Complete!"}
|
|
||||||
{currentPhase === "error" && "Error occurred"}
|
|
||||||
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"No App Specification Found"
|
|
||||||
)}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
{isCreating
|
|
||||||
? currentPhase === "feature_generation"
|
|
||||||
? "The app specification has been created! Now generating features from the implementation roadmap..."
|
|
||||||
: "We're analyzing your project and generating a comprehensive specification. This may take a few moments..."
|
|
||||||
: "Create an app specification to help our system understand your project. We'll analyze your codebase and generate a comprehensive spec based on your description."}
|
|
||||||
</p>
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="mb-4 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
|
||||||
<p className="text-sm text-destructive font-medium">Error:</p>
|
|
||||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isCreating && (
|
|
||||||
<div className="flex gap-2 justify-center">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
onClick={() => setShowCreateDialog(true)}
|
|
||||||
>
|
|
||||||
<FilePlus2 className="w-5 h-5 mr-2" />
|
|
||||||
Create app_spec
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={showCreateDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !isCreating) {
|
|
||||||
setShowCreateDialog(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create App Specification</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) => setProjectOverview(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
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start space-x-3 pt-2">
|
|
||||||
<Checkbox
|
|
||||||
id="generate-features"
|
|
||||||
checked={generateFeatures}
|
|
||||||
onCheckedChange={(checked) => setGenerateFeatures(checked === true)}
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label
|
|
||||||
htmlFor="generate-features"
|
|
||||||
className={`text-sm font-medium ${isCreating ? "" : "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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setShowCreateDialog(false)}
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleCreateSpec}
|
|
||||||
disabled={!projectOverview.trim() || isCreating}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={showCreateDialog && !isCreating}
|
|
||||||
>
|
|
||||||
{isCreating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
Generate Spec
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HotkeyButton>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-1 flex flex-col overflow-hidden content-bg"
|
|
||||||
data-testid="spec-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">
|
|
||||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold">App Specification</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{currentProject.path}/.automaker/app_spec.txt
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{(isRegenerating || isCreating || isGeneratingFeatures) && (
|
|
||||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
|
|
||||||
<div className="relative">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-primary flex-shrink-0" />
|
|
||||||
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
|
||||||
{isGeneratingFeatures ? "Generating Features" : isCreating ? "Generating Specification" : "Regenerating Specification"}
|
|
||||||
</span>
|
|
||||||
{currentPhase && (
|
|
||||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
|
||||||
{currentPhase === "initialization" && "Initializing..."}
|
|
||||||
{currentPhase === "setup" && "Setting up tools..."}
|
|
||||||
{currentPhase === "analysis" && "Analyzing project structure..."}
|
|
||||||
{currentPhase === "spec_complete" && "Spec created! Generating features..."}
|
|
||||||
{currentPhase === "feature_generation" && "Creating features from roadmap..."}
|
|
||||||
{currentPhase === "complete" && "Complete!"}
|
|
||||||
{currentPhase === "error" && "Error occurred"}
|
|
||||||
{!["initialization", "setup", "analysis", "spec_complete", "feature_generation", "complete", "error"].includes(currentPhase) && currentPhase}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-gradient-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
|
|
||||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0" />
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">Error</span>
|
|
||||||
<span className="text-xs text-destructive/90 leading-tight font-medium">{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowRegenerateDialog(true)}
|
|
||||||
disabled={isRegenerating || isCreating || isGeneratingFeatures}
|
|
||||||
data-testid="regenerate-spec"
|
|
||||||
>
|
|
||||||
{isRegenerating ? (
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{isRegenerating ? "Regenerating..." : "Regenerate"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={saveSpec}
|
|
||||||
disabled={!hasChanges || isSaving || isCreating || isRegenerating || isGeneratingFeatures}
|
|
||||||
data-testid="save-spec"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{isSaving ? "Saving..." : hasChanges ? "Save Changes" : "Saved"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editor */}
|
|
||||||
<div className="flex-1 p-4 overflow-hidden">
|
|
||||||
<Card className="h-full overflow-hidden">
|
|
||||||
<XmlSyntaxEditor
|
|
||||||
value={appSpec}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Write your app specification here..."
|
|
||||||
data-testid="spec-editor"
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Regenerate Dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={showRegenerateDialog}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !isRegenerating) {
|
|
||||||
setShowRegenerateDialog(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Regenerate App Specification</DialogTitle>
|
|
||||||
<DialogDescription className="text-muted-foreground">
|
|
||||||
We will regenerate your app spec based on a short project definition and the
|
|
||||||
current tech stack found in your project. The agent will analyze your codebase
|
|
||||||
to understand your existing technologies 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">
|
|
||||||
Describe your project
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Provide a clear description of what your app should do. Be as detailed as you
|
|
||||||
want - the more context you provide, the more comprehensive the spec will be.
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
className="w-full h-40 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={projectDefinition}
|
|
||||||
onChange={(e) => setProjectDefinition(e.target.value)}
|
|
||||||
placeholder="e.g., A task management app where users can create projects, add tasks with due dates, assign tasks to team members, track progress with a kanban board, and receive notifications for upcoming deadlines..."
|
|
||||||
disabled={isRegenerating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex justify-between sm:justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleGenerateFeatures}
|
|
||||||
disabled={isRegenerating || isGeneratingFeatures}
|
|
||||||
title="Generate features from the existing app_spec.txt without regenerating the spec"
|
|
||||||
>
|
|
||||||
{isGeneratingFeatures ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ListPlus className="w-4 h-4 mr-2" />
|
|
||||||
Generate Features
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setShowRegenerateDialog(false)}
|
|
||||||
disabled={isRegenerating || isGeneratingFeatures}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<HotkeyButton
|
|
||||||
onClick={handleRegenerate}
|
|
||||||
disabled={!projectDefinition.trim() || isRegenerating || isGeneratingFeatures}
|
|
||||||
hotkey={{ key: "Enter", cmdCtrl: true }}
|
|
||||||
hotkeyActive={showRegenerateDialog && !isRegenerating && !isGeneratingFeatures}
|
|
||||||
>
|
|
||||||
{isRegenerating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Regenerating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
Regenerate Spec
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HotkeyButton>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import type { ApiKeys } from "@/store/app-store";
|
|
||||||
|
|
||||||
export type ProviderKey = "anthropic" | "google" | "openai";
|
|
||||||
|
|
||||||
export interface ProviderConfig {
|
|
||||||
key: ProviderKey;
|
|
||||||
label: string;
|
|
||||||
inputId: string;
|
|
||||||
placeholder: string;
|
|
||||||
value: string;
|
|
||||||
setValue: Dispatch<SetStateAction<string>>;
|
|
||||||
showValue: boolean;
|
|
||||||
setShowValue: Dispatch<SetStateAction<boolean>>;
|
|
||||||
hasStoredKey: string | null | undefined;
|
|
||||||
inputTestId: string;
|
|
||||||
toggleTestId: string;
|
|
||||||
testButton: {
|
|
||||||
onClick: () => Promise<void> | void;
|
|
||||||
disabled: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
testId: string;
|
|
||||||
};
|
|
||||||
result: { success: boolean; message: string } | null;
|
|
||||||
resultTestId: string;
|
|
||||||
resultMessageTestId: string;
|
|
||||||
descriptionPrefix: string;
|
|
||||||
descriptionLinkHref: string;
|
|
||||||
descriptionLinkText: string;
|
|
||||||
descriptionSuffix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderConfigParams {
|
|
||||||
apiKeys: ApiKeys;
|
|
||||||
anthropic: {
|
|
||||||
value: string;
|
|
||||||
setValue: Dispatch<SetStateAction<string>>;
|
|
||||||
show: boolean;
|
|
||||||
setShow: Dispatch<SetStateAction<boolean>>;
|
|
||||||
testing: boolean;
|
|
||||||
onTest: () => Promise<void>;
|
|
||||||
result: { success: boolean; message: string } | null;
|
|
||||||
};
|
|
||||||
google: {
|
|
||||||
value: string;
|
|
||||||
setValue: Dispatch<SetStateAction<string>>;
|
|
||||||
show: boolean;
|
|
||||||
setShow: Dispatch<SetStateAction<boolean>>;
|
|
||||||
testing: boolean;
|
|
||||||
onTest: () => Promise<void>;
|
|
||||||
result: { success: boolean; message: string } | null;
|
|
||||||
};
|
|
||||||
openai: {
|
|
||||||
value: string;
|
|
||||||
setValue: Dispatch<SetStateAction<string>>;
|
|
||||||
show: boolean;
|
|
||||||
setShow: Dispatch<SetStateAction<boolean>>;
|
|
||||||
testing: boolean;
|
|
||||||
onTest: () => Promise<void>;
|
|
||||||
result: { success: boolean; message: string } | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildProviderConfigs = ({
|
|
||||||
apiKeys,
|
|
||||||
anthropic,
|
|
||||||
google,
|
|
||||||
openai,
|
|
||||||
}: ProviderConfigParams): ProviderConfig[] => [
|
|
||||||
{
|
|
||||||
key: "anthropic",
|
|
||||||
label: "Anthropic API Key (Claude)",
|
|
||||||
inputId: "anthropic-key",
|
|
||||||
placeholder: "sk-ant-...",
|
|
||||||
value: anthropic.value,
|
|
||||||
setValue: anthropic.setValue,
|
|
||||||
showValue: anthropic.show,
|
|
||||||
setShowValue: anthropic.setShow,
|
|
||||||
hasStoredKey: apiKeys.anthropic,
|
|
||||||
inputTestId: "anthropic-api-key-input",
|
|
||||||
toggleTestId: "toggle-anthropic-visibility",
|
|
||||||
testButton: {
|
|
||||||
onClick: anthropic.onTest,
|
|
||||||
disabled: !anthropic.value || anthropic.testing,
|
|
||||||
loading: anthropic.testing,
|
|
||||||
testId: "test-claude-connection",
|
|
||||||
},
|
|
||||||
result: anthropic.result,
|
|
||||||
resultTestId: "test-connection-result",
|
|
||||||
resultMessageTestId: "test-connection-message",
|
|
||||||
descriptionPrefix: "Used for Claude AI features. Get your key at",
|
|
||||||
descriptionLinkHref: "https://console.anthropic.com/account/keys",
|
|
||||||
descriptionLinkText: "console.anthropic.com",
|
|
||||||
descriptionSuffix:
|
|
||||||
". Alternatively, the CLAUDE_CODE_OAUTH_TOKEN environment variable can be used.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "google",
|
|
||||||
label: "Google API Key (Gemini)",
|
|
||||||
inputId: "google-key",
|
|
||||||
placeholder: "AIza...",
|
|
||||||
value: google.value,
|
|
||||||
setValue: google.setValue,
|
|
||||||
showValue: google.show,
|
|
||||||
setShowValue: google.setShow,
|
|
||||||
hasStoredKey: apiKeys.google,
|
|
||||||
inputTestId: "google-api-key-input",
|
|
||||||
toggleTestId: "toggle-google-visibility",
|
|
||||||
testButton: {
|
|
||||||
onClick: google.onTest,
|
|
||||||
disabled: !google.value || google.testing,
|
|
||||||
loading: google.testing,
|
|
||||||
testId: "test-gemini-connection",
|
|
||||||
},
|
|
||||||
result: google.result,
|
|
||||||
resultTestId: "gemini-test-connection-result",
|
|
||||||
resultMessageTestId: "gemini-test-connection-message",
|
|
||||||
descriptionPrefix:
|
|
||||||
"Used for Gemini AI features (including image/design prompts). Get your key at",
|
|
||||||
descriptionLinkHref: "https://makersuite.google.com/app/apikey",
|
|
||||||
descriptionLinkText: "makersuite.google.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "openai",
|
|
||||||
label: "OpenAI API Key (Codex/GPT)",
|
|
||||||
inputId: "openai-key",
|
|
||||||
placeholder: "sk-...",
|
|
||||||
value: openai.value,
|
|
||||||
setValue: openai.setValue,
|
|
||||||
showValue: openai.show,
|
|
||||||
setShowValue: openai.setShow,
|
|
||||||
hasStoredKey: apiKeys.openai,
|
|
||||||
inputTestId: "openai-api-key-input",
|
|
||||||
toggleTestId: "toggle-openai-visibility",
|
|
||||||
testButton: {
|
|
||||||
onClick: openai.onTest,
|
|
||||||
disabled: !openai.value || openai.testing,
|
|
||||||
loading: openai.testing,
|
|
||||||
testId: "test-openai-connection",
|
|
||||||
},
|
|
||||||
result: openai.result,
|
|
||||||
resultTestId: "openai-test-connection-result",
|
|
||||||
resultMessageTestId: "openai-test-connection-message",
|
|
||||||
descriptionPrefix: "Used for OpenAI Codex CLI and GPT models. Get your key at",
|
|
||||||
descriptionLinkHref: "https://platform.openai.com/api-keys",
|
|
||||||
descriptionLinkText: "platform.openai.com",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user