Public: anyone can read this; the crew can change it.
feat: Add Repo Contributor feature with AI-powered code editing #25
Check · Review · Land · Live — It conflicts with its base.
claude/ai-repo-editor-feature-01QXZ4wtQBo5FM7hGE7XbuXa → main 10 commits
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).
56 files
changed +8922
−311
bun.lockb
Binary file not shown.
package-lock.json +236 −4
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | "name": "trevato.dev", |
| 9 | 9 | "version": "0.1.0", |
| 10 | 10 | "dependencies": { |
| 11 | "@ai-sdk/anthropic": "^2.0.50", | |
| 11 | 12 | "@calcom/embed-react": "^1.5.0", |
| 12 | 13 | "@radix-ui/react-avatar": "^1.1.0", |
| 13 | 14 | "@radix-ui/react-dialog": "^1.1.1", |
| @@ -23,8 +24,10 @@ | ||
| 23 | 24 | "@uiw/react-textarea-code-editor": "^3.0.2", |
| 24 | 25 | "@vercel/analytics": "^1.5.0", |
| 25 | 26 | "@vercel/sandbox": "^1.0.2", |
| 27 | "ai": "^6.0.0-beta.122", | |
| 26 | 28 | "class-variance-authority": "^0.7.0", |
| 27 | 29 | "clsx": "^2.1.1", |
| 30 | "crypto-js": "^4.2.0", | |
| 28 | 31 | "framer-motion": "^11.5.4", |
| 29 | 32 | "lucide-react": "^0.439.0", |
| 30 | 33 | "next": "14.2.9", |
| @@ -40,9 +43,11 @@ | ||
| 40 | 43 | "remark-gfm": "^4.0.0", |
| 41 | 44 | "tailwind-merge": "^2.5.2", |
| 42 | 45 | "tailwindcss-animate": "^1.0.7", |
| 43 | "three": "^0.168.0" | |
| 46 | "three": "^0.168.0", | |
| 47 | "zod": "^4.1.13" | |
| 44 | 48 | }, |
| 45 | 49 | "devDependencies": { |
| 50 | "@types/crypto-js": "^4.2.2", | |
| 46 | 51 | "@types/node": "^20", |
| 47 | 52 | "@types/react": "^18", |
| 48 | 53 | "@types/react-dom": "^18", |
| @@ -54,6 +59,120 @@ | ||
| 54 | 59 | "typescript": "^5" |
| 55 | 60 | } |
| 56 | 61 | }, |
| 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 | }, | |
| 57 | 176 | "node_modules/@alloc/quick-lru": { |
| 58 | 177 | "version": "5.2.0", |
| 59 | 178 | "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", |
| @@ -832,6 +951,15 @@ | ||
| 832 | 951 | "node": ">= 18" |
| 833 | 952 | } |
| 834 | 953 | }, |
| 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 | }, | |
| 835 | 963 | "node_modules/@pkgjs/parseargs": { |
| 836 | 964 | "version": "0.11.0", |
| 837 | 965 | "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", |
| @@ -1648,6 +1776,12 @@ | ||
| 1648 | 1776 | "integrity": "sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==", |
| 1649 | 1777 | "dev": true |
| 1650 | 1778 | }, |
| 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 | }, | |
| 1651 | 1785 | "node_modules/@supabase/auth-js": { |
| 1652 | 1786 | "version": "2.65.0", |
| 1653 | 1787 | "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.65.0.tgz", |
| @@ -1757,6 +1891,13 @@ | ||
| 1757 | 1891 | "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.145.tgz", |
| 1758 | 1892 | "integrity": "sha512-dtByW6WiFk5W5Jfgz1VM+YPA21xMXTuSFoLYIDY0L44jDLLflVPtZkYuu3/YxpGcvjzKFBZLU+GyKjR0HOYtyw==" |
| 1759 | 1893 | }, |
| 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 | }, | |
| 1760 | 1901 | "node_modules/@types/debounce": { |
| 1761 | 1902 | "version": "1.2.4", |
| 1762 | 1903 | "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", |
| @@ -2249,6 +2390,15 @@ | ||
| 2249 | 2390 | "zod": "3.24.4" |
| 2250 | 2391 | } |
| 2251 | 2392 | }, |
| 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 | }, | |
| 2252 | 2402 | "node_modules/@webgpu/types": { |
| 2253 | 2403 | "version": "0.1.44", |
| 2254 | 2404 | "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.44.tgz", |
| @@ -2276,6 +2426,67 @@ | ||
| 2276 | 2426 | "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" |
| 2277 | 2427 | } |
| 2278 | 2428 | }, |
| 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 | }, | |
| 2279 | 2490 | "node_modules/ajv": { |
| 2280 | 2491 | "version": "6.12.6", |
| 2281 | 2492 | "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", |
| @@ -2967,6 +3178,12 @@ | ||
| 2967 | 3178 | "node": ">= 8" |
| 2968 | 3179 | } |
| 2969 | 3180 | }, |
| 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 | }, | |
| 2970 | 3187 | "node_modules/cssesc": { |
| 2971 | 3188 | "version": "3.0.0", |
| 2972 | 3189 | "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", |
| @@ -3891,6 +4108,15 @@ | ||
| 3891 | 4108 | "bare-events": "^2.7.0" |
| 3892 | 4109 | } |
| 3893 | 4110 | }, |
| 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 | }, | |
| 3894 | 4120 | "node_modules/extend": { |
| 3895 | 4121 | "version": "3.0.2", |
| 3896 | 4122 | "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", |
| @@ -5330,6 +5556,12 @@ | ||
| 5330 | 5556 | "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", |
| 5331 | 5557 | "dev": true |
| 5332 | 5558 | }, |
| 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 | }, | |
| 5333 | 5565 | "node_modules/json-schema-traverse": { |
| 5334 | 5566 | "version": "0.4.1", |
| 5335 | 5567 | "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", |
| @@ -9621,9 +9853,9 @@ | ||
| 9621 | 9853 | } |
| 9622 | 9854 | }, |
| 9623 | 9855 | "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==", | |
| 9627 | 9859 | "license": "MIT", |
| 9628 | 9860 | "funding": { |
| 9629 | 9861 | "url": "https://github.com/sponsors/colinhacks" |
package.json +42 −19
| @@ -9,49 +9,72 @@ | ||
| 9 | 9 | "lint": "next lint" |
| 10 | 10 | }, |
| 11 | 11 | "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", | |
| 13 | 15 | "@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", | |
| 15 | 20 | "@radix-ui/react-icons": "^1.3.0", |
| 16 | 21 | "@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", | |
| 18 | 27 | "@radix-ui/react-tabs": "^1.1.0", |
| 19 | 28 | "@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", | |
| 23 | 33 | "@supabase/ssr": "^0.5.1", |
| 34 | "@tailwindcss/postcss": "^4.1.17", | |
| 24 | 35 | "@uiw/react-textarea-code-editor": "^3.0.2", |
| 25 | 36 | "@vercel/analytics": "^1.5.0", |
| 26 | 37 | "@vercel/sandbox": "^1.0.2", |
| 38 | "@xyflow/react": "^12.9.3", | |
| 39 | "ai": "^5.0.104", | |
| 27 | 40 | "class-variance-authority": "^0.7.0", |
| 28 | 41 | "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", | |
| 32 | 50 | "octokit": "^4.0.2", |
| 33 | "react": "^18", | |
| 34 | "react-dom": "^18", | |
| 51 | "react": "19", | |
| 52 | "react-dom": "19", | |
| 35 | 53 | "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", | |
| 39 | 57 | "react-type-animation": "^3.2.0", |
| 40 | 58 | "rehype-raw": "^7.0.0", |
| 41 | 59 | "remark-gfm": "^4.0.0", |
| 60 | "shiki": "^3.17.0", | |
| 61 | "streamdown": "^1.6.9", | |
| 42 | 62 | "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" | |
| 45 | 67 | }, |
| 46 | 68 | "devDependencies": { |
| 69 | "@types/crypto-js": "^4.2.2", | |
| 47 | 70 | "@types/node": "^20", |
| 48 | "@types/react": "^18", | |
| 49 | "@types/react-dom": "^18", | |
| 71 | "@types/react": "19", | |
| 72 | "@types/react-dom": "19", | |
| 50 | 73 | "@types/react-syntax-highlighter": "^15.5.13", |
| 51 | 74 | "eslint": "^8", |
| 52 | 75 | "eslint-config-next": "14.2.9", |
| 53 | 76 | "postcss": "^8", |
| 54 | "tailwindcss": "^3.4.1", | |
| 77 | "tailwindcss": "4", | |
| 55 | 78 | "typescript": "^5" |
| 56 | 79 | } |
| 57 | 80 | } |
postcss.config.mjs +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** @type {import('postcss-load-config').Config} */ |
| 2 | 2 | const config = { |
| 3 | 3 | plugins: { |
| 4 | tailwindcss: {}, | |
| 4 | '@tailwindcss/postcss': {}, | |
| 5 | 5 | }, |
| 6 | 6 | }; |
| 7 | 7 |
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 | : 222.2 84% 4.9%; | |
| 8 | : 210 40% 98%; | |
| 9 | ||
| 10 | : 222.2 84% 4.9%; | |
| 11 | : 210 40% 98%; | |
| 12 | ||
| 13 | : 222.2 84% 4.9%; | |
| 14 | : 210 40% 98%; | |
| 15 | ||
| 16 | : 217.2 91.2% 59.8%; | |
| 17 | : 222.2 47.4% 11.2%; | |
| 18 | ||
| 19 | : 217.2 32.6% 17.5%; | |
| 20 | : 210 40% 98%; | |
| 21 | ||
| 22 | : 217.2 32.6% 17.5%; | |
| 23 | : 215 20.2% 65.1%; | |
| 24 | ||
| 25 | : 217.2 32.6% 17.5%; | |
| 26 | : 210 40% 98%; | |
| 27 | ||
| 28 | : 0 62.8% 30.6%; | |
| 29 | : 210 40% 98%; | |
| 30 | ||
| 31 | : 217.2 32.6% 17.5%; | |
| 32 | : 217.2 32.6% 17.5%; | |
| 33 | : 224.3 76.3% 48%; | |
| 34 | } | |
| 1 | @import "tailwindcss"; | |
| 2 | ||
| 3 | @theme { | |
| 4 | : hsl(222.2 84% 4.9%); | |
| 5 | : hsl(210 40% 98%); | |
| 6 | ||
| 7 | : hsl(222.2 84% 4.9%); | |
| 8 | : hsl(210 40% 98%); | |
| 9 | ||
| 10 | : hsl(222.2 84% 4.9%); | |
| 11 | : hsl(210 40% 98%); | |
| 12 | ||
| 13 | : hsl(217.2 91.2% 59.8%); | |
| 14 | : hsl(222.2 47.4% 11.2%); | |
| 15 | ||
| 16 | : hsl(217.2 32.6% 17.5%); | |
| 17 | : hsl(210 40% 98%); | |
| 18 | ||
| 19 | : hsl(217.2 32.6% 17.5%); | |
| 20 | : hsl(215 20.2% 65.1%); | |
| 21 | ||
| 22 | : hsl(217.2 32.6% 17.5%); | |
| 23 | : hsl(210 40% 98%); | |
| 24 | ||
| 25 | : hsl(0 62.8% 30.6%); | |
| 26 | : hsl(210 40% 98%); | |
| 27 | ||
| 28 | : hsl(217.2 32.6% 17.5%); | |
| 29 | : hsl(217.2 32.6% 17.5%); | |
| 30 | : hsl(224.3 76.3% 48%); | |
| 31 | ||
| 32 | : 0.5rem; | |
| 33 | : calc(0.5rem - 2px); | |
| 34 | : calc(0.5rem - 4px); | |
| 35 | 35 | } |
| 36 | ||
| 36 | ||
| 37 | 37 | @layer base { |
| 38 | 38 | * { |
| 39 | 39 | @apply border-border; |
| @@ -100,4 +100,4 @@ body { | ||
| 100 | 100 | |
| 101 | 101 | .animate-glow { |
| 102 | 102 | 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 | ||
| 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'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't hesitate to send multiple messages to | |
| 400 | refine the changes until they'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/GitHubProject.tsx +3 −5
| @@ -78,20 +78,18 @@ export const GitHubProject: React.FC<GitHubProjectProps> = ({ | ||
| 78 | 78 | remarkPlugins={[remarkGfm]} |
| 79 | 79 | rehypePlugins={[rehypeRaw]} |
| 80 | 80 | components={{ |
| 81 | code({ node, className, children, ...props }) { | |
| 81 | code({ className, children }) { | |
| 82 | 82 | const match = /language-(\w+)/.exec(className || '') |
| 83 | 83 | return match ? ( |
| 84 | // @ts-expect-error syntax highlight is fine | |
| 85 | 84 | <SyntaxHighlighter |
| 86 | style={vscDarkPlus as any} | |
| 85 | style={vscDarkPlus} | |
| 87 | 86 | language={match[1]} |
| 88 | 87 | PreTag="div" |
| 89 | {...props} | |
| 90 | 88 | > |
| 91 | 89 | {String(children).replace(/\n$/, '')} |
| 92 | 90 | </SyntaxHighlighter> |
| 93 | 91 | ) : ( |
| 94 | <code className={className} {...props}> | |
| 92 | <code className={className}> | |
| 95 | 93 | {children} |
| 96 | 94 | </code> |
| 97 | 95 | ) |
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 | ||
| 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"></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 @@ | ||
| 1 | 1 | 'use client' |
| 2 | 2 | |
| 3 | 3 | import React, { Suspense, useRef } from 'react' |
| 4 | import { Canvas, useFrame, useLoader } from '@react-three/fiber' | |
| 4 | import { Canvas, useLoader } from '@react-three/fiber' | |
| 5 | 5 | import { OrbitControls, PerspectiveCamera, Environment } from '@react-three/drei' |
| 6 | 6 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' |
| 7 | 7 | import { Card } from "@/components/ui/card" |
| @@ -15,7 +15,7 @@ const LoadingSpinner: React.FC = () => ( | ||
| 15 | 15 | export const ObjectViewer: React.FC = () => { |
| 16 | 16 | const ChicagoModel = () => { |
| 17 | 17 | const gltf = useLoader(GLTFLoader, '/chicago.glb') |
| 18 | const modelRef = useRef() | |
| 18 | const modelRef = useRef(null) | |
| 19 | 19 | |
| 20 | 20 | return <primitive object={gltf.scene} ref={modelRef} scale={[0.01, 0.01, 0.01]} /> |
| 21 | 21 | } |
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" | ||
| 5 | 5 | import { cn } from "@/lib/utils" |
| 6 | 6 | |
| 7 | 7 | 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", | |
| 9 | 9 | { |
| 10 | 10 | variants: { |
| 11 | 11 | 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", | |
| 14 | 13 | 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", | |
| 16 | 15 | 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", | |
| 18 | 17 | 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", | |
| 21 | 21 | link: "text-primary underline-offset-4 hover:underline", |
| 22 | 22 | }, |
| 23 | 23 | 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", | |
| 28 | 30 | }, |
| 29 | 31 | }, |
| 30 | 32 | defaultVariants: { |
| @@ -34,24 +36,25 @@ const buttonVariants = cva( | ||
| 34 | 36 | } |
| 35 | 37 | ) |
| 36 | 38 | |
| 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 | ) | |
| 41 | 58 | } |
| 42 | 59 | |
| 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 | ||
| 57 | 60 | 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 @@ | ||
| 2 | 2 | |
| 3 | 3 | import * as React from "react" |
| 4 | 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" |
| 5 | import { Cross2Icon } from "@radix-ui/react-icons" | |
| 5 | import { XIcon } from "lucide-react" | |
| 6 | 6 | |
| 7 | 7 | import { cn } from "@/lib/utils" |
| 8 | 8 | |
| 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 | } | |
| 10 | 14 | |
| 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 | } | |
| 12 | 20 | |
| 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 | } | |
| 14 | 26 | |
| 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 | } | |
| 16 | 32 | |
| 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" | |
| 40 | 40 | 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", | |
| 42 | 42 | className |
| 43 | 43 | )} |
| 44 | 44 | {...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 | } | |
| 55 | 48 | |
| 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({ | |
| 57 | 107 | className, |
| 58 | 108 | ...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 | } | |
| 69 | 118 | |
| 70 | const DialogFooter = ({ | |
| 119 | function DialogDescription({ | |
| 71 | 120 | className, |
| 72 | 121 | ...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 | } | |
| 110 | 131 | |
| 111 | 132 | export { |
| 112 | 133 | Dialog, |
| 113 | DialogPortal, | |
| 114 | DialogOverlay, | |
| 115 | DialogTrigger, | |
| 116 | 134 | DialogClose, |
| 117 | 135 | DialogContent, |
| 118 | DialogHeader, | |
| 119 | DialogFooter, | |
| 120 | DialogTitle, | |
| 121 | 136 | DialogDescription, |
| 137 | DialogFooter, | |
| 138 | DialogHeader, | |
| 139 | DialogOverlay, | |
| 140 | DialogPortal, | |
| 141 | DialogTitle, | |
| 142 | DialogTrigger, | |
| 122 | 143 | } |
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" | ||
| 2 | 2 | |
| 3 | 3 | import { cn } from "@/lib/utils" |
| 4 | 4 | |
| 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 | } | |
| 24 | 20 | |
| 25 | 21 | 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" | ||
| 2 | 2 | |
| 3 | 3 | import { cn } from "@/lib/utils" |
| 4 | 4 | |
| 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 | } | |
| 23 | 17 | |
| 24 | 18 | export { Textarea } |
src/components/ui/tooltip.tsx +49 −18
| @@ -5,26 +5,57 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip" | ||
| 5 | 5 | |
| 6 | 6 | import { cn } from "@/lib/utils" |
| 7 | 7 | |
| 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 | } | |
| 9 | 20 | |
| 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 | } | |
| 11 | 30 | |
| 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 | } | |
| 13 | 36 | |
| 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 | } | |
| 29 | 60 | |
| 30 | 61 | 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"; | ||
| 3 | 3 | import { cookies } from "next/headers"; |
| 4 | 4 | |
| 5 | 5 | export async function createClient<Database>() { |
| 6 | const cookieStore = cookies(); | |
| 6 | const cookieStore = await cookies(); | |
| 7 | 7 | |
| 8 | 8 | // Create a server's supabase client with newly configured cookie, |
| 9 | 9 | // which could be used to maintain user's session |
supabase/config.toml +4
| @@ -27,6 +27,10 @@ shadow_port = 54320 | ||
| 27 | 27 | # server_version;` on the remote database to check. |
| 28 | 28 | major_version = 15 |
| 29 | 29 | |
| 30 | [db.migrations] | |
| 31 | # Declarative schema - migrations are generated from this file | |
| 32 | schema_paths = ["./schema.sql"] | |
| 33 | ||
| 30 | 34 | [db.pooler] |
| 31 | 35 | enabled = false |
| 32 | 36 | # 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 @@ | ||
| 14 | 14 | "moduleResolution": "bundler", |
| 15 | 15 | "resolveJsonModule": true, |
| 16 | 16 | "isolatedModules": true, |
| 17 | "jsx": "preserve", | |
| 17 | "jsx": "react-jsx", | |
| 18 | 18 | "incremental": true, |
| 19 | 19 | "plugins": [ |
| 20 | 20 | { |
| @@ -32,7 +32,8 @@ | ||
| 32 | 32 | "next-env.d.ts", |
| 33 | 33 | "**/*.ts", |
| 34 | 34 | "**/*.tsx", |
| 35 | ".next/types/**/*.ts" | |
| 35 | ".next/types/**/*.ts", | |
| 36 | ".next/dev/types/**/*.ts" | |
| 36 | 37 | ], |
| 37 | 38 | "exclude": [ |
| 38 | 39 | "node_modules" |