Public: anyone can read this; the crew can change it.

feat: Add Repo Contributor feature with AI-powered code editing #25

opened this view on origin

Check · Review · Land · Live — It conflicts with its base. Next: builder, it conflicts with its base.

Conflicts with main

claude/ai-repo-editor-feature-01QXZ4wtQBo5FM7hGE7XbuXa → main 10 commits

  • Can't merge. Conflicts in 1 file: package.json. Merge main into the branch and push again.

    Check faileddeploy 9e03487

A guest

This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.

This pull cannot land as it is, whatever its review says, because it changes guarded paths: package.json (release machinery).

Review

Sign in to review this change.
56 files changed +8922 −311
bun.lockb
Binary file not shown.
package-lock.json +236 −4
@@ -8,6 +8,7 @@
88 "name": "trevato.dev",
99 "version": "0.1.0",
1010 "dependencies": {
11 "@ai-sdk/anthropic": "^2.0.50",
1112 "@calcom/embed-react": "^1.5.0",
1213 "@radix-ui/react-avatar": "^1.1.0",
1314 "@radix-ui/react-dialog": "^1.1.1",
@@ -23,8 +24,10 @@
2324 "@uiw/react-textarea-code-editor": "^3.0.2",
2425 "@vercel/analytics": "^1.5.0",
2526 "@vercel/sandbox": "^1.0.2",
27 "ai": "^6.0.0-beta.122",
2628 "class-variance-authority": "^0.7.0",
2729 "clsx": "^2.1.1",
30 "crypto-js": "^4.2.0",
2831 "framer-motion": "^11.5.4",
2932 "lucide-react": "^0.439.0",
3033 "next": "14.2.9",
@@ -40,9 +43,11 @@
4043 "remark-gfm": "^4.0.0",
4144 "tailwind-merge": "^2.5.2",
4245 "tailwindcss-animate": "^1.0.7",
43 "three": "^0.168.0"
46 "three": "^0.168.0",
47 "zod": "^4.1.13"
4448 },
4549 "devDependencies": {
50 "@types/crypto-js": "^4.2.2",
4651 "@types/node": "^20",
4752 "@types/react": "^18",
4853 "@types/react-dom": "^18",
@@ -54,6 +59,120 @@
5459 "typescript": "^5"
5560 }
5661 },
62 "node_modules/@ai-sdk/anthropic": {
63 "version": "2.0.50",
64 "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-2.0.50.tgz",
65 "integrity": "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw==",
66 "license": "Apache-2.0",
67 "dependencies": {
68 "@ai-sdk/provider": "2.0.0",
69 "@ai-sdk/provider-utils": "3.0.18"
70 },
71 "engines": {
72 "node": ">=18"
73 },
74 "peerDependencies": {
75 "zod": "^3.25.76 || ^4.1.8"
76 }
77 },
78 "node_modules/@ai-sdk/gateway": {
79 "version": "2.0.0-beta.66",
80 "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.0-beta.66.tgz",
81 "integrity": "sha512-9H4Y4pFcTlDqLOjhJNfHVJrmQiGGqzQLIDNKSGhab90KYgeZc7NouQF752jUIlEZCY1S4QynuUKISTUsKR6Qjg==",
82 "license": "Apache-2.0",
83 "dependencies": {
84 "@ai-sdk/provider": "3.0.0-beta.20",
85 "@ai-sdk/provider-utils": "4.0.0-beta.38",
86 "@vercel/oidc": "3.0.5"
87 },
88 "engines": {
89 "node": ">=18"
90 },
91 "peerDependencies": {
92 "zod": "^3.25.76 || ^4.1.8"
93 }
94 },
95 "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": {
96 "version": "3.0.0-beta.20",
97 "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.0-beta.20.tgz",
98 "integrity": "sha512-+JqXbqHHtucRsMFGidygRyftpjX1GD2r4cG3Sh2URZ6g8IaN8k4loXNh2gX92dd4YjlYYn3eTHp3R8dDJfX25Q==",
99 "license": "Apache-2.0",
100 "dependencies": {
101 "json-schema": "^0.4.0"
102 },
103 "engines": {
104 "node": ">=18"
105 }
106 },
107 "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": {
108 "version": "4.0.0-beta.38",
109 "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.0-beta.38.tgz",
110 "integrity": "sha512-m1klVKT8KntgEIxHnSGEzdhdn48Uf/w6fe5rPWGnpTd+P532mADV7BC4txNYp40ziS5Z9VV1g1wn2xRScwEeRw==",
111 "license": "Apache-2.0",
112 "dependencies": {
113 "@ai-sdk/provider": "3.0.0-beta.20",
114 "@standard-schema/spec": "^1.0.0",
115 "eventsource-parser": "^3.0.6"
116 },
117 "engines": {
118 "node": ">=18"
119 },
120 "peerDependencies": {
121 "@valibot/to-json-schema": "^1.3.0",
122 "arktype": "^2.1.22",
123 "effect": "^3.18.4",
124 "zod": "^3.25.76 || ^4.1.8"
125 },
126 "peerDependenciesMeta": {
127 "@valibot/to-json-schema": {
128 "optional": true
129 },
130 "arktype": {
131 "optional": true
132 },
133 "effect": {
134 "optional": true
135 }
136 }
137 },
138 "node_modules/@ai-sdk/gateway/node_modules/@vercel/oidc": {
139 "version": "3.0.5",
140 "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.0.5.tgz",
141 "integrity": "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==",
142 "license": "Apache-2.0",
143 "engines": {
144 "node": ">= 20"
145 }
146 },
147 "node_modules/@ai-sdk/provider": {
148 "version": "2.0.0",
149 "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz",
150 "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==",
151 "license": "Apache-2.0",
152 "dependencies": {
153 "json-schema": "^0.4.0"
154 },
155 "engines": {
156 "node": ">=18"
157 }
158 },
159 "node_modules/@ai-sdk/provider-utils": {
160 "version": "3.0.18",
161 "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.18.tgz",
162 "integrity": "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==",
163 "license": "Apache-2.0",
164 "dependencies": {
165 "@ai-sdk/provider": "2.0.0",
166 "@standard-schema/spec": "^1.0.0",
167 "eventsource-parser": "^3.0.6"
168 },
169 "engines": {
170 "node": ">=18"
171 },
172 "peerDependencies": {
173 "zod": "^3.25.76 || ^4.1.8"
174 }
175 },
57176 "node_modules/@alloc/quick-lru": {
58177 "version": "5.2.0",
59178 "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -832,6 +951,15 @@
832951 "node": ">= 18"
833952 }
834953 },
954 "node_modules/@opentelemetry/api": {
955 "version": "1.9.0",
956 "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
957 "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
958 "license": "Apache-2.0",
959 "engines": {
960 "node": ">=8.0.0"
961 }
962 },
835963 "node_modules/@pkgjs/parseargs": {
836964 "version": "0.11.0",
837965 "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1648,6 +1776,12 @@
16481776 "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==",
16491777 "dev": true
16501778 },
1779 "node_modules/@standard-schema/spec": {
1780 "version": "1.0.0",
1781 "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
1782 "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
1783 "license": "MIT"
1784 },
16511785 "node_modules/@supabase/auth-js": {
16521786 "version": "2.65.0",
16531787 "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz",
@@ -1757,6 +1891,13 @@
17571891 "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.145.tgz",
17581892 "integrity": "sha512-dtByW6WiFk5W5Jfgz1VM+YPA21xMXTuSFoLYIDY0L44jDLLflVPtZkYuu3/YxpGcvjzKFBZLU+GyKjR0HOYtyw=="
17591893 },
1894 "node_modules/@types/crypto-js": {
1895 "version": "4.2.2",
1896 "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
1897 "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
1898 "dev": true,
1899 "license": "MIT"
1900 },
17601901 "node_modules/@types/debounce": {
17611902 "version": "1.2.4",
17621903 "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz",
@@ -2249,6 +2390,15 @@
22492390 "zod": "3.24.4"
22502391 }
22512392 },
2393 "node_modules/@vercel/sandbox/node_modules/zod": {
2394 "version": "3.24.4",
2395 "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
2396 "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
2397 "license": "MIT",
2398 "funding": {
2399 "url": "https://github.com/sponsors/colinhacks"
2400 }
2401 },
22522402 "node_modules/@webgpu/types": {
22532403 "version": "0.1.44",
22542404 "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz",
@@ -2276,6 +2426,67 @@
22762426 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
22772427 }
22782428 },
2429 "node_modules/ai": {
2430 "version": "6.0.0-beta.122",
2431 "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.0-beta.122.tgz",
2432 "integrity": "sha512-Lk8hWSX22eyJBwZvIRY+Bgl8piVB9Jadqv+ine0B2lDJWPg3lsmQac3kSAzGhPBeNeaxm22sHCs9JhuJh3gW5Q==",
2433 "license": "Apache-2.0",
2434 "dependencies": {
2435 "@ai-sdk/gateway": "2.0.0-beta.66",
2436 "@ai-sdk/provider": "3.0.0-beta.20",
2437 "@ai-sdk/provider-utils": "4.0.0-beta.38",
2438 "@opentelemetry/api": "1.9.0"
2439 },
2440 "engines": {
2441 "node": ">=18"
2442 },
2443 "peerDependencies": {
2444 "zod": "^3.25.76 || ^4.1.8"
2445 }
2446 },
2447 "node_modules/ai/node_modules/@ai-sdk/provider": {
2448 "version": "3.0.0-beta.20",
2449 "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.0-beta.20.tgz",
2450 "integrity": "sha512-+JqXbqHHtucRsMFGidygRyftpjX1GD2r4cG3Sh2URZ6g8IaN8k4loXNh2gX92dd4YjlYYn3eTHp3R8dDJfX25Q==",
2451 "license": "Apache-2.0",
2452 "dependencies": {
2453 "json-schema": "^0.4.0"
2454 },
2455 "engines": {
2456 "node": ">=18"
2457 }
2458 },
2459 "node_modules/ai/node_modules/@ai-sdk/provider-utils": {
2460 "version": "4.0.0-beta.38",
2461 "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.0-beta.38.tgz",
2462 "integrity": "sha512-m1klVKT8KntgEIxHnSGEzdhdn48Uf/w6fe5rPWGnpTd+P532mADV7BC4txNYp40ziS5Z9VV1g1wn2xRScwEeRw==",
2463 "license": "Apache-2.0",
2464 "dependencies": {
2465 "@ai-sdk/provider": "3.0.0-beta.20",
2466 "@standard-schema/spec": "^1.0.0",
2467 "eventsource-parser": "^3.0.6"
2468 },
2469 "engines": {
2470 "node": ">=18"
2471 },
2472 "peerDependencies": {
2473 "@valibot/to-json-schema": "^1.3.0",
2474 "arktype": "^2.1.22",
2475 "effect": "^3.18.4",
2476 "zod": "^3.25.76 || ^4.1.8"
2477 },
2478 "peerDependenciesMeta": {
2479 "@valibot/to-json-schema": {
2480 "optional": true
2481 },
2482 "arktype": {
2483 "optional": true
2484 },
2485 "effect": {
2486 "optional": true
2487 }
2488 }
2489 },
22792490 "node_modules/ajv": {
22802491 "version": "6.12.6",
22812492 "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2967,6 +3178,12 @@
29673178 "node": ">= 8"
29683179 }
29693180 },
3181 "node_modules/crypto-js": {
3182 "version": "4.2.0",
3183 "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
3184 "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
3185 "license": "MIT"
3186 },
29703187 "node_modules/cssesc": {
29713188 "version": "3.0.0",
29723189 "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3891,6 +4108,15 @@
38914108 "bare-events": "^2.7.0"
38924109 }
38934110 },
4111 "node_modules/eventsource-parser": {
4112 "version": "3.0.6",
4113 "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
4114 "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
4115 "license": "MIT",
4116 "engines": {
4117 "node": ">=18.0.0"
4118 }
4119 },
38944120 "node_modules/extend": {
38954121 "version": "3.0.2",
38964122 "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -5330,6 +5556,12 @@
53305556 "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
53315557 "dev": true
53325558 },
5559 "node_modules/json-schema": {
5560 "version": "0.4.0",
5561 "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
5562 "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
5563 "license": "(AFL-2.1 OR BSD-3-Clause)"
5564 },
53335565 "node_modules/json-schema-traverse": {
53345566 "version": "0.4.1",
53355567 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -9621,9 +9853,9 @@
96219853 }
96229854 },
96239855 "node_modules/zod": {
9624 "version": "3.24.4",
9625 "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
9626 "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
9856 "version": "4.1.13",
9857 "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
9858 "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
96279859 "license": "MIT",
96289860 "funding": {
96299861 "url": "https://github.com/sponsors/colinhacks"
package.json +42 −19
@@ -9,49 +9,72 @@
99 "lint": "next lint"
1010 },
1111 "dependencies": {
12 "@calcom/embed-react": "^1.5.0",
12 "@ai-sdk/anthropic": "^3.0.0-beta.66",
13 "@ai-sdk/react": "^3.0.0-beta.124",
14 "@calcom/embed-react": "^1.5.3",
1315 "@radix-ui/react-avatar": "^1.1.0",
14 "@radix-ui/react-dialog": "^1.1.1",
16 "@radix-ui/react-collapsible": "^1.1.12",
17 "@radix-ui/react-dialog": "^1.1.15",
18 "@radix-ui/react-dropdown-menu": "^2.1.16",
19 "@radix-ui/react-hover-card": "^1.1.15",
1520 "@radix-ui/react-icons": "^1.3.0",
1621 "@radix-ui/react-label": "^2.1.0",
17 "@radix-ui/react-slot": "^1.1.0",
22 "@radix-ui/react-progress": "^1.1.8",
23 "@radix-ui/react-scroll-area": "^1.2.10",
24 "@radix-ui/react-select": "^2.2.6",
25 "@radix-ui/react-separator": "^1.1.8",
26 "@radix-ui/react-slot": "^1.2.4",
1827 "@radix-ui/react-tabs": "^1.1.0",
1928 "@radix-ui/react-toast": "^1.2.1",
20 "@radix-ui/react-tooltip": "^1.1.2",
21 "@react-three/drei": "^9.112.0",
22 "@react-three/fiber": "^8.17.7",
29 "@radix-ui/react-tooltip": "^1.2.8",
30 "@radix-ui/react-use-controllable-state": "^1.2.2",
31 "@react-three/drei": "^10.7.7",
32 "@react-three/fiber": "^9.4.2",
2333 "@supabase/ssr": "^0.5.1",
34 "@tailwindcss/postcss": "^4.1.17",
2435 "@uiw/react-textarea-code-editor": "^3.0.2",
2536 "@vercel/analytics": "^1.5.0",
2637 "@vercel/sandbox": "^1.0.2",
38 "@xyflow/react": "^12.9.3",
39 "ai": "^5.0.104",
2740 "class-variance-authority": "^0.7.0",
2841 "clsx": "^2.1.1",
29 "framer-motion": "^11.5.4",
30 "lucide-react": "^0.439.0",
31 "next": "14.2.9",
42 "cmdk": "^1.1.1",
43 "crypto-js": "^4.2.0",
44 "embla-carousel-react": "^8.6.0",
45 "framer-motion": "^12.23.24",
46 "lucide-react": "^0.555.0",
47 "motion": "^12.23.24",
48 "nanoid": "^5.1.6",
49 "next": "^16.0.5",
3250 "octokit": "^4.0.2",
33 "react": "^18",
34 "react-dom": "^18",
51 "react": "19",
52 "react-dom": "19",
3553 "react-icons": "^5.3.0",
36 "react-markdown": "^9.0.1",
37 "react-player": "^2.16.0",
38 "react-syntax-highlighter": "^15.5.0",
54 "react-markdown": "^10.1.0",
55 "react-player": "^3.4.0",
56 "react-syntax-highlighter": "^16.1.0",
3957 "react-type-animation": "^3.2.0",
4058 "rehype-raw": "^7.0.0",
4159 "remark-gfm": "^4.0.0",
60 "shiki": "^3.17.0",
61 "streamdown": "^1.6.9",
4262 "tailwind-merge": "^2.5.2",
43 "tailwindcss-animate": "^1.0.7",
44 "three": "^0.168.0"
63 "three": "^0.181.2",
64 "tokenlens": "^1.3.1",
65 "use-stick-to-bottom": "^1.1.1",
66 "zod": "^3.23.8"
4567 },
4668 "devDependencies": {
69 "@types/crypto-js": "^4.2.2",
4770 "@types/node": "^20",
48 "@types/react": "^18",
49 "@types/react-dom": "^18",
71 "@types/react": "19",
72 "@types/react-dom": "19",
5073 "@types/react-syntax-highlighter": "^15.5.13",
5174 "eslint": "^8",
5275 "eslint-config-next": "14.2.9",
5376 "postcss": "^8",
54 "tailwindcss": "^3.4.1",
77 "tailwindcss": "4",
5578 "typescript": "^5"
5679 }
5780 }
postcss.config.mjs +1 −1
@@ -1,7 +1,7 @@
11 /** @type {import('postcss-load-config').Config} */
22 const config = {
33 plugins: {
4 tailwindcss: {},
4 '@tailwindcss/postcss': {},
55 },
66 };
77
src/app/api/auth/github/callback/route.ts added +78
@@ -0,0 +1,78 @@
1 import { NextRequest, NextResponse } from 'next/server';
2 import { createClient } from '@/utils/supabase/server';
3 import { encrypt } from '@/lib/encryption';
4
5 // GitHub OAuth callback - Supabase handles the token exchange
6 export async function GET(request: NextRequest) {
7 const { searchParams } = new URL(request.url);
8 const code = searchParams.get('code');
9 const error = searchParams.get('error');
10 const errorDescription = searchParams.get('error_description');
11
12 const redirectUrl = new URL('/playground/contributor', request.url);
13
14 if (error) {
15 redirectUrl.searchParams.set('github_error', errorDescription || error);
16 return NextResponse.redirect(redirectUrl);
17 }
18
19 if (!code) {
20 redirectUrl.searchParams.set('github_error', 'missing_code');
21 return NextResponse.redirect(redirectUrl);
22 }
23
24 try {
25 const supabase = await createClient();
26
27 // Exchange the code for a session - Supabase handles this
28 const { data, error: exchangeError } = await supabase.auth.exchangeCodeForSession(code);
29
30 if (exchangeError) {
31 console.error('Error exchanging code for session:', exchangeError);
32 redirectUrl.searchParams.set('github_error', exchangeError.message);
33 return NextResponse.redirect(redirectUrl);
34 }
35
36 // Get the provider token from the session
37 const providerToken = data.session?.provider_token;
38 const user = data.user;
39
40 if (!user) {
41 redirectUrl.searchParams.set('github_error', 'no_user');
42 return NextResponse.redirect(redirectUrl);
43 }
44
45 // Get GitHub user info from user metadata
46 const githubUsername = user.user_metadata?.user_name || user.user_metadata?.preferred_username;
47 const githubAvatarUrl = user.user_metadata?.avatar_url;
48
49 // Store the provider token if we have one
50 if (providerToken) {
51 const { error: upsertError } = await supabase
52 .from('github_tokens')
53 .upsert({
54 user_id: user.id,
55 access_token_encrypted: encrypt(providerToken),
56 github_username: githubUsername,
57 github_avatar_url: githubAvatarUrl,
58 // Supabase doesn't expose the exact scopes, but we store based on what was requested
59 scope: 'repo', // Default to full repo access
60 }, {
61 onConflict: 'user_id',
62 });
63
64 if (upsertError) {
65 console.error('Error storing GitHub token:', upsertError);
66 // Don't fail the auth, just log the error
67 }
68 }
69
70 // Redirect to the contributor page
71 redirectUrl.searchParams.set('github_success', 'true');
72 return NextResponse.redirect(redirectUrl);
73 } catch (err) {
74 console.error('GitHub callback error:', err);
75 redirectUrl.searchParams.set('github_error', 'callback_failed');
76 return NextResponse.redirect(redirectUrl);
77 }
78 }
src/app/api/auth/github/route.ts added +31
@@ -0,0 +1,31 @@
1 import { NextRequest, NextResponse } from 'next/server';
2 import { createClient } from '@/utils/supabase/server';
3
4 // This route is now mainly for backwards compatibility
5 // The actual OAuth flow is handled client-side via supabase.auth.linkIdentity()
6 export async function GET(request: NextRequest) {
7 const supabase = await createClient();
8 const { data: { user } } = await supabase.auth.getUser();
9 const origin = new URL(request.url).origin;
10
11 if (!user) {
12 return NextResponse.redirect(new URL('/auth/sign-in', origin));
13 }
14
15 // Check if user already has GitHub identity linked
16 const githubIdentity = user.identities?.find(
17 (identity) => identity.provider === 'github'
18 );
19
20 if (githubIdentity) {
21 // Already linked - redirect back with success
22 return NextResponse.redirect(
23 new URL('/playground/contributor?github_success=true', origin)
24 );
25 }
26
27 // Redirect to contributor page - let client-side handle the OAuth
28 return NextResponse.redirect(
29 new URL('/playground/contributor', origin)
30 );
31 }
src/app/api/auth/github/status/route.ts added +96
@@ -0,0 +1,96 @@
1 import { NextResponse } from 'next/server';
2 import { createClient } from '@/utils/supabase/server';
3 import { encrypt } from '@/lib/encryption';
4
5 export async function GET() {
6 try {
7 const supabase = await createClient();
8 const { data: { user } } = await supabase.auth.getUser();
9
10 if (!user) {
11 return NextResponse.json({ connected: false });
12 }
13
14 // First check if we have a stored token
15 const { data: tokenData } = await supabase
16 .from('github_tokens')
17 .select('github_username, github_avatar_url, scope')
18 .eq('user_id', user.id)
19 .single();
20
21 if (tokenData) {
22 return NextResponse.json({
23 connected: true,
24 username: tokenData.github_username,
25 avatarUrl: tokenData.github_avatar_url,
26 scope: tokenData.scope,
27 });
28 }
29
30 // Check if user signed in with GitHub (identity already linked)
31 const githubIdentity = user.identities?.find(
32 (identity) => identity.provider === 'github'
33 );
34
35 if (githubIdentity) {
36 // User signed in with GitHub - get info from user metadata
37 const githubUsername = user.user_metadata?.user_name ||
38 user.user_metadata?.preferred_username ||
39 githubIdentity.identity_data?.user_name;
40 const githubAvatarUrl = user.user_metadata?.avatar_url ||
41 githubIdentity.identity_data?.avatar_url;
42
43 // Try to get the session to store the provider token
44 const { data: { session } } = await supabase.auth.getSession();
45
46 if (session?.provider_token) {
47 // Store the token for future use
48 await supabase
49 .from('github_tokens')
50 .upsert({
51 user_id: user.id,
52 access_token_encrypted: encrypt(session.provider_token),
53 github_username: githubUsername,
54 github_avatar_url: githubAvatarUrl,
55 scope: 'repo', // Assume full access when signed in with GitHub
56 }, {
57 onConflict: 'user_id',
58 });
59 }
60
61 return NextResponse.json({
62 connected: true,
63 username: githubUsername,
64 avatarUrl: githubAvatarUrl,
65 scope: 'repo',
66 fromIdentity: true, // Flag that this came from identity, not stored token
67 });
68 }
69
70 return NextResponse.json({ connected: false });
71 } catch (error) {
72 console.error('Error checking GitHub status:', error);
73 return NextResponse.json({ connected: false });
74 }
75 }
76
77 export async function DELETE() {
78 try {
79 const supabase = await createClient();
80 const { data: { user } } = await supabase.auth.getUser();
81
82 if (!user) {
83 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
84 }
85
86 await supabase
87 .from('github_tokens')
88 .delete()
89 .eq('user_id', user.id);
90
91 return NextResponse.json({ success: true });
92 } catch (error) {
93 console.error('Error disconnecting GitHub:', error);
94 return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 });
95 }
96 }
src/app/api/contributor/branches/route.ts added +36
@@ -0,0 +1,36 @@
1 import { NextRequest, NextResponse } from 'next/server';
2 import { getAuthenticatedOctokit, listRepoBranches } from '@/lib/github';
3
4 export async function GET(request: NextRequest) {
5 try {
6 const { searchParams } = new URL(request.url);
7 const repoFullName = searchParams.get('repo');
8
9 if (!repoFullName) {
10 return NextResponse.json(
11 { error: 'Repository name is required' },
12 { status: 400 }
13 );
14 }
15
16 const auth = await getAuthenticatedOctokit();
17
18 if (!auth) {
19 return NextResponse.json(
20 { error: 'GitHub not connected' },
21 { status: 401 }
22 );
23 }
24
25 const [owner, repo] = repoFullName.split('/');
26 const branches = await listRepoBranches(auth.octokit, owner, repo);
27
28 return NextResponse.json({ branches });
29 } catch (error) {
30 console.error('Error fetching branches:', error);
31 return NextResponse.json(
32 { error: 'Failed to fetch branches' },
33 { status: 500 }
34 );
35 }
36 }
src/app/api/contributor/chat/route.ts added +213
@@ -0,0 +1,213 @@
1 import { NextRequest } from 'next/server';
2 import { streamText, type LanguageModel } from 'ai';
3 import { anthropic } from '@ai-sdk/anthropic';
4 import { createClient } from '@/utils/supabase/server';
5 import { createRepoSandbox, commitChanges, pushBranch } from '@/lib/agent/sandbox';
6 import { createAgentTools } from '@/lib/agent/tools';
7 import { SYSTEM_PROMPT } from '@/lib/agent/prompts';
8 import { decryptEnvVars, decrypt } from '@/lib/encryption';
9 import { Sandbox } from '@vercel/sandbox';
10
11 export const runtime = 'nodejs';
12 export const maxDuration = 300; // 5 minutes
13
14 interface Message {
15 role: 'user' | 'assistant';
16 content: string;
17 }
18
19 interface DbMessage {
20 role: string;
21 content: string;
22 }
23
24 interface EnvVarRecord {
25 key_encrypted: string;
26 value_encrypted: string;
27 }
28
29 interface PrMetadata {
30 pr_number?: number;
31 status: string;
32 commits_pushed?: number;
33 }
34
35 export async function POST(request: NextRequest) {
36 let sandbox: Sandbox | null = null;
37
38 try {
39 const supabase = await createClient();
40 const { data: { user } } = await supabase.auth.getUser();
41
42 if (!user) {
43 return new Response(JSON.stringify({ error: 'Unauthorized' }), {
44 status: 401,
45 headers: { 'Content-Type': 'application/json' },
46 });
47 }
48
49 const body = await request.json();
50 const { conversationId, message } = body;
51
52 if (!conversationId || !message) {
53 return new Response(JSON.stringify({ error: 'Missing required fields' }), {
54 status: 400,
55 headers: { 'Content-Type': 'application/json' },
56 });
57 }
58
59 // Get conversation details - ensure user owns it
60 const { data: conversation, error: convError } = await supabase
61 .from('conversations')
62 .select(`
63 *,
64 messages (role, content),
65 environment_variables (key_encrypted, value_encrypted),
66 pr_metadata (pr_number, status, commits_pushed)
67 `)
68 .eq('id', conversationId)
69 .eq('user_id', user.id)
70 .single();
71
72 if (convError || !conversation) {
73 return new Response(JSON.stringify({ error: 'Conversation not found' }), {
74 status: 404,
75 headers: { 'Content-Type': 'application/json' },
76 });
77 }
78
79 // Get GitHub token
80 const { data: tokenData } = await supabase
81 .from('github_tokens')
82 .select('access_token_encrypted')
83 .eq('user_id', user.id)
84 .single();
85
86 if (!tokenData) {
87 return new Response(JSON.stringify({ error: 'GitHub not connected' }), {
88 status: 401,
89 headers: { 'Content-Type': 'application/json' },
90 });
91 }
92
93 const githubToken = decrypt(tokenData.access_token_encrypted);
94
95 // Save user message
96 await supabase.from('messages').insert({
97 conversation_id: conversationId,
98 role: 'user',
99 content: message,
100 });
101
102 // Decrypt environment variables
103 const envVarRecords = (conversation.environment_variables || []) as EnvVarRecord[];
104 const envVars = decryptEnvVars(envVarRecords);
105
106 // Build message history
107 const dbMessages = (conversation.messages || []) as DbMessage[];
108 const messages: Message[] = [
109 ...dbMessages.map((m) => ({
110 role: m.role as 'user' | 'assistant',
111 content: m.content,
112 })),
113 { role: 'user' as const, content: message },
114 ];
115
116 // Get PR metadata (it's an array from Supabase, get first item)
117 const prMetaArray = (conversation.pr_metadata || []) as PrMetadata[];
118 const prMeta = prMetaArray[0] || null;
119 const prOpen = prMeta?.status === 'open';
120
121 // Create sandbox with repo - start this early
122 const repoUrl = `https://github.com/${conversation.repo_full_name}.git`;
123 sandbox = await createRepoSandbox({
124 repoUrl,
125 branch: prOpen ? conversation.work_branch : conversation.base_branch,
126 newBranch: prOpen ? undefined : conversation.work_branch,
127 envVars,
128 });
129
130 // Create tools with sandbox
131 const tools = createAgentTools(sandbox);
132
133 // Stream the response
134 const result = streamText({
135 model: anthropic('claude-sonnet-4-20250514') as unknown as LanguageModel,
136 system: SYSTEM_PROMPT,
137 messages,
138 tools,
139 toolChoice: 'auto',
140 onFinish: async ({ text, usage }) => {
141 // Commit changes after agent completes
142 const commitResult = await commitChanges(
143 sandbox!,
144 `Agent work: ${message.slice(0, 50)}${message.length > 50 ? '...' : ''}`
145 );
146
147 let filesChanged: string[] = [];
148 let commitSha: string | undefined;
149 let commitMessage: string | undefined;
150
151 if (commitResult.success && commitResult.sha) {
152 commitSha = commitResult.sha;
153 commitMessage = commitResult.message;
154 filesChanged = commitResult.filesChanged || [];
155
156 // If PR is open, auto-push
157 if (prOpen) {
158 await pushBranch(
159 sandbox!,
160 conversation.work_branch,
161 githubToken,
162 conversation.repo_full_name
163 );
164
165 // Update PR metadata
166 await supabase
167 .from('pr_metadata')
168 .update({
169 commits_pushed: (prMeta?.commits_pushed || 0) + 1,
170 head_sha: commitSha,
171 })
172 .eq('conversation_id', conversationId);
173 }
174 }
175
176 // Save assistant message
177 await supabase.from('messages').insert({
178 conversation_id: conversationId,
179 role: 'assistant',
180 content: text,
181 files_changed: filesChanged,
182 commit_sha: commitSha,
183 commit_message: commitMessage,
184 tokens_used: usage?.totalTokens || 0,
185 });
186
187 // Update conversation timestamp
188 await supabase
189 .from('conversations')
190 .update({ updated_at: new Date().toISOString() })
191 .eq('id', conversationId);
192
193 // Stop sandbox
194 await sandbox?.stop();
195 },
196 });
197
198 return result.toTextStreamResponse();
199 } catch (error) {
200 console.error('Chat error:', error);
201 await sandbox?.stop();
202 return new Response(
203 JSON.stringify({
204 error: 'Failed to process message',
205 details: error instanceof Error ? error.message : 'Unknown error',
206 }),
207 {
208 status: 500,
209 headers: { 'Content-Type': 'application/json' },
210 }
211 );
212 }
213 }
src/app/api/contributor/conversations/route.ts added +236
@@ -0,0 +1,236 @@
1 import { NextRequest, NextResponse } from 'next/server';
2 import { createClient } from '@/utils/supabase/server';
3 import { encrypt, encryptEnvVars, decryptEnvVars } from '@/lib/encryption';
4
5 export async function GET(request: NextRequest) {
6 try {
7 const supabase = await createClient();
8 const { data: { user } } = await supabase.auth.getUser();
9
10 if (!user) {
11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 }
13
14 const { searchParams } = new URL(request.url);
15 const conversationId = searchParams.get('id');
16
17 if (conversationId) {
18 // Get single conversation with messages - ensure user owns it
19 const { data: conversation, error } = await supabase
20 .from('conversations')
21 .select(`
22 *,
23 messages (
24 id, role, content, tool_calls, files_changed, commit_sha, commit_message, created_at
25 ),
26 pr_metadata (
27 pr_number, pr_url, title, body, status, commits_pushed, head_sha
28 ),
29 environment_variables (
30 id, key_encrypted, value_encrypted
31 )
32 `)
33 .eq('id', conversationId)
34 .eq('user_id', user.id)
35 .single();
36
37 if (error || !conversation) {
38 return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
39 }
40
41 // Decrypt env vars
42 const envVars = conversation.environment_variables
43 ? decryptEnvVars(conversation.environment_variables)
44 : {};
45
46 return NextResponse.json({
47 ...conversation,
48 environment_variables: undefined,
49 envVars,
50 });
51 }
52
53 // List all conversations for current user
54 const { data: conversations, error } = await supabase
55 .from('conversations')
56 .select(`
57 id, repo_full_name, base_branch, work_branch, title, status, created_at, updated_at,
58 pr_metadata (
59 pr_number, pr_url, status
60 )
61 `)
62 .eq('user_id', user.id)
63 .order('updated_at', { ascending: false });
64
65 if (error) {
66 throw error;
67 }
68
69 return NextResponse.json({ conversations });
70 } catch (error) {
71 console.error('Error fetching conversations:', error);
72 return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 });
73 }
74 }
75
76 export async function POST(request: NextRequest) {
77 try {
78 const supabase = await createClient();
79 const { data: { user } } = await supabase.auth.getUser();
80
81 if (!user) {
82 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
83 }
84
85 const body = await request.json();
86 const { repoFullName, baseBranch, workBranch, title, envVars } = body;
87
88 if (!repoFullName || !baseBranch || !workBranch) {
89 return NextResponse.json(
90 { error: 'Missing required fields' },
91 { status: 400 }
92 );
93 }
94
95 // Create conversation
96 const { data: conversation, error: convError } = await supabase
97 .from('conversations')
98 .insert({
99 user_id: user.id,
100 repo_full_name: repoFullName,
101 base_branch: baseBranch,
102 work_branch: workBranch,
103 title: title || `Work on ${repoFullName}`,
104 status: 'active',
105 })
106 .select()
107 .single();
108
109 if (convError || !conversation) {
110 throw convError;
111 }
112
113 // Insert encrypted env vars if provided
114 if (envVars && Object.keys(envVars).length > 0) {
115 const encryptedVars = encryptEnvVars(envVars);
116 await supabase.from('environment_variables').insert(
117 encryptedVars.map(v => ({
118 conversation_id: conversation.id,
119 ...v,
120 }))
121 );
122 }
123
124 // Create initial PR metadata (draft state)
125 await supabase.from('pr_metadata').insert({
126 conversation_id: conversation.id,
127 title: title || `Work on ${repoFullName}`,
128 body: '',
129 status: 'draft',
130 });
131
132 return NextResponse.json({ conversation });
133 } catch (error) {
134 console.error('Error creating conversation:', error);
135 return NextResponse.json({ error: 'Failed to create conversation' }, { status: 500 });
136 }
137 }
138
139 export async function PATCH(request: NextRequest) {
140 try {
141 const supabase = await createClient();
142 const { data: { user } } = await supabase.auth.getUser();
143
144 if (!user) {
145 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
146 }
147
148 const body = await request.json();
149 const { id, title, status, envVars } = body;
150
151 if (!id) {
152 return NextResponse.json({ error: 'Conversation ID required' }, { status: 400 });
153 }
154
155 const updates: Record<string, unknown> = {};
156 if (title) updates.title = title;
157 if (status) updates.status = status;
158
159 if (Object.keys(updates).length > 0) {
160 const { error } = await supabase
161 .from('conversations')
162 .update(updates)
163 .eq('id', id)
164 .eq('user_id', user.id);
165
166 if (error) throw error;
167 }
168
169 // Update env vars if provided (verify ownership first)
170 if (envVars !== undefined) {
171 // Verify user owns this conversation
172 const { data: owned } = await supabase
173 .from('conversations')
174 .select('id')
175 .eq('id', id)
176 .eq('user_id', user.id)
177 .single();
178
179 if (!owned) {
180 return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
181 }
182
183 // Delete existing and insert new
184 await supabase
185 .from('environment_variables')
186 .delete()
187 .eq('conversation_id', id);
188
189 if (Object.keys(envVars).length > 0) {
190 const encryptedVars = encryptEnvVars(envVars);
191 await supabase.from('environment_variables').insert(
192 encryptedVars.map(v => ({
193 conversation_id: id,
194 ...v,
195 }))
196 );
197 }
198 }
199
200 return NextResponse.json({ success: true });
201 } catch (error) {
202 console.error('Error updating conversation:', error);
203 return NextResponse.json({ error: 'Failed to update conversation' }, { status: 500 });
204 }
205 }
206
207 export async function DELETE(request: NextRequest) {
208 try {
209 const supabase = await createClient();
210 const { data: { user } } = await supabase.auth.getUser();
211
212 if (!user) {
213 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
214 }
215
216 const { searchParams } = new URL(request.url);
217 const id = searchParams.get('id');
218
219 if (!id) {
220 return NextResponse.json({ error: 'Conversation ID required' }, { status: 400 });
221 }
222
223 const { error } = await supabase
224 .from('conversations')
225 .delete()
226 .eq('id', id)
227 .eq('user_id', user.id);
228
229 if (error) throw error;
230
231 return NextResponse.json({ success: true });
232 } catch (error) {
233 console.error('Error deleting conversation:', error);
234 return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
235 }
236 }
src/app/api/contributor/pr/route.ts added +275
@@ -0,0 +1,275 @@
1 import { NextRequest, NextResponse } from 'next/server';
2 import { createClient } from '@/utils/supabase/server';
3 import { getAuthenticatedOctokit, generatePRUrl, createPullRequest, getPullRequestStatus } from '@/lib/github';
4 import { createRepoSandbox, pushBranch } from '@/lib/agent/sandbox';
5 import { decrypt, decryptEnvVars } from '@/lib/encryption';
6
7 export const runtime = 'nodejs';
8 export const maxDuration = 60;
9
10 // Get PR status/info
11 export async function GET(request: NextRequest) {
12 try {
13 const supabase = await createClient();
14 const { data: { user } } = await supabase.auth.getUser();
15
16 if (!user) {
17 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
18 }
19
20 const { searchParams } = new URL(request.url);
21 const conversationId = searchParams.get('conversationId');
22
23 if (!conversationId) {
24 return NextResponse.json({ error: 'Conversation ID required' }, { status: 400 });
25 }
26
27 // Get conversation and PR metadata
28 const { data: conversation } = await supabase
29 .from('conversations')
30 .select(`
31 repo_full_name,
32 pr_metadata (
33 pr_number, pr_url, title, body, status, commits_pushed, head_sha, created_at
34 )
35 `)
36 .eq('id', conversationId)
37 .single();
38
39 if (!conversation) {
40 return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
41 }
42
43 // pr_metadata comes as array from Supabase relational query
44 const prMetaArray = (conversation.pr_metadata || []) as Array<{
45 pr_number?: number;
46 pr_url?: string;
47 title?: string;
48 body?: string;
49 status: string;
50 commits_pushed?: number;
51 head_sha?: string;
52 created_at?: string;
53 }>;
54 const prMeta = prMetaArray[0] || null;
55
56 // If PR exists, get live status from GitHub
57 if (prMeta?.pr_number) {
58 const auth = await getAuthenticatedOctokit();
59 if (auth) {
60 try {
61 const status = await getPullRequestStatus(
62 auth.octokit,
63 conversation.repo_full_name,
64 prMeta.pr_number
65 );
66
67 // Update local status if changed
68 if (status.merged && prMeta.status !== 'merged') {
69 await supabase
70 .from('pr_metadata')
71 .update({ status: 'merged' })
72 .eq('conversation_id', conversationId);
73
74 await supabase
75 .from('conversations')
76 .update({ status: 'merged' })
77 .eq('id', conversationId);
78
79 prMeta.status = 'merged';
80 } else if (status.state === 'closed' && !status.merged && prMeta.status !== 'closed') {
81 await supabase
82 .from('pr_metadata')
83 .update({ status: 'closed' })
84 .eq('conversation_id', conversationId);
85
86 prMeta.status = 'closed';
87 }
88
89 return NextResponse.json({
90 ...prMeta,
91 mergeable: status.mergeable,
92 githubState: status.state,
93 });
94 } catch {
95 // PR might have been deleted
96 }
97 }
98 }
99
100 return NextResponse.json(prMeta || { status: 'draft' });
101 } catch (error) {
102 console.error('Error getting PR status:', error);
103 return NextResponse.json({ error: 'Failed to get PR status' }, { status: 500 });
104 }
105 }
106
107 // Create/Open PR
108 export async function POST(request: NextRequest) {
109 try {
110 const supabase = await createClient();
111 const { data: { user } } = await supabase.auth.getUser();
112
113 if (!user) {
114 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
115 }
116
117 const body = await request.json();
118 const { conversationId, title, body: prBody, useUrlParams = true } = body;
119
120 if (!conversationId) {
121 return NextResponse.json({ error: 'Conversation ID required' }, { status: 400 });
122 }
123
124 // Get conversation details
125 const { data: conversation } = await supabase
126 .from('conversations')
127 .select(`
128 repo_full_name, base_branch, work_branch,
129 environment_variables (key_encrypted, value_encrypted),
130 pr_metadata (id, status)
131 `)
132 .eq('id', conversationId)
133 .single();
134
135 if (!conversation) {
136 return NextResponse.json({ error: 'Conversation not found' }, { status: 404 });
137 }
138
139 // Get GitHub token
140 const { data: tokenData } = await supabase
141 .from('github_tokens')
142 .select('access_token_encrypted')
143 .eq('user_id', user.id)
144 .single();
145
146 if (!tokenData) {
147 return NextResponse.json({ error: 'GitHub not connected' }, { status: 401 });
148 }
149
150 const githubToken = decrypt(tokenData.access_token_encrypted);
151
152 // First, push the branch to GitHub
153 // We need to recreate sandbox to get the latest state
154 const envVarRecords = (conversation.environment_variables || []) as Array<{
155 key_encrypted: string;
156 value_encrypted: string;
157 }>;
158 const envVars = decryptEnvVars(envVarRecords);
159
160 const sandbox = await createRepoSandbox({
161 repoUrl: `https://github.com/${conversation.repo_full_name}.git`,
162 branch: conversation.work_branch,
163 envVars,
164 });
165
166 try {
167 const pushResult = await pushBranch(
168 sandbox,
169 conversation.work_branch,
170 githubToken,
171 conversation.repo_full_name
172 );
173
174 if (!pushResult.success) {
175 return NextResponse.json(
176 { error: `Failed to push branch: ${pushResult.error}` },
177 { status: 500 }
178 );
179 }
180 } finally {
181 await sandbox.stop();
182 }
183
184 const finalTitle = title || `Work on ${conversation.repo_full_name}`;
185 const finalBody = prBody || '';
186
187 // Update PR metadata
188 await supabase
189 .from('pr_metadata')
190 .update({
191 title: finalTitle,
192 body: finalBody,
193 status: 'open',
194 commits_pushed: 1,
195 })
196 .eq('conversation_id', conversationId);
197
198 if (useUrlParams) {
199 // Return URL for user to open PR
200 const prUrl = generatePRUrl(
201 conversation.repo_full_name,
202 conversation.base_branch,
203 conversation.work_branch,
204 finalTitle,
205 finalBody
206 );
207
208 return NextResponse.json({ prUrl, method: 'url' });
209 } else {
210 // Create PR directly via API
211 const auth = await getAuthenticatedOctokit();
212 if (!auth) {
213 return NextResponse.json({ error: 'GitHub auth failed' }, { status: 401 });
214 }
215
216 const pr = await createPullRequest(
217 auth.octokit,
218 conversation.repo_full_name,
219 conversation.base_branch,
220 conversation.work_branch,
221 finalTitle,
222 finalBody
223 );
224
225 // Update PR metadata with PR number
226 await supabase
227 .from('pr_metadata')
228 .update({
229 pr_number: pr.number,
230 pr_url: pr.url,
231 })
232 .eq('conversation_id', conversationId);
233
234 return NextResponse.json({ prUrl: pr.url, prNumber: pr.number, method: 'api' });
235 }
236 } catch (error) {
237 console.error('Error creating PR:', error);
238 return NextResponse.json({ error: 'Failed to create PR' }, { status: 500 });
239 }
240 }
241
242 // Update PR metadata (title, body)
243 export async function PATCH(request: NextRequest) {
244 try {
245 const supabase = await createClient();
246 const { data: { user } } = await supabase.auth.getUser();
247
248 if (!user) {
249 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
250 }
251
252 const body = await request.json();
253 const { conversationId, title, body: prBody } = body;
254
255 if (!conversationId) {
256 return NextResponse.json({ error: 'Conversation ID required' }, { status: 400 });
257 }
258
259 const updates: Record<string, string> = {};
260 if (title) updates.title = title;
261 if (prBody !== undefined) updates.body = prBody;
262
263 const { error } = await supabase
264 .from('pr_metadata')
265 .update(updates)
266 .eq('conversation_id', conversationId);
267
268 if (error) throw error;
269
270 return NextResponse.json({ success: true });
271 } catch (error) {
272 console.error('Error updating PR:', error);
273 return NextResponse.json({ error: 'Failed to update PR' }, { status: 500 });
274 }
275 }
src/app/api/contributor/repos/route.ts added +25
@@ -0,0 +1,25 @@
1 import { NextResponse } from 'next/server';
2 import { getAuthenticatedOctokit, listUserRepos } from '@/lib/github';
3
4 export async function GET() {
5 try {
6 const auth = await getAuthenticatedOctokit();
7
8 if (!auth) {
9 return NextResponse.json(
10 { error: 'GitHub not connected' },
11 { status: 401 }
12 );
13 }
14
15 const repos = await listUserRepos(auth.octokit);
16
17 return NextResponse.json({ repos });
18 } catch (error) {
19 console.error('Error fetching repos:', error);
20 return NextResponse.json(
21 { error: 'Failed to fetch repositories' },
22 { status: 500 }
23 );
24 }
25 }
src/app/api/contributor/stats/route.ts added +101
@@ -0,0 +1,101 @@
1 import { NextResponse } from 'next/server';
2 import { createClient } from '@/utils/supabase/server';
3
4 export async function GET() {
5 try {
6 const supabase = await createClient();
7 const { data: { user } } = await supabase.auth.getUser();
8
9 if (!user) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12
13 // Get stats from view
14 const { data: stats, error: statsError } = await supabase
15 .from('user_stats')
16 .select('*')
17 .eq('user_id', user.id)
18 .single();
19
20 if (statsError && statsError.code !== 'PGRST116') {
21 // PGRST116 is "no rows returned" which is fine for new users
22 throw statsError;
23 }
24
25 // Get recent activity
26 const { data: recentConversations } = await supabase
27 .from('conversations')
28 .select(`
29 id, repo_full_name, title, status, created_at, updated_at,
30 pr_metadata (pr_number, pr_url, status)
31 `)
32 .order('updated_at', { ascending: false })
33 .limit(10);
34
35 // Get repo breakdown
36 const { data: conversations } = await supabase
37 .from('conversations')
38 .select('repo_full_name, status');
39
40 const repoBreakdown = (conversations || []).reduce((acc, conv) => {
41 if (!acc[conv.repo_full_name]) {
42 acc[conv.repo_full_name] = { total: 0, merged: 0, closed: 0, active: 0 };
43 }
44 acc[conv.repo_full_name].total++;
45 if (conv.status === 'merged') acc[conv.repo_full_name].merged++;
46 else if (conv.status === 'closed') acc[conv.repo_full_name].closed++;
47 else if (conv.status === 'active') acc[conv.repo_full_name].active++;
48 return acc;
49 }, {} as Record<string, { total: number; merged: number; closed: number; active: number }>);
50
51 // Sort repos by total conversations
52 const topRepos = Object.entries(repoBreakdown)
53 .sort((a, b) => b[1].total - a[1].total)
54 .slice(0, 5)
55 .map(([repo, data]) => ({ repo, ...data }));
56
57 // Get message count over time (last 30 days)
58 const thirtyDaysAgo = new Date();
59 thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
60
61 const { data: messages } = await supabase
62 .from('messages')
63 .select('created_at, role')
64 .gte('created_at', thirtyDaysAgo.toISOString());
65
66 // Group messages by day
67 const messagesByDay = (messages || []).reduce((acc, msg) => {
68 const day = new Date(msg.created_at).toISOString().split('T')[0];
69 if (!acc[day]) acc[day] = { user: 0, assistant: 0 };
70 acc[day][msg.role as 'user' | 'assistant']++;
71 return acc;
72 }, {} as Record<string, { user: number; assistant: number }>);
73
74 // Convert to array sorted by date
75 const activityTimeline = Object.entries(messagesByDay)
76 .sort((a, b) => a[0].localeCompare(b[0]))
77 .map(([date, counts]) => ({ date, ...counts }));
78
79 return NextResponse.json({
80 summary: stats || {
81 total_conversations: 0,
82 merged_conversations: 0,
83 closed_conversations: 0,
84 active_conversations: 0,
85 open_prs: 0,
86 merged_prs: 0,
87 merge_rate_percent: null,
88 total_commits_pushed: 0,
89 total_messages: 0,
90 avg_messages_per_conversation: 0,
91 total_tokens_used: 0,
92 },
93 recentConversations: recentConversations || [],
94 topRepos,
95 activityTimeline,
96 });
97 } catch (error) {
98 console.error('Error fetching stats:', error);
99 return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 });
100 }
101 }
src/app/globals.css +36 −36
@@ -1,39 +1,39 @@
1 @tailwind base;
2 @tailwind components;
3 @tailwind utilities;
4
5 @layer base {
6 :root {
7 --background: 222.2 84% 4.9%;
8 --foreground: 210 40% 98%;
9
10 --card: 222.2 84% 4.9%;
11 --card-foreground: 210 40% 98%;
12
13 --popover: 222.2 84% 4.9%;
14 --popover-foreground: 210 40% 98%;
15
16 --primary: 217.2 91.2% 59.8%;
17 --primary-foreground: 222.2 47.4% 11.2%;
18
19 --secondary: 217.2 32.6% 17.5%;
20 --secondary-foreground: 210 40% 98%;
21
22 --muted: 217.2 32.6% 17.5%;
23 --muted-foreground: 215 20.2% 65.1%;
24
25 --accent: 217.2 32.6% 17.5%;
26 --accent-foreground: 210 40% 98%;
27
28 --destructive: 0 62.8% 30.6%;
29 --destructive-foreground: 210 40% 98%;
30
31 --border: 217.2 32.6% 17.5%;
32 --input: 217.2 32.6% 17.5%;
33 --ring: 224.3 76.3% 48%;
34 }
1 @import "tailwindcss";
2
3 @theme {
4 --color-background: hsl(222.2 84% 4.9%);
5 --color-foreground: hsl(210 40% 98%);
6
7 --color-card: hsl(222.2 84% 4.9%);
8 --color-card-foreground: hsl(210 40% 98%);
9
10 --color-popover: hsl(222.2 84% 4.9%);
11 --color-popover-foreground: hsl(210 40% 98%);
12
13 --color-primary: hsl(217.2 91.2% 59.8%);
14 --color-primary-foreground: hsl(222.2 47.4% 11.2%);
15
16 --color-secondary: hsl(217.2 32.6% 17.5%);
17 --color-secondary-foreground: hsl(210 40% 98%);
18
19 --color-muted: hsl(217.2 32.6% 17.5%);
20 --color-muted-foreground: hsl(215 20.2% 65.1%);
21
22 --color-accent: hsl(217.2 32.6% 17.5%);
23 --color-accent-foreground: hsl(210 40% 98%);
24
25 --color-destructive: hsl(0 62.8% 30.6%);
26 --color-destructive-foreground: hsl(210 40% 98%);
27
28 --color-border: hsl(217.2 32.6% 17.5%);
29 --color-input: hsl(217.2 32.6% 17.5%);
30 --color-ring: hsl(224.3 76.3% 48%);
31
32 --radius-lg: 0.5rem;
33 --radius-md: calc(0.5rem - 2px);
34 --radius-sm: calc(0.5rem - 4px);
3535 }
36
36
3737 @layer base {
3838 * {
3939 @apply border-border;
@@ -100,4 +100,4 @@ body {
100100
101101 .animate-glow {
102102 animation: glow 3s ease-in-out infinite;
103 }↵
103 }
src/app/playground/contributor/page.tsx added +730
@@ -0,0 +1,730 @@
1 'use client';
2
3 import React, { useState, useEffect, useCallback, useMemo } from 'react';
4 import { motion } from 'framer-motion';
5 import { useChat } from '@ai-sdk/react';
6 import { TextStreamChatTransport } from 'ai';
7 import { Card } from '@/components/ui/card';
8 import { Button } from '@/components/ui/button';
9 import {
10 Conversation,
11 ConversationContent,
12 ConversationEmptyState,
13 ConversationScrollButton,
14 } from '@/components/ai-elements/conversation';
15 import { Message, MessageContent } from '@/components/ai-elements/message';
16 import {
17 PromptInput,
18 PromptInputTextarea,
19 PromptInputFooter,
20 PromptInputSubmit,
21 } from '@/components/ai-elements/prompt-input';
22 import { ConversationList } from '@/components/contributor/ConversationList';
23 import { RepoSelector, Repository } from '@/components/contributor/RepoSelector';
24 import { BranchSelector } from '@/components/contributor/BranchSelector';
25 import { EnvVarsPanel } from '@/components/contributor/EnvVarsPanel';
26 import { PRStatus } from '@/components/contributor/PRStatus';
27 import { GitHubConnect } from '@/components/contributor/GitHubConnect';
28 import { createClient } from '@/utils/supabase/client';
29 import { toast } from '@/hooks/use-toast';
30 import {
31 MenuIcon,
32 XIcon,
33 BarChart2Icon,
34 ArrowLeftIcon,
35 Loader2Icon,
36 MessageCircleIcon,
37 } from 'lucide-react';
38 import Link from 'next/link';
39 import ReactMarkdown from 'react-markdown';
40
41 interface ConversationData {
42 id: string;
43 repo_full_name: string;
44 base_branch: string;
45 work_branch: string;
46 title: string | null;
47 status: 'active' | 'merged' | 'closed' | 'abandoned';
48 created_at: string;
49 updated_at: string;
50 messages?: Array<{
51 id: string;
52 role: 'user' | 'assistant';
53 content: string;
54 files_changed?: string[];
55 commit_sha?: string;
56 commit_message?: string;
57 created_at: string;
58 }>;
59 pr_metadata?: {
60 pr_number?: number;
61 pr_url?: string;
62 title?: string;
63 body?: string;
64 status: 'draft' | 'open' | 'merged' | 'closed';
65 commits_pushed?: number;
66 };
67 envVars?: Record<string, string>;
68 }
69
70 interface EnvVar {
71 key: string;
72 value: string;
73 }
74
75 // Helper to extract text content from AI SDK 6 message parts
76 function getMessageText(message: { parts: Array<{ type: string; text?: string }> }): string {
77 return message.parts
78 .filter((part) => part.type === 'text')
79 .map((part) => part.text || '')
80 .join('');
81 }
82
83 export default function ContributorPage() {
84 const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
85 const [githubConnected, setGithubConnected] = useState(false);
86 const [sidebarOpen, setSidebarOpen] = useState(true);
87 const [view, setView] = useState<'setup' | 'chat'>('setup');
88
89 // Conversations
90 const [conversations, setConversations] = useState<ConversationData[]>([]);
91 const [activeConversation, setActiveConversation] = useState<ConversationData | null>(null);
92 const [loadingConversations, setLoadingConversations] = useState(true);
93
94 // New conversation setup
95 const [selectedRepo, setSelectedRepo] = useState<Repository | null>(null);
96 const [selectedBranch, setSelectedBranch] = useState<string | null>(null);
97 const [workBranch, setWorkBranch] = useState<string>('');
98 const [envVars, setEnvVars] = useState<EnvVar[]>([]);
99 const [creatingConversation, setCreatingConversation] = useState(false);
100
101 // Local input state (AI SDK 6 no longer provides input/setInput)
102 const [input, setInput] = useState('');
103
104 // Create transport with conversation-specific body
105 const transport = useMemo(() => new TextStreamChatTransport({
106 api: '/api/contributor/chat',
107 body: {
108 conversationId: activeConversation?.id,
109 },
110 }), [activeConversation?.id]);
111
112 // AI SDK useChat hook
113 const {
114 messages,
115 sendMessage,
116 status,
117 setMessages,
118 } = useChat({
119 transport,
120 onFinish: () => {
121 // Reload conversation to get commit info
122 if (activeConversation) {
123 loadConversation(activeConversation.id);
124 }
125 },
126 onError: (error) => {
127 toast({
128 title: 'Error',
129 description: error.message || 'Failed to send message',
130 variant: 'destructive',
131 });
132 },
133 });
134
135 const isLoading = status === 'streaming' || status === 'submitted';
136
137 // Check authentication
138 useEffect(() => {
139 const checkAuth = async () => {
140 const supabase = createClient();
141 const { data: { user } } = await supabase.auth.getUser();
142 setIsAuthenticated(!!user);
143 };
144 checkAuth();
145 }, []);
146
147 // Fetch conversations
148 const fetchConversations = useCallback(async () => {
149 if (!githubConnected) return;
150 try {
151 const response = await fetch('/api/contributor/conversations');
152 if (response.ok) {
153 const data = await response.json();
154 setConversations(data.conversations || []);
155 }
156 } catch (error) {
157 console.error('Failed to fetch conversations:', error);
158 } finally {
159 setLoadingConversations(false);
160 }
161 }, [githubConnected]);
162
163 useEffect(() => {
164 if (githubConnected) {
165 fetchConversations();
166 }
167 }, [githubConnected, fetchConversations]);
168
169 // Load conversation details
170 const loadConversation = async (id: string) => {
171 try {
172 const response = await fetch(`/api/contributor/conversations?id=${id}`);
173 if (response.ok) {
174 const conversation = await response.json();
175 setActiveConversation(conversation);
176
177 // Transform messages for useChat
178 const chatMessages = (conversation.messages || []).map((m: {
179 id: string;
180 role: 'user' | 'assistant';
181 content: string;
182 }) => ({
183 id: m.id,
184 role: m.role,
185 content: m.content,
186 }));
187 setMessages(chatMessages);
188
189 // Set env vars
190 if (conversation.envVars) {
191 setEnvVars(
192 Object.entries(conversation.envVars).map(([key, value]) => ({
193 key,
194 value: value as string,
195 }))
196 );
197 }
198
199 setView('chat');
200 }
201 } catch (error) {
202 console.error('Failed to load conversation:', error);
203 toast({
204 title: 'Error',
205 description: 'Failed to load conversation',
206 variant: 'destructive',
207 });
208 }
209 };
210
211 // Create new conversation
212 const createConversation = async () => {
213 if (!selectedRepo || !selectedBranch || !workBranch) {
214 toast({
215 title: 'Missing fields',
216 description: 'Please select a repository, branch, and work branch name',
217 variant: 'destructive',
218 });
219 return;
220 }
221
222 setCreatingConversation(true);
223 try {
224 const envVarsObj = envVars.reduce((acc, { key, value }) => {
225 if (key.trim()) acc[key.trim()] = value;
226 return acc;
227 }, {} as Record<string, string>);
228
229 const response = await fetch('/api/contributor/conversations', {
230 method: 'POST',
231 headers: { 'Content-Type': 'application/json' },
232 body: JSON.stringify({
233 repoFullName: selectedRepo.full_name,
234 baseBranch: selectedBranch,
235 workBranch: workBranch,
236 title: `Work on ${selectedRepo.name}`,
237 envVars: envVarsObj,
238 }),
239 });
240
241 if (!response.ok) throw new Error('Failed to create conversation');
242
243 const data = await response.json();
244 await loadConversation(data.conversation.id);
245 fetchConversations();
246
247 toast({
248 title: 'Conversation created',
249 description: 'You can now start working on your changes',
250 });
251 } catch (error) {
252 console.error('Failed to create conversation:', error);
253 toast({
254 title: 'Error',
255 description: 'Failed to create conversation',
256 variant: 'destructive',
257 });
258 } finally {
259 setCreatingConversation(false);
260 }
261 };
262
263 // Open PR
264 const handleOpenPR = async (title: string, body: string) => {
265 if (!activeConversation) return;
266
267 try {
268 const response = await fetch('/api/contributor/pr', {
269 method: 'POST',
270 headers: { 'Content-Type': 'application/json' },
271 body: JSON.stringify({
272 conversationId: activeConversation.id,
273 title,
274 body,
275 useUrlParams: true,
276 }),
277 });
278
279 if (!response.ok) throw new Error('Failed to open PR');
280
281 const data = await response.json();
282 window.open(data.prUrl, '_blank');
283 await loadConversation(activeConversation.id);
284
285 toast({
286 title: 'PR Ready',
287 description: 'Complete the PR creation in the new tab',
288 });
289 } catch (error) {
290 console.error('Failed to open PR:', error);
291 toast({
292 title: 'Error',
293 description: 'Failed to open PR',
294 variant: 'destructive',
295 });
296 }
297 };
298
299 // Update PR metadata
300 const handleUpdatePR = async (title: string, body: string) => {
301 if (!activeConversation) return;
302
303 try {
304 await fetch('/api/contributor/pr', {
305 method: 'PATCH',
306 headers: { 'Content-Type': 'application/json' },
307 body: JSON.stringify({
308 conversationId: activeConversation.id,
309 title,
310 body,
311 }),
312 });
313
314 await loadConversation(activeConversation.id);
315 } catch (error) {
316 console.error('Failed to update PR:', error);
317 }
318 };
319
320 // Reset to new conversation
321 const handleNewConversation = () => {
322 setActiveConversation(null);
323 setMessages([]);
324 setSelectedRepo(null);
325 setSelectedBranch(null);
326 setWorkBranch('');
327 setEnvVars([]);
328 setView('setup');
329 };
330
331 // Auto-generate work branch name
332 useEffect(() => {
333 if (selectedRepo && selectedBranch && !workBranch) {
334 const timestamp = Date.now().toString(36);
335 setWorkBranch(`ai-contributor/${timestamp}`);
336 }
337 // eslint-disable-next-line react-hooks/exhaustive-deps
338 }, [selectedRepo, selectedBranch]);
339
340 // Auth check
341 if (isAuthenticated === null) {
342 return (
343 <div className="min-h-screen bg-black flex items-center justify-center">
344 <Loader2Icon className="w-8 h-8 text-blue-500 animate-spin" />
345 </div>
346 );
347 }
348
349 if (!isAuthenticated) {
350 return (
351 <motion.div
352 initial={{ opacity: 0 }}
353 animate={{ opacity: 1 }}
354 className="min-h-screen bg-black flex items-center justify-center p-4"
355 >
356 <Card className="max-w-md w-full bg-black/50 backdrop-blur-md border-blue-500/30 p-8 text-center">
357 <h2 className="text-2xl font-bold text-white mb-4">Sign In Required</h2>
358 <p className="text-gray-400 mb-6">
359 Please sign in to use the Repo Contributor feature.
360 </p>
361 <Link
362 href="/auth/sign-in"
363 className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-xl
364 hover:bg-blue-700 transition-colors neon-border"
365 >
366 Sign In
367 </Link>
368 </Card>
369 </motion.div>
370 );
371 }
372
373 return (
374 <motion.div
375 initial={{ opacity: 0 }}
376 animate={{ opacity: 1 }}
377 className="min-h-screen bg-black flex"
378 >
379 {/* Sidebar */}
380 <div
381 className={`fixed inset-y-0 left-0 z-30 w-80 bg-black/60 backdrop-blur-md border-r border-blue-500/20
382 transform transition-transform duration-300 ease-in-out
383 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
384 lg:relative lg:translate-x-0`}
385 >
386 <div className="flex flex-col h-full">
387 {/* Sidebar Header */}
388 <div className="p-4 border-b border-blue-500/20 flex items-center justify-between">
389 <h1 className="text-lg font-bold text-white text-glow">Repo Contributor</h1>
390 <div className="flex items-center gap-2">
391 <Link
392 href="/playground/contributor/stats"
393 className="p-2 text-gray-400 hover:text-white transition-colors"
394 title="View Stats"
395 >
396 <BarChart2Icon className="w-5 h-5" />
397 </Link>
398 <button
399 onClick={() => setSidebarOpen(false)}
400 className="p-2 text-gray-400 hover:text-white lg:hidden"
401 >
402 <XIcon className="w-5 h-5" />
403 </button>
404 </div>
405 </div>
406
407 {/* GitHub Connection */}
408 <div className="p-4 border-b border-blue-500/20">
409 <GitHubConnect onStatusChange={setGithubConnected} />
410 </div>
411
412 {/* Conversations */}
413 {githubConnected && (
414 <ConversationList
415 conversations={conversations}
416 activeId={activeConversation?.id}
417 onSelect={loadConversation}
418 onNew={handleNewConversation}
419 loading={loadingConversations}
420 />
421 )}
422 </div>
423 </div>
424
425 {/* Main Content */}
426 <div className="flex-1 flex flex-col min-w-0">
427 {/* Mobile Header */}
428 <div className="lg:hidden flex items-center gap-3 p-4 border-b border-blue-500/20 bg-black/30 backdrop-blur-sm">
429 <button
430 onClick={() => setSidebarOpen(true)}
431 className="p-2 text-gray-400 hover:text-white"
432 >
433 <MenuIcon className="w-5 h-5" />
434 </button>
435 <h1 className="text-lg font-semibold text-white">
436 {activeConversation?.title || 'Repo Contributor'}
437 </h1>
438 </div>
439
440 {!githubConnected ? (
441 <div className="flex-1 flex items-center justify-center p-8">
442 <motion.div
443 initial={{ opacity: 0, y: 20 }}
444 animate={{ opacity: 1, y: 0 }}
445 className="text-center"
446 >
447 <h2 className="text-xl font-semibold text-white mb-2">
448 Connect GitHub to Get Started
449 </h2>
450 <p className="text-gray-400">
451 Use the sidebar to connect your GitHub account
452 </p>
453 </motion.div>
454 </div>
455 ) : view === 'setup' ? (
456 /* Setup View */
457 <motion.div
458 initial={{ opacity: 0, y: 20 }}
459 animate={{ opacity: 1, y: 0 }}
460 className="flex-1 overflow-y-auto p-4 sm:p-8"
461 >
462 <div className="max-w-2xl mx-auto space-y-6">
463 <div>
464 <h2 className="text-2xl font-bold text-white mb-2 text-glow">
465 Start New Contribution
466 </h2>
467 <p className="text-gray-400">
468 Select a repository and branch to begin working on your changes.
469 </p>
470 </div>
471
472 {/* Repository Selection */}
473 <motion.div
474 initial={{ opacity: 0, y: 10 }}
475 animate={{ opacity: 1, y: 0 }}
476 transition={{ delay: 0.1 }}
477 className="space-y-2"
478 >
479 <label className="block text-sm font-medium text-gray-300">
480 Repository
481 </label>
482 <RepoSelector
483 selectedRepo={selectedRepo}
484 onSelect={(repo) => {
485 setSelectedRepo(repo);
486 setSelectedBranch(repo.default_branch);
487 }}
488 />
489 </motion.div>
490
491 {/* Base Branch Selection */}
492 <motion.div
493 initial={{ opacity: 0, y: 10 }}
494 animate={{ opacity: 1, y: 0 }}
495 transition={{ delay: 0.2 }}
496 className="space-y-2"
497 >
498 <label className="block text-sm font-medium text-gray-300">
499 Base Branch
500 </label>
501 <BranchSelector
502 repoFullName={selectedRepo?.full_name || null}
503 selectedBranch={selectedBranch}
504 onSelect={setSelectedBranch}
505 allowNew={false}
506 />
507 <p className="text-xs text-gray-500">
508 The branch your changes will be based on
509 </p>
510 </motion.div>
511
512 {/* Work Branch */}
513 <motion.div
514 initial={{ opacity: 0, y: 10 }}
515 animate={{ opacity: 1, y: 0 }}
516 transition={{ delay: 0.3 }}
517 className="space-y-2"
518 >
519 <label className="block text-sm font-medium text-gray-300">
520 Work Branch
521 </label>
522 <input
523 type="text"
524 value={workBranch}
525 onChange={(e) => setWorkBranch(e.target.value)}
526 placeholder="ai-contributor/feature-name"
527 className="w-full px-4 py-3 bg-gray-800/80 border border-gray-700 rounded-xl
528 text-sm text-white placeholder:text-gray-500
529 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
530 />
531 <p className="text-xs text-gray-500">
532 A new branch will be created for your changes
533 </p>
534 </motion.div>
535
536 {/* Environment Variables */}
537 <motion.div
538 initial={{ opacity: 0, y: 10 }}
539 animate={{ opacity: 1, y: 0 }}
540 transition={{ delay: 0.4 }}
541 className="space-y-2"
542 >
543 <label className="block text-sm font-medium text-gray-300">
544 Environment Variables (Optional)
545 </label>
546 <div className="relative">
547 <EnvVarsPanel envVars={envVars} onChange={setEnvVars} />
548 </div>
549 <p className="text-xs text-gray-500">
550 Securely stored and available during code execution
551 </p>
552 </motion.div>
553
554 {/* Start Button */}
555 <motion.div
556 initial={{ opacity: 0, y: 10 }}
557 animate={{ opacity: 1, y: 0 }}
558 transition={{ delay: 0.5 }}
559 >
560 <Button
561 onClick={createConversation}
562 disabled={!selectedRepo || !selectedBranch || !workBranch || creatingConversation}
563 className="w-full py-6 bg-blue-600 text-white text-lg font-medium rounded-xl
564 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
565 transition-colors neon-border"
566 >
567 {creatingConversation ? (
568 <>
569 <Loader2Icon className="w-5 h-5 animate-spin mr-2" />
570 Creating...
571 </>
572 ) : (
573 'Start Contributing'
574 )}
575 </Button>
576 </motion.div>
577 </div>
578 </motion.div>
579 ) : (
580 /* Chat View */
581 <div className="flex-1 flex flex-col min-h-0">
582 {/* Chat Header */}
583 <div className="hidden lg:flex items-center gap-4 p-4 border-b border-blue-500/20 bg-black/30 backdrop-blur-sm">
584 <button
585 onClick={handleNewConversation}
586 className="p-2 text-gray-400 hover:text-white transition-colors"
587 >
588 <ArrowLeftIcon className="w-5 h-5" />
589 </button>
590 <div className="flex-1 min-w-0">
591 <h2 className="text-lg font-semibold text-white truncate">
592 {activeConversation?.title || activeConversation?.repo_full_name}
593 </h2>
594 <p className="text-sm text-gray-400 truncate">
595 {activeConversation?.repo_full_name} • {activeConversation?.work_branch}
596 </p>
597 </div>
598 <div className="flex items-center gap-2">
599 <EnvVarsPanel
600 envVars={envVars}
601 onChange={async (newEnvVars) => {
602 setEnvVars(newEnvVars);
603 if (activeConversation) {
604 const envVarsObj = newEnvVars.reduce((acc, { key, value }) => {
605 if (key.trim()) acc[key.trim()] = value;
606 return acc;
607 }, {} as Record<string, string>);
608
609 await fetch('/api/contributor/conversations', {
610 method: 'PATCH',
611 headers: { 'Content-Type': 'application/json' },
612 body: JSON.stringify({
613 id: activeConversation.id,
614 envVars: envVarsObj,
615 }),
616 });
617 }
618 }}
619 />
620 </div>
621 </div>
622
623 {/* PR Status */}
624 {activeConversation?.pr_metadata && (
625 <div className="p-4 border-b border-blue-500/20 bg-black/20">
626 <PRStatus
627 conversationId={activeConversation.id}
628 prStatus={activeConversation.pr_metadata.status}
629 prUrl={activeConversation.pr_metadata.pr_url}
630 prNumber={activeConversation.pr_metadata.pr_number}
631 title={activeConversation.pr_metadata.title}
632 body={activeConversation.pr_metadata.body}
633 commitsPushed={activeConversation.pr_metadata.commits_pushed}
634 onOpenPR={handleOpenPR}
635 onUpdatePR={handleUpdatePR}
636 />
637 </div>
638 )}
639
640 {/* Conversation Area */}
641 <Conversation className="flex-1 bg-black/20">
642 <ConversationContent className="p-4 gap-4">
643 {messages.length === 0 ? (
644 <ConversationEmptyState
645 icon={<MessageCircleIcon className="w-12 h-12" />}
646 title="Start a conversation"
647 description="Describe what you want to accomplish in this repository. The AI will help you make the changes."
648 className="text-gray-400"
649 />
650 ) : (
651 messages.map((message) => {
652 const text = getMessageText(message);
653 return (
654 <Message key={message.id} from={message.role}>
655 <MessageContent
656 className={
657 message.role === 'user'
658 ? 'bg-blue-600/20 border border-blue-500/30 backdrop-blur-sm rounded-xl px-4 py-3'
659 : 'bg-gray-800/50 border border-gray-700/30 backdrop-blur-sm rounded-xl px-4 py-3'
660 }
661 >
662 {message.role === 'user' ? (
663 <p className="text-sm whitespace-pre-wrap text-white">{text}</p>
664 ) : (
665 <div className="prose prose-sm prose-invert max-w-none">
666 <ReactMarkdown>{text}</ReactMarkdown>
667 </div>
668 )}
669 </MessageContent>
670 </Message>
671 );
672 })
673 )}
674 {isLoading && messages[messages.length - 1]?.role === 'user' && (
675 <Message from="assistant">
676 <MessageContent className="bg-gray-800/50 border border-gray-700/30 backdrop-blur-sm rounded-xl px-4 py-3">
677 <div className="flex items-center gap-2 text-gray-400">
678 <Loader2Icon className="w-4 h-4 animate-spin" />
679 <span className="text-sm">Thinking...</span>
680 </div>
681 </MessageContent>
682 </Message>
683 )}
684 </ConversationContent>
685 <ConversationScrollButton className="bg-blue-600 hover:bg-blue-700 border-blue-500" />
686 </Conversation>
687
688 {/* Input Area */}
689 <div className="border-t border-blue-500/20 p-4 bg-black/30 backdrop-blur-sm">
690 <PromptInput
691 onSubmit={({ text }) => {
692 if (!text.trim() || isLoading) return;
693 sendMessage({ parts: [{ type: 'text', text }] });
694 setInput('');
695 }}
696 className="bg-gray-800/80 border border-gray-700 rounded-xl overflow-hidden"
697 >
698 <PromptInputTextarea
699 value={input}
700 onChange={(e) => setInput(e.target.value)}
701 placeholder="Describe what you want to change..."
702 disabled={isLoading}
703 className="bg-transparent border-none text-white placeholder:text-gray-500 focus:ring-0"
704 />
705 <PromptInputFooter className="border-t border-gray-700/50 px-3 py-2">
706 <p className="text-xs text-gray-500">
707 Press Enter to send, Shift+Enter for new line
708 </p>
709 <PromptInputSubmit
710 status={status}
711 disabled={!input.trim() || isLoading}
712 className="bg-blue-600 hover:bg-blue-700 text-white rounded-full"
713 />
714 </PromptInputFooter>
715 </PromptInput>
716 </div>
717 </div>
718 )}
719 </div>
720
721 {/* Mobile Sidebar Backdrop */}
722 {sidebarOpen && (
723 <div
724 className="fixed inset-0 z-20 bg-black/50 backdrop-blur-sm lg:hidden"
725 onClick={() => setSidebarOpen(false)}
726 />
727 )}
728 </motion.div>
729 );
730 }
src/app/playground/contributor/stats/page.tsx added +415
@@ -0,0 +1,415 @@
1 'use client';
2
3 import React, { useState, useEffect } from 'react';
4 import { Card } from '@/components/ui/card';
5 import Link from 'next/link';
6 import {
7 ArrowLeftIcon,
8 TrendingUpIcon,
9 GitPullRequestIcon,
10 GitMergeIcon,
11 MessageSquareIcon,
12 ZapIcon,
13 BarChart2Icon,
14 Loader2Icon,
15 CheckCircleIcon,
16 XCircleIcon,
17 ClockIcon,
18 } from 'lucide-react';
19
20 interface Stats {
21 summary: {
22 total_conversations: number;
23 merged_conversations: number;
24 closed_conversations: number;
25 active_conversations: number;
26 open_prs: number;
27 merged_prs: number;
28 merge_rate_percent: number | null;
29 total_commits_pushed: number;
30 total_messages: number;
31 avg_messages_per_conversation: number;
32 total_tokens_used: number;
33 };
34 recentConversations: Array<{
35 id: string;
36 repo_full_name: string;
37 title: string | null;
38 status: string;
39 created_at: string;
40 updated_at: string;
41 pr_metadata?: {
42 pr_number?: number;
43 pr_url?: string;
44 status: string;
45 };
46 }>;
47 topRepos: Array<{
48 repo: string;
49 total: number;
50 merged: number;
51 closed: number;
52 active: number;
53 }>;
54 activityTimeline: Array<{
55 date: string;
56 user: number;
57 assistant: number;
58 }>;
59 }
60
61 export default function StatsPage() {
62 const [stats, setStats] = useState<Stats | null>(null);
63 const [loading, setLoading] = useState(true);
64
65 useEffect(() => {
66 fetchStats();
67 }, []);
68
69 const fetchStats = async () => {
70 try {
71 const response = await fetch('/api/contributor/stats');
72 if (response.ok) {
73 const data = await response.json();
74 setStats(data);
75 }
76 } catch (error) {
77 console.error('Failed to fetch stats:', error);
78 } finally {
79 setLoading(false);
80 }
81 };
82
83 const formatNumber = (num: number) => {
84 if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
85 if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
86 return num.toString();
87 };
88
89 const getStatusIcon = (status: string) => {
90 switch (status) {
91 case 'merged':
92 return <GitMergeIcon className="w-4 h-4 text-purple-500" />;
93 case 'closed':
94 return <XCircleIcon className="w-4 h-4 text-red-500" />;
95 case 'open':
96 return <GitPullRequestIcon className="w-4 h-4 text-green-500" />;
97 default:
98 return <ClockIcon className="w-4 h-4 text-gray-500" />;
99 }
100 };
101
102 if (loading) {
103 return (
104 <div className="min-h-screen bg-black flex items-center justify-center">
105 <Loader2Icon className="w-8 h-8 text-blue-500 animate-spin" />
106 </div>
107 );
108 }
109
110 return (
111 <div className="min-h-screen bg-black">
112 {/* Header */}
113 <header className="border-b border-gray-800">
114 <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
115 <div className="flex items-center gap-4">
116 <Link
117 href="/playground/contributor"
118 className="p-2 text-gray-400 hover:text-white transition-colors"
119 >
120 <ArrowLeftIcon className="w-5 h-5" />
121 </Link>
122 <div>
123 <h1 className="text-xl font-bold text-white">Contributor Stats</h1>
124 <p className="text-sm text-gray-400">Track your contribution metrics</p>
125 </div>
126 </div>
127 </div>
128 </header>
129
130 <main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
131 {/* Summary Cards */}
132 <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
133 <Card className="bg-gray-900/50 border-gray-800 p-4">
134 <div className="flex items-center gap-3">
135 <div className="p-2 bg-blue-500/10 rounded-lg">
136 <MessageSquareIcon className="w-5 h-5 text-blue-500" />
137 </div>
138 <div>
139 <p className="text-2xl font-bold text-white">
140 {formatNumber(stats?.summary.total_conversations || 0)}
141 </p>
142 <p className="text-xs text-gray-400">Total Conversations</p>
143 </div>
144 </div>
145 </Card>
146
147 <Card className="bg-gray-900/50 border-gray-800 p-4">
148 <div className="flex items-center gap-3">
149 <div className="p-2 bg-purple-500/10 rounded-lg">
150 <GitMergeIcon className="w-5 h-5 text-purple-500" />
151 </div>
152 <div>
153 <p className="text-2xl font-bold text-white">
154 {formatNumber(stats?.summary.merged_prs || 0)}
155 </p>
156 <p className="text-xs text-gray-400">PRs Merged</p>
157 </div>
158 </div>
159 </Card>
160
161 <Card className="bg-gray-900/50 border-gray-800 p-4">
162 <div className="flex items-center gap-3">
163 <div className="p-2 bg-green-500/10 rounded-lg">
164 <TrendingUpIcon className="w-5 h-5 text-green-500" />
165 </div>
166 <div>
167 <p className="text-2xl font-bold text-white">
168 {stats?.summary.merge_rate_percent != null
169 ? `${stats.summary.merge_rate_percent}%`
170 : '-'}
171 </p>
172 <p className="text-xs text-gray-400">Merge Rate</p>
173 </div>
174 </div>
175 </Card>
176
177 <Card className="bg-gray-900/50 border-gray-800 p-4">
178 <div className="flex items-center gap-3">
179 <div className="p-2 bg-yellow-500/10 rounded-lg">
180 <ZapIcon className="w-5 h-5 text-yellow-500" />
181 </div>
182 <div>
183 <p className="text-2xl font-bold text-white">
184 {formatNumber(stats?.summary.total_tokens_used || 0)}
185 </p>
186 <p className="text-xs text-gray-400">Tokens Used</p>
187 </div>
188 </div>
189 </Card>
190 </div>
191
192 {/* More Stats */}
193 <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
194 <div className="text-center p-4 bg-gray-900/30 rounded-xl border border-gray-800">
195 <p className="text-3xl font-bold text-white">
196 {formatNumber(stats?.summary.total_commits_pushed || 0)}
197 </p>
198 <p className="text-sm text-gray-400 mt-1">Commits Pushed</p>
199 </div>
200 <div className="text-center p-4 bg-gray-900/30 rounded-xl border border-gray-800">
201 <p className="text-3xl font-bold text-white">
202 {formatNumber(stats?.summary.total_messages || 0)}
203 </p>
204 <p className="text-sm text-gray-400 mt-1">Messages Sent</p>
205 </div>
206 <div className="text-center p-4 bg-gray-900/30 rounded-xl border border-gray-800">
207 <p className="text-3xl font-bold text-white">
208 {stats?.summary.avg_messages_per_conversation?.toFixed(1) || 0}
209 </p>
210 <p className="text-sm text-gray-400 mt-1">Avg Messages/Conv</p>
211 </div>
212 <div className="text-center p-4 bg-gray-900/30 rounded-xl border border-gray-800">
213 <p className="text-3xl font-bold text-white">
214 {stats?.summary.open_prs || 0}
215 </p>
216 <p className="text-sm text-gray-400 mt-1">Open PRs</p>
217 </div>
218 </div>
219
220 <div className="grid lg:grid-cols-2 gap-8">
221 {/* Top Repositories */}
222 <Card className="bg-gray-900/50 border-gray-800 overflow-hidden">
223 <div className="p-4 border-b border-gray-800">
224 <h2 className="text-lg font-semibold text-white flex items-center gap-2">
225 <BarChart2Icon className="w-5 h-5 text-blue-500" />
226 Top Repositories
227 </h2>
228 </div>
229 <div className="p-4">
230 {stats?.topRepos && stats.topRepos.length > 0 ? (
231 <div className="space-y-4">
232 {stats.topRepos.map((repo, index) => (
233 <div key={repo.repo} className="space-y-2">
234 <div className="flex items-center justify-between">
235 <span className="text-sm text-white font-medium truncate flex-1">
236 {repo.repo}
237 </span>
238 <span className="text-sm text-gray-400 ml-2">
239 {repo.total} conversations
240 </span>
241 </div>
242 <div className="flex h-2 rounded-full overflow-hidden bg-gray-800">
243 {repo.merged > 0 && (
244 <div
245 className="bg-purple-500"
246 style={{ width: `${(repo.merged / repo.total) * 100}%` }}
247 title={`${repo.merged} merged`}
248 />
249 )}
250 {repo.closed > 0 && (
251 <div
252 className="bg-red-500"
253 style={{ width: `${(repo.closed / repo.total) * 100}%` }}
254 title={`${repo.closed} closed`}
255 />
256 )}
257 {repo.active > 0 && (
258 <div
259 className="bg-gray-500"
260 style={{ width: `${(repo.active / repo.total) * 100}%` }}
261 title={`${repo.active} active`}
262 />
263 )}
264 </div>
265 <div className="flex gap-4 text-xs text-gray-500">
266 <span className="flex items-center gap-1">
267 <div className="w-2 h-2 rounded-full bg-purple-500" />
268 {repo.merged} merged
269 </span>
270 <span className="flex items-center gap-1">
271 <div className="w-2 h-2 rounded-full bg-red-500" />
272 {repo.closed} closed
273 </span>
274 <span className="flex items-center gap-1">
275 <div className="w-2 h-2 rounded-full bg-gray-500" />
276 {repo.active} active
277 </span>
278 </div>
279 </div>
280 ))}
281 </div>
282 ) : (
283 <p className="text-gray-500 text-center py-8">No repository data yet</p>
284 )}
285 </div>
286 </Card>
287
288 {/* Recent Conversations */}
289 <Card className="bg-gray-900/50 border-gray-800 overflow-hidden">
290 <div className="p-4 border-b border-gray-800">
291 <h2 className="text-lg font-semibold text-white flex items-center gap-2">
292 <MessageSquareIcon className="w-5 h-5 text-blue-500" />
293 Recent Activity
294 </h2>
295 </div>
296 <div className="divide-y divide-gray-800">
297 {stats?.recentConversations && stats.recentConversations.length > 0 ? (
298 stats.recentConversations.slice(0, 5).map((conv) => (
299 <Link
300 key={conv.id}
301 href={`/playground/contributor?conversation=${conv.id}`}
302 className="block p-4 hover:bg-gray-800/50 transition-colors"
303 >
304 <div className="flex items-start gap-3">
305 {getStatusIcon(conv.pr_metadata?.status || conv.status)}
306 <div className="min-w-0 flex-1">
307 <p className="text-sm text-white font-medium truncate">
308 {conv.title || conv.repo_full_name}
309 </p>
310 <p className="text-xs text-gray-400 truncate">
311 {conv.repo_full_name}
312 </p>
313 <p className="text-xs text-gray-500 mt-1">
314 {new Date(conv.updated_at).toLocaleDateString()}
315 </p>
316 </div>
317 {conv.pr_metadata?.pr_number && (
318 <span className="text-xs text-gray-500">
319 #{conv.pr_metadata.pr_number}
320 </span>
321 )}
322 </div>
323 </Link>
324 ))
325 ) : (
326 <p className="text-gray-500 text-center py-8">No recent activity</p>
327 )}
328 </div>
329 </Card>
330 </div>
331
332 {/* Activity Timeline */}
333 {stats?.activityTimeline && stats.activityTimeline.length > 0 && (
334 <Card className="bg-gray-900/50 border-gray-800 overflow-hidden">
335 <div className="p-4 border-b border-gray-800">
336 <h2 className="text-lg font-semibold text-white">
337 Activity (Last 30 Days)
338 </h2>
339 </div>
340 <div className="p-4">
341 <div className="flex items-end gap-1 h-32">
342 {stats.activityTimeline.map((day, index) => {
343 const total = day.user + day.assistant;
344 const maxTotal = Math.max(
345 ...stats.activityTimeline.map((d) => d.user + d.assistant)
346 );
347 const height = maxTotal > 0 ? (total / maxTotal) * 100 : 0;
348
349 return (
350 <div
351 key={day.date}
352 className="flex-1 flex flex-col justify-end group relative"
353 >
354 <div
355 className="bg-blue-500/50 hover:bg-blue-500 transition-colors rounded-t"
356 style={{ height: `${height}%`, minHeight: total > 0 ? '4px' : '0' }}
357 />
358 <div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2
359 bg-gray-800 text-white text-xs px-2 py-1 rounded
360 opacity-0 group-hover:opacity-100 transition-opacity
361 whitespace-nowrap pointer-events-none z-10">
362 {new Date(day.date).toLocaleDateString()}: {total} messages
363 </div>
364 </div>
365 );
366 })}
367 </div>
368 <div className="flex justify-between mt-2 text-xs text-gray-500">
369 <span>30 days ago</span>
370 <span>Today</span>
371 </div>
372 </div>
373 </Card>
374 )}
375
376 {/* Tips */}
377 <Card className="bg-gradient-to-r from-blue-900/20 to-purple-900/20 border-blue-500/30 p-6">
378 <h3 className="text-lg font-semibold text-white mb-4">
379 Tips to Improve Your Merge Rate
380 </h3>
381 <ul className="space-y-3 text-sm text-gray-300">
382 <li className="flex items-start gap-3">
383 <CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
384 <span>
385 <strong>Be specific:</strong> Clear, detailed prompts help the AI make
386 better changes that are more likely to be accepted.
387 </span>
388 </li>
389 <li className="flex items-start gap-3">
390 <CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
391 <span>
392 <strong>Review changes:</strong> Always review the AI&apos;s changes before
393 opening a PR. Small corrections can prevent rejections.
394 </span>
395 </li>
396 <li className="flex items-start gap-3">
397 <CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
398 <span>
399 <strong>Iterate:</strong> Don&apos;t hesitate to send multiple messages to
400 refine the changes until they&apos;re perfect.
401 </span>
402 </li>
403 <li className="flex items-start gap-3">
404 <CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-0.5" />
405 <span>
406 <strong>Good PR descriptions:</strong> Take time to write clear PR
407 titles and descriptions explaining the changes.
408 </span>
409 </li>
410 </ul>
411 </Card>
412 </main>
413 </div>
414 );
415 }
src/components/FloatingNavigation.tsx +1
@@ -11,6 +11,7 @@ import { createClient } from '@/utils/supabase/client'
1111 const navItems = [
1212 { href: '/', label: 'Home' },
1313 { href: '/playground/function-creator', label: 'Function Creator' },
14 { href: '/playground/contributor', label: 'Repo Contributor' },
1415 { href: '/playground/3d-viewer', label: '3D Viewer' },
1516 { href: '/playground/color-picker', label: 'Color Picker' },
1617 { href: '/projects', label: 'Side Projects' },
src/components/GitHubProject.tsx +3 −5
@@ -78,20 +78,18 @@ export const GitHubProject: React.FC<GitHubProjectProps> = ({
7878 remarkPlugins={[remarkGfm]}
7979 rehypePlugins={[rehypeRaw]}
8080 components={{
81 code({ node, className, children, ...props }) {
81 code({ className, children }) {
8282 const match = /language-(\w+)/.exec(className || '')
8383 return match ? (
84 // @ts-expect-error syntax highlight is fine
8584 <SyntaxHighlighter
86 style={vscDarkPlus as any}
85 style={vscDarkPlus}
8786 language={match[1]}
8887 PreTag="div"
89 {...props}
9088 >
9189 {String(children).replace(/\n$/, '')}
9290 </SyntaxHighlighter>
9391 ) : (
94 <code className={className} {...props}>
92 <code className={className}>
9593 {children}
9694 </code>
9795 )
src/components/ai-elements/code-block.tsx added +178
@@ -0,0 +1,178 @@
1 "use client";
2
3 import { Button } from "@/components/ui/button";
4 import { cn } from "@/lib/utils";
5 import { CheckIcon, CopyIcon } from "lucide-react";
6 import {
7 type ComponentProps,
8 createContext,
9 type HTMLAttributes,
10 useContext,
11 useEffect,
12 useRef,
13 useState,
14 } from "react";
15 import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
16
17 type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
18 code: string;
19 language: BundledLanguage;
20 showLineNumbers?: boolean;
21 };
22
23 type CodeBlockContextType = {
24 code: string;
25 };
26
27 const CodeBlockContext = createContext<CodeBlockContextType>({
28 code: "",
29 });
30
31 const lineNumberTransformer: ShikiTransformer = {
32 name: "line-numbers",
33 line(node, line) {
34 node.children.unshift({
35 type: "element",
36 tagName: "span",
37 properties: {
38 className: [
39 "inline-block",
40 "min-w-10",
41 "mr-4",
42 "text-right",
43 "select-none",
44 "text-muted-foreground",
45 ],
46 },
47 children: [{ type: "text", value: String(line) }],
48 });
49 },
50 };
51
52 export async function highlightCode(
53 code: string,
54 language: BundledLanguage,
55 showLineNumbers = false
56 ) {
57 const transformers: ShikiTransformer[] = showLineNumbers
58 ? [lineNumberTransformer]
59 : [];
60
61 return await Promise.all([
62 codeToHtml(code, {
63 lang: language,
64 theme: "one-light",
65 transformers,
66 }),
67 codeToHtml(code, {
68 lang: language,
69 theme: "one-dark-pro",
70 transformers,
71 }),
72 ]);
73 }
74
75 export const CodeBlock = ({
76 code,
77 language,
78 showLineNumbers = false,
79 className,
80 children,
81 ...props
82 }: CodeBlockProps) => {
83 const [html, setHtml] = useState<string>("");
84 const [darkHtml, setDarkHtml] = useState<string>("");
85 const mounted = useRef(false);
86
87 useEffect(() => {
88 highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
89 if (!mounted.current) {
90 setHtml(light);
91 setDarkHtml(dark);
92 mounted.current = true;
93 }
94 });
95
96 return () => {
97 mounted.current = false;
98 };
99 }, [code, language, showLineNumbers]);
100
101 return (
102 <CodeBlockContext.Provider value={{ code }}>
103 <div
104 className={cn(
105 "group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
106 className
107 )}
108 {...props}
109 >
110 <div className="relative">
111 <div
112 className="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
113 // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
114 dangerouslySetInnerHTML={{ __html: html }}
115 />
116 <div
117 className="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
118 // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
119 dangerouslySetInnerHTML={{ __html: darkHtml }}
120 />
121 {children && (
122 <div className="absolute top-2 right-2 flex items-center gap-2">
123 {children}
124 </div>
125 )}
126 </div>
127 </div>
128 </CodeBlockContext.Provider>
129 );
130 };
131
132 export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
133 onCopy?: () => void;
134 onError?: (error: Error) => void;
135 timeout?: number;
136 };
137
138 export const CodeBlockCopyButton = ({
139 onCopy,
140 onError,
141 timeout = 2000,
142 children,
143 className,
144 ...props
145 }: CodeBlockCopyButtonProps) => {
146 const [isCopied, setIsCopied] = useState(false);
147 const { code } = useContext(CodeBlockContext);
148
149 const copyToClipboard = async () => {
150 if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
151 onError?.(new Error("Clipboard API not available"));
152 return;
153 }
154
155 try {
156 await navigator.clipboard.writeText(code);
157 setIsCopied(true);
158 onCopy?.();
159 setTimeout(() => setIsCopied(false), timeout);
160 } catch (error) {
161 onError?.(error as Error);
162 }
163 };
164
165 const Icon = isCopied ? CheckIcon : CopyIcon;
166
167 return (
168 <Button
169 className={cn("shrink-0", className)}
170 onClick={copyToClipboard}
171 size="icon"
172 variant="ghost"
173 {...props}
174 >
175 {children ?? <Icon size={14} />}
176 </Button>
177 );
178 };
src/components/ai-elements/conversation.tsx added +100
@@ -0,0 +1,100 @@
1 "use client";
2
3 import { Button } from "@/components/ui/button";
4 import { cn } from "@/lib/utils";
5 import { ArrowDownIcon } from "lucide-react";
6 import type { ComponentProps } from "react";
7 import { useCallback } from "react";
8 import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
9
10 export type ConversationProps = ComponentProps<typeof StickToBottom>;
11
12 export const Conversation = ({ className, ...props }: ConversationProps) => (
13 <StickToBottom
14 className={cn("relative flex-1 overflow-y-hidden", className)}
15 initial="smooth"
16 resize="smooth"
17 role="log"
18 {...props}
19 />
20 );
21
22 export type ConversationContentProps = ComponentProps<
23 typeof StickToBottom.Content
24 >;
25
26 export const ConversationContent = ({
27 className,
28 ...props
29 }: ConversationContentProps) => (
30 <StickToBottom.Content
31 className={cn("flex flex-col gap-8 p-4", className)}
32 {...props}
33 />
34 );
35
36 export type ConversationEmptyStateProps = ComponentProps<"div"> & {
37 title?: string;
38 description?: string;
39 icon?: React.ReactNode;
40 };
41
42 export const ConversationEmptyState = ({
43 className,
44 title = "No messages yet",
45 description = "Start a conversation to see messages here",
46 icon,
47 children,
48 ...props
49 }: ConversationEmptyStateProps) => (
50 <div
51 className={cn(
52 "flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
53 className
54 )}
55 {...props}
56 >
57 {children ?? (
58 <>
59 {icon && <div className="text-muted-foreground">{icon}</div>}
60 <div className="space-y-1">
61 <h3 className="font-medium text-sm">{title}</h3>
62 {description && (
63 <p className="text-muted-foreground text-sm">{description}</p>
64 )}
65 </div>
66 </>
67 )}
68 </div>
69 );
70
71 export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
72
73 export const ConversationScrollButton = ({
74 className,
75 ...props
76 }: ConversationScrollButtonProps) => {
77 const { isAtBottom, scrollToBottom } = useStickToBottomContext();
78
79 const handleScrollToBottom = useCallback(() => {
80 scrollToBottom();
81 }, [scrollToBottom]);
82
83 return (
84 !isAtBottom && (
85 <Button
86 className={cn(
87 "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
88 className
89 )}
90 onClick={handleScrollToBottom}
91 size="icon"
92 type="button"
93 variant="outline"
94 {...props}
95 >
96 <ArrowDownIcon className="size-4" />
97 </Button>
98 )
99 );
100 };
src/components/ai-elements/message.tsx added +448
@@ -0,0 +1,448 @@
1 "use client";
2
3 import { Button } from "@/components/ui/button";
4 import {
5 ButtonGroup,
6 ButtonGroupText,
7 } from "@/components/ui/button-group";
8 import {
9 Tooltip,
10 TooltipContent,
11 TooltipProvider,
12 TooltipTrigger,
13 } from "@/components/ui/tooltip";
14 import { cn } from "@/lib/utils";
15 import type { FileUIPart, UIMessage } from "ai";
16 import {
17 ChevronLeftIcon,
18 ChevronRightIcon,
19 PaperclipIcon,
20 XIcon,
21 } from "lucide-react";
22 import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
23 import { createContext, memo, useContext, useEffect, useState } from "react";
24 import { Streamdown } from "streamdown";
25
26 export type MessageProps = HTMLAttributes<HTMLDivElement> & {
27 from: UIMessage["role"];
28 };
29
30 export const Message = ({ className, from, ...props }: MessageProps) => (
31 <div
32 className={cn(
33 "group flex w-full max-w-[80%] flex-col gap-2",
34 from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
35 className
36 )}
37 {...props}
38 />
39 );
40
41 export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
42
43 export const MessageContent = ({
44 children,
45 className,
46 ...props
47 }: MessageContentProps) => (
48 <div
49 className={cn(
50 "is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
51 "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
52 "group-[.is-assistant]:text-foreground",
53 className
54 )}
55 {...props}
56 >
57 {children}
58 </div>
59 );
60
61 export type MessageActionsProps = ComponentProps<"div">;
62
63 export const MessageActions = ({
64 className,
65 children,
66 ...props
67 }: MessageActionsProps) => (
68 <div className={cn("flex items-center gap-1", className)} {...props}>
69 {children}
70 </div>
71 );
72
73 export type MessageActionProps = ComponentProps<typeof Button> & {
74 tooltip?: string;
75 label?: string;
76 };
77
78 export const MessageAction = ({
79 tooltip,
80 children,
81 label,
82 variant = "ghost",
83 size = "icon-sm",
84 ...props
85 }: MessageActionProps) => {
86 const button = (
87 <Button size={size} type="button" variant={variant} {...props}>
88 {children}
89 <span className="sr-only">{label || tooltip}</span>
90 </Button>
91 );
92
93 if (tooltip) {
94 return (
95 <TooltipProvider>
96 <Tooltip>
97 <TooltipTrigger asChild>{button}</TooltipTrigger>
98 <TooltipContent>
99 <p>{tooltip}</p>
100 </TooltipContent>
101 </Tooltip>
102 </TooltipProvider>
103 );
104 }
105
106 return button;
107 };
108
109 type MessageBranchContextType = {
110 currentBranch: number;
111 totalBranches: number;
112 goToPrevious: () => void;
113 goToNext: () => void;
114 branches: ReactElement[];
115 setBranches: (branches: ReactElement[]) => void;
116 };
117
118 const MessageBranchContext = createContext<MessageBranchContextType | null>(
119 null
120 );
121
122 const useMessageBranch = () => {
123 const context = useContext(MessageBranchContext);
124
125 if (!context) {
126 throw new Error(
127 "MessageBranch components must be used within MessageBranch"
128 );
129 }
130
131 return context;
132 };
133
134 export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
135 defaultBranch?: number;
136 onBranchChange?: (branchIndex: number) => void;
137 };
138
139 export const MessageBranch = ({
140 defaultBranch = 0,
141 onBranchChange,
142 className,
143 ...props
144 }: MessageBranchProps) => {
145 const [currentBranch, setCurrentBranch] = useState(defaultBranch);
146 const [branches, setBranches] = useState<ReactElement[]>([]);
147
148 const handleBranchChange = (newBranch: number) => {
149 setCurrentBranch(newBranch);
150 onBranchChange?.(newBranch);
151 };
152
153 const goToPrevious = () => {
154 const newBranch =
155 currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
156 handleBranchChange(newBranch);
157 };
158
159 const goToNext = () => {
160 const newBranch =
161 currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
162 handleBranchChange(newBranch);
163 };
164
165 const contextValue: MessageBranchContextType = {
166 currentBranch,
167 totalBranches: branches.length,
168 goToPrevious,
169 goToNext,
170 branches,
171 setBranches,
172 };
173
174 return (
175 <MessageBranchContext.Provider value={contextValue}>
176 <div
177 className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
178 {...props}
179 />
180 </MessageBranchContext.Provider>
181 );
182 };
183
184 export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
185
186 export const MessageBranchContent = ({
187 children,
188 ...props
189 }: MessageBranchContentProps) => {
190 const { currentBranch, setBranches, branches } = useMessageBranch();
191 const childrenArray = Array.isArray(children) ? children : [children];
192
193 // Use useEffect to update branches when they change
194 useEffect(() => {
195 if (branches.length !== childrenArray.length) {
196 setBranches(childrenArray);
197 }
198 }, [childrenArray, branches, setBranches]);
199
200 return childrenArray.map((branch, index) => (
201 <div
202 className={cn(
203 "grid gap-2 overflow-hidden [&>div]:pb-0",
204 index === currentBranch ? "block" : "hidden"
205 )}
206 key={branch.key}
207 {...props}
208 >
209 {branch}
210 </div>
211 ));
212 };
213
214 export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
215 from: UIMessage["role"];
216 };
217
218 export const MessageBranchSelector = ({
219 className,
220 from,
221 ...props
222 }: MessageBranchSelectorProps) => {
223 const { totalBranches } = useMessageBranch();
224
225 // Don't render if there's only one branch
226 if (totalBranches <= 1) {
227 return null;
228 }
229
230 return (
231 <ButtonGroup
232 className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
233 orientation="horizontal"
234 {...props}
235 />
236 );
237 };
238
239 export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
240
241 export const MessageBranchPrevious = ({
242 children,
243 ...props
244 }: MessageBranchPreviousProps) => {
245 const { goToPrevious, totalBranches } = useMessageBranch();
246
247 return (
248 <Button
249 aria-label="Previous branch"
250 disabled={totalBranches <= 1}
251 onClick={goToPrevious}
252 size="icon-sm"
253 type="button"
254 variant="ghost"
255 {...props}
256 >
257 {children ?? <ChevronLeftIcon size={14} />}
258 </Button>
259 );
260 };
261
262 export type MessageBranchNextProps = ComponentProps<typeof Button>;
263
264 export const MessageBranchNext = ({
265 children,
266 className,
267 ...props
268 }: MessageBranchNextProps) => {
269 const { goToNext, totalBranches } = useMessageBranch();
270
271 return (
272 <Button
273 aria-label="Next branch"
274 disabled={totalBranches <= 1}
275 onClick={goToNext}
276 size="icon-sm"
277 type="button"
278 variant="ghost"
279 {...props}
280 >
281 {children ?? <ChevronRightIcon size={14} />}
282 </Button>
283 );
284 };
285
286 export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
287
288 export const MessageBranchPage = ({
289 className,
290 ...props
291 }: MessageBranchPageProps) => {
292 const { currentBranch, totalBranches } = useMessageBranch();
293
294 return (
295 <ButtonGroupText
296 className={cn(
297 "border-none bg-transparent text-muted-foreground shadow-none",
298 className
299 )}
300 {...props}
301 >
302 {currentBranch + 1} of {totalBranches}
303 </ButtonGroupText>
304 );
305 };
306
307 export type MessageResponseProps = ComponentProps<typeof Streamdown>;
308
309 export const MessageResponse = memo(
310 ({ className, ...props }: MessageResponseProps) => (
311 <Streamdown
312 className={cn(
313 "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
314 className
315 )}
316 {...props}
317 />
318 ),
319 (prevProps, nextProps) => prevProps.children === nextProps.children
320 );
321
322 MessageResponse.displayName = "MessageResponse";
323
324 export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
325 data: FileUIPart;
326 className?: string;
327 onRemove?: () => void;
328 };
329
330 export function MessageAttachment({
331 data,
332 className,
333 onRemove,
334 ...props
335 }: MessageAttachmentProps) {
336 const filename = data.filename || "";
337 const mediaType =
338 data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
339 const isImage = mediaType === "image";
340 const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
341
342 return (
343 <div
344 className={cn(
345 "group relative size-24 overflow-hidden rounded-lg",
346 className
347 )}
348 {...props}
349 >
350 {isImage ? (
351 <>
352 <img
353 alt={filename || "attachment"}
354 className="size-full object-cover"
355 height={100}
356 src={data.url}
357 width={100}
358 />
359 {onRemove && (
360 <Button
361 aria-label="Remove attachment"
362 className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
363 onClick={(e) => {
364 e.stopPropagation();
365 onRemove();
366 }}
367 type="button"
368 variant="ghost"
369 >
370 <XIcon />
371 <span className="sr-only">Remove</span>
372 </Button>
373 )}
374 </>
375 ) : (
376 <>
377 <Tooltip>
378 <TooltipTrigger asChild>
379 <div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
380 <PaperclipIcon className="size-4" />
381 </div>
382 </TooltipTrigger>
383 <TooltipContent>
384 <p>{attachmentLabel}</p>
385 </TooltipContent>
386 </Tooltip>
387 {onRemove && (
388 <Button
389 aria-label="Remove attachment"
390 className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
391 onClick={(e) => {
392 e.stopPropagation();
393 onRemove();
394 }}
395 type="button"
396 variant="ghost"
397 >
398 <XIcon />
399 <span className="sr-only">Remove</span>
400 </Button>
401 )}
402 </>
403 )}
404 </div>
405 );
406 }
407
408 export type MessageAttachmentsProps = ComponentProps<"div">;
409
410 export function MessageAttachments({
411 children,
412 className,
413 ...props
414 }: MessageAttachmentsProps) {
415 if (!children) {
416 return null;
417 }
418
419 return (
420 <div
421 className={cn(
422 "ml-auto flex w-fit flex-wrap items-start gap-2",
423 className
424 )}
425 {...props}
426 >
427 {children}
428 </div>
429 );
430 }
431
432 export type MessageToolbarProps = ComponentProps<"div">;
433
434 export const MessageToolbar = ({
435 className,
436 children,
437 ...props
438 }: MessageToolbarProps) => (
439 <div
440 className={cn(
441 "mt-4 flex w-full items-center justify-between gap-4",
442 className
443 )}
444 {...props}
445 >
446 {children}
447 </div>
448 );
src/components/ai-elements/prompt-input.tsx added +1404
@@ -0,0 +1,1404 @@
1 "use client";
2
3 import { Button } from "@/components/ui/button";
4 import {
5 Command,
6 CommandEmpty,
7 CommandGroup,
8 CommandInput,
9 CommandItem,
10 CommandList,
11 CommandSeparator,
12 } from "@/components/ui/command";
13 import {
14 DropdownMenu,
15 DropdownMenuContent,
16 DropdownMenuItem,
17 DropdownMenuTrigger,
18 } from "@/components/ui/dropdown-menu";
19 import {
20 HoverCard,
21 HoverCardContent,
22 HoverCardTrigger,
23 } from "@/components/ui/hover-card";
24 import {
25 InputGroup,
26 InputGroupAddon,
27 InputGroupButton,
28 InputGroupTextarea,
29 } from "@/components/ui/input-group";
30 import {
31 Select,
32 SelectContent,
33 SelectItem,
34 SelectTrigger,
35 SelectValue,
36 } from "@/components/ui/select";
37 import { cn } from "@/lib/utils";
38 import type { ChatStatus, FileUIPart } from "ai";
39 import {
40 CornerDownLeftIcon,
41 ImageIcon,
42 Loader2Icon,
43 MicIcon,
44 PaperclipIcon,
45 PlusIcon,
46 SquareIcon,
47 XIcon,
48 } from "lucide-react";
49 import { nanoid } from "nanoid";
50 import {
51 type ChangeEvent,
52 type ChangeEventHandler,
53 Children,
54 type ClipboardEventHandler,
55 type ComponentProps,
56 createContext,
57 type FormEvent,
58 type FormEventHandler,
59 Fragment,
60 type HTMLAttributes,
61 type KeyboardEventHandler,
62 type PropsWithChildren,
63 type ReactNode,
64 type RefObject,
65 useCallback,
66 useContext,
67 useEffect,
68 useMemo,
69 useRef,
70 useState,
71 } from "react";
72
73 // ============================================================================
74 // Provider Context & Types
75 // ============================================================================
76
77 export type AttachmentsContext = {
78 files: (FileUIPart & { id: string })[];
79 add: (files: File[] | FileList) => void;
80 remove: (id: string) => void;
81 clear: () => void;
82 openFileDialog: () => void;
83 fileInputRef: RefObject<HTMLInputElement | null>;
84 };
85
86 export type TextInputContext = {
87 value: string;
88 setInput: (v: string) => void;
89 clear: () => void;
90 };
91
92 export type PromptInputControllerProps = {
93 textInput: TextInputContext;
94 attachments: AttachmentsContext;
95 /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
96 __registerFileInput: (
97 ref: RefObject<HTMLInputElement | null>,
98 open: () => void
99 ) => void;
100 };
101
102 const PromptInputController = createContext<PromptInputControllerProps | null>(
103 null
104 );
105 const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
106 null
107 );
108
109 export const usePromptInputController = () => {
110 const ctx = useContext(PromptInputController);
111 if (!ctx) {
112 throw new Error(
113 "Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
114 );
115 }
116 return ctx;
117 };
118
119 // Optional variants (do NOT throw). Useful for dual-mode components.
120 const useOptionalPromptInputController = () =>
121 useContext(PromptInputController);
122
123 export const useProviderAttachments = () => {
124 const ctx = useContext(ProviderAttachmentsContext);
125 if (!ctx) {
126 throw new Error(
127 "Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
128 );
129 }
130 return ctx;
131 };
132
133 const useOptionalProviderAttachments = () =>
134 useContext(ProviderAttachmentsContext);
135
136 export type PromptInputProviderProps = PropsWithChildren<{
137 initialInput?: string;
138 }>;
139
140 /**
141 * Optional global provider that lifts PromptInput state outside of PromptInput.
142 * If you don't use it, PromptInput stays fully self-managed.
143 */
144 export function PromptInputProvider({
145 initialInput: initialTextInput = "",
146 children,
147 }: PromptInputProviderProps) {
148 // ----- textInput state
149 const [textInput, setTextInput] = useState(initialTextInput);
150 const clearInput = useCallback(() => setTextInput(""), []);
151
152 // ----- attachments state (global when wrapped)
153 const [attachmentFiles, setAttachmentFiles] = useState<
154 (FileUIPart & { id: string })[]
155 >([]);
156 const fileInputRef = useRef<HTMLInputElement | null>(null);
157 const openRef = useRef<() => void>(() => {});
158
159 const add = useCallback((files: File[] | FileList) => {
160 const incoming = Array.from(files);
161 if (incoming.length === 0) {
162 return;
163 }
164
165 setAttachmentFiles((prev) =>
166 prev.concat(
167 incoming.map((file) => ({
168 id: nanoid(),
169 type: "file" as const,
170 url: URL.createObjectURL(file),
171 mediaType: file.type,
172 filename: file.name,
173 }))
174 )
175 );
176 }, []);
177
178 const remove = useCallback((id: string) => {
179 setAttachmentFiles((prev) => {
180 const found = prev.find((f) => f.id === id);
181 if (found?.url) {
182 URL.revokeObjectURL(found.url);
183 }
184 return prev.filter((f) => f.id !== id);
185 });
186 }, []);
187
188 const clear = useCallback(() => {
189 setAttachmentFiles((prev) => {
190 for (const f of prev) {
191 if (f.url) {
192 URL.revokeObjectURL(f.url);
193 }
194 }
195 return [];
196 });
197 }, []);
198
199 // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
200 const attachmentsRef = useRef(attachmentFiles);
201 attachmentsRef.current = attachmentFiles;
202
203 // Cleanup blob URLs on unmount to prevent memory leaks
204 useEffect(() => {
205 return () => {
206 for (const f of attachmentsRef.current) {
207 if (f.url) {
208 URL.revokeObjectURL(f.url);
209 }
210 }
211 };
212 }, []);
213
214 const openFileDialog = useCallback(() => {
215 openRef.current?.();
216 }, []);
217
218 const attachments = useMemo<AttachmentsContext>(
219 () => ({
220 files: attachmentFiles,
221 add,
222 remove,
223 clear,
224 openFileDialog,
225 fileInputRef,
226 }),
227 [attachmentFiles, add, remove, clear, openFileDialog]
228 );
229
230 const __registerFileInput = useCallback(
231 (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
232 fileInputRef.current = ref.current;
233 openRef.current = open;
234 },
235 []
236 );
237
238 const controller = useMemo<PromptInputControllerProps>(
239 () => ({
240 textInput: {
241 value: textInput,
242 setInput: setTextInput,
243 clear: clearInput,
244 },
245 attachments,
246 __registerFileInput,
247 }),
248 [textInput, clearInput, attachments, __registerFileInput]
249 );
250
251 return (
252 <PromptInputController.Provider value={controller}>
253 <ProviderAttachmentsContext.Provider value={attachments}>
254 {children}
255 </ProviderAttachmentsContext.Provider>
256 </PromptInputController.Provider>
257 );
258 }
259
260 // ============================================================================
261 // Component Context & Hooks
262 // ============================================================================
263
264 const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
265
266 export const usePromptInputAttachments = () => {
267 // Dual-mode: prefer provider if present, otherwise use local
268 const provider = useOptionalProviderAttachments();
269 const local = useContext(LocalAttachmentsContext);
270 const context = provider ?? local;
271 if (!context) {
272 throw new Error(
273 "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
274 );
275 }
276 return context;
277 };
278
279 export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
280 data: FileUIPart & { id: string };
281 className?: string;
282 };
283
284 export function PromptInputAttachment({
285 data,
286 className,
287 ...props
288 }: PromptInputAttachmentProps) {
289 const attachments = usePromptInputAttachments();
290
291 const filename = data.filename || "";
292
293 const mediaType =
294 data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
295 const isImage = mediaType === "image";
296
297 const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
298
299 return (
300 <PromptInputHoverCard>
301 <HoverCardTrigger asChild>
302 <div
303 className={cn(
304 "group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
305 className
306 )}
307 key={data.id}
308 {...props}
309 >
310 <div className="relative size-5 shrink-0">
311 <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
312 {isImage ? (
313 <img
314 alt={filename || "attachment"}
315 className="size-5 object-cover"
316 height={20}
317 src={data.url}
318 width={20}
319 />
320 ) : (
321 <div className="flex size-5 items-center justify-center text-muted-foreground">
322 <PaperclipIcon className="size-3" />
323 </div>
324 )}
325 </div>
326 <Button
327 aria-label="Remove attachment"
328 className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5"
329 onClick={(e) => {
330 e.stopPropagation();
331 attachments.remove(data.id);
332 }}
333 type="button"
334 variant="ghost"
335 >
336 <XIcon />
337 <span className="sr-only">Remove</span>
338 </Button>
339 </div>
340
341 <span className="flex-1 truncate">{attachmentLabel}</span>
342 </div>
343 </HoverCardTrigger>
344 <PromptInputHoverCardContent className="w-auto p-2">
345 <div className="w-auto space-y-3">
346 {isImage && (
347 <div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
348 <img
349 alt={filename || "attachment preview"}
350 className="max-h-full max-w-full object-contain"
351 height={384}
352 src={data.url}
353 width={448}
354 />
355 </div>
356 )}
357 <div className="flex items-center gap-2.5">
358 <div className="min-w-0 flex-1 space-y-1 px-0.5">
359 <h4 className="truncate font-semibold text-sm leading-none">
360 {filename || (isImage ? "Image" : "Attachment")}
361 </h4>
362 {data.mediaType && (
363 <p className="truncate font-mono text-muted-foreground text-xs">
364 {data.mediaType}
365 </p>
366 )}
367 </div>
368 </div>
369 </div>
370 </PromptInputHoverCardContent>
371 </PromptInputHoverCard>
372 );
373 }
374
375 export type PromptInputAttachmentsProps = Omit<
376 HTMLAttributes<HTMLDivElement>,
377 "children"
378 > & {
379 children: (attachment: FileUIPart & { id: string }) => ReactNode;
380 };
381
382 export function PromptInputAttachments({
383 children,
384 className,
385 ...props
386 }: PromptInputAttachmentsProps) {
387 const attachments = usePromptInputAttachments();
388
389 if (!attachments.files.length) {
390 return null;
391 }
392
393 return (
394 <div
395 className={cn("flex flex-wrap items-center gap-2 p-3 w-full", className)}
396 {...props}
397 >
398 {attachments.files.map((file) => (
399 <Fragment key={file.id}>{children(file)}</Fragment>
400 ))}
401 </div>
402 );
403 }
404
405 export type PromptInputActionAddAttachmentsProps = ComponentProps<
406 typeof DropdownMenuItem
407 > & {
408 label?: string;
409 };
410
411 export const PromptInputActionAddAttachments = ({
412 label = "Add photos or files",
413 ...props
414 }: PromptInputActionAddAttachmentsProps) => {
415 const attachments = usePromptInputAttachments();
416
417 return (
418 <DropdownMenuItem
419 {...props}
420 onSelect={(e) => {
421 e.preventDefault();
422 attachments.openFileDialog();
423 }}
424 >
425 <ImageIcon className="mr-2 size-4" /> {label}
426 </DropdownMenuItem>
427 );
428 };
429
430 export type PromptInputMessage = {
431 text: string;
432 files: FileUIPart[];
433 };
434
435 export type PromptInputProps = Omit<
436 HTMLAttributes<HTMLFormElement>,
437 "onSubmit" | "onError"
438 > & {
439 accept?: string; // e.g., "image/*" or leave undefined for any
440 multiple?: boolean;
441 // When true, accepts drops anywhere on document. Default false (opt-in).
442 globalDrop?: boolean;
443 // Render a hidden input with given name and keep it in sync for native form posts. Default false.
444 syncHiddenInput?: boolean;
445 // Minimal constraints
446 maxFiles?: number;
447 maxFileSize?: number; // bytes
448 onError?: (err: {
449 code: "max_files" | "max_file_size" | "accept";
450 message: string;
451 }) => void;
452 onSubmit: (
453 message: PromptInputMessage,
454 event: FormEvent<HTMLFormElement>
455 ) => void | Promise<void>;
456 };
457
458 export const PromptInput = ({
459 className,
460 accept,
461 multiple,
462 globalDrop,
463 syncHiddenInput,
464 maxFiles,
465 maxFileSize,
466 onError,
467 onSubmit,
468 children,
469 ...props
470 }: PromptInputProps) => {
471 // Try to use a provider controller if present
472 const controller = useOptionalPromptInputController();
473 const usingProvider = !!controller;
474
475 // Refs
476 const inputRef = useRef<HTMLInputElement | null>(null);
477 const formRef = useRef<HTMLFormElement | null>(null);
478
479 // ----- Local attachments (only used when no provider)
480 const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
481 const files = usingProvider ? controller.attachments.files : items;
482
483 // Keep a ref to files for cleanup on unmount (avoids stale closure)
484 const filesRef = useRef(files);
485 filesRef.current = files;
486
487 const openFileDialogLocal = useCallback(() => {
488 inputRef.current?.click();
489 }, []);
490
491 const matchesAccept = useCallback(
492 (f: File) => {
493 if (!accept || accept.trim() === "") {
494 return true;
495 }
496 if (accept.includes("image/*")) {
497 return f.type.startsWith("image/");
498 }
499 // NOTE: keep simple; expand as needed
500 return true;
501 },
502 [accept]
503 );
504
505 const addLocal = useCallback(
506 (fileList: File[] | FileList) => {
507 const incoming = Array.from(fileList);
508 const accepted = incoming.filter((f) => matchesAccept(f));
509 if (incoming.length && accepted.length === 0) {
510 onError?.({
511 code: "accept",
512 message: "No files match the accepted types.",
513 });
514 return;
515 }
516 const withinSize = (f: File) =>
517 maxFileSize ? f.size <= maxFileSize : true;
518 const sized = accepted.filter(withinSize);
519 if (accepted.length > 0 && sized.length === 0) {
520 onError?.({
521 code: "max_file_size",
522 message: "All files exceed the maximum size.",
523 });
524 return;
525 }
526
527 setItems((prev) => {
528 const capacity =
529 typeof maxFiles === "number"
530 ? Math.max(0, maxFiles - prev.length)
531 : undefined;
532 const capped =
533 typeof capacity === "number" ? sized.slice(0, capacity) : sized;
534 if (typeof capacity === "number" && sized.length > capacity) {
535 onError?.({
536 code: "max_files",
537 message: "Too many files. Some were not added.",
538 });
539 }
540 const next: (FileUIPart & { id: string })[] = [];
541 for (const file of capped) {
542 next.push({
543 id: nanoid(),
544 type: "file",
545 url: URL.createObjectURL(file),
546 mediaType: file.type,
547 filename: file.name,
548 });
549 }
550 return prev.concat(next);
551 });
552 },
553 [matchesAccept, maxFiles, maxFileSize, onError]
554 );
555
556 const removeLocal = useCallback(
557 (id: string) =>
558 setItems((prev) => {
559 const found = prev.find((file) => file.id === id);
560 if (found?.url) {
561 URL.revokeObjectURL(found.url);
562 }
563 return prev.filter((file) => file.id !== id);
564 }),
565 []
566 );
567
568 const clearLocal = useCallback(
569 () =>
570 setItems((prev) => {
571 for (const file of prev) {
572 if (file.url) {
573 URL.revokeObjectURL(file.url);
574 }
575 }
576 return [];
577 }),
578 []
579 );
580
581 const add = usingProvider ? controller.attachments.add : addLocal;
582 const remove = usingProvider ? controller.attachments.remove : removeLocal;
583 const clear = usingProvider ? controller.attachments.clear : clearLocal;
584 const openFileDialog = usingProvider
585 ? controller.attachments.openFileDialog
586 : openFileDialogLocal;
587
588 // Let provider know about our hidden file input so external menus can call openFileDialog()
589 useEffect(() => {
590 if (!usingProvider) return;
591 controller.__registerFileInput(inputRef, () => inputRef.current?.click());
592 }, [usingProvider, controller]);
593
594 // Note: File input cannot be programmatically set for security reasons
595 // The syncHiddenInput prop is no longer functional
596 useEffect(() => {
597 if (syncHiddenInput && inputRef.current && files.length === 0) {
598 inputRef.current.value = "";
599 }
600 }, [files, syncHiddenInput]);
601
602 // Attach drop handlers on nearest form and document (opt-in)
603 useEffect(() => {
604 const form = formRef.current;
605 if (!form) return;
606
607 const onDragOver = (e: DragEvent) => {
608 if (e.dataTransfer?.types?.includes("Files")) {
609 e.preventDefault();
610 }
611 };
612 const onDrop = (e: DragEvent) => {
613 if (e.dataTransfer?.types?.includes("Files")) {
614 e.preventDefault();
615 }
616 if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
617 add(e.dataTransfer.files);
618 }
619 };
620 form.addEventListener("dragover", onDragOver);
621 form.addEventListener("drop", onDrop);
622 return () => {
623 form.removeEventListener("dragover", onDragOver);
624 form.removeEventListener("drop", onDrop);
625 };
626 }, [add]);
627
628 useEffect(() => {
629 if (!globalDrop) return;
630
631 const onDragOver = (e: DragEvent) => {
632 if (e.dataTransfer?.types?.includes("Files")) {
633 e.preventDefault();
634 }
635 };
636 const onDrop = (e: DragEvent) => {
637 if (e.dataTransfer?.types?.includes("Files")) {
638 e.preventDefault();
639 }
640 if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
641 add(e.dataTransfer.files);
642 }
643 };
644 document.addEventListener("dragover", onDragOver);
645 document.addEventListener("drop", onDrop);
646 return () => {
647 document.removeEventListener("dragover", onDragOver);
648 document.removeEventListener("drop", onDrop);
649 };
650 }, [add, globalDrop]);
651
652 useEffect(
653 () => () => {
654 if (!usingProvider) {
655 for (const f of filesRef.current) {
656 if (f.url) URL.revokeObjectURL(f.url);
657 }
658 }
659 },
660 // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
661 [usingProvider]
662 );
663
664 const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
665 if (event.currentTarget.files) {
666 add(event.currentTarget.files);
667 }
668 // Reset input value to allow selecting files that were previously removed
669 event.currentTarget.value = "";
670 };
671
672 const convertBlobUrlToDataUrl = async (
673 url: string
674 ): Promise<string | null> => {
675 try {
676 const response = await fetch(url);
677 const blob = await response.blob();
678 return new Promise((resolve) => {
679 const reader = new FileReader();
680 reader.onloadend = () => resolve(reader.result as string);
681 reader.onerror = () => resolve(null);
682 reader.readAsDataURL(blob);
683 });
684 } catch {
685 return null;
686 }
687 };
688
689 const ctx = useMemo<AttachmentsContext>(
690 () => ({
691 files: files.map((item) => ({ ...item, id: item.id })),
692 add,
693 remove,
694 clear,
695 openFileDialog,
696 fileInputRef: inputRef,
697 }),
698 [files, add, remove, clear, openFileDialog]
699 );
700
701 const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
702 event.preventDefault();
703
704 const form = event.currentTarget;
705 const text = usingProvider
706 ? controller.textInput.value
707 : (() => {
708 const formData = new FormData(form);
709 return (formData.get("message") as string) || "";
710 })();
711
712 // Reset form immediately after capturing text to avoid race condition
713 // where user input during async blob conversion would be lost
714 if (!usingProvider) {
715 form.reset();
716 }
717
718 // Convert blob URLs to data URLs asynchronously
719 Promise.all(
720 files.map(async ({ id, ...item }) => {
721 if (item.url && item.url.startsWith("blob:")) {
722 const dataUrl = await convertBlobUrlToDataUrl(item.url);
723 // If conversion failed, keep the original blob URL
724 return {
725 ...item,
726 url: dataUrl ?? item.url,
727 };
728 }
729 return item;
730 })
731 )
732 .then((convertedFiles: FileUIPart[]) => {
733 try {
734 const result = onSubmit({ text, files: convertedFiles }, event);
735
736 // Handle both sync and async onSubmit
737 if (result instanceof Promise) {
738 result
739 .then(() => {
740 clear();
741 if (usingProvider) {
742 controller.textInput.clear();
743 }
744 })
745 .catch(() => {
746 // Don't clear on error - user may want to retry
747 });
748 } else {
749 // Sync function completed without throwing, clear attachments
750 clear();
751 if (usingProvider) {
752 controller.textInput.clear();
753 }
754 }
755 } catch {
756 // Don't clear on error - user may want to retry
757 }
758 })
759 .catch(() => {
760 // Don't clear on error - user may want to retry
761 });
762 };
763
764 // Render with or without local provider
765 const inner = (
766 <>
767 <input
768 accept={accept}
769 aria-label="Upload files"
770 className="hidden"
771 multiple={multiple}
772 onChange={handleChange}
773 ref={inputRef}
774 title="Upload files"
775 type="file"
776 />
777 <form
778 className={cn("w-full", className)}
779 onSubmit={handleSubmit}
780 ref={formRef}
781 {...props}
782 >
783 <InputGroup className="overflow-hidden">{children}</InputGroup>
784 </form>
785 </>
786 );
787
788 return usingProvider ? (
789 inner
790 ) : (
791 <LocalAttachmentsContext.Provider value={ctx}>
792 {inner}
793 </LocalAttachmentsContext.Provider>
794 );
795 };
796
797 export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
798
799 export const PromptInputBody = ({
800 className,
801 ...props
802 }: PromptInputBodyProps) => (
803 <div className={cn("contents", className)} {...props} />
804 );
805
806 export type PromptInputTextareaProps = ComponentProps<
807 typeof InputGroupTextarea
808 >;
809
810 export const PromptInputTextarea = ({
811 onChange,
812 className,
813 placeholder = "What would you like to know?",
814 ...props
815 }: PromptInputTextareaProps) => {
816 const controller = useOptionalPromptInputController();
817 const attachments = usePromptInputAttachments();
818 const [isComposing, setIsComposing] = useState(false);
819
820 const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
821 if (e.key === "Enter") {
822 if (isComposing || e.nativeEvent.isComposing) {
823 return;
824 }
825 if (e.shiftKey) {
826 return;
827 }
828 e.preventDefault();
829
830 // Check if the submit button is disabled before submitting
831 const form = e.currentTarget.form;
832 const submitButton = form?.querySelector(
833 'button[type="submit"]'
834 ) as HTMLButtonElement | null;
835 if (submitButton?.disabled) {
836 return;
837 }
838
839 form?.requestSubmit();
840 }
841
842 // Remove last attachment when Backspace is pressed and textarea is empty
843 if (
844 e.key === "Backspace" &&
845 e.currentTarget.value === "" &&
846 attachments.files.length > 0
847 ) {
848 e.preventDefault();
849 const lastAttachment = attachments.files.at(-1);
850 if (lastAttachment) {
851 attachments.remove(lastAttachment.id);
852 }
853 }
854 };
855
856 const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
857 const items = event.clipboardData?.items;
858
859 if (!items) {
860 return;
861 }
862
863 const files: File[] = [];
864
865 for (const item of items) {
866 if (item.kind === "file") {
867 const file = item.getAsFile();
868 if (file) {
869 files.push(file);
870 }
871 }
872 }
873
874 if (files.length > 0) {
875 event.preventDefault();
876 attachments.add(files);
877 }
878 };
879
880 const controlledProps = controller
881 ? {
882 value: controller.textInput.value,
883 onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
884 controller.textInput.setInput(e.currentTarget.value);
885 onChange?.(e);
886 },
887 }
888 : {
889 onChange,
890 };
891
892 return (
893 <InputGroupTextarea
894 className={cn("field-sizing-content max-h-48 min-h-16", className)}
895 name="message"
896 onCompositionEnd={() => setIsComposing(false)}
897 onCompositionStart={() => setIsComposing(true)}
898 onKeyDown={handleKeyDown}
899 onPaste={handlePaste}
900 placeholder={placeholder}
901 {...props}
902 {...controlledProps}
903 />
904 );
905 };
906
907 export type PromptInputHeaderProps = Omit<
908 ComponentProps<typeof InputGroupAddon>,
909 "align"
910 >;
911
912 export const PromptInputHeader = ({
913 className,
914 ...props
915 }: PromptInputHeaderProps) => (
916 <InputGroupAddon
917 align="block-end"
918 className={cn("order-first flex-wrap gap-1", className)}
919 {...props}
920 />
921 );
922
923 export type PromptInputFooterProps = Omit<
924 ComponentProps<typeof InputGroupAddon>,
925 "align"
926 >;
927
928 export const PromptInputFooter = ({
929 className,
930 ...props
931 }: PromptInputFooterProps) => (
932 <InputGroupAddon
933 align="block-end"
934 className={cn("justify-between gap-1", className)}
935 {...props}
936 />
937 );
938
939 export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
940
941 export const PromptInputTools = ({
942 className,
943 ...props
944 }: PromptInputToolsProps) => (
945 <div className={cn("flex items-center gap-1", className)} {...props} />
946 );
947
948 export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
949
950 export const PromptInputButton = ({
951 variant = "ghost",
952 className,
953 size,
954 ...props
955 }: PromptInputButtonProps) => {
956 const newSize =
957 size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
958
959 return (
960 <InputGroupButton
961 className={cn(className)}
962 size={newSize}
963 type="button"
964 variant={variant}
965 {...props}
966 />
967 );
968 };
969
970 export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
971 export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
972 <DropdownMenu {...props} />
973 );
974
975 export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
976
977 export const PromptInputActionMenuTrigger = ({
978 className,
979 children,
980 ...props
981 }: PromptInputActionMenuTriggerProps) => (
982 <DropdownMenuTrigger asChild>
983 <PromptInputButton className={className} {...props}>
984 {children ?? <PlusIcon className="size-4" />}
985 </PromptInputButton>
986 </DropdownMenuTrigger>
987 );
988
989 export type PromptInputActionMenuContentProps = ComponentProps<
990 typeof DropdownMenuContent
991 >;
992 export const PromptInputActionMenuContent = ({
993 className,
994 ...props
995 }: PromptInputActionMenuContentProps) => (
996 <DropdownMenuContent align="start" className={cn(className)} {...props} />
997 );
998
999 export type PromptInputActionMenuItemProps = ComponentProps<
1000 typeof DropdownMenuItem
1001 >;
1002 export const PromptInputActionMenuItem = ({
1003 className,
1004 ...props
1005 }: PromptInputActionMenuItemProps) => (
1006 <DropdownMenuItem className={cn(className)} {...props} />
1007 );
1008
1009 // Note: Actions that perform side-effects (like opening a file dialog)
1010 // are provided in opt-in modules (e.g., prompt-input-attachments).
1011
1012 export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
1013 status?: ChatStatus;
1014 };
1015
1016 export const PromptInputSubmit = ({
1017 className,
1018 variant = "default",
1019 size = "icon-sm",
1020 status,
1021 children,
1022 ...props
1023 }: PromptInputSubmitProps) => {
1024 let Icon = <CornerDownLeftIcon className="size-4" />;
1025
1026 if (status === "submitted") {
1027 Icon = <Loader2Icon className="size-4 animate-spin" />;
1028 } else if (status === "streaming") {
1029 Icon = <SquareIcon className="size-4" />;
1030 } else if (status === "error") {
1031 Icon = <XIcon className="size-4" />;
1032 }
1033
1034 return (
1035 <InputGroupButton
1036 aria-label="Submit"
1037 className={cn(className)}
1038 size={size}
1039 type="submit"
1040 variant={variant}
1041 {...props}
1042 >
1043 {children ?? Icon}
1044 </InputGroupButton>
1045 );
1046 };
1047
1048 interface SpeechRecognition extends EventTarget {
1049 continuous: boolean;
1050 interimResults: boolean;
1051 lang: string;
1052 start(): void;
1053 stop(): void;
1054 onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
1055 onend: ((this: SpeechRecognition, ev: Event) => any) | null;
1056 onresult:
1057 | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
1058 | null;
1059 onerror:
1060 | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
1061 | null;
1062 }
1063
1064 interface SpeechRecognitionEvent extends Event {
1065 results: SpeechRecognitionResultList;
1066 resultIndex: number;
1067 }
1068
1069 type SpeechRecognitionResultList = {
1070 readonly length: number;
1071 item(index: number): SpeechRecognitionResult;
1072 [index: number]: SpeechRecognitionResult;
1073 };
1074
1075 type SpeechRecognitionResult = {
1076 readonly length: number;
1077 item(index: number): SpeechRecognitionAlternative;
1078 [index: number]: SpeechRecognitionAlternative;
1079 isFinal: boolean;
1080 };
1081
1082 type SpeechRecognitionAlternative = {
1083 transcript: string;
1084 confidence: number;
1085 };
1086
1087 interface SpeechRecognitionErrorEvent extends Event {
1088 error: string;
1089 }
1090
1091 declare global {
1092 interface Window {
1093 SpeechRecognition: {
1094 new (): SpeechRecognition;
1095 };
1096 webkitSpeechRecognition: {
1097 new (): SpeechRecognition;
1098 };
1099 }
1100 }
1101
1102 export type PromptInputSpeechButtonProps = ComponentProps<
1103 typeof PromptInputButton
1104 > & {
1105 textareaRef?: RefObject<HTMLTextAreaElement | null>;
1106 onTranscriptionChange?: (text: string) => void;
1107 };
1108
1109 export const PromptInputSpeechButton = ({
1110 className,
1111 textareaRef,
1112 onTranscriptionChange,
1113 ...props
1114 }: PromptInputSpeechButtonProps) => {
1115 const [isListening, setIsListening] = useState(false);
1116 const [recognition, setRecognition] = useState<SpeechRecognition | null>(
1117 null
1118 );
1119 const recognitionRef = useRef<SpeechRecognition | null>(null);
1120
1121 useEffect(() => {
1122 if (
1123 typeof window !== "undefined" &&
1124 ("SpeechRecognition" in window || "webkitSpeechRecognition" in window)
1125 ) {
1126 const SpeechRecognition =
1127 window.SpeechRecognition || window.webkitSpeechRecognition;
1128 const speechRecognition = new SpeechRecognition();
1129
1130 speechRecognition.continuous = true;
1131 speechRecognition.interimResults = true;
1132 speechRecognition.lang = "en-US";
1133
1134 speechRecognition.onstart = () => {
1135 setIsListening(true);
1136 };
1137
1138 speechRecognition.onend = () => {
1139 setIsListening(false);
1140 };
1141
1142 speechRecognition.onresult = (event) => {
1143 let finalTranscript = "";
1144
1145 for (let i = event.resultIndex; i < event.results.length; i++) {
1146 const result = event.results[i];
1147 if (result.isFinal) {
1148 finalTranscript += result[0]?.transcript ?? "";
1149 }
1150 }
1151
1152 if (finalTranscript && textareaRef?.current) {
1153 const textarea = textareaRef.current;
1154 const currentValue = textarea.value;
1155 const newValue =
1156 currentValue + (currentValue ? " " : "") + finalTranscript;
1157
1158 textarea.value = newValue;
1159 textarea.dispatchEvent(new Event("input", { bubbles: true }));
1160 onTranscriptionChange?.(newValue);
1161 }
1162 };
1163
1164 speechRecognition.onerror = (event) => {
1165 console.error("Speech recognition error:", event.error);
1166 setIsListening(false);
1167 };
1168
1169 recognitionRef.current = speechRecognition;
1170 setRecognition(speechRecognition);
1171 }
1172
1173 return () => {
1174 if (recognitionRef.current) {
1175 recognitionRef.current.stop();
1176 }
1177 };
1178 }, [textareaRef, onTranscriptionChange]);
1179
1180 const toggleListening = useCallback(() => {
1181 if (!recognition) {
1182 return;
1183 }
1184
1185 if (isListening) {
1186 recognition.stop();
1187 } else {
1188 recognition.start();
1189 }
1190 }, [recognition, isListening]);
1191
1192 return (
1193 <PromptInputButton
1194 className={cn(
1195 "relative transition-all duration-200",
1196 isListening && "animate-pulse bg-accent text-accent-foreground",
1197 className
1198 )}
1199 disabled={!recognition}
1200 onClick={toggleListening}
1201 {...props}
1202 >
1203 <MicIcon className="size-4" />
1204 </PromptInputButton>
1205 );
1206 };
1207
1208 export type PromptInputSelectProps = ComponentProps<typeof Select>;
1209
1210 export const PromptInputSelect = (props: PromptInputSelectProps) => (
1211 <Select {...props} />
1212 );
1213
1214 export type PromptInputSelectTriggerProps = ComponentProps<
1215 typeof SelectTrigger
1216 >;
1217
1218 export const PromptInputSelectTrigger = ({
1219 className,
1220 ...props
1221 }: PromptInputSelectTriggerProps) => (
1222 <SelectTrigger
1223 className={cn(
1224 "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
1225 "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
1226 className
1227 )}
1228 {...props}
1229 />
1230 );
1231
1232 export type PromptInputSelectContentProps = ComponentProps<
1233 typeof SelectContent
1234 >;
1235
1236 export const PromptInputSelectContent = ({
1237 className,
1238 ...props
1239 }: PromptInputSelectContentProps) => (
1240 <SelectContent className={cn(className)} {...props} />
1241 );
1242
1243 export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
1244
1245 export const PromptInputSelectItem = ({
1246 className,
1247 ...props
1248 }: PromptInputSelectItemProps) => (
1249 <SelectItem className={cn(className)} {...props} />
1250 );
1251
1252 export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
1253
1254 export const PromptInputSelectValue = ({
1255 className,
1256 ...props
1257 }: PromptInputSelectValueProps) => (
1258 <SelectValue className={cn(className)} {...props} />
1259 );
1260
1261 export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
1262
1263 export const PromptInputHoverCard = ({
1264 openDelay = 0,
1265 closeDelay = 0,
1266 ...props
1267 }: PromptInputHoverCardProps) => (
1268 <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
1269 );
1270
1271 export type PromptInputHoverCardTriggerProps = ComponentProps<
1272 typeof HoverCardTrigger
1273 >;
1274
1275 export const PromptInputHoverCardTrigger = (
1276 props: PromptInputHoverCardTriggerProps
1277 ) => <HoverCardTrigger {...props} />;
1278
1279 export type PromptInputHoverCardContentProps = ComponentProps<
1280 typeof HoverCardContent
1281 >;
1282
1283 export const PromptInputHoverCardContent = ({
1284 align = "start",
1285 ...props
1286 }: PromptInputHoverCardContentProps) => (
1287 <HoverCardContent align={align} {...props} />
1288 );
1289
1290 export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
1291
1292 export const PromptInputTabsList = ({
1293 className,
1294 ...props
1295 }: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
1296
1297 export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
1298
1299 export const PromptInputTab = ({
1300 className,
1301 ...props
1302 }: PromptInputTabProps) => <div className={cn(className)} {...props} />;
1303
1304 export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
1305
1306 export const PromptInputTabLabel = ({
1307 className,
1308 ...props
1309 }: PromptInputTabLabelProps) => (
1310 <h3
1311 className={cn(
1312 "mb-2 px-3 font-medium text-muted-foreground text-xs",
1313 className
1314 )}
1315 {...props}
1316 />
1317 );
1318
1319 export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
1320
1321 export const PromptInputTabBody = ({
1322 className,
1323 ...props
1324 }: PromptInputTabBodyProps) => (
1325 <div className={cn("space-y-1", className)} {...props} />
1326 );
1327
1328 export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
1329
1330 export const PromptInputTabItem = ({
1331 className,
1332 ...props
1333 }: PromptInputTabItemProps) => (
1334 <div
1335 className={cn(
1336 "flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
1337 className
1338 )}
1339 {...props}
1340 />
1341 );
1342
1343 export type PromptInputCommandProps = ComponentProps<typeof Command>;
1344
1345 export const PromptInputCommand = ({
1346 className,
1347 ...props
1348 }: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
1349
1350 export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
1351
1352 export const PromptInputCommandInput = ({
1353 className,
1354 ...props
1355 }: PromptInputCommandInputProps) => (
1356 <CommandInput className={cn(className)} {...props} />
1357 );
1358
1359 export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
1360
1361 export const PromptInputCommandList = ({
1362 className,
1363 ...props
1364 }: PromptInputCommandListProps) => (
1365 <CommandList className={cn(className)} {...props} />
1366 );
1367
1368 export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
1369
1370 export const PromptInputCommandEmpty = ({
1371 className,
1372 ...props
1373 }: PromptInputCommandEmptyProps) => (
1374 <CommandEmpty className={cn(className)} {...props} />
1375 );
1376
1377 export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
1378
1379 export const PromptInputCommandGroup = ({
1380 className,
1381 ...props
1382 }: PromptInputCommandGroupProps) => (
1383 <CommandGroup className={cn(className)} {...props} />
1384 );
1385
1386 export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
1387
1388 export const PromptInputCommandItem = ({
1389 className,
1390 ...props
1391 }: PromptInputCommandItemProps) => (
1392 <CommandItem className={cn(className)} {...props} />
1393 );
1394
1395 export type PromptInputCommandSeparatorProps = ComponentProps<
1396 typeof CommandSeparator
1397 >;
1398
1399 export const PromptInputCommandSeparator = ({
1400 className,
1401 ...props
1402 }: PromptInputCommandSeparatorProps) => (
1403 <CommandSeparator className={cn(className)} {...props} />
1404 );
src/components/ai-elements/shimmer.tsx added +64
@@ -0,0 +1,64 @@
1 "use client";
2
3 import { cn } from "@/lib/utils";
4 import { motion } from "motion/react";
5 import {
6 type CSSProperties,
7 type ElementType,
8 type JSX,
9 memo,
10 useMemo,
11 } from "react";
12
13 export type TextShimmerProps = {
14 children: string;
15 as?: ElementType;
16 className?: string;
17 duration?: number;
18 spread?: number;
19 };
20
21 const ShimmerComponent = ({
22 children,
23 as: Component = "p",
24 className,
25 duration = 2,
26 spread = 2,
27 }: TextShimmerProps) => {
28 const MotionComponent = motion.create(
29 Component as keyof JSX.IntrinsicElements
30 );
31
32 const dynamicSpread = useMemo(
33 () => (children?.length ?? 0) * spread,
34 [children, spread]
35 );
36
37 return (
38 <MotionComponent
39 animate={{ backgroundPosition: "0% center" }}
40 className={cn(
41 "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
42 "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
43 className
44 )}
45 initial={{ backgroundPosition: "100% center" }}
46 style={
47 {
48 "--spread": `${dynamicSpread}px`,
49 backgroundImage:
50 "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
51 } as CSSProperties
52 }
53 transition={{
54 repeat: Number.POSITIVE_INFINITY,
55 duration,
56 ease: "linear",
57 }}
58 >
59 {children}
60 </MotionComponent>
61 );
62 };
63
64 export const Shimmer = memo(ShimmerComponent);
src/components/ai-elements/tool.tsx added +165
@@ -0,0 +1,165 @@
1 "use client";
2
3 import { Badge } from "@/components/ui/badge";
4 import {
5 Collapsible,
6 CollapsibleContent,
7 CollapsibleTrigger,
8 } from "@/components/ui/collapsible";
9 import { cn } from "@/lib/utils";
10 import type { ToolUIPart } from "ai";
11 import {
12 CheckCircleIcon,
13 ChevronDownIcon,
14 CircleIcon,
15 ClockIcon,
16 WrenchIcon,
17 XCircleIcon,
18 } from "lucide-react";
19 import type { ComponentProps, ReactNode } from "react";
20 import { isValidElement } from "react";
21 import { CodeBlock } from "./code-block";
22
23 export type ToolProps = ComponentProps<typeof Collapsible>;
24
25 export const Tool = ({ className, ...props }: ToolProps) => (
26 <Collapsible
27 className={cn("not-prose mb-4 w-full rounded-md border", className)}
28 {...props}
29 />
30 );
31
32 export type ToolHeaderProps = {
33 title?: string;
34 type: ToolUIPart["type"];
35 state: ToolUIPart["state"];
36 className?: string;
37 };
38
39 const getStatusBadge = (status: ToolUIPart["state"]) => {
40 const labels: Record<ToolUIPart["state"], string> = {
41 "input-streaming": "Pending",
42 "input-available": "Running",
43 // @ts-expect-error state only available in AI SDK v6
44 "approval-requested": "Awaiting Approval",
45 "approval-responded": "Responded",
46 "output-available": "Completed",
47 "output-error": "Error",
48 "output-denied": "Denied",
49 };
50
51 const icons: Record<ToolUIPart["state"], ReactNode> = {
52 "input-streaming": <CircleIcon className="size-4" />,
53 "input-available": <ClockIcon className="size-4 animate-pulse" />,
54 // @ts-expect-error state only available in AI SDK v6
55 "approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
56 "approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
57 "output-available": <CheckCircleIcon className="size-4 text-green-600" />,
58 "output-error": <XCircleIcon className="size-4 text-red-600" />,
59 "output-denied": <XCircleIcon className="size-4 text-orange-600" />,
60 };
61
62 return (
63 <Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
64 {icons[status]}
65 {labels[status]}
66 </Badge>
67 );
68 };
69
70 export const ToolHeader = ({
71 className,
72 title,
73 type,
74 state,
75 ...props
76 }: ToolHeaderProps) => (
77 <CollapsibleTrigger
78 className={cn(
79 "flex w-full items-center justify-between gap-4 p-3",
80 className
81 )}
82 {...props}
83 >
84 <div className="flex items-center gap-2">
85 <WrenchIcon className="size-4 text-muted-foreground" />
86 <span className="font-medium text-sm">
87 {title ?? type.split("-").slice(1).join("-")}
88 </span>
89 {getStatusBadge(state)}
90 </div>
91 <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
92 </CollapsibleTrigger>
93 );
94
95 export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
96
97 export const ToolContent = ({ className, ...props }: ToolContentProps) => (
98 <CollapsibleContent
99 className={cn(
100 "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
101 className
102 )}
103 {...props}
104 />
105 );
106
107 export type ToolInputProps = ComponentProps<"div"> & {
108 input: ToolUIPart["input"];
109 };
110
111 export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
112 <div className={cn("space-y-2 overflow-hidden p-4", className)} {...props}>
113 <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
114 Parameters
115 </h4>
116 <div className="rounded-md bg-muted/50">
117 <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
118 </div>
119 </div>
120 );
121
122 export type ToolOutputProps = ComponentProps<"div"> & {
123 output: ToolUIPart["output"];
124 errorText: ToolUIPart["errorText"];
125 };
126
127 export const ToolOutput = ({
128 className,
129 output,
130 errorText,
131 ...props
132 }: ToolOutputProps) => {
133 if (!(output || errorText)) {
134 return null;
135 }
136
137 let Output = <div>{output as ReactNode}</div>;
138
139 if (typeof output === "object" && !isValidElement(output)) {
140 Output = (
141 <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
142 );
143 } else if (typeof output === "string") {
144 Output = <CodeBlock code={output} language="json" />;
145 }
146
147 return (
148 <div className={cn("space-y-2 p-4", className)} {...props}>
149 <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
150 {errorText ? "Error" : "Result"}
151 </h4>
152 <div
153 className={cn(
154 "overflow-x-auto rounded-md text-xs [&_table]:w-full",
155 errorText
156 ? "bg-destructive/10 text-destructive"
157 : "bg-muted/50 text-foreground"
158 )}
159 >
160 {errorText && <div>{errorText}</div>}
161 {Output}
162 </div>
163 </div>
164 );
165 };
src/components/contributor/BranchSelector.tsx added +191
@@ -0,0 +1,191 @@
1 'use client';
2
3 import React, { useState, useEffect } from 'react';
4 import { GitBranchIcon, ChevronDownIcon, Loader2Icon, ShieldIcon, PlusIcon } from 'lucide-react';
5
6 export interface Branch {
7 name: string;
8 protected: boolean;
9 }
10
11 interface BranchSelectorProps {
12 repoFullName: string | null;
13 selectedBranch: string | null;
14 onSelect: (branch: string) => void;
15 disabled?: boolean;
16 allowNew?: boolean;
17 newBranchPrefix?: string;
18 }
19
20 export function BranchSelector({
21 repoFullName,
22 selectedBranch,
23 onSelect,
24 disabled,
25 allowNew = true,
26 newBranchPrefix = 'ai-contributor/',
27 }: BranchSelectorProps) {
28 const [isOpen, setIsOpen] = useState(false);
29 const [branches, setBranches] = useState<Branch[]>([]);
30 const [loading, setLoading] = useState(false);
31 const [newBranchName, setNewBranchName] = useState('');
32 const [showNewBranch, setShowNewBranch] = useState(false);
33
34 useEffect(() => {
35 if (repoFullName && isOpen) {
36 fetchBranches();
37 }
38 // eslint-disable-next-line react-hooks/exhaustive-deps
39 }, [repoFullName, isOpen]);
40
41 const fetchBranches = async () => {
42 if (!repoFullName) return;
43 setLoading(true);
44 try {
45 const response = await fetch(`/api/contributor/branches?repo=${encodeURIComponent(repoFullName)}`);
46 if (!response.ok) throw new Error('Failed to fetch branches');
47 const data = await response.json();
48 setBranches(data.branches || []);
49 } catch (err) {
50 console.error('Failed to fetch branches:', err);
51 } finally {
52 setLoading(false);
53 }
54 };
55
56 const handleCreateNew = () => {
57 if (newBranchName.trim()) {
58 const fullBranchName = newBranchPrefix + newBranchName.trim().toLowerCase().replace(/\s+/g, '-');
59 onSelect(fullBranchName);
60 setIsOpen(false);
61 setShowNewBranch(false);
62 setNewBranchName('');
63 }
64 };
65
66 return (
67 <div className="relative">
68 <button
69 onClick={() => !disabled && repoFullName && setIsOpen(!isOpen)}
70 disabled={disabled || !repoFullName}
71 className="w-full flex items-center justify-between gap-2 px-4 py-3
72 bg-gray-800/80 border border-gray-700 rounded-xl
73 text-left text-sm text-white
74 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed
75 transition-colors duration-200"
76 >
77 <div className="flex items-center gap-3 min-w-0">
78 <GitBranchIcon className="w-4 h-4 text-gray-400 flex-shrink-0" />
79 {selectedBranch ? (
80 <span className="truncate font-medium">{selectedBranch}</span>
81 ) : (
82 <span className="text-gray-400">
83 {repoFullName ? 'Select a branch...' : 'Select repo first'}
84 </span>
85 )}
86 </div>
87 <ChevronDownIcon className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
88 </button>
89
90 {isOpen && (
91 <div className="absolute z-50 w-full mt-2 bg-gray-900 border border-gray-700 rounded-xl shadow-xl overflow-hidden">
92 {/* New Branch Option */}
93 {allowNew && (
94 <div className="p-3 border-b border-gray-800">
95 {showNewBranch ? (
96 <div className="space-y-2">
97 <div className="flex items-center gap-2">
98 <span className="text-xs text-gray-500 flex-shrink-0">{newBranchPrefix}</span>
99 <input
100 type="text"
101 value={newBranchName}
102 onChange={(e) => setNewBranchName(e.target.value)}
103 onKeyDown={(e) => e.key === 'Enter' && handleCreateNew()}
104 placeholder="branch-name"
105 className="flex-1 px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-lg
106 text-sm text-white placeholder:text-gray-500
107 focus:outline-none focus:ring-2 focus:ring-blue-500"
108 autoFocus
109 />
110 </div>
111 <div className="flex gap-2">
112 <button
113 onClick={handleCreateNew}
114 disabled={!newBranchName.trim()}
115 className="flex-1 px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg
116 hover:bg-blue-700 disabled:opacity-50 transition-colors"
117 >
118 Create & Select
119 </button>
120 <button
121 onClick={() => {
122 setShowNewBranch(false);
123 setNewBranchName('');
124 }}
125 className="px-3 py-1.5 text-gray-400 text-xs hover:text-white transition-colors"
126 >
127 Cancel
128 </button>
129 </div>
130 </div>
131 ) : (
132 <button
133 onClick={() => setShowNewBranch(true)}
134 className="w-full flex items-center gap-2 px-3 py-2 text-sm text-blue-400
135 hover:bg-gray-800 rounded-lg transition-colors"
136 >
137 <PlusIcon className="w-4 h-4" />
138 Create new branch
139 </button>
140 )}
141 </div>
142 )}
143
144 {/* Branch List */}
145 <div className="max-h-64 overflow-y-auto">
146 {loading ? (
147 <div className="flex items-center justify-center py-8">
148 <Loader2Icon className="w-6 h-6 text-blue-500 animate-spin" />
149 </div>
150 ) : branches.length === 0 ? (
151 <div className="p-4 text-center text-gray-500 text-sm">No branches found</div>
152 ) : (
153 branches.map((branch) => (
154 <button
155 key={branch.name}
156 onClick={() => {
157 onSelect(branch.name);
158 setIsOpen(false);
159 }}
160 className={`w-full px-4 py-3 flex items-center gap-3 text-left
161 hover:bg-gray-800 transition-colors text-sm
162 ${selectedBranch === branch.name ? 'bg-gray-800' : ''}`}
163 >
164 <GitBranchIcon className="w-4 h-4 text-gray-400 flex-shrink-0" />
165 <span className="flex-1 truncate text-white">{branch.name}</span>
166 {branch.protected && (
167 <span title="Protected">
168 <ShieldIcon className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
169 </span>
170 )}
171 </button>
172 ))
173 )}
174 </div>
175 </div>
176 )}
177
178 {/* Backdrop */}
179 {isOpen && (
180 <div
181 className="fixed inset-0 z-40"
182 onClick={() => {
183 setIsOpen(false);
184 setShowNewBranch(false);
185 setNewBranchName('');
186 }}
187 />
188 )}
189 </div>
190 );
191 }
src/components/contributor/ConversationList.tsx added +151
@@ -0,0 +1,151 @@
1 'use client';
2
3 import React from 'react';
4 import { MessageSquareIcon, GitPullRequestIcon, CheckCircleIcon, XCircleIcon, ClockIcon, PlusIcon } from 'lucide-react';
5
6 interface Conversation {
7 id: string;
8 repo_full_name: string;
9 base_branch: string;
10 work_branch: string;
11 title: string | null;
12 status: 'active' | 'merged' | 'closed' | 'abandoned';
13 created_at: string;
14 updated_at: string;
15 pr_metadata?: {
16 pr_number?: number;
17 pr_url?: string;
18 status: 'draft' | 'open' | 'merged' | 'closed';
19 };
20 }
21
22 interface ConversationListProps {
23 conversations: Conversation[];
24 activeId?: string;
25 onSelect: (id: string) => void;
26 onNew: () => void;
27 loading?: boolean;
28 }
29
30 export function ConversationList({
31 conversations,
32 activeId,
33 onSelect,
34 onNew,
35 loading,
36 }: ConversationListProps) {
37 const getStatusIcon = (conversation: Conversation) => {
38 const prStatus = conversation.pr_metadata?.status;
39
40 if (prStatus === 'merged' || conversation.status === 'merged') {
41 return <CheckCircleIcon className="w-4 h-4 text-purple-500" />;
42 }
43 if (prStatus === 'closed' || conversation.status === 'closed') {
44 return <XCircleIcon className="w-4 h-4 text-red-500" />;
45 }
46 if (prStatus === 'open') {
47 return <GitPullRequestIcon className="w-4 h-4 text-green-500" />;
48 }
49 return <ClockIcon className="w-4 h-4 text-gray-500" />;
50 };
51
52 const formatDate = (dateString: string) => {
53 const date = new Date(dateString);
54 const now = new Date();
55 const diffMs = now.getTime() - date.getTime();
56 const diffMins = Math.floor(diffMs / 60000);
57 const diffHours = Math.floor(diffMs / 3600000);
58 const diffDays = Math.floor(diffMs / 86400000);
59
60 if (diffMins < 1) return 'Just now';
61 if (diffMins < 60) return `${diffMins}m ago`;
62 if (diffHours < 24) return `${diffHours}h ago`;
63 if (diffDays < 7) return `${diffDays}d ago`;
64 return date.toLocaleDateString();
65 };
66
67 return (
68 <div className="flex flex-col h-full">
69 {/* Header */}
70 <div className="p-4 border-b border-gray-800">
71 <div className="flex items-center justify-between">
72 <h2 className="text-lg font-semibold text-white">Conversations</h2>
73 <button
74 onClick={onNew}
75 className="flex items-center gap-1.5 px-3 py-1.5 text-sm
76 bg-blue-600 text-white rounded-lg
77 hover:bg-blue-700 transition-colors"
78 >
79 <PlusIcon className="w-4 h-4" />
80 <span className="hidden sm:inline">New</span>
81 </button>
82 </div>
83 </div>
84
85 {/* List */}
86 <div className="flex-1 overflow-y-auto">
87 {loading ? (
88 <div className="p-4 space-y-3">
89 {[1, 2, 3].map((i) => (
90 <div key={i} className="animate-pulse">
91 <div className="h-16 bg-gray-800 rounded-lg" />
92 </div>
93 ))}
94 </div>
95 ) : conversations.length === 0 ? (
96 <div className="p-8 text-center">
97 <MessageSquareIcon className="w-12 h-12 text-gray-600 mx-auto mb-3" />
98 <p className="text-gray-400 text-sm">No conversations yet</p>
99 <p className="text-gray-500 text-xs mt-1">
100 Start a new conversation to contribute to a repository
101 </p>
102 </div>
103 ) : (
104 <div className="p-2">
105 {conversations.map((conversation) => (
106 <button
107 key={conversation.id}
108 onClick={() => onSelect(conversation.id)}
109 className={`w-full p-3 rounded-lg text-left mb-1
110 transition-colors duration-150
111 ${activeId === conversation.id
112 ? 'bg-blue-600/20 border border-blue-500/30'
113 : 'hover:bg-gray-800/50 border border-transparent'
114 }`}
115 >
116 <div className="flex items-start gap-3">
117 <div className="flex-shrink-0 mt-0.5">
118 {getStatusIcon(conversation)}
119 </div>
120 <div className="min-w-0 flex-1">
121 <div className="flex items-center justify-between gap-2">
122 <span className="text-sm font-medium text-white truncate">
123 {conversation.title || conversation.repo_full_name}
124 </span>
125 <span className="text-xs text-gray-500 flex-shrink-0">
126 {formatDate(conversation.updated_at)}
127 </span>
128 </div>
129 <p className="text-xs text-gray-400 truncate mt-0.5">
130 {conversation.repo_full_name}
131 </p>
132 <div className="flex items-center gap-2 mt-1">
133 <span className="text-[10px] text-gray-500 font-mono bg-gray-800 px-1.5 py-0.5 rounded">
134 {conversation.work_branch}
135 </span>
136 {conversation.pr_metadata?.pr_number && (
137 <span className="text-[10px] text-gray-500">
138 #{conversation.pr_metadata.pr_number}
139 </span>
140 )}
141 </div>
142 </div>
143 </div>
144 </button>
145 ))}
146 </div>
147 )}
148 </div>
149 </div>
150 );
151 }
src/components/contributor/EnvVarsPanel.tsx added +159
@@ -0,0 +1,159 @@
1 'use client';
2
3 import React, { useState } from 'react';
4 import { SettingsIcon, PlusIcon, XIcon, EyeIcon, EyeOffIcon } from 'lucide-react';
5
6 interface EnvVar {
7 key: string;
8 value: string;
9 }
10
11 interface EnvVarsPanelProps {
12 envVars: EnvVar[];
13 onChange: (envVars: EnvVar[]) => void;
14 disabled?: boolean;
15 }
16
17 export function EnvVarsPanel({ envVars, onChange, disabled }: EnvVarsPanelProps) {
18 const [isOpen, setIsOpen] = useState(false);
19 const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
20
21 const addEnvVar = () => {
22 onChange([...envVars, { key: '', value: '' }]);
23 };
24
25 const removeEnvVar = (index: number) => {
26 onChange(envVars.filter((_, i) => i !== index));
27 setVisibleValues((prev) => {
28 const next = new Set(prev);
29 next.delete(index);
30 return next;
31 });
32 };
33
34 const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
35 const newEnvVars = [...envVars];
36 newEnvVars[index] = { ...newEnvVars[index], [field]: value };
37 onChange(newEnvVars);
38 };
39
40 const toggleVisibility = (index: number) => {
41 setVisibleValues((prev) => {
42 const next = new Set(prev);
43 if (next.has(index)) {
44 next.delete(index);
45 } else {
46 next.add(index);
47 }
48 return next;
49 });
50 };
51
52 const nonEmptyCount = envVars.filter((v) => v.key.trim()).length;
53
54 return (
55 <div>
56 <button
57 onClick={() => setIsOpen(!isOpen)}
58 disabled={disabled}
59 className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400
60 hover:text-white hover:bg-gray-800/50 rounded-lg
61 disabled:opacity-50 disabled:cursor-not-allowed
62 transition-colors duration-200"
63 >
64 <SettingsIcon className="w-4 h-4" />
65 <span>Env Variables</span>
66 {nonEmptyCount > 0 && (
67 <span className="px-1.5 py-0.5 bg-blue-600 text-white text-xs rounded-full">
68 {nonEmptyCount}
69 </span>
70 )}
71 </button>
72
73 {isOpen && (
74 <div className="absolute z-50 left-4 right-4 sm:left-auto sm:right-4 sm:w-96 mt-2
75 bg-gray-900 border border-gray-700 rounded-xl shadow-xl overflow-hidden">
76 <div className="p-4 border-b border-gray-800">
77 <h3 className="text-sm font-medium text-white">Environment Variables</h3>
78 <p className="text-xs text-gray-500 mt-1">
79 These will be available to your code as environment variables.
80 </p>
81 </div>
82
83 <div className="p-4 space-y-3 max-h-80 overflow-y-auto">
84 {envVars.length === 0 ? (
85 <div className="text-center text-gray-500 text-sm py-4">
86 No environment variables set
87 </div>
88 ) : (
89 envVars.map((envVar, index) => (
90 <div key={index} className="flex items-center gap-2">
91 <input
92 type="text"
93 value={envVar.key}
94 onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
95 placeholder="KEY"
96 className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg
97 text-sm text-white placeholder:text-gray-500 font-mono
98 focus:outline-none focus:ring-2 focus:ring-blue-500"
99 />
100 <div className="flex-1 relative">
101 <input
102 type={visibleValues.has(index) ? 'text' : 'password'}
103 value={envVar.value}
104 onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
105 placeholder="value"
106 className="w-full px-3 py-2 pr-9 bg-gray-800 border border-gray-700 rounded-lg
107 text-sm text-white placeholder:text-gray-500
108 focus:outline-none focus:ring-2 focus:ring-blue-500"
109 />
110 <button
111 type="button"
112 onClick={() => toggleVisibility(index)}
113 className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
114 >
115 {visibleValues.has(index) ? (
116 <EyeOffIcon className="w-4 h-4" />
117 ) : (
118 <EyeIcon className="w-4 h-4" />
119 )}
120 </button>
121 </div>
122 <button
123 onClick={() => removeEnvVar(index)}
124 className="p-2 text-gray-500 hover:text-red-400 transition-colors"
125 >
126 <XIcon className="w-4 h-4" />
127 </button>
128 </div>
129 ))
130 )}
131 </div>
132
133 <div className="p-4 border-t border-gray-800 flex justify-between">
134 <button
135 onClick={addEnvVar}
136 className="flex items-center gap-2 px-3 py-2 text-sm text-blue-400
137 hover:bg-gray-800 rounded-lg transition-colors"
138 >
139 <PlusIcon className="w-4 h-4" />
140 Add Variable
141 </button>
142 <button
143 onClick={() => setIsOpen(false)}
144 className="px-4 py-2 text-sm text-white bg-gray-800
145 hover:bg-gray-700 rounded-lg transition-colors"
146 >
147 Done
148 </button>
149 </div>
150 </div>
151 )}
152
153 {/* Backdrop */}
154 {isOpen && (
155 <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
156 )}
157 </div>
158 );
159 }
src/components/contributor/GitHubConnect.tsx added +292
@@ -0,0 +1,292 @@
1 'use client';
2
3 import React, { useEffect, useState } from 'react';
4 import Image from 'next/image';
5 import { GithubIcon, CheckCircleIcon, Loader2Icon, LogOutIcon, AlertCircleIcon } from 'lucide-react';
6 import { createClient } from '@/utils/supabase/client';
7
8 interface GitHubStatus {
9 connected: boolean;
10 username?: string;
11 avatarUrl?: string;
12 scope?: string;
13 }
14
15 interface GitHubConnectProps {
16 onStatusChange?: (connected: boolean) => void;
17 }
18
19 export function GitHubConnect({ onStatusChange }: GitHubConnectProps) {
20 const [status, setStatus] = useState<GitHubStatus | null>(null);
21 const [loading, setLoading] = useState(true);
22 const [connecting, setConnecting] = useState(false);
23 const [disconnecting, setDisconnecting] = useState(false);
24 const [error, setError] = useState<string | null>(null);
25
26 useEffect(() => {
27 handleUrlParams();
28 checkStatus();
29 // eslint-disable-next-line react-hooks/exhaustive-deps
30 }, []);
31
32 const handleUrlParams = () => {
33 const params = new URLSearchParams(window.location.search);
34 const githubError = params.get('github_error');
35 const githubSuccess = params.get('github_success');
36
37 if (githubError) {
38 setError(decodeURIComponent(githubError));
39 }
40
41 // Clean up URL params
42 if (githubError || githubSuccess) {
43 window.history.replaceState({}, '', window.location.pathname);
44 }
45 };
46
47 const checkStatus = async () => {
48 try {
49 const supabase = createClient();
50 const { data: { user } } = await supabase.auth.getUser();
51
52 if (!user) {
53 setStatus({ connected: false });
54 onStatusChange?.(false);
55 setLoading(false);
56 return;
57 }
58
59 // Check if user has GitHub identity linked
60 const githubIdentity = user.identities?.find(
61 (identity) => identity.provider === 'github'
62 );
63
64 if (githubIdentity) {
65 // User has GitHub linked - get info from user metadata
66 const githubUsername = user.user_metadata?.user_name ||
67 user.user_metadata?.preferred_username ||
68 githubIdentity.identity_data?.user_name;
69 const githubAvatarUrl = user.user_metadata?.avatar_url ||
70 githubIdentity.identity_data?.avatar_url;
71
72 setStatus({
73 connected: true,
74 username: githubUsername,
75 avatarUrl: githubAvatarUrl,
76 scope: 'repo',
77 });
78 onStatusChange?.(true);
79
80 // Also check/update server-side token storage
81 fetch('/api/auth/github/status').catch(() => {});
82 } else {
83 setStatus({ connected: false });
84 onStatusChange?.(false);
85 }
86 } catch (err) {
87 console.error('Failed to check GitHub status:', err);
88 setStatus({ connected: false });
89 onStatusChange?.(false);
90 } finally {
91 setLoading(false);
92 }
93 };
94
95 const handleConnect = async (scope: 'public_repo' | 'repo') => {
96 setConnecting(true);
97 setError(null);
98
99 try {
100 const supabase = createClient();
101 const { data: { user } } = await supabase.auth.getUser();
102
103 if (!user) {
104 // Not logged in - redirect to sign in
105 window.location.href = '/auth/sign-in';
106 return;
107 }
108
109 // Check if already has GitHub identity
110 const githubIdentity = user.identities?.find(
111 (identity) => identity.provider === 'github'
112 );
113
114 if (githubIdentity) {
115 // Already connected - just refresh status
116 await checkStatus();
117 setConnecting(false);
118 return;
119 }
120
121 // Link GitHub identity using client-side Supabase
122 // This properly handles the OAuth flow and session
123 const { data, error: linkError } = await supabase.auth.linkIdentity({
124 provider: 'github',
125 options: {
126 scopes: scope,
127 redirectTo: `${window.location.origin}/api/auth/github/callback`,
128 },
129 });
130
131 if (linkError) {
132 // Handle "identity already exists" gracefully
133 if (linkError.message.includes('identity_already_exists') ||
134 linkError.code === 'identity_already_exists') {
135 await checkStatus();
136 setConnecting(false);
137 return;
138 }
139 throw linkError;
140 }
141
142 // The client will automatically redirect to the OAuth URL
143 // If for some reason it doesn't, handle manually
144 if (data.url && !data.url.includes('supabase')) {
145 window.location.href = data.url;
146 }
147 } catch (err) {
148 console.error('Failed to connect GitHub:', err);
149 setError(err instanceof Error ? err.message : 'Failed to connect GitHub');
150 setConnecting(false);
151 }
152 };
153
154 const handleDisconnect = async () => {
155 setDisconnecting(true);
156 try {
157 await fetch('/api/auth/github/status', { method: 'DELETE' });
158 setStatus({ connected: false });
159 onStatusChange?.(false);
160 } catch (err) {
161 console.error('Failed to disconnect:', err);
162 } finally {
163 setDisconnecting(false);
164 }
165 };
166
167 if (loading) {
168 return (
169 <div className="flex items-center justify-center p-8">
170 <Loader2Icon className="w-6 h-6 text-blue-500 animate-spin" />
171 </div>
172 );
173 }
174
175 if (error) {
176 return (
177 <div className="bg-red-900/20 border border-red-800 rounded-xl p-6">
178 <div className="flex items-start gap-3">
179 <AlertCircleIcon className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
180 <div>
181 <h3 className="text-sm font-medium text-red-400">GitHub Connection Error</h3>
182 <p className="text-sm text-gray-400 mt-1">{error}</p>
183 <button
184 onClick={() => setError(null)}
185 className="mt-3 text-sm text-red-400 hover:text-red-300 underline"
186 >
187 Try again
188 </button>
189 </div>
190 </div>
191 </div>
192 );
193 }
194
195 if (status?.connected) {
196 return (
197 <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4">
198 <div className="flex items-center justify-between">
199 <div className="flex items-center gap-3">
200 {status.avatarUrl ? (
201 <Image
202 src={status.avatarUrl}
203 alt={status.username || 'GitHub avatar'}
204 width={40}
205 height={40}
206 className="rounded-full"
207 unoptimized
208 />
209 ) : (
210 <div className="w-10 h-10 bg-gray-800 rounded-full flex items-center justify-center">
211 <GithubIcon className="w-5 h-5 text-gray-400" />
212 </div>
213 )}
214 <div>
215 <div className="flex items-center gap-2">
216 <span className="text-sm font-medium text-white">{status.username}</span>
217 <CheckCircleIcon className="w-4 h-4 text-green-500" />
218 </div>
219 <span className="text-xs text-gray-500">
220 {status.scope?.includes('repo') ? 'Full access' : 'Public repos only'}
221 </span>
222 </div>
223 </div>
224 <button
225 onClick={handleDisconnect}
226 disabled={disconnecting}
227 className="flex items-center gap-2 px-3 py-1.5 text-sm text-red-400
228 hover:bg-red-500/10 rounded-lg transition-colors
229 disabled:opacity-50"
230 >
231 {disconnecting ? (
232 <Loader2Icon className="w-4 h-4 animate-spin" />
233 ) : (
234 <LogOutIcon className="w-4 h-4" />
235 )}
236 <span className="hidden sm:inline">Disconnect</span>
237 </button>
238 </div>
239 </div>
240 );
241 }
242
243 return (
244 <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-6">
245 <div className="text-center mb-6">
246 <GithubIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
247 <h3 className="text-lg font-medium text-white">Connect GitHub</h3>
248 <p className="text-sm text-gray-400 mt-1">
249 Connect your GitHub account to access your repositories
250 </p>
251 </div>
252
253 <div className="space-y-3">
254 <button
255 onClick={() => handleConnect('public_repo')}
256 disabled={connecting}
257 className="w-full flex items-center justify-center gap-2 px-4 py-3
258 bg-gray-800 text-white rounded-xl
259 hover:bg-gray-700 transition-colors
260 disabled:opacity-50 disabled:cursor-not-allowed"
261 >
262 {connecting ? (
263 <Loader2Icon className="w-5 h-5 animate-spin" />
264 ) : (
265 <GithubIcon className="w-5 h-5" />
266 )}
267 <span>Public Repositories Only</span>
268 </button>
269
270 <button
271 onClick={() => handleConnect('repo')}
272 disabled={connecting}
273 className="w-full flex items-center justify-center gap-2 px-4 py-3
274 bg-white text-black rounded-xl
275 hover:bg-gray-100 transition-colors
276 disabled:opacity-50 disabled:cursor-not-allowed"
277 >
278 {connecting ? (
279 <Loader2Icon className="w-5 h-5 animate-spin" />
280 ) : (
281 <GithubIcon className="w-5 h-5" />
282 )}
283 <span>All Repositories (including private)</span>
284 </button>
285 </div>
286
287 <p className="text-xs text-gray-500 text-center mt-4">
288 You can change this later in your GitHub settings
289 </p>
290 </div>
291 );
292 }
src/components/contributor/PRStatus.tsx added +214
@@ -0,0 +1,214 @@
1 'use client';
2
3 import React, { useState } from 'react';
4 import { GitPullRequestIcon, ExternalLinkIcon, Loader2Icon, CheckCircleIcon, XCircleIcon, CircleDotIcon, EditIcon } from 'lucide-react';
5
6 interface PRStatusProps {
7 conversationId: string;
8 prStatus: 'draft' | 'open' | 'merged' | 'closed';
9 prUrl?: string;
10 prNumber?: number;
11 title?: string;
12 body?: string;
13 commitsPushed?: number;
14 onOpenPR: (title: string, body: string) => Promise<void>;
15 onUpdatePR?: (title: string, body: string) => Promise<void>;
16 }
17
18 export function PRStatus({
19 prStatus,
20 prUrl,
21 prNumber,
22 title: initialTitle = '',
23 body: initialBody = '',
24 commitsPushed = 0,
25 onOpenPR,
26 onUpdatePR,
27 }: PRStatusProps) {
28 const [isEditing, setIsEditing] = useState(false);
29 const [title, setTitle] = useState(initialTitle);
30 const [body, setBody] = useState(initialBody);
31 const [isLoading, setIsLoading] = useState(false);
32
33 const getStatusIcon = () => {
34 switch (prStatus) {
35 case 'merged':
36 return <CheckCircleIcon className="w-5 h-5 text-purple-500" />;
37 case 'closed':
38 return <XCircleIcon className="w-5 h-5 text-red-500" />;
39 case 'open':
40 return <GitPullRequestIcon className="w-5 h-5 text-green-500" />;
41 default:
42 return <CircleDotIcon className="w-5 h-5 text-gray-500" />;
43 }
44 };
45
46 const getStatusText = () => {
47 switch (prStatus) {
48 case 'merged':
49 return 'Merged';
50 case 'closed':
51 return 'Closed';
52 case 'open':
53 return 'Open';
54 default:
55 return 'Draft';
56 }
57 };
58
59 const getStatusColor = () => {
60 switch (prStatus) {
61 case 'merged':
62 return 'text-purple-400 bg-purple-500/10 border-purple-500/30';
63 case 'closed':
64 return 'text-red-400 bg-red-500/10 border-red-500/30';
65 case 'open':
66 return 'text-green-400 bg-green-500/10 border-green-500/30';
67 default:
68 return 'text-gray-400 bg-gray-500/10 border-gray-500/30';
69 }
70 };
71
72 const handleOpenPR = async () => {
73 setIsLoading(true);
74 try {
75 await onOpenPR(title || 'AI Contributor Changes', body);
76 } finally {
77 setIsLoading(false);
78 setIsEditing(false);
79 }
80 };
81
82 const handleUpdatePR = async () => {
83 if (!onUpdatePR) return;
84 setIsLoading(true);
85 try {
86 await onUpdatePR(title, body);
87 } finally {
88 setIsLoading(false);
89 setIsEditing(false);
90 }
91 };
92
93 return (
94 <div className="bg-gray-900/50 border border-gray-800 rounded-xl overflow-hidden">
95 {/* Header */}
96 <div className="p-4 flex items-center justify-between border-b border-gray-800">
97 <div className="flex items-center gap-3">
98 {getStatusIcon()}
99 <div>
100 <div className="flex items-center gap-2">
101 <span className={`px-2 py-0.5 text-xs font-medium rounded-full border ${getStatusColor()}`}>
102 {getStatusText()}
103 </span>
104 {prNumber && (
105 <span className="text-sm text-gray-400">#{prNumber}</span>
106 )}
107 </div>
108 {commitsPushed > 0 && prStatus === 'open' && (
109 <p className="text-xs text-gray-500 mt-1">
110 {commitsPushed} commit{commitsPushed !== 1 ? 's' : ''} pushed
111 </p>
112 )}
113 </div>
114 </div>
115
116 {prUrl ? (
117 <a
118 href={prUrl}
119 target="_blank"
120 rel="noopener noreferrer"
121 className="flex items-center gap-2 px-3 py-1.5 text-sm text-blue-400
122 hover:bg-blue-500/10 rounded-lg transition-colors"
123 >
124 <span>View on GitHub</span>
125 <ExternalLinkIcon className="w-4 h-4" />
126 </a>
127 ) : prStatus === 'draft' && (
128 <button
129 onClick={() => setIsEditing(true)}
130 className="flex items-center gap-2 px-3 py-1.5 text-sm
131 bg-green-600 text-white rounded-lg
132 hover:bg-green-700 transition-colors"
133 >
134 <GitPullRequestIcon className="w-4 h-4" />
135 <span>Open Pull Request</span>
136 </button>
137 )}
138 </div>
139
140 {/* PR Details */}
141 {(isEditing || (title && prStatus !== 'draft')) && (
142 <div className="p-4 space-y-3">
143 {isEditing ? (
144 <>
145 <div>
146 <label className="block text-xs text-gray-400 mb-1.5">Title</label>
147 <input
148 type="text"
149 value={title}
150 onChange={(e) => setTitle(e.target.value)}
151 placeholder="PR Title"
152 className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg
153 text-sm text-white placeholder:text-gray-500
154 focus:outline-none focus:ring-2 focus:ring-blue-500"
155 />
156 </div>
157 <div>
158 <label className="block text-xs text-gray-400 mb-1.5">Description</label>
159 <textarea
160 value={body}
161 onChange={(e) => setBody(e.target.value)}
162 placeholder="Describe your changes..."
163 rows={4}
164 className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg
165 text-sm text-white placeholder:text-gray-500 resize-none
166 focus:outline-none focus:ring-2 focus:ring-blue-500"
167 />
168 </div>
169 <div className="flex justify-end gap-2">
170 <button
171 onClick={() => {
172 setIsEditing(false);
173 setTitle(initialTitle);
174 setBody(initialBody);
175 }}
176 className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
177 >
178 Cancel
179 </button>
180 <button
181 onClick={prStatus === 'draft' ? handleOpenPR : handleUpdatePR}
182 disabled={isLoading || !title.trim()}
183 className="flex items-center gap-2 px-4 py-2 text-sm
184 bg-green-600 text-white rounded-lg
185 hover:bg-green-700 disabled:opacity-50 transition-colors"
186 >
187 {isLoading && <Loader2Icon className="w-4 h-4 animate-spin" />}
188 {prStatus === 'draft' ? 'Create PR' : 'Update'}
189 </button>
190 </div>
191 </>
192 ) : (
193 <>
194 <div className="flex items-start justify-between">
195 <h3 className="text-sm font-medium text-white">{title}</h3>
196 {onUpdatePR && prStatus === 'open' && (
197 <button
198 onClick={() => setIsEditing(true)}
199 className="p-1 text-gray-500 hover:text-white transition-colors"
200 >
201 <EditIcon className="w-4 h-4" />
202 </button>
203 )}
204 </div>
205 {body && (
206 <p className="text-sm text-gray-400 whitespace-pre-wrap">{body}</p>
207 )}
208 </>
209 )}
210 </div>
211 )}
212 </div>
213 );
214 }
src/components/contributor/RepoSelector.tsx added +190
@@ -0,0 +1,190 @@
1 'use client';
2
3 import React, { useState, useEffect } from 'react';
4 import { SearchIcon, LockIcon, GlobeIcon, ChevronDownIcon, Loader2Icon } from 'lucide-react';
5
6 export interface Repository {
7 id: number;
8 name: string;
9 full_name: string;
10 private: boolean;
11 description: string | null;
12 default_branch: string;
13 language: string | null;
14 updated_at: string;
15 }
16
17 interface RepoSelectorProps {
18 selectedRepo: Repository | null;
19 onSelect: (repo: Repository) => void;
20 disabled?: boolean;
21 }
22
23 export function RepoSelector({ selectedRepo, onSelect, disabled }: RepoSelectorProps) {
24 const [isOpen, setIsOpen] = useState(false);
25 const [repos, setRepos] = useState<Repository[]>([]);
26 const [loading, setLoading] = useState(false);
27 const [search, setSearch] = useState('');
28 const [error, setError] = useState<string | null>(null);
29
30 useEffect(() => {
31 if (isOpen && repos.length === 0) {
32 fetchRepos();
33 }
34 // eslint-disable-next-line react-hooks/exhaustive-deps
35 }, [isOpen]);
36
37 const fetchRepos = async () => {
38 setLoading(true);
39 setError(null);
40 try {
41 const response = await fetch('/api/contributor/repos');
42 if (!response.ok) throw new Error('Failed to fetch repos');
43 const data = await response.json();
44 setRepos(data.repos || []);
45 } catch (err) {
46 setError('Failed to load repositories');
47 console.error(err);
48 } finally {
49 setLoading(false);
50 }
51 };
52
53 const filteredRepos = repos.filter(
54 (repo) =>
55 repo.full_name.toLowerCase().includes(search.toLowerCase()) ||
56 (repo.description?.toLowerCase().includes(search.toLowerCase()))
57 );
58
59 const getLanguageColor = (language: string | null) => {
60 const colors: Record<string, string> = {
61 TypeScript: 'bg-blue-500',
62 JavaScript: 'bg-yellow-500',
63 Python: 'bg-green-500',
64 Rust: 'bg-orange-500',
65 Go: 'bg-cyan-500',
66 Java: 'bg-red-500',
67 };
68 return colors[language || ''] || 'bg-gray-500';
69 };
70
71 return (
72 <div className="relative">
73 <button
74 onClick={() => !disabled && setIsOpen(!isOpen)}
75 disabled={disabled}
76 className="w-full flex items-center justify-between gap-2 px-4 py-3
77 bg-gray-800/80 border border-gray-700 rounded-xl
78 text-left text-sm text-white
79 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed
80 transition-colors duration-200"
81 >
82 <div className="flex items-center gap-3 min-w-0">
83 {selectedRepo ? (
84 <>
85 {selectedRepo.private ? (
86 <LockIcon className="w-4 h-4 text-yellow-500 flex-shrink-0" />
87 ) : (
88 <GlobeIcon className="w-4 h-4 text-green-500 flex-shrink-0" />
89 )}
90 <span className="truncate font-medium">{selectedRepo.full_name}</span>
91 </>
92 ) : (
93 <span className="text-gray-400">Select a repository...</span>
94 )}
95 </div>
96 <ChevronDownIcon className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
97 </button>
98
99 {isOpen && (
100 <div className="absolute z-50 w-full mt-2 bg-gray-900 border border-gray-700 rounded-xl shadow-xl overflow-hidden">
101 {/* Search */}
102 <div className="p-3 border-b border-gray-800">
103 <div className="relative">
104 <SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
105 <input
106 type="text"
107 value={search}
108 onChange={(e) => setSearch(e.target.value)}
109 placeholder="Search repositories..."
110 className="w-full pl-9 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg
111 text-sm text-white placeholder:text-gray-500
112 focus:outline-none focus:ring-2 focus:ring-blue-500"
113 autoFocus
114 />
115 </div>
116 </div>
117
118 {/* List */}
119 <div className="max-h-80 overflow-y-auto">
120 {loading ? (
121 <div className="flex items-center justify-center py-8">
122 <Loader2Icon className="w-6 h-6 text-blue-500 animate-spin" />
123 </div>
124 ) : error ? (
125 <div className="p-4 text-center text-red-400 text-sm">{error}</div>
126 ) : filteredRepos.length === 0 ? (
127 <div className="p-4 text-center text-gray-500 text-sm">
128 {search ? 'No matching repositories' : 'No repositories found'}
129 </div>
130 ) : (
131 filteredRepos.map((repo) => (
132 <button
133 key={repo.id}
134 onClick={() => {
135 onSelect(repo);
136 setIsOpen(false);
137 setSearch('');
138 }}
139 className={`w-full px-4 py-3 flex items-start gap-3 text-left
140 hover:bg-gray-800 transition-colors
141 ${selectedRepo?.id === repo.id ? 'bg-gray-800' : ''}`}
142 >
143 <div className="flex-shrink-0 mt-0.5">
144 {repo.private ? (
145 <LockIcon className="w-4 h-4 text-yellow-500" />
146 ) : (
147 <GlobeIcon className="w-4 h-4 text-green-500" />
148 )}
149 </div>
150 <div className="min-w-0 flex-1">
151 <div className="font-medium text-white text-sm truncate">
152 {repo.full_name}
153 </div>
154 {repo.description && (
155 <div className="text-xs text-gray-400 truncate mt-0.5">
156 {repo.description}
157 </div>
158 )}
159 <div className="flex items-center gap-3 mt-1.5">
160 {repo.language && (
161 <div className="flex items-center gap-1.5">
162 <div className={`w-2 h-2 rounded-full ${getLanguageColor(repo.language)}`} />
163 <span className="text-xs text-gray-500">{repo.language}</span>
164 </div>
165 )}
166 <span className="text-xs text-gray-500">
167 Updated {new Date(repo.updated_at).toLocaleDateString()}
168 </span>
169 </div>
170 </div>
171 </button>
172 ))
173 )}
174 </div>
175 </div>
176 )}
177
178 {/* Backdrop */}
179 {isOpen && (
180 <div
181 className="fixed inset-0 z-40"
182 onClick={() => {
183 setIsOpen(false);
184 setSearch('');
185 }}
186 />
187 )}
188 </div>
189 );
190 }
src/components/playground/ObjectViewer.tsx +2 −2
@@ -1,7 +1,7 @@
11 'use client'
22
33 import React, { Suspense, useRef } from 'react'
4 import { Canvas, useFrame, useLoader } from '@react-three/fiber'
4 import { Canvas, useLoader } from '@react-three/fiber'
55 import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei'
66 import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
77 import { Card } from "@/components/ui/card"
@@ -15,7 +15,7 @@ const LoadingSpinner: React.FC = () => (
1515 export const ObjectViewer: React.FC = () => {
1616 const ChicagoModel = () => {
1717 const gltf = useLoader(GLTFLoader, '/chicago.glb')
18 const modelRef = useRef()
18 const modelRef = useRef(null)
1919
2020 return <primitive object={gltf.scene} ref={modelRef} scale={[0.01, 0.01, 0.01]} />
2121 }
src/components/ui/badge.tsx added +46
@@ -0,0 +1,46 @@
1 import * as React from "react"
2 import { Slot } from "@radix-ui/react-slot"
3 import { cva, type VariantProps } from "class-variance-authority"
4
5 import { cn } from "@/lib/utils"
6
7 const badgeVariants = cva(
8 "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
9 {
10 variants: {
11 variant: {
12 default:
13 "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14 secondary:
15 "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16 destructive:
17 "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18 outline:
19 "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20 },
21 },
22 defaultVariants: {
23 variant: "default",
24 },
25 }
26 )
27
28 function Badge({
29 className,
30 variant,
31 asChild = false,
32 ...props
33 }: React.ComponentProps<"span"> &
34 VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35 const Comp = asChild ? Slot : "span"
36
37 return (
38 <Comp
39 data-slot="badge"
40 className={cn(badgeVariants({ variant }), className)}
41 {...props}
42 />
43 )
44 }
45
46 export { Badge, badgeVariants }
src/components/ui/button-group.tsx added +83
@@ -0,0 +1,83 @@
1 import { Slot } from "@radix-ui/react-slot"
2 import { cva, type VariantProps } from "class-variance-authority"
3
4 import { cn } from "@/lib/utils"
5 import { Separator } from "@/components/ui/separator"
6
7 const buttonGroupVariants = cva(
8 "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
9 {
10 variants: {
11 orientation: {
12 horizontal:
13 "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
14 vertical:
15 "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
16 },
17 },
18 defaultVariants: {
19 orientation: "horizontal",
20 },
21 }
22 )
23
24 function ButtonGroup({
25 className,
26 orientation,
27 ...props
28 }: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
29 return (
30 <div
31 role="group"
32 data-slot="button-group"
33 data-orientation={orientation}
34 className={cn(buttonGroupVariants({ orientation }), className)}
35 {...props}
36 />
37 )
38 }
39
40 function ButtonGroupText({
41 className,
42 asChild = false,
43 ...props
44 }: React.ComponentProps<"div"> & {
45 asChild?: boolean
46 }) {
47 const Comp = asChild ? Slot : "div"
48
49 return (
50 <Comp
51 className={cn(
52 "bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
53 className
54 )}
55 {...props}
56 />
57 )
58 }
59
60 function ButtonGroupSeparator({
61 className,
62 orientation = "vertical",
63 ...props
64 }: React.ComponentProps<typeof Separator>) {
65 return (
66 <Separator
67 data-slot="button-group-separator"
68 orientation={orientation}
69 className={cn(
70 "bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
71 className
72 )}
73 {...props}
74 />
75 )
76 }
77
78 export {
79 ButtonGroup,
80 ButtonGroupSeparator,
81 ButtonGroupText,
82 buttonGroupVariants,
83 }
src/components/ui/button.tsx +32 −29
@@ -5,26 +5,28 @@ import { cva, type VariantProps } from "class-variance-authority"
55 import { cn } from "@/lib/utils"
66
77 const buttonVariants = cva(
8 "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
8 "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_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",
99 {
1010 variants: {
1111 variant: {
12 default:
13 "bg-primary text-primary-foreground shadow hover:bg-primary/90",
12 default: "bg-primary text-primary-foreground hover:bg-primary/90",
1413 destructive:
15 "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
14 "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
1615 outline:
17 "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
16 "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
1817 secondary:
19 "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 ghost: "hover:bg-accent hover:text-accent-foreground",
18 "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 ghost:
20 "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
2121 link: "text-primary underline-offset-4 hover:underline",
2222 },
2323 size: {
24 default: "h-9 px-4 py-2",
25 sm: "h-8 rounded-md px-3 text-xs",
26 lg: "h-10 rounded-md px-8",
27 icon: "h-9 w-9",
24 default: "h-9 px-4 py-2 has-[>svg]:px-3",
25 sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26 lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27 icon: "size-9",
28 "icon-sm": "size-8",
29 "icon-lg": "size-10",
2830 },
2931 },
3032 defaultVariants: {
@@ -34,24 +36,25 @@ const buttonVariants = cva(
3436 }
3537 )
3638
37 export interface ButtonProps
38 extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39 VariantProps<typeof buttonVariants> {
40 asChild?: boolean
39 function Button({
40 className,
41 variant,
42 size,
43 asChild = false,
44 ...props
45 }: React.ComponentProps<"button"> &
46 VariantProps<typeof buttonVariants> & {
47 asChild?: boolean
48 }) {
49 const Comp = asChild ? Slot : "button"
50
51 return (
52 <Comp
53 data-slot="button"
54 className={cn(buttonVariants({ variant, size, className }))}
55 {...props}
56 />
57 )
4158 }
4259
43 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44 ({ className, variant, size, asChild = false, ...props }, ref) => {
45 const Comp = asChild ? Slot : "button"
46 return (
47 <Comp
48 className={cn(buttonVariants({ variant, size, className }))}
49 ref={ref}
50 {...props}
51 />
52 )
53 }
54 )
55 Button.displayName = "Button"
56
5760 export { Button, buttonVariants }
src/components/ui/collapsible.tsx added +33
@@ -0,0 +1,33 @@
1 "use client"
2
3 import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
4
5 function Collapsible({
6 ...props
7 }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
8 return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
9 }
10
11 function CollapsibleTrigger({
12 ...props
13 }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
14 return (
15 <CollapsiblePrimitive.CollapsibleTrigger
16 data-slot="collapsible-trigger"
17 {...props}
18 />
19 )
20 }
21
22 function CollapsibleContent({
23 ...props
24 }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
25 return (
26 <CollapsiblePrimitive.CollapsibleContent
27 data-slot="collapsible-content"
28 {...props}
29 />
30 )
31 }
32
33 export { Collapsible, CollapsibleTrigger, CollapsibleContent }
src/components/ui/command.tsx added +184
@@ -0,0 +1,184 @@
1 "use client"
2
3 import * as React from "react"
4 import { Command as CommandPrimitive } from "cmdk"
5 import { SearchIcon } from "lucide-react"
6
7 import { cn } from "@/lib/utils"
8 import {
9 Dialog,
10 DialogContent,
11 DialogDescription,
12 DialogHeader,
13 DialogTitle,
14 } from "@/components/ui/dialog"
15
16 function Command({
17 className,
18 ...props
19 }: React.ComponentProps<typeof CommandPrimitive>) {
20 return (
21 <CommandPrimitive
22 data-slot="command"
23 className={cn(
24 "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
25 className
26 )}
27 {...props}
28 />
29 )
30 }
31
32 function CommandDialog({
33 title = "Command Palette",
34 description = "Search for a command to run...",
35 children,
36 className,
37 showCloseButton = true,
38 ...props
39 }: React.ComponentProps<typeof Dialog> & {
40 title?: string
41 description?: string
42 className?: string
43 showCloseButton?: boolean
44 }) {
45 return (
46 <Dialog {...props}>
47 <DialogHeader className="sr-only">
48 <DialogTitle>{title}</DialogTitle>
49 <DialogDescription>{description}</DialogDescription>
50 </DialogHeader>
51 <DialogContent
52 className={cn("overflow-hidden p-0", className)}
53 showCloseButton={showCloseButton}
54 >
55 <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
56 {children}
57 </Command>
58 </DialogContent>
59 </Dialog>
60 )
61 }
62
63 function CommandInput({
64 className,
65 ...props
66 }: React.ComponentProps<typeof CommandPrimitive.Input>) {
67 return (
68 <div
69 data-slot="command-input-wrapper"
70 className="flex h-9 items-center gap-2 border-b px-3"
71 >
72 <SearchIcon className="size-4 shrink-0 opacity-50" />
73 <CommandPrimitive.Input
74 data-slot="command-input"
75 className={cn(
76 "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
77 className
78 )}
79 {...props}
80 />
81 </div>
82 )
83 }
84
85 function CommandList({
86 className,
87 ...props
88 }: React.ComponentProps<typeof CommandPrimitive.List>) {
89 return (
90 <CommandPrimitive.List
91 data-slot="command-list"
92 className={cn(
93 "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
94 className
95 )}
96 {...props}
97 />
98 )
99 }
100
101 function CommandEmpty({
102 ...props
103 }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
104 return (
105 <CommandPrimitive.Empty
106 data-slot="command-empty"
107 className="py-6 text-center text-sm"
108 {...props}
109 />
110 )
111 }
112
113 function CommandGroup({
114 className,
115 ...props
116 }: React.ComponentProps<typeof CommandPrimitive.Group>) {
117 return (
118 <CommandPrimitive.Group
119 data-slot="command-group"
120 className={cn(
121 "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
122 className
123 )}
124 {...props}
125 />
126 )
127 }
128
129 function CommandSeparator({
130 className,
131 ...props
132 }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
133 return (
134 <CommandPrimitive.Separator
135 data-slot="command-separator"
136 className={cn("bg-border -mx-1 h-px", className)}
137 {...props}
138 />
139 )
140 }
141
142 function CommandItem({
143 className,
144 ...props
145 }: React.ComponentProps<typeof CommandPrimitive.Item>) {
146 return (
147 <CommandPrimitive.Item
148 data-slot="command-item"
149 className={cn(
150 "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
151 className
152 )}
153 {...props}
154 />
155 )
156 }
157
158 function CommandShortcut({
159 className,
160 ...props
161 }: React.ComponentProps<"span">) {
162 return (
163 <span
164 data-slot="command-shortcut"
165 className={cn(
166 "text-muted-foreground ml-auto text-xs tracking-widest",
167 className
168 )}
169 {...props}
170 />
171 )
172 }
173
174 export {
175 Command,
176 CommandDialog,
177 CommandInput,
178 CommandList,
179 CommandEmpty,
180 CommandGroup,
181 CommandItem,
182 CommandShortcut,
183 CommandSeparator,
184 }
src/components/ui/dialog.tsx +115 −94
@@ -2,121 +2,142 @@
22
33 import * as React from "react"
44 import * as DialogPrimitive from "@radix-ui/react-dialog"
5 import { Cross2Icon } from "@radix-ui/react-icons"
5 import { XIcon } from "lucide-react"
66
77 import { cn } from "@/lib/utils"
88
9 const Dialog = DialogPrimitive.Root
9 function Dialog({
10 ...props
11 }: React.ComponentProps<typeof DialogPrimitive.Root>) {
12 return <DialogPrimitive.Root data-slot="dialog" {...props} />
13 }
1014
11 const DialogTrigger = DialogPrimitive.Trigger
15 function DialogTrigger({
16 ...props
17 }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
18 return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
19 }
1220
13 const DialogPortal = DialogPrimitive.Portal
21 function DialogPortal({
22 ...props
23 }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
24 return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
25 }
1426
15 const DialogClose = DialogPrimitive.Close
27 function DialogClose({
28 ...props
29 }: React.ComponentProps<typeof DialogPrimitive.Close>) {
30 return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
31 }
1632
17 const DialogOverlay = React.forwardRef<
18 React.ElementRef<typeof DialogPrimitive.Overlay>,
19 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20 >(({ className, ...props }, ref) => (
21 <DialogPrimitive.Overlay
22 ref={ref}
23 className={cn(
24 "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25 className
26 )}
27 {...props}
28 />
29 ))
30 DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31
32 const DialogContent = React.forwardRef<
33 React.ElementRef<typeof DialogPrimitive.Content>,
34 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
35 >(({ className, children, ...props }, ref) => (
36 <DialogPortal>
37 <DialogOverlay />
38 <DialogPrimitive.Content
39 ref={ref}
33 function DialogOverlay({
34 className,
35 ...props
36 }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
37 return (
38 <DialogPrimitive.Overlay
39 data-slot="dialog-overlay"
4040 className={cn(
41 "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
41 "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",
4242 className
4343 )}
4444 {...props}
45 >
46 {children}
47 <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
48 <Cross2Icon className="h-4 w-4" />
49 <span className="sr-only">Close</span>
50 </DialogPrimitive.Close>
51 </DialogPrimitive.Content>
52 </DialogPortal>
53 ))
54 DialogContent.displayName = DialogPrimitive.Content.displayName
45 />
46 )
47 }
5548
56 const DialogHeader = ({
49 function DialogContent({
50 className,
51 children,
52 showCloseButton = true,
53 ...props
54 }: React.ComponentProps<typeof DialogPrimitive.Content> & {
55 showCloseButton?: boolean
56 }) {
57 return (
58 <DialogPortal data-slot="dialog-portal">
59 <DialogOverlay />
60 <DialogPrimitive.Content
61 data-slot="dialog-content"
62 className={cn(
63 "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 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
64 className
65 )}
66 {...props}
67 >
68 {children}
69 {showCloseButton && (
70 <DialogPrimitive.Close
71 data-slot="dialog-close"
72 className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
73 >
74 <XIcon />
75 <span className="sr-only">Close</span>
76 </DialogPrimitive.Close>
77 )}
78 </DialogPrimitive.Content>
79 </DialogPortal>
80 )
81 }
82
83 function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84 return (
85 <div
86 data-slot="dialog-header"
87 className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
88 {...props}
89 />
90 )
91 }
92
93 function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94 return (
95 <div
96 data-slot="dialog-footer"
97 className={cn(
98 "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
99 className
100 )}
101 {...props}
102 />
103 )
104 }
105
106 function DialogTitle({
57107 className,
58108 ...props
59 }: React.HTMLAttributes<HTMLDivElement>) => (
60 <div
61 className={cn(
62 "flex flex-col space-y-1.5 text-center sm:text-left",
63 className
64 )}
65 {...props}
66 />
67 )
68 DialogHeader.displayName = "DialogHeader"
109 }: React.ComponentProps<typeof DialogPrimitive.Title>) {
110 return (
111 <DialogPrimitive.Title
112 data-slot="dialog-title"
113 className={cn("text-lg leading-none font-semibold", className)}
114 {...props}
115 />
116 )
117 }
69118
70 const DialogFooter = ({
119 function DialogDescription({
71120 className,
72121 ...props
73 }: React.HTMLAttributes<HTMLDivElement>) => (
74 <div
75 className={cn(
76 "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
77 className
78 )}
79 {...props}
80 />
81 )
82 DialogFooter.displayName = "DialogFooter"
83
84 const DialogTitle = React.forwardRef<
85 React.ElementRef<typeof DialogPrimitive.Title>,
86 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
87 >(({ className, ...props }, ref) => (
88 <DialogPrimitive.Title
89 ref={ref}
90 className={cn(
91 "text-lg font-semibold leading-none tracking-tight",
92 className
93 )}
94 {...props}
95 />
96 ))
97 DialogTitle.displayName = DialogPrimitive.Title.displayName
98
99 const DialogDescription = React.forwardRef<
100 React.ElementRef<typeof DialogPrimitive.Description>,
101 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
102 >(({ className, ...props }, ref) => (
103 <DialogPrimitive.Description
104 ref={ref}
105 className={cn("text-sm text-muted-foreground", className)}
106 {...props}
107 />
108 ))
109 DialogDescription.displayName = DialogPrimitive.Description.displayName
122 }: React.ComponentProps<typeof DialogPrimitive.Description>) {
123 return (
124 <DialogPrimitive.Description
125 data-slot="dialog-description"
126 className={cn("text-muted-foreground text-sm", className)}
127 {...props}
128 />
129 )
130 }
110131
111132 export {
112133 Dialog,
113 DialogPortal,
114 DialogOverlay,
115 DialogTrigger,
116134 DialogClose,
117135 DialogContent,
118 DialogHeader,
119 DialogFooter,
120 DialogTitle,
121136 DialogDescription,
137 DialogFooter,
138 DialogHeader,
139 DialogOverlay,
140 DialogPortal,
141 DialogTitle,
142 DialogTrigger,
122143 }
src/components/ui/dropdown-menu.tsx added +257
@@ -0,0 +1,257 @@
1 "use client"
2
3 import * as React from "react"
4 import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
6
7 import { cn } from "@/lib/utils"
8
9 function DropdownMenu({
10 ...props
11 }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
12 return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
13 }
14
15 function DropdownMenuPortal({
16 ...props
17 }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
18 return (
19 <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
20 )
21 }
22
23 function DropdownMenuTrigger({
24 ...props
25 }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
26 return (
27 <DropdownMenuPrimitive.Trigger
28 data-slot="dropdown-menu-trigger"
29 {...props}
30 />
31 )
32 }
33
34 function DropdownMenuContent({
35 className,
36 sideOffset = 4,
37 ...props
38 }: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
39 return (
40 <DropdownMenuPrimitive.Portal>
41 <DropdownMenuPrimitive.Content
42 data-slot="dropdown-menu-content"
43 sideOffset={sideOffset}
44 className={cn(
45 "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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
46 className
47 )}
48 {...props}
49 />
50 </DropdownMenuPrimitive.Portal>
51 )
52 }
53
54 function DropdownMenuGroup({
55 ...props
56 }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
57 return (
58 <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
59 )
60 }
61
62 function DropdownMenuItem({
63 className,
64 inset,
65 variant = "default",
66 ...props
67 }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
68 inset?: boolean
69 variant?: "default" | "destructive"
70 }) {
71 return (
72 <DropdownMenuPrimitive.Item
73 data-slot="dropdown-menu-item"
74 data-inset={inset}
75 data-variant={variant}
76 className={cn(
77 "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
78 className
79 )}
80 {...props}
81 />
82 )
83 }
84
85 function DropdownMenuCheckboxItem({
86 className,
87 children,
88 checked,
89 ...props
90 }: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
91 return (
92 <DropdownMenuPrimitive.CheckboxItem
93 data-slot="dropdown-menu-checkbox-item"
94 className={cn(
95 "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
96 className
97 )}
98 checked={checked}
99 {...props}
100 >
101 <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
102 <DropdownMenuPrimitive.ItemIndicator>
103 <CheckIcon className="size-4" />
104 </DropdownMenuPrimitive.ItemIndicator>
105 </span>
106 {children}
107 </DropdownMenuPrimitive.CheckboxItem>
108 )
109 }
110
111 function DropdownMenuRadioGroup({
112 ...props
113 }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
114 return (
115 <DropdownMenuPrimitive.RadioGroup
116 data-slot="dropdown-menu-radio-group"
117 {...props}
118 />
119 )
120 }
121
122 function DropdownMenuRadioItem({
123 className,
124 children,
125 ...props
126 }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
127 return (
128 <DropdownMenuPrimitive.RadioItem
129 data-slot="dropdown-menu-radio-item"
130 className={cn(
131 "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
132 className
133 )}
134 {...props}
135 >
136 <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
137 <DropdownMenuPrimitive.ItemIndicator>
138 <CircleIcon className="size-2 fill-current" />
139 </DropdownMenuPrimitive.ItemIndicator>
140 </span>
141 {children}
142 </DropdownMenuPrimitive.RadioItem>
143 )
144 }
145
146 function DropdownMenuLabel({
147 className,
148 inset,
149 ...props
150 }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
151 inset?: boolean
152 }) {
153 return (
154 <DropdownMenuPrimitive.Label
155 data-slot="dropdown-menu-label"
156 data-inset={inset}
157 className={cn(
158 "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
159 className
160 )}
161 {...props}
162 />
163 )
164 }
165
166 function DropdownMenuSeparator({
167 className,
168 ...props
169 }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
170 return (
171 <DropdownMenuPrimitive.Separator
172 data-slot="dropdown-menu-separator"
173 className={cn("bg-border -mx-1 my-1 h-px", className)}
174 {...props}
175 />
176 )
177 }
178
179 function DropdownMenuShortcut({
180 className,
181 ...props
182 }: React.ComponentProps<"span">) {
183 return (
184 <span
185 data-slot="dropdown-menu-shortcut"
186 className={cn(
187 "text-muted-foreground ml-auto text-xs tracking-widest",
188 className
189 )}
190 {...props}
191 />
192 )
193 }
194
195 function DropdownMenuSub({
196 ...props
197 }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
198 return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
199 }
200
201 function DropdownMenuSubTrigger({
202 className,
203 inset,
204 children,
205 ...props
206 }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
207 inset?: boolean
208 }) {
209 return (
210 <DropdownMenuPrimitive.SubTrigger
211 data-slot="dropdown-menu-sub-trigger"
212 data-inset={inset}
213 className={cn(
214 "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
215 className
216 )}
217 {...props}
218 >
219 {children}
220 <ChevronRightIcon className="ml-auto size-4" />
221 </DropdownMenuPrimitive.SubTrigger>
222 )
223 }
224
225 function DropdownMenuSubContent({
226 className,
227 ...props
228 }: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
229 return (
230 <DropdownMenuPrimitive.SubContent
231 data-slot="dropdown-menu-sub-content"
232 className={cn(
233 "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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
234 className
235 )}
236 {...props}
237 />
238 )
239 }
240
241 export {
242 DropdownMenu,
243 DropdownMenuPortal,
244 DropdownMenuTrigger,
245 DropdownMenuContent,
246 DropdownMenuGroup,
247 DropdownMenuLabel,
248 DropdownMenuItem,
249 DropdownMenuCheckboxItem,
250 DropdownMenuRadioGroup,
251 DropdownMenuRadioItem,
252 DropdownMenuSeparator,
253 DropdownMenuShortcut,
254 DropdownMenuSub,
255 DropdownMenuSubTrigger,
256 DropdownMenuSubContent,
257 }
src/components/ui/hover-card.tsx added +44
@@ -0,0 +1,44 @@
1 "use client"
2
3 import * as React from "react"
4 import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
5
6 import { cn } from "@/lib/utils"
7
8 function HoverCard({
9 ...props
10 }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
11 return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
12 }
13
14 function HoverCardTrigger({
15 ...props
16 }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
17 return (
18 <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
19 )
20 }
21
22 function HoverCardContent({
23 className,
24 align = "center",
25 sideOffset = 4,
26 ...props
27 }: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
28 return (
29 <HoverCardPrimitive.Portal data-slot="hover-card-portal">
30 <HoverCardPrimitive.Content
31 data-slot="hover-card-content"
32 align={align}
33 sideOffset={sideOffset}
34 className={cn(
35 "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-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
36 className
37 )}
38 {...props}
39 />
40 </HoverCardPrimitive.Portal>
41 )
42 }
43
44 export { HoverCard, HoverCardTrigger, HoverCardContent }
src/components/ui/input-group.tsx added +170
@@ -0,0 +1,170 @@
1 "use client"
2
3 import * as React from "react"
4 import { cva, type VariantProps } from "class-variance-authority"
5
6 import { cn } from "@/lib/utils"
7 import { Button } from "@/components/ui/button"
8 import { Input } from "@/components/ui/input"
9 import { Textarea } from "@/components/ui/textarea"
10
11 function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
12 return (
13 <div
14 data-slot="input-group"
15 role="group"
16 className={cn(
17 "group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
18 "h-9 min-w-0 has-[>textarea]:h-auto",
19
20 // Variants based on alignment.
21 "has-[>[data-align=inline-start]]:[&>input]:pl-2",
22 "has-[>[data-align=inline-end]]:[&>input]:pr-2",
23 "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
24 "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
25
26 // Focus state.
27 "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
28
29 // Error state.
30 "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
31
32 className
33 )}
34 {...props}
35 />
36 )
37 }
38
39 const inputGroupAddonVariants = cva(
40 "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
41 {
42 variants: {
43 align: {
44 "inline-start":
45 "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
46 "inline-end":
47 "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
48 "block-start":
49 "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
50 "block-end":
51 "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
52 },
53 },
54 defaultVariants: {
55 align: "inline-start",
56 },
57 }
58 )
59
60 function InputGroupAddon({
61 className,
62 align = "inline-start",
63 ...props
64 }: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
65 return (
66 <div
67 role="group"
68 data-slot="input-group-addon"
69 data-align={align}
70 className={cn(inputGroupAddonVariants({ align }), className)}
71 onClick={(e) => {
72 if ((e.target as HTMLElement).closest("button")) {
73 return
74 }
75 e.currentTarget.parentElement?.querySelector("input")?.focus()
76 }}
77 {...props}
78 />
79 )
80 }
81
82 const inputGroupButtonVariants = cva(
83 "text-sm shadow-none flex gap-2 items-center",
84 {
85 variants: {
86 size: {
87 xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
88 sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
89 "icon-xs":
90 "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
91 "icon-sm": "size-8 p-0 has-[>svg]:p-0",
92 },
93 },
94 defaultVariants: {
95 size: "xs",
96 },
97 }
98 )
99
100 function InputGroupButton({
101 className,
102 type = "button",
103 variant = "ghost",
104 size = "xs",
105 ...props
106 }: Omit<React.ComponentProps<typeof Button>, "size"> &
107 VariantProps<typeof inputGroupButtonVariants>) {
108 return (
109 <Button
110 type={type}
111 data-size={size}
112 variant={variant}
113 className={cn(inputGroupButtonVariants({ size }), className)}
114 {...props}
115 />
116 )
117 }
118
119 function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
120 return (
121 <span
122 className={cn(
123 "text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
124 className
125 )}
126 {...props}
127 />
128 )
129 }
130
131 function InputGroupInput({
132 className,
133 ...props
134 }: React.ComponentProps<"input">) {
135 return (
136 <Input
137 data-slot="input-group-control"
138 className={cn(
139 "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
140 className
141 )}
142 {...props}
143 />
144 )
145 }
146
147 function InputGroupTextarea({
148 className,
149 ...props
150 }: React.ComponentProps<"textarea">) {
151 return (
152 <Textarea
153 data-slot="input-group-control"
154 className={cn(
155 "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
156 className
157 )}
158 {...props}
159 />
160 )
161 }
162
163 export {
164 InputGroup,
165 InputGroupAddon,
166 InputGroupButton,
167 InputGroupText,
168 InputGroupInput,
169 InputGroupTextarea,
170 }
src/components/ui/input.tsx +15 −19
@@ -2,24 +2,20 @@ import * as React from "react"
22
33 import { cn } from "@/lib/utils"
44
5 export interface InputProps
6 extends React.InputHTMLAttributes<HTMLInputElement> {}
7
8 const Input = React.forwardRef<HTMLInputElement, InputProps>(
9 ({ className, type, ...props }, ref) => {
10 return (
11 <input
12 type={type}
13 className={cn(
14 "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
15 className
16 )}
17 ref={ref}
18 {...props}
19 />
20 )
21 }
22 )
23 Input.displayName = "Input"
5 function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 return (
7 <input
8 type={type}
9 data-slot="input"
10 className={cn(
11 "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent 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",
12 "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
13 "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
14 className
15 )}
16 {...props}
17 />
18 )
19 }
2420
2521 export { Input }
src/components/ui/select.tsx added +187
@@ -0,0 +1,187 @@
1 "use client"
2
3 import * as React from "react"
4 import * as SelectPrimitive from "@radix-ui/react-select"
5 import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6
7 import { cn } from "@/lib/utils"
8
9 function Select({
10 ...props
11 }: React.ComponentProps<typeof SelectPrimitive.Root>) {
12 return <SelectPrimitive.Root data-slot="select" {...props} />
13 }
14
15 function SelectGroup({
16 ...props
17 }: React.ComponentProps<typeof SelectPrimitive.Group>) {
18 return <SelectPrimitive.Group data-slot="select-group" {...props} />
19 }
20
21 function SelectValue({
22 ...props
23 }: React.ComponentProps<typeof SelectPrimitive.Value>) {
24 return <SelectPrimitive.Value data-slot="select-value" {...props} />
25 }
26
27 function SelectTrigger({
28 className,
29 size = "default",
30 children,
31 ...props
32 }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
33 size?: "sm" | "default"
34 }) {
35 return (
36 <SelectPrimitive.Trigger
37 data-slot="select-trigger"
38 data-size={size}
39 className={cn(
40 "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
41 className
42 )}
43 {...props}
44 >
45 {children}
46 <SelectPrimitive.Icon asChild>
47 <ChevronDownIcon className="size-4 opacity-50" />
48 </SelectPrimitive.Icon>
49 </SelectPrimitive.Trigger>
50 )
51 }
52
53 function SelectContent({
54 className,
55 children,
56 position = "popper",
57 align = "center",
58 ...props
59 }: React.ComponentProps<typeof SelectPrimitive.Content>) {
60 return (
61 <SelectPrimitive.Portal>
62 <SelectPrimitive.Content
63 data-slot="select-content"
64 className={cn(
65 "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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
66 position === "popper" &&
67 "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
68 className
69 )}
70 position={position}
71 align={align}
72 {...props}
73 >
74 <SelectScrollUpButton />
75 <SelectPrimitive.Viewport
76 className={cn(
77 "p-1",
78 position === "popper" &&
79 "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
80 )}
81 >
82 {children}
83 </SelectPrimitive.Viewport>
84 <SelectScrollDownButton />
85 </SelectPrimitive.Content>
86 </SelectPrimitive.Portal>
87 )
88 }
89
90 function SelectLabel({
91 className,
92 ...props
93 }: React.ComponentProps<typeof SelectPrimitive.Label>) {
94 return (
95 <SelectPrimitive.Label
96 data-slot="select-label"
97 className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
98 {...props}
99 />
100 )
101 }
102
103 function SelectItem({
104 className,
105 children,
106 ...props
107 }: React.ComponentProps<typeof SelectPrimitive.Item>) {
108 return (
109 <SelectPrimitive.Item
110 data-slot="select-item"
111 className={cn(
112 "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
113 className
114 )}
115 {...props}
116 >
117 <span className="absolute right-2 flex size-3.5 items-center justify-center">
118 <SelectPrimitive.ItemIndicator>
119 <CheckIcon className="size-4" />
120 </SelectPrimitive.ItemIndicator>
121 </span>
122 <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
123 </SelectPrimitive.Item>
124 )
125 }
126
127 function SelectSeparator({
128 className,
129 ...props
130 }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
131 return (
132 <SelectPrimitive.Separator
133 data-slot="select-separator"
134 className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
135 {...props}
136 />
137 )
138 }
139
140 function SelectScrollUpButton({
141 className,
142 ...props
143 }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
144 return (
145 <SelectPrimitive.ScrollUpButton
146 data-slot="select-scroll-up-button"
147 className={cn(
148 "flex cursor-default items-center justify-center py-1",
149 className
150 )}
151 {...props}
152 >
153 <ChevronUpIcon className="size-4" />
154 </SelectPrimitive.ScrollUpButton>
155 )
156 }
157
158 function SelectScrollDownButton({
159 className,
160 ...props
161 }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
162 return (
163 <SelectPrimitive.ScrollDownButton
164 data-slot="select-scroll-down-button"
165 className={cn(
166 "flex cursor-default items-center justify-center py-1",
167 className
168 )}
169 {...props}
170 >
171 <ChevronDownIcon className="size-4" />
172 </SelectPrimitive.ScrollDownButton>
173 )
174 }
175
176 export {
177 Select,
178 SelectContent,
179 SelectGroup,
180 SelectItem,
181 SelectLabel,
182 SelectScrollDownButton,
183 SelectScrollUpButton,
184 SelectSeparator,
185 SelectTrigger,
186 SelectValue,
187 }
src/components/ui/separator.tsx added +28
@@ -0,0 +1,28 @@
1 "use client"
2
3 import * as React from "react"
4 import * as SeparatorPrimitive from "@radix-ui/react-separator"
5
6 import { cn } from "@/lib/utils"
7
8 function Separator({
9 className,
10 orientation = "horizontal",
11 decorative = true,
12 ...props
13 }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
14 return (
15 <SeparatorPrimitive.Root
16 data-slot="separator"
17 decorative={decorative}
18 orientation={orientation}
19 className={cn(
20 "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
21 className
22 )}
23 {...props}
24 />
25 )
26 }
27
28 export { Separator }
src/components/ui/textarea.tsx +12 −18
@@ -2,23 +2,17 @@ import * as React from "react"
22
33 import { cn } from "@/lib/utils"
44
5 export interface TextareaProps
6 extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
7
8 const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
9 ({ className, ...props }, ref) => {
10 return (
11 <textarea
12 className={cn(
13 "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
14 className
15 )}
16 ref={ref}
17 {...props}
18 />
19 )
20 }
21 )
22 Textarea.displayName = "Textarea"
5 function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 return (
7 <textarea
8 data-slot="textarea"
9 className={cn(
10 "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11 className
12 )}
13 {...props}
14 />
15 )
16 }
2317
2418 export { Textarea }
src/components/ui/tooltip.tsx +49 −18
@@ -5,26 +5,57 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"
55
66 import { cn } from "@/lib/utils"
77
8 const TooltipProvider = TooltipPrimitive.Provider
8 function TooltipProvider({
9 delayDuration = 0,
10 ...props
11 }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
12 return (
13 <TooltipPrimitive.Provider
14 data-slot="tooltip-provider"
15 delayDuration={delayDuration}
16 {...props}
17 />
18 )
19 }
920
10 const Tooltip = TooltipPrimitive.Root
21 function Tooltip({
22 ...props
23 }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
24 return (
25 <TooltipProvider>
26 <TooltipPrimitive.Root data-slot="tooltip" {...props} />
27 </TooltipProvider>
28 )
29 }
1130
12 const TooltipTrigger = TooltipPrimitive.Trigger
31 function TooltipTrigger({
32 ...props
33 }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
34 return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
35 }
1336
14 const TooltipContent = React.forwardRef<
15 React.ElementRef<typeof TooltipPrimitive.Content>,
16 React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
17 >(({ className, sideOffset = 4, ...props }, ref) => (
18 <TooltipPrimitive.Content
19 ref={ref}
20 sideOffset={sideOffset}
21 className={cn(
22 "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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",
23 className
24 )}
25 {...props}
26 />
27 ))
28 TooltipContent.displayName = TooltipPrimitive.Content.displayName
37 function TooltipContent({
38 className,
39 sideOffset = 0,
40 children,
41 ...props
42 }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
43 return (
44 <TooltipPrimitive.Portal>
45 <TooltipPrimitive.Content
46 data-slot="tooltip-content"
47 sideOffset={sideOffset}
48 className={cn(
49 "bg-foreground text-background 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
50 className
51 )}
52 {...props}
53 >
54 {children}
55 <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
56 </TooltipPrimitive.Content>
57 </TooltipPrimitive.Portal>
58 )
59 }
2960
3061 export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
src/lib/agent/prompts.ts added +79
@@ -0,0 +1,79 @@
1 export const SYSTEM_PROMPT = `You are an expert software engineer assistant that helps users contribute to their GitHub repositories. You have access to a sandboxed environment with the repository cloned and ready to work with.
2
3 ## Your Capabilities
4 - Read, write, and edit files in the repository
5 - Run shell commands (npm, pip, git, etc.)
6 - Search the codebase for patterns
7 - Create directories and organize code
8
9 ## Guidelines
10
11 ### Code Quality
12 - Write clean, maintainable code that follows the existing patterns in the codebase
13 - Include appropriate comments for complex logic
14 - Follow the language's best practices and conventions
15 - Ensure your changes don't break existing functionality
16
17 ### Communication
18 - Be concise but thorough in explanations
19 - When making changes, briefly explain what you're doing and why
20 - If you encounter errors, explain them clearly and attempt to fix them
21 - Ask clarifying questions if the user's request is ambiguous
22
23 ### Workflow
24 1. First, understand the task by exploring relevant parts of the codebase
25 2. Make incremental changes, testing as you go when possible
26 3. After completing the task, summarize what was done
27
28 ### Commit Messages
29 When your work is complete, you'll be asked to suggest a commit message. Follow these conventions:
30 - Use imperative mood ("Add feature" not "Added feature")
31 - Keep the subject line under 50 characters
32 - Include a body if needed to explain the "why"
33 - Reference any relevant issue numbers
34
35 ### Important
36 - Never commit sensitive information (API keys, passwords, secrets)
37 - Don't modify files outside the scope of the user's request
38 - If a task seems risky or destructive, warn the user first
39 - Always preserve existing functionality unless explicitly asked to change it
40
41 You are working in a Vercel Sandbox environment with the user's repository. The repository has been cloned and is ready for you to work with. Use your tools to explore, modify, and improve the codebase as requested.`;
42
43 export function generateCommitMessagePrompt(filesChanged: string[], summary: string): string {
44 return `Based on the following changes, generate a concise commit message:
45
46 Files changed:
47 ${filesChanged.map(f => `- ${f}`).join('\n')}
48
49 Summary of changes:
50 ${summary}
51
52 Format your response as:
53 {
54 "subject": "Brief imperative description (max 50 chars)",
55 "body": "Optional longer description explaining why (can be empty string)"
56 }`;
57 }
58
59 export function generatePRDescriptionPrompt(
60 title: string,
61 filesChanged: string[],
62 commits: string[]
63 ): string {
64 return `Generate a pull request description for the following changes:
65
66 Title: ${title}
67
68 Files changed:
69 ${filesChanged.map(f => `- ${f}`).join('\n')}
70
71 Commits:
72 ${commits.map(c => `- ${c}`).join('\n')}
73
74 Format your response as:
75 {
76 "title": "PR title (can refine the provided title)",
77 "body": "Markdown formatted PR description with:\n- Summary of changes\n- Any notable implementation details\n- Testing notes if applicable"
78 }`;
79 }
src/lib/agent/sandbox.ts added +209
@@ -0,0 +1,209 @@
1 import { Sandbox } from '@vercel/sandbox';
2
3 export interface SandboxConfig {
4 repoUrl: string;
5 branch?: string;
6 newBranch?: string;
7 envVars?: Record<string, string>;
8 }
9
10 export async function createRepoSandbox(config: SandboxConfig): Promise<Sandbox> {
11 const { repoUrl, branch, newBranch, envVars = {} } = config;
12
13 // Create sandbox with git repo source
14 const sandbox = await Sandbox.create({
15 source: {
16 url: repoUrl,
17 type: 'git',
18 },
19 runtime: 'node22',
20 timeout: 5 * 60 * 1000, // 5 minutes
21 });
22
23 try {
24 // If we need to checkout a specific branch
25 if (branch) {
26 await sandbox.runCommand({
27 cmd: 'git',
28 args: ['checkout', branch],
29 });
30 }
31
32 // If we need to create a new branch for work
33 if (newBranch) {
34 await sandbox.runCommand({
35 cmd: 'git',
36 args: ['checkout', '-b', newBranch],
37 });
38 }
39
40 // Configure git for commits
41 await sandbox.runCommand({
42 cmd: 'git',
43 args: ['config', 'user.email', '[email protected]'],
44 });
45 await sandbox.runCommand({
46 cmd: 'git',
47 args: ['config', 'user.name', 'Repo Contributor'],
48 });
49
50 // Write environment variables to .env file if any
51 if (Object.keys(envVars).length > 0) {
52 const envContent = Object.entries(envVars)
53 .map(([key, value]) => `${key}=${value}`)
54 .join('\n');
55 await sandbox.writeFiles([
56 { path: '.env', content: Buffer.from(envContent) },
57 ]);
58 }
59
60 return sandbox;
61 } catch (error) {
62 // Clean up on error
63 await sandbox.stop();
64 throw error;
65 }
66 }
67
68 export interface CommitResult {
69 success: boolean;
70 sha?: string;
71 message?: string;
72 filesChanged?: string[];
73 error?: string;
74 }
75
76 export async function commitChanges(
77 sandbox: Sandbox,
78 message: string
79 ): Promise<CommitResult> {
80 try {
81 // Get status to see changed files
82 const statusResult = await sandbox.runCommand({
83 cmd: 'git',
84 args: ['status', '--porcelain'],
85 });
86 const statusOutput = await statusResult.stdout();
87 const filesChanged = statusOutput
88 .trim()
89 .split('\n')
90 .filter(Boolean)
91 .map(line => line.slice(3)); // Remove status prefix
92
93 if (filesChanged.length === 0) {
94 return { success: true, message: 'No changes to commit', filesChanged: [] };
95 }
96
97 // Stage all changes
98 await sandbox.runCommand({
99 cmd: 'git',
100 args: ['add', '-A'],
101 });
102
103 // Commit
104 const commitResult = await sandbox.runCommand({
105 cmd: 'git',
106 args: ['commit', '-m', message],
107 });
108
109 if (commitResult.exitCode !== 0) {
110 const stderr = await commitResult.stderr();
111 return { success: false, error: stderr };
112 }
113
114 // Get commit SHA
115 const shaResult = await sandbox.runCommand({
116 cmd: 'git',
117 args: ['rev-parse', 'HEAD'],
118 });
119 const sha = (await shaResult.stdout()).trim();
120
121 return {
122 success: true,
123 sha,
124 message,
125 filesChanged,
126 };
127 } catch (error) {
128 return { success: false, error: `Failed to commit: ${error}` };
129 }
130 }
131
132 export interface PushResult {
133 success: boolean;
134 error?: string;
135 }
136
137 export async function pushBranch(
138 sandbox: Sandbox,
139 branch: string,
140 token: string,
141 repoFullName: string
142 ): Promise<PushResult> {
143 try {
144 // Set up authenticated remote
145 const remoteUrl = `https://x-access-token:${token}@github.com/${repoFullName}.git`;
146
147 await sandbox.runCommand({
148 cmd: 'git',
149 args: ['remote', 'set-url', 'origin', remoteUrl],
150 });
151
152 // Push the branch
153 const pushResult = await sandbox.runCommand({
154 cmd: 'git',
155 args: ['push', '-u', 'origin', branch],
156 });
157
158 if (pushResult.exitCode !== 0) {
159 const stderr = await pushResult.stderr();
160 return { success: false, error: stderr };
161 }
162
163 return { success: true };
164 } catch (error) {
165 return { success: false, error: `Failed to push: ${error}` };
166 }
167 }
168
169 export async function getGitDiff(sandbox: Sandbox): Promise<string> {
170 try {
171 const result = await sandbox.runCommand({
172 cmd: 'git',
173 args: ['diff', 'HEAD~1', '--stat'],
174 });
175 return await result.stdout();
176 } catch {
177 return '';
178 }
179 }
180
181 export async function getLastCommitInfo(sandbox: Sandbox): Promise<{
182 sha: string;
183 message: string;
184 filesChanged: string[];
185 } | null> {
186 try {
187 const shaResult = await sandbox.runCommand({
188 cmd: 'git',
189 args: ['rev-parse', 'HEAD'],
190 });
191 const sha = (await shaResult.stdout()).trim();
192
193 const messageResult = await sandbox.runCommand({
194 cmd: 'git',
195 args: ['log', '-1', '--pretty=%B'],
196 });
197 const message = (await messageResult.stdout()).trim();
198
199 const filesResult = await sandbox.runCommand({
200 cmd: 'git',
201 args: ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'],
202 });
203 const filesChanged = (await filesResult.stdout()).trim().split('\n').filter(Boolean);
204
205 return { sha, message, filesChanged };
206 } catch {
207 return null;
208 }
209 }
src/lib/agent/tools.ts added +195
@@ -0,0 +1,195 @@
1 import { z } from 'zod';
2 import { Sandbox } from '@vercel/sandbox';
3
4 async function readStreamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
5 const reader = stream.getReader();
6 const chunks: Uint8Array[] = [];
7 while (true) {
8 const { done, value } = await reader.read();
9 if (done) break;
10 if (value) chunks.push(value);
11 }
12 return new TextDecoder().decode(Buffer.concat(chunks));
13 }
14
15 export function createAgentTools(sandbox: Sandbox) {
16 return {
17 read_file: {
18 description: 'Read the contents of a file at a given path',
19 inputSchema: z.object({
20 path: z.string().describe('The path to the file to read, relative to the repository root'),
21 }),
22 execute: async (args: { path: string }) => {
23 try {
24 const stream = await sandbox.readFile({ path: args.path });
25 if (!stream) {
26 return { success: false, error: `File not found: ${args.path}` };
27 }
28 const content = await readStreamToString(stream as unknown as ReadableStream<Uint8Array>);
29 return { success: true, content };
30 } catch (error) {
31 return { success: false, error: `Failed to read file: ${error}` };
32 }
33 },
34 },
35
36 write_file: {
37 description: 'Create or overwrite a file with the given content',
38 inputSchema: z.object({
39 path: z.string().describe('The path where the file should be written'),
40 content: z.string().describe('The content to write to the file'),
41 }),
42 execute: async (args: { path: string; content: string }) => {
43 try {
44 await sandbox.writeFiles([
45 { path: args.path, content: Buffer.from(args.content) },
46 ]);
47 return { success: true, message: `File written: ${args.path}` };
48 } catch (error) {
49 return { success: false, error: `Failed to write file: ${error}` };
50 }
51 },
52 },
53
54 edit_file: {
55 description: 'Apply a search and replace edit to a file. Use this for surgical changes.',
56 inputSchema: z.object({
57 path: z.string().describe('The path to the file to edit'),
58 search: z.string().describe('The exact text to search for'),
59 replace: z.string().describe('The text to replace it with'),
60 }),
61 execute: async (args: { path: string; search: string; replace: string }) => {
62 try {
63 const stream = await sandbox.readFile({ path: args.path });
64 if (!stream) {
65 return { success: false, error: `File not found: ${args.path}` };
66 }
67 const content = await readStreamToString(stream as unknown as ReadableStream<Uint8Array>);
68
69 if (!content.includes(args.search)) {
70 return { success: false, error: `Search text not found in file` };
71 }
72
73 const newContent = content.replaceAll(args.search, args.replace);
74 await sandbox.writeFiles([
75 { path: args.path, content: Buffer.from(newContent) },
76 ]);
77 return { success: true, message: `File edited: ${args.path}` };
78 } catch (error) {
79 return { success: false, error: `Failed to edit file: ${error}` };
80 }
81 },
82 },
83
84 run_command: {
85 description: 'Execute a shell command in the repository. Use for npm, pip, git, or any CLI operations.',
86 inputSchema: z.object({
87 command: z.string().describe('The command to execute (e.g., "npm install", "python -m pytest")'),
88 cwd: z.string().optional().describe('Working directory for the command, relative to repo root'),
89 }),
90 execute: async (args: { command: string; cwd?: string }) => {
91 try {
92 // Use shell wrapper for proper command parsing
93 const result = await sandbox.runCommand({
94 cmd: 'sh',
95 args: ['-c', args.command],
96 cwd: args.cwd || undefined,
97 });
98
99 const stdout = await result.stdout();
100 const stderr = await result.stderr();
101 const exitCode = result.exitCode;
102
103 return {
104 success: exitCode === 0,
105 exitCode,
106 stdout,
107 stderr,
108 };
109 } catch (error) {
110 return { success: false, error: `Failed to run command: ${error}` };
111 }
112 },
113 },
114
115 list_files: {
116 description: 'List files in a directory, optionally with a glob pattern',
117 inputSchema: z.object({
118 path: z.string().default('.').describe('The directory path to list'),
119 pattern: z.string().optional().describe('Glob pattern to filter files (e.g., "**/*.ts")'),
120 }),
121 execute: async (args: { path: string; pattern?: string }) => {
122 try {
123 const cmd = args.pattern
124 ? `find ${args.path} -type f -name "${args.pattern}" 2>/dev/null | head -100`
125 : `find ${args.path} -type f 2>/dev/null | head -100`;
126
127 const result = await sandbox.runCommand({
128 cmd: 'sh',
129 args: ['-c', cmd],
130 });
131
132 const stdout = await result.stdout();
133 const files = stdout.trim().split('\n').filter(Boolean);
134
135 return { success: true, files };
136 } catch (error) {
137 return { success: false, error: `Failed to list files: ${error}` };
138 }
139 },
140 },
141
142 search_code: {
143 description: 'Search for a pattern in the codebase using grep',
144 inputSchema: z.object({
145 pattern: z.string().describe('The regex pattern to search for'),
146 path: z.string().default('.').describe('The directory to search in'),
147 filePattern: z.string().optional().describe('File pattern to limit search (e.g., "*.ts")'),
148 }),
149 execute: async (args: { pattern: string; path: string; filePattern?: string }) => {
150 try {
151 let cmd = `grep -rn "${args.pattern}" ${args.path}`;
152 if (args.filePattern) {
153 cmd += ` --include="${args.filePattern}"`;
154 }
155 cmd += ' 2>/dev/null | head -50';
156
157 const result = await sandbox.runCommand({
158 cmd: 'sh',
159 args: ['-c', cmd],
160 });
161
162 const stdout = await result.stdout();
163 const matches = stdout.trim().split('\n').filter(Boolean).map(line => {
164 const [file, lineNum, ...content] = line.split(':');
165 return { file, line: parseInt(lineNum), content: content.join(':') };
166 });
167
168 return { success: true, matches };
169 } catch (error) {
170 return { success: false, error: `Failed to search: ${error}` };
171 }
172 },
173 },
174
175 create_directory: {
176 description: 'Create a directory (including parent directories if needed)',
177 inputSchema: z.object({
178 path: z.string().describe('The directory path to create'),
179 }),
180 execute: async (args: { path: string }) => {
181 try {
182 await sandbox.runCommand({
183 cmd: 'mkdir',
184 args: ['-p', args.path],
185 });
186 return { success: true, message: `Directory created: ${args.path}` };
187 } catch (error) {
188 return { success: false, error: `Failed to create directory: ${error}` };
189 }
190 },
191 },
192 };
193 }
194
195 export type AgentTools = ReturnType<typeof createAgentTools>;
src/lib/encryption.ts added +45
@@ -0,0 +1,45 @@
1 import CryptoJS from 'crypto-js';
2
3 let cachedKey: string | null = null;
4
5 function getEncryptionKey(): string {
6 if (cachedKey) return cachedKey;
7
8 const key = process.env.ENCRYPTION_KEY;
9 if (!key) {
10 if (process.env.NODE_ENV === 'production') {
11 throw new Error('ENCRYPTION_KEY environment variable is required in production');
12 }
13 cachedKey = 'dev-only-insecure-key';
14 return cachedKey;
15 }
16 cachedKey = key;
17 return cachedKey;
18 }
19
20 export function encrypt(text: string): string {
21 return CryptoJS.AES.encrypt(text, getEncryptionKey()).toString();
22 }
23
24 export function decrypt(ciphertext: string): string {
25 const bytes = CryptoJS.AES.decrypt(ciphertext, getEncryptionKey());
26 return bytes.toString(CryptoJS.enc.Utf8);
27 }
28
29 export function encryptEnvVars(envVars: Record<string, string>): Array<{ key_encrypted: string; value_encrypted: string }> {
30 return Object.entries(envVars).map(([key, value]) => ({
31 key_encrypted: encrypt(key),
32 value_encrypted: encrypt(value),
33 }));
34 }
35
36 export function decryptEnvVars(encrypted: Array<{ key_encrypted: string; value_encrypted: string }>): Record<string, string> {
37 return encrypted.reduce((acc, { key_encrypted, value_encrypted }) => {
38 const key = decrypt(key_encrypted);
39 const value = decrypt(value_encrypted);
40 if (key) {
41 acc[key] = value;
42 }
43 return acc;
44 }, {} as Record<string, string>);
45 }
src/lib/github.ts added +160
@@ -0,0 +1,160 @@
1 import { Octokit } from 'octokit';
2 import { createClient } from '@/utils/supabase/server';
3 import { decrypt } from './encryption';
4
5 export async function getAuthenticatedOctokit(): Promise<{ octokit: Octokit; username: string } | null> {
6 const supabase = await createClient();
7 const { data: { user } } = await supabase.auth.getUser();
8
9 if (!user) return null;
10
11 const { data: tokenData } = await supabase
12 .from('github_tokens')
13 .select('access_token_encrypted, github_username')
14 .eq('user_id', user.id)
15 .single();
16
17 if (!tokenData) return null;
18
19 const accessToken = decrypt(tokenData.access_token_encrypted);
20 const octokit = new Octokit({ auth: accessToken });
21
22 return { octokit, username: tokenData.github_username || '' };
23 }
24
25 export interface Repository {
26 id: number;
27 name: string;
28 full_name: string;
29 private: boolean;
30 description: string | null;
31 default_branch: string;
32 language: string | null;
33 updated_at: string;
34 html_url: string;
35 }
36
37 export interface Branch {
38 name: string;
39 protected: boolean;
40 commit: {
41 sha: string;
42 };
43 }
44
45 export async function listUserRepos(octokit: Octokit): Promise<Repository[]> {
46 const repos: Repository[] = [];
47 let page = 1;
48
49 while (true) {
50 const response = await octokit.rest.repos.listForAuthenticatedUser({
51 per_page: 100,
52 page,
53 sort: 'updated',
54 direction: 'desc',
55 });
56
57 if (response.data.length === 0) break;
58
59 repos.push(...response.data.map(repo => ({
60 id: repo.id,
61 name: repo.name,
62 full_name: repo.full_name,
63 private: repo.private,
64 description: repo.description,
65 default_branch: repo.default_branch,
66 language: repo.language,
67 updated_at: repo.updated_at || '',
68 html_url: repo.html_url,
69 })));
70
71 if (response.data.length < 100) break;
72 page++;
73 }
74
75 return repos;
76 }
77
78 export async function listRepoBranches(
79 octokit: Octokit,
80 owner: string,
81 repo: string
82 ): Promise<Branch[]> {
83 const response = await octokit.rest.repos.listBranches({
84 owner,
85 repo,
86 per_page: 100,
87 });
88
89 return response.data.map(branch => ({
90 name: branch.name,
91 protected: branch.protected,
92 commit: { sha: branch.commit.sha },
93 }));
94 }
95
96 export async function getRepoDefaultBranch(
97 octokit: Octokit,
98 owner: string,
99 repo: string
100 ): Promise<string> {
101 const response = await octokit.rest.repos.get({ owner, repo });
102 return response.data.default_branch;
103 }
104
105 export function generatePRUrl(
106 repoFullName: string,
107 baseBranch: string,
108 headBranch: string,
109 title: string,
110 body: string
111 ): string {
112 const encodedTitle = encodeURIComponent(title);
113 const encodedBody = encodeURIComponent(body);
114 return `https://github.com/${repoFullName}/compare/${baseBranch}...${headBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
115 }
116
117 export async function createPullRequest(
118 octokit: Octokit,
119 repoFullName: string,
120 baseBranch: string,
121 headBranch: string,
122 title: string,
123 body: string
124 ): Promise<{ number: number; url: string }> {
125 const [owner, repo] = repoFullName.split('/');
126
127 const response = await octokit.rest.pulls.create({
128 owner,
129 repo,
130 title,
131 body,
132 head: headBranch,
133 base: baseBranch,
134 });
135
136 return {
137 number: response.data.number,
138 url: response.data.html_url,
139 };
140 }
141
142 export async function getPullRequestStatus(
143 octokit: Octokit,
144 repoFullName: string,
145 prNumber: number
146 ): Promise<{ state: string; merged: boolean; mergeable: boolean | null }> {
147 const [owner, repo] = repoFullName.split('/');
148
149 const response = await octokit.rest.pulls.get({
150 owner,
151 repo,
152 pull_number: prNumber,
153 });
154
155 return {
156 state: response.data.state,
157 merged: response.data.merged,
158 mergeable: response.data.mergeable,
159 };
160 }
src/utils/supabase/server.ts +1 −1
@@ -3,7 +3,7 @@ import { createServerClient, type CookieOptions } from "@supabase/ssr";
33 import { cookies } from "next/headers";
44
55 export async function createClient<Database>() {
6 const cookieStore = cookies();
6 const cookieStore = await cookies();
77
88 // Create a server's supabase client with newly configured cookie,
99 // which could be used to maintain user's session
supabase/config.toml +4
@@ -27,6 +27,10 @@ shadow_port = 54320
2727 # server_version;` on the remote database to check.
2828 major_version = 15
2929
30 [db.migrations]
31 # Declarative schema - migrations are generated from this file
32 schema_paths = ["./schema.sql"]
33
3034 [db.pooler]
3135 enabled = false
3236 # Port to use for the local connection pooler.
supabase/migrations/20251201002129_init.sql added +594
@@ -0,0 +1,594 @@
1
2 create table "public"."conversations" (
3 "id" uuid not null default extensions.uuid_generate_v4(),
4 "user_id" uuid not null,
5 "repo_full_name" text not null,
6 "base_branch" text not null,
7 "work_branch" text not null,
8 "title" text,
9 "status" text not null default 'active'::text,
10 "created_at" timestamp with time zone default now(),
11 "updated_at" timestamp with time zone default now()
12 );
13
14
15 alter table "public"."conversations" enable row level security;
16
17
18 create table "public"."environment_variables" (
19 "id" uuid not null default extensions.uuid_generate_v4(),
20 "conversation_id" uuid not null,
21 "key_encrypted" text not null,
22 "value_encrypted" text not null,
23 "created_at" timestamp with time zone default now(),
24 "updated_at" timestamp with time zone default now()
25 );
26
27
28 alter table "public"."environment_variables" enable row level security;
29
30
31 create table "public"."github_tokens" (
32 "id" uuid not null default extensions.uuid_generate_v4(),
33 "user_id" uuid not null,
34 "access_token_encrypted" text not null,
35 "refresh_token_encrypted" text,
36 "scope" text not null default 'public_repo'::text,
37 "github_username" text,
38 "github_avatar_url" text,
39 "expires_at" timestamp with time zone,
40 "created_at" timestamp with time zone default now(),
41 "updated_at" timestamp with time zone default now()
42 );
43
44
45 alter table "public"."github_tokens" enable row level security;
46
47
48 create table "public"."messages" (
49 "id" uuid not null default extensions.uuid_generate_v4(),
50 "conversation_id" uuid not null,
51 "role" text not null,
52 "content" text not null,
53 "tool_calls" jsonb default '[]'::jsonb,
54 "files_changed" jsonb default '[]'::jsonb,
55 "commit_sha" text,
56 "commit_message" text,
57 "tokens_used" integer default 0,
58 "created_at" timestamp with time zone default now()
59 );
60
61
62 alter table "public"."messages" enable row level security;
63
64
65 create table "public"."pr_metadata" (
66 "id" uuid not null default extensions.uuid_generate_v4(),
67 "conversation_id" uuid not null,
68 "pr_number" integer,
69 "pr_url" text,
70 "title" text not null,
71 "body" text,
72 "status" text not null default 'draft'::text,
73 "commits_pushed" integer default 0,
74 "head_sha" text,
75 "created_at" timestamp with time zone default now(),
76 "updated_at" timestamp with time zone default now()
77 );
78
79
80 alter table "public"."pr_metadata" enable row level security;
81
82 CREATE UNIQUE INDEX conversations_pkey ON public.conversations USING btree (id);
83
84 CREATE UNIQUE INDEX environment_variables_pkey ON public.environment_variables USING btree (id);
85
86 CREATE UNIQUE INDEX github_tokens_pkey ON public.github_tokens USING btree (id);
87
88 CREATE UNIQUE INDEX github_tokens_user_id_key ON public.github_tokens USING btree (user_id);
89
90 CREATE INDEX idx_conversations_status ON public.conversations USING btree (status);
91
92 CREATE INDEX idx_conversations_user_id ON public.conversations USING btree (user_id);
93
94 CREATE INDEX idx_environment_variables_conversation_id ON public.environment_variables USING btree (conversation_id);
95
96 CREATE INDEX idx_github_tokens_user_id ON public.github_tokens USING btree (user_id);
97
98 CREATE INDEX idx_messages_conversation_id ON public.messages USING btree (conversation_id);
99
100 CREATE INDEX idx_messages_created_at ON public.messages USING btree (created_at);
101
102 CREATE UNIQUE INDEX messages_pkey ON public.messages USING btree (id);
103
104 CREATE UNIQUE INDEX pr_metadata_conversation_id_key ON public.pr_metadata USING btree (conversation_id);
105
106 CREATE UNIQUE INDEX pr_metadata_pkey ON public.pr_metadata USING btree (id);
107
108 alter table "public"."conversations" add constraint "conversations_pkey" PRIMARY KEY using index "conversations_pkey";
109
110 alter table "public"."environment_variables" add constraint "environment_variables_pkey" PRIMARY KEY using index "environment_variables_pkey";
111
112 alter table "public"."github_tokens" add constraint "github_tokens_pkey" PRIMARY KEY using index "github_tokens_pkey";
113
114 alter table "public"."messages" add constraint "messages_pkey" PRIMARY KEY using index "messages_pkey";
115
116 alter table "public"."pr_metadata" add constraint "pr_metadata_pkey" PRIMARY KEY using index "pr_metadata_pkey";
117
118 alter table "public"."conversations" add constraint "conversations_status_check" CHECK ((status = ANY (ARRAY['active'::text, 'merged'::text, 'closed'::text, 'abandoned'::text]))) not valid;
119
120 alter table "public"."conversations" validate constraint "conversations_status_check";
121
122 alter table "public"."conversations" add constraint "conversations_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE not valid;
123
124 alter table "public"."conversations" validate constraint "conversations_user_id_fkey";
125
126 alter table "public"."environment_variables" add constraint "environment_variables_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE not valid;
127
128 alter table "public"."environment_variables" validate constraint "environment_variables_conversation_id_fkey";
129
130 alter table "public"."github_tokens" add constraint "github_tokens_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE not valid;
131
132 alter table "public"."github_tokens" validate constraint "github_tokens_user_id_fkey";
133
134 alter table "public"."github_tokens" add constraint "github_tokens_user_id_key" UNIQUE using index "github_tokens_user_id_key";
135
136 alter table "public"."messages" add constraint "messages_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE not valid;
137
138 alter table "public"."messages" validate constraint "messages_conversation_id_fkey";
139
140 alter table "public"."messages" add constraint "messages_role_check" CHECK ((role = ANY (ARRAY['user'::text, 'assistant'::text, 'system'::text]))) not valid;
141
142 alter table "public"."messages" validate constraint "messages_role_check";
143
144 alter table "public"."pr_metadata" add constraint "pr_metadata_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON DELETE CASCADE not valid;
145
146 alter table "public"."pr_metadata" validate constraint "pr_metadata_conversation_id_fkey";
147
148 alter table "public"."pr_metadata" add constraint "pr_metadata_conversation_id_key" UNIQUE using index "pr_metadata_conversation_id_key";
149
150 alter table "public"."pr_metadata" add constraint "pr_metadata_status_check" CHECK ((status = ANY (ARRAY['draft'::text, 'open'::text, 'merged'::text, 'closed'::text]))) not valid;
151
152 alter table "public"."pr_metadata" validate constraint "pr_metadata_status_check";
153
154 set check_function_bodies = off;
155
156 CREATE OR REPLACE FUNCTION public.update_updated_at_column()
157 RETURNS trigger
158 LANGUAGE plpgsql
159 AS $function$
160 begin
161 new.updated_at = now();
162 return new;
163 end;
164 $function$
165 ;
166
167 create or replace view "public"."user_stats" as SELECT c.user_id,
168 count(DISTINCT c.id) AS total_conversations,
169 count(DISTINCT
170 CASE
171 WHEN (c.status = 'merged'::text) THEN c.id
172 ELSE NULL::uuid
173 END) AS merged_conversations,
174 count(DISTINCT
175 CASE
176 WHEN (c.status = 'closed'::text) THEN c.id
177 ELSE NULL::uuid
178 END) AS closed_conversations,
179 count(DISTINCT
180 CASE
181 WHEN (c.status = 'active'::text) THEN c.id
182 ELSE NULL::uuid
183 END) AS active_conversations,
184 count(DISTINCT pr.id) FILTER (WHERE (pr.status = 'open'::text)) AS open_prs,
185 count(DISTINCT pr.id) FILTER (WHERE (pr.status = 'merged'::text)) AS merged_prs,
186 round((((count(DISTINCT
187 CASE
188 WHEN (c.status = 'merged'::text) THEN c.id
189 ELSE NULL::uuid
190 END))::numeric / (NULLIF(count(DISTINCT
191 CASE
192 WHEN (c.status = ANY (ARRAY['merged'::text, 'closed'::text])) THEN c.id
193 ELSE NULL::uuid
194 END), 0))::numeric) * (100)::numeric), 1) AS merge_rate_percent,
195 COALESCE(sum(pr.commits_pushed), (0)::bigint) AS total_commits_pushed,
196 count(m.id) AS total_messages,
197 round(((count(m.id))::numeric / (NULLIF(count(DISTINCT c.id), 0))::numeric), 1) AS avg_messages_per_conversation,
198 COALESCE(sum(m.tokens_used), (0)::bigint) AS total_tokens_used
199 FROM ((public.conversations c
200 LEFT JOIN public.messages m ON ((m.conversation_id = c.id)))
201 LEFT JOIN public.pr_metadata pr ON ((pr.conversation_id = c.id)))
202 GROUP BY c.user_id;
203
204
205 grant delete on table "public"."conversations" to "anon";
206
207 grant insert on table "public"."conversations" to "anon";
208
209 grant references on table "public"."conversations" to "anon";
210
211 grant select on table "public"."conversations" to "anon";
212
213 grant trigger on table "public"."conversations" to "anon";
214
215 grant truncate on table "public"."conversations" to "anon";
216
217 grant update on table "public"."conversations" to "anon";
218
219 grant delete on table "public"."conversations" to "authenticated";
220
221 grant insert on table "public"."conversations" to "authenticated";
222
223 grant references on table "public"."conversations" to "authenticated";
224
225 grant select on table "public"."conversations" to "authenticated";
226
227 grant trigger on table "public"."conversations" to "authenticated";
228
229 grant truncate on table "public"."conversations" to "authenticated";
230
231 grant update on table "public"."conversations" to "authenticated";
232
233 grant delete on table "public"."conversations" to "service_role";
234
235 grant insert on table "public"."conversations" to "service_role";
236
237 grant references on table "public"."conversations" to "service_role";
238
239 grant select on table "public"."conversations" to "service_role";
240
241 grant trigger on table "public"."conversations" to "service_role";
242
243 grant truncate on table "public"."conversations" to "service_role";
244
245 grant update on table "public"."conversations" to "service_role";
246
247 grant delete on table "public"."environment_variables" to "anon";
248
249 grant insert on table "public"."environment_variables" to "anon";
250
251 grant references on table "public"."environment_variables" to "anon";
252
253 grant select on table "public"."environment_variables" to "anon";
254
255 grant trigger on table "public"."environment_variables" to "anon";
256
257 grant truncate on table "public"."environment_variables" to "anon";
258
259 grant update on table "public"."environment_variables" to "anon";
260
261 grant delete on table "public"."environment_variables" to "authenticated";
262
263 grant insert on table "public"."environment_variables" to "authenticated";
264
265 grant references on table "public"."environment_variables" to "authenticated";
266
267 grant select on table "public"."environment_variables" to "authenticated";
268
269 grant trigger on table "public"."environment_variables" to "authenticated";
270
271 grant truncate on table "public"."environment_variables" to "authenticated";
272
273 grant update on table "public"."environment_variables" to "authenticated";
274
275 grant delete on table "public"."environment_variables" to "service_role";
276
277 grant insert on table "public"."environment_variables" to "service_role";
278
279 grant references on table "public"."environment_variables" to "service_role";
280
281 grant select on table "public"."environment_variables" to "service_role";
282
283 grant trigger on table "public"."environment_variables" to "service_role";
284
285 grant truncate on table "public"."environment_variables" to "service_role";
286
287 grant update on table "public"."environment_variables" to "service_role";
288
289 grant delete on table "public"."github_tokens" to "anon";
290
291 grant insert on table "public"."github_tokens" to "anon";
292
293 grant references on table "public"."github_tokens" to "anon";
294
295 grant select on table "public"."github_tokens" to "anon";
296
297 grant trigger on table "public"."github_tokens" to "anon";
298
299 grant truncate on table "public"."github_tokens" to "anon";
300
301 grant update on table "public"."github_tokens" to "anon";
302
303 grant delete on table "public"."github_tokens" to "authenticated";
304
305 grant insert on table "public"."github_tokens" to "authenticated";
306
307 grant references on table "public"."github_tokens" to "authenticated";
308
309 grant select on table "public"."github_tokens" to "authenticated";
310
311 grant trigger on table "public"."github_tokens" to "authenticated";
312
313 grant truncate on table "public"."github_tokens" to "authenticated";
314
315 grant update on table "public"."github_tokens" to "authenticated";
316
317 grant delete on table "public"."github_tokens" to "service_role";
318
319 grant insert on table "public"."github_tokens" to "service_role";
320
321 grant references on table "public"."github_tokens" to "service_role";
322
323 grant select on table "public"."github_tokens" to "service_role";
324
325 grant trigger on table "public"."github_tokens" to "service_role";
326
327 grant truncate on table "public"."github_tokens" to "service_role";
328
329 grant update on table "public"."github_tokens" to "service_role";
330
331 grant delete on table "public"."messages" to "anon";
332
333 grant insert on table "public"."messages" to "anon";
334
335 grant references on table "public"."messages" to "anon";
336
337 grant select on table "public"."messages" to "anon";
338
339 grant trigger on table "public"."messages" to "anon";
340
341 grant truncate on table "public"."messages" to "anon";
342
343 grant update on table "public"."messages" to "anon";
344
345 grant delete on table "public"."messages" to "authenticated";
346
347 grant insert on table "public"."messages" to "authenticated";
348
349 grant references on table "public"."messages" to "authenticated";
350
351 grant select on table "public"."messages" to "authenticated";
352
353 grant trigger on table "public"."messages" to "authenticated";
354
355 grant truncate on table "public"."messages" to "authenticated";
356
357 grant update on table "public"."messages" to "authenticated";
358
359 grant delete on table "public"."messages" to "service_role";
360
361 grant insert on table "public"."messages" to "service_role";
362
363 grant references on table "public"."messages" to "service_role";
364
365 grant select on table "public"."messages" to "service_role";
366
367 grant trigger on table "public"."messages" to "service_role";
368
369 grant truncate on table "public"."messages" to "service_role";
370
371 grant update on table "public"."messages" to "service_role";
372
373 grant delete on table "public"."pr_metadata" to "anon";
374
375 grant insert on table "public"."pr_metadata" to "anon";
376
377 grant references on table "public"."pr_metadata" to "anon";
378
379 grant select on table "public"."pr_metadata" to "anon";
380
381 grant trigger on table "public"."pr_metadata" to "anon";
382
383 grant truncate on table "public"."pr_metadata" to "anon";
384
385 grant update on table "public"."pr_metadata" to "anon";
386
387 grant delete on table "public"."pr_metadata" to "authenticated";
388
389 grant insert on table "public"."pr_metadata" to "authenticated";
390
391 grant references on table "public"."pr_metadata" to "authenticated";
392
393 grant select on table "public"."pr_metadata" to "authenticated";
394
395 grant trigger on table "public"."pr_metadata" to "authenticated";
396
397 grant truncate on table "public"."pr_metadata" to "authenticated";
398
399 grant update on table "public"."pr_metadata" to "authenticated";
400
401 grant delete on table "public"."pr_metadata" to "service_role";
402
403 grant insert on table "public"."pr_metadata" to "service_role";
404
405 grant references on table "public"."pr_metadata" to "service_role";
406
407 grant select on table "public"."pr_metadata" to "service_role";
408
409 grant trigger on table "public"."pr_metadata" to "service_role";
410
411 grant truncate on table "public"."pr_metadata" to "service_role";
412
413 grant update on table "public"."pr_metadata" to "service_role";
414
415
416 create policy "Users can delete own conversations"
417 on "public"."conversations"
418 as permissive
419 for delete
420 to public
421 using ((auth.uid() = user_id));
422
423
424
425 create policy "Users can insert own conversations"
426 on "public"."conversations"
427 as permissive
428 for insert
429 to public
430 with check ((auth.uid() = user_id));
431
432
433
434 create policy "Users can update own conversations"
435 on "public"."conversations"
436 as permissive
437 for update
438 to public
439 using ((auth.uid() = user_id));
440
441
442
443 create policy "Users can view own conversations"
444 on "public"."conversations"
445 as permissive
446 for select
447 to public
448 using ((auth.uid() = user_id));
449
450
451
452 create policy "Users can delete env vars in own conversations"
453 on "public"."environment_variables"
454 as permissive
455 for delete
456 to public
457 using ((EXISTS ( SELECT 1
458 FROM public.conversations
459 WHERE ((conversations.id = environment_variables.conversation_id) AND (conversations.user_id = auth.uid())))));
460
461
462
463 create policy "Users can insert env vars in own conversations"
464 on "public"."environment_variables"
465 as permissive
466 for insert
467 to public
468 with check ((EXISTS ( SELECT 1
469 FROM public.conversations
470 WHERE ((conversations.id = environment_variables.conversation_id) AND (conversations.user_id = auth.uid())))));
471
472
473
474 create policy "Users can update env vars in own conversations"
475 on "public"."environment_variables"
476 as permissive
477 for update
478 to public
479 using ((EXISTS ( SELECT 1
480 FROM public.conversations
481 WHERE ((conversations.id = environment_variables.conversation_id) AND (conversations.user_id = auth.uid())))));
482
483
484
485 create policy "Users can view env vars in own conversations"
486 on "public"."environment_variables"
487 as permissive
488 for select
489 to public
490 using ((EXISTS ( SELECT 1
491 FROM public.conversations
492 WHERE ((conversations.id = environment_variables.conversation_id) AND (conversations.user_id = auth.uid())))));
493
494
495
496 create policy "Users can delete own github tokens"
497 on "public"."github_tokens"
498 as permissive
499 for delete
500 to public
501 using ((auth.uid() = user_id));
502
503
504
505 create policy "Users can insert own github tokens"
506 on "public"."github_tokens"
507 as permissive
508 for insert
509 to public
510 with check ((auth.uid() = user_id));
511
512
513
514 create policy "Users can update own github tokens"
515 on "public"."github_tokens"
516 as permissive
517 for update
518 to public
519 using ((auth.uid() = user_id));
520
521
522
523 create policy "Users can view own github tokens"
524 on "public"."github_tokens"
525 as permissive
526 for select
527 to public
528 using ((auth.uid() = user_id));
529
530
531
532 create policy "Users can insert messages in own conversations"
533 on "public"."messages"
534 as permissive
535 for insert
536 to public
537 with check ((EXISTS ( SELECT 1
538 FROM public.conversations
539 WHERE ((conversations.id = messages.conversation_id) AND (conversations.user_id = auth.uid())))));
540
541
542
543 create policy "Users can view messages in own conversations"
544 on "public"."messages"
545 as permissive
546 for select
547 to public
548 using ((EXISTS ( SELECT 1
549 FROM public.conversations
550 WHERE ((conversations.id = messages.conversation_id) AND (conversations.user_id = auth.uid())))));
551
552
553
554 create policy "Users can insert pr metadata in own conversations"
555 on "public"."pr_metadata"
556 as permissive
557 for insert
558 to public
559 with check ((EXISTS ( SELECT 1
560 FROM public.conversations
561 WHERE ((conversations.id = pr_metadata.conversation_id) AND (conversations.user_id = auth.uid())))));
562
563
564
565 create policy "Users can update pr metadata in own conversations"
566 on "public"."pr_metadata"
567 as permissive
568 for update
569 to public
570 using ((EXISTS ( SELECT 1
571 FROM public.conversations
572 WHERE ((conversations.id = pr_metadata.conversation_id) AND (conversations.user_id = auth.uid())))));
573
574
575
576 create policy "Users can view pr metadata in own conversations"
577 on "public"."pr_metadata"
578 as permissive
579 for select
580 to public
581 using ((EXISTS ( SELECT 1
582 FROM public.conversations
583 WHERE ((conversations.id = pr_metadata.conversation_id) AND (conversations.user_id = auth.uid())))));
584
585
586 CREATE TRIGGER update_conversations_updated_at BEFORE UPDATE ON public.conversations FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
587
588 CREATE TRIGGER update_environment_variables_updated_at BEFORE UPDATE ON public.environment_variables FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
589
590 CREATE TRIGGER update_github_tokens_updated_at BEFORE UPDATE ON public.github_tokens FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
591
592 CREATE TRIGGER update_pr_metadata_updated_at BEFORE UPDATE ON public.pr_metadata FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
593
594
supabase/schema.sql added +264
@@ -0,0 +1,264 @@
1 -- Repo Contributor Database Schema
2 -- Run this in your Supabase SQL editor
3
4 -- Enable UUID extension
5 create extension if not exists "uuid-ossp";
6
7 -- GitHub tokens table (encrypted)
8 create table if not exists github_tokens (
9 id uuid primary key default uuid_generate_v4(),
10 user_id uuid references auth.users(id) on delete cascade not null,
11 access_token_encrypted text not null,
12 refresh_token_encrypted text,
13 scope text not null default 'public_repo',
14 github_username text,
15 github_avatar_url text,
16 expires_at timestamptz,
17 created_at timestamptz default now(),
18 updated_at timestamptz default now(),
19 unique(user_id)
20 );
21
22 -- Conversations table
23 create table if not exists conversations (
24 id uuid primary key default uuid_generate_v4(),
25 user_id uuid references auth.users(id) on delete cascade not null,
26 repo_full_name text not null, -- e.g., "owner/repo"
27 base_branch text not null,
28 work_branch text not null,
29 title text,
30 status text not null default 'active' check (status in ('active', 'merged', 'closed', 'abandoned')),
31 created_at timestamptz default now(),
32 updated_at timestamptz default now()
33 );
34
35 -- Messages table
36 create table if not exists messages (
37 id uuid primary key default uuid_generate_v4(),
38 conversation_id uuid references conversations(id) on delete cascade not null,
39 role text not null check (role in ('user', 'assistant', 'system')),
40 content text not null,
41 tool_calls jsonb default '[]'::jsonb,
42 files_changed jsonb default '[]'::jsonb,
43 commit_sha text,
44 commit_message text,
45 tokens_used integer default 0,
46 created_at timestamptz default now()
47 );
48
49 -- Environment variables table (encrypted per conversation)
50 create table if not exists environment_variables (
51 id uuid primary key default uuid_generate_v4(),
52 conversation_id uuid references conversations(id) on delete cascade not null,
53 key_encrypted text not null,
54 value_encrypted text not null,
55 created_at timestamptz default now(),
56 updated_at timestamptz default now()
57 );
58
59 -- PR metadata table
60 create table if not exists pr_metadata (
61 id uuid primary key default uuid_generate_v4(),
62 conversation_id uuid references conversations(id) on delete cascade not null unique,
63 pr_number integer,
64 pr_url text,
65 title text not null,
66 body text,
67 status text not null default 'draft' check (status in ('draft', 'open', 'merged', 'closed')),
68 commits_pushed integer default 0,
69 head_sha text,
70 created_at timestamptz default now(),
71 updated_at timestamptz default now()
72 );
73
74 -- Indexes for performance
75 create index if not exists idx_conversations_user_id on conversations(user_id);
76 create index if not exists idx_conversations_status on conversations(status);
77 create index if not exists idx_messages_conversation_id on messages(conversation_id);
78 create index if not exists idx_messages_created_at on messages(created_at);
79 create index if not exists idx_environment_variables_conversation_id on environment_variables(conversation_id);
80 create index if not exists idx_github_tokens_user_id on github_tokens(user_id);
81
82 -- Row Level Security (RLS)
83 alter table github_tokens enable row level security;
84 alter table conversations enable row level security;
85 alter table messages enable row level security;
86 alter table environment_variables enable row level security;
87 alter table pr_metadata enable row level security;
88
89 -- RLS Policies: Users can only access their own data
90 create policy "Users can view own github tokens"
91 on github_tokens for select
92 using (auth.uid() = user_id);
93
94 create policy "Users can insert own github tokens"
95 on github_tokens for insert
96 with check (auth.uid() = user_id);
97
98 create policy "Users can update own github tokens"
99 on github_tokens for update
100 using (auth.uid() = user_id);
101
102 create policy "Users can delete own github tokens"
103 on github_tokens for delete
104 using (auth.uid() = user_id);
105
106 create policy "Users can view own conversations"
107 on conversations for select
108 using (auth.uid() = user_id);
109
110 create policy "Users can insert own conversations"
111 on conversations for insert
112 with check (auth.uid() = user_id);
113
114 create policy "Users can update own conversations"
115 on conversations for update
116 using (auth.uid() = user_id);
117
118 create policy "Users can delete own conversations"
119 on conversations for delete
120 using (auth.uid() = user_id);
121
122 create policy "Users can view messages in own conversations"
123 on messages for select
124 using (
125 exists (
126 select 1 from conversations
127 where conversations.id = messages.conversation_id
128 and conversations.user_id = auth.uid()
129 )
130 );
131
132 create policy "Users can insert messages in own conversations"
133 on messages for insert
134 with check (
135 exists (
136 select 1 from conversations
137 where conversations.id = messages.conversation_id
138 and conversations.user_id = auth.uid()
139 )
140 );
141
142 create policy "Users can view env vars in own conversations"
143 on environment_variables for select
144 using (
145 exists (
146 select 1 from conversations
147 where conversations.id = environment_variables.conversation_id
148 and conversations.user_id = auth.uid()
149 )
150 );
151
152 create policy "Users can insert env vars in own conversations"
153 on environment_variables for insert
154 with check (
155 exists (
156 select 1 from conversations
157 where conversations.id = environment_variables.conversation_id
158 and conversations.user_id = auth.uid()
159 )
160 );
161
162 create policy "Users can update env vars in own conversations"
163 on environment_variables for update
164 using (
165 exists (
166 select 1 from conversations
167 where conversations.id = environment_variables.conversation_id
168 and conversations.user_id = auth.uid()
169 )
170 );
171
172 create policy "Users can delete env vars in own conversations"
173 on environment_variables for delete
174 using (
175 exists (
176 select 1 from conversations
177 where conversations.id = environment_variables.conversation_id
178 and conversations.user_id = auth.uid()
179 )
180 );
181
182 create policy "Users can view pr metadata in own conversations"
183 on pr_metadata for select
184 using (
185 exists (
186 select 1 from conversations
187 where conversations.id = pr_metadata.conversation_id
188 and conversations.user_id = auth.uid()
189 )
190 );
191
192 create policy "Users can insert pr metadata in own conversations"
193 on pr_metadata for insert
194 with check (
195 exists (
196 select 1 from conversations
197 where conversations.id = pr_metadata.conversation_id
198 and conversations.user_id = auth.uid()
199 )
200 );
201
202 create policy "Users can update pr metadata in own conversations"
203 on pr_metadata for update
204 using (
205 exists (
206 select 1 from conversations
207 where conversations.id = pr_metadata.conversation_id
208 and conversations.user_id = auth.uid()
209 )
210 );
211
212 -- Function to update updated_at timestamp
213 create or replace function update_updated_at_column()
214 returns trigger as $$
215 begin
216 new.updated_at = now();
217 return new;
218 end;
219 $$ language plpgsql;
220
221 -- Triggers for updated_at
222 create trigger update_github_tokens_updated_at
223 before update on github_tokens
224 for each row execute function update_updated_at_column();
225
226 create trigger update_conversations_updated_at
227 before update on conversations
228 for each row execute function update_updated_at_column();
229
230 create trigger update_environment_variables_updated_at
231 before update on environment_variables
232 for each row execute function update_updated_at_column();
233
234 create trigger update_pr_metadata_updated_at
235 before update on pr_metadata
236 for each row execute function update_updated_at_column();
237
238 -- Stats view for analytics
239 create or replace view user_stats as
240 select
241 c.user_id,
242 count(distinct c.id) as total_conversations,
243 count(distinct case when c.status = 'merged' then c.id end) as merged_conversations,
244 count(distinct case when c.status = 'closed' then c.id end) as closed_conversations,
245 count(distinct case when c.status = 'active' then c.id end) as active_conversations,
246 count(distinct pr.id) filter (where pr.status = 'open') as open_prs,
247 count(distinct pr.id) filter (where pr.status = 'merged') as merged_prs,
248 round(
249 count(distinct case when c.status = 'merged' then c.id end)::numeric /
250 nullif(count(distinct case when c.status in ('merged', 'closed') then c.id end), 0) * 100,
251 1
252 ) as merge_rate_percent,
253 coalesce(sum(pr.commits_pushed), 0) as total_commits_pushed,
254 count(m.id) as total_messages,
255 round(
256 count(m.id)::numeric /
257 nullif(count(distinct c.id), 0),
258 1
259 ) as avg_messages_per_conversation,
260 coalesce(sum(m.tokens_used), 0) as total_tokens_used
261 from conversations c
262 left join messages m on m.conversation_id = c.id
263 left join pr_metadata pr on pr.conversation_id = c.id
264 group by c.user_id;
tailwind.config.ts deleted −63
@@ -1,63 +0,0 @@
1 import type { Config } from "tailwindcss";
2
3 const config: Config = {
4 darkMode: ["class"],
5 content: [
6 "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
7 "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
8 "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
9 ],
10 theme: {
11 extend: {
12 colors: {
13 background: 'hsl(var(--background))',
14 foreground: 'hsl(var(--foreground))',
15 card: {
16 DEFAULT: 'hsl(var(--card))',
17 foreground: 'hsl(var(--card-foreground))'
18 },
19 popover: {
20 DEFAULT: 'hsl(var(--popover))',
21 foreground: 'hsl(var(--popover-foreground))'
22 },
23 primary: {
24 DEFAULT: 'hsl(var(--primary))',
25 foreground: 'hsl(var(--primary-foreground))'
26 },
27 secondary: {
28 DEFAULT: 'hsl(var(--secondary))',
29 foreground: 'hsl(var(--secondary-foreground))'
30 },
31 muted: {
32 DEFAULT: 'hsl(var(--muted))',
33 foreground: 'hsl(var(--muted-foreground))'
34 },
35 accent: {
36 DEFAULT: 'hsl(var(--accent))',
37 foreground: 'hsl(var(--accent-foreground))'
38 },
39 destructive: {
40 DEFAULT: 'hsl(var(--destructive))',
41 foreground: 'hsl(var(--destructive-foreground))'
42 },
43 border: 'hsl(var(--border))',
44 input: 'hsl(var(--input))',
45 ring: 'hsl(var(--ring))',
46 chart: {
47 '1': 'hsl(var(--chart-1))',
48 '2': 'hsl(var(--chart-2))',
49 '3': 'hsl(var(--chart-3))',
50 '4': 'hsl(var(--chart-4))',
51 '5': 'hsl(var(--chart-5))'
52 }
53 },
54 borderRadius: {
55 lg: 'var(--radius)',
56 md: 'calc(var(--radius) - 2px)',
57 sm: 'calc(var(--radius) - 4px)'
58 }
59 }
60 },
61 plugins: [require("tailwindcss-animate")],
62 };
63 export default config;
tsconfig.json +3 −2
@@ -14,7 +14,7 @@
1414 "moduleResolution": "bundler",
1515 "resolveJsonModule": true,
1616 "isolatedModules": true,
17 "jsx": "preserve",
17 "jsx": "react-jsx",
1818 "incremental": true,
1919 "plugins": [
2020 {
@@ -32,7 +32,8 @@
3232 "next-env.d.ts",
3333 "**/*.ts",
3434 "**/*.tsx",
35 ".next/types/**/*.ts"
35 ".next/types/**/*.ts",
36 ".next/dev/types/**/*.ts"
3637 ],
3738 "exclude": [
3839 "node_modules"

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs