16 Commits

Author SHA1 Message Date
simos
36f8f50d63 feat(editor): add sidebar mode to CodeEditor component
Add an optional  prop to the CodeEditor component to support
rendering in both modal and sidebar layouts. When enabled, the editor
adapts its container styling and removes the fixed overlay positioning,
allowing it to be embedded inline. This includes conditional rendering
of the loading state and main container to properly display within a
sidebar context while maintaining the existing modal behavior as the
default.
2025-10-31 09:47:30 +00:00
simos
4e14222487 feat(ui): add collapsible sidebar functionality
Implement a collapsible sidebar feature for the desktop view that allows
users to toggle between expanded and collapsed states. The sidebar state
is persisted using localStorage to maintain user preference across
sessions.

Changes include:
- Add sidebarVisible state with localStorage persistence
- Import Sparkles and SettingsIcon from lucide-react
- Implement smooth transition animation (300ms) for sidebar collapse
- Add collapsed sidebar view with icon-only navigation buttons
- Pass onToggleSidebar prop to Sidebar component
- Adjust sidebar width dynamically (80 -> 14 when collapsed)

This improves the user experience by providing more screen real estate
for the main content area when needed, while keeping quick access to
essential navigation through the collapsed icon view.
2025-10-31 09:36:14 +00:00
simos
e2ba000e86 Release 1.10.4 2025-10-31 10:17:44 +01:00
simos
64e2909f0f feat(updates): add system update endpoint and UI
Add automatic update functionality to allow users to update the
application directly from the UI without manual git commands.

Changes:
- Add POST /api/system/update endpoint that runs git pull and npm
  install
- Enhance useVersionCheck hook to fetch release information including
  changelog
- Update VersionUpgradeModal to display changelog and handle one-click
  updates
- Add update progress tracking with output display and error handling
- Bump version to 1.10.4

The update endpoint executes git checkout main, git pull, and npm
install, providing real-time output to the user. After successful
update, users are prompted to restart the server.
2025-10-31 09:15:50 +00:00
simos
6541760eb7 Release 1.10.3 2025-10-31 10:00:05 +01:00
simos
50454175c9 fix(agent): improve branch name and URL parsing
Enhance the robustness of GitHub URL parsing and branch name
generation with better regex patterns and edge case handling.

Changes:
- Update GitHub URL regex to use non-greedy matching and anchored
  .git suffix detection for more precise parsing
- Replace string-based .git removal with regex-based end anchor
- Add comprehensive validation for empty branch names with fallback
- Implement proper length calculation accounting for timestamp suffix
- Add final regex validation to ensure branch names meet safety
  requirements
- Improve edge case handling for hyphens after truncation
- Add deterministic fallback for invalid branch name patterns

These changes prevent potential parsing errors with malformed URLs
and ensure generated branch names always meet Git naming conventions.
2025-10-31 08:59:02 +00:00
simos
d6ceb222c3 Release 1.10.2 2025-10-31 09:48:04 +01:00
simos
9cfb7e659d modified: .gitignore
new file:   release.sh
2025-10-31 09:45:35 +01:00
simos
018b337871 modified: .gitignore
modified:   package.json
2025-10-31 09:42:56 +01:00
simos
0b8b1d0677 Release 1.10.1 2025-10-31 09:40:11 +01:00
simos
2e1e5b463a Release 1.10.0 2025-10-31 09:39:39 +01:00
simos
cafe18961e Merge pull request #220 from siteboon/feature/agent-auto-pr 2025-10-31 09:31:08 +01:00
simos
8f3a97b8b0 feat(agent): add automated branch and PR creation
Added createBranch and createPR options to the agent API endpoint, enabling automatic branch creation and pull request generation after successful agent task completion. Branch names are auto-generated from the agent message, and PR titles/descriptions are auto-generated from commit messages. This streamlines CI/CD workflows by eliminating manual Git operations after agent runs.
2025-10-31 09:29:52 +01:00
viper151
b612035b20 Merge pull request #218 from siteboon/feature/modernize-tool-design 2025-10-31 07:14:11 +01:00
viper151
c4e196692c Merge branch 'main' into feature/modernize-tool-design 2025-10-31 03:01:49 +01:00
simos
da6f35adc9 feat: UI updates to ChatInterface component and global styles. Changing how tools look like 2025-10-31 02:01:08 +00:00
13 changed files with 1521 additions and 328 deletions

45
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.9.1",
"version": "1.10.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
"version": "1.9.1",
"version": "1.10.4",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -18,6 +18,7 @@
"@codemirror/lang-python": "^6.2.1",
"@codemirror/merge": "^6.11.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@octokit/rest": "^22.0.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
@@ -147,7 +148,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -590,7 +590,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -2188,7 +2187,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
"integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@@ -2198,9 +2196,7 @@
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.4.tgz",
"integrity": "sha512-jOT8V1Ba5BdC79sKrRWDdMT5l1R+XNHTPR6CPWzUP2EcfAcvIHZWF0eAbmRcpOOP5gVIwnqNg0C4nvh6Abc3OA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.1",
@@ -2218,7 +2214,6 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.0.tgz",
"integrity": "sha512-hoYicJZaqISMAI3JfaDr1qMNi48OctWuOih1m80bkYow/ayPw6Jj52tqWJ6GEoFTk1gBqfanSoI1iY99Z5+ekQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^14.0.0",
@@ -2232,14 +2227,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/endpoint/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2249,7 +2242,6 @@
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.1.tgz",
"integrity": "sha512-j1nQNU1ZxNFx2ZtKmL4sMrs4egy5h65OMDmSbVyuCzjOcwsHq6EaYjOTGXPQxgfiN8dJ4CriYHk6zF050WEULg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/request": "^10.0.2",
@@ -2264,14 +2256,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/graphql/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2281,14 +2271,12 @@
"version": "26.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz",
"integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-13.1.1.tgz",
"integrity": "sha512-q9iQGlZlxAVNRN2jDNskJW/Cafy7/XE52wjZ5TTvyhyOD904Cvx//DNyoO3J/MXJ0ve3rPoNWKEg5iZrisQSuw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^14.1.0"
@@ -2304,14 +2292,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2321,7 +2307,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
"integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@@ -2334,7 +2319,6 @@
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-16.1.0.tgz",
"integrity": "sha512-nCsyiKoGRnhH5LkH8hJEZb9swpqOcsW+VXv1QoyUNQXJeVODG4+xM6UICEqyqe9XFr6LkL8BIiFCPev8zMDXPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^15.0.0"
@@ -2350,7 +2334,6 @@
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.3.tgz",
"integrity": "sha512-V6jhKokg35vk098iBqp2FBKunk3kMTXlmq+PtbV9Gl3TfskWlebSofU9uunVKhUN7xl+0+i5vt0TGTG8/p/7HA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/endpoint": "^11.0.0",
@@ -2367,7 +2350,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.0.tgz",
"integrity": "sha512-KRA7VTGdVyJlh0cP5Tf94hTiYVVqmt2f3I6mnimmaVz4UG3gQV/k4mDJlJv3X67iX6rmN7gSHCF8ssqeMnmhZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^14.0.0"
@@ -2380,14 +2362,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/request-error/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2397,14 +2377,12 @@
"version": "25.1.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz",
"integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/request/node_modules/@octokit/types": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz",
"integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^25.1.0"
@@ -2414,7 +2392,6 @@
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.0.tgz",
"integrity": "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/core": "^7.0.2",
@@ -2430,7 +2407,6 @@
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.0.tgz",
"integrity": "sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^26.0.0"
@@ -2916,7 +2892,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -3052,8 +3027,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/abbrev": {
"version": "2.0.0",
@@ -3419,7 +3393,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
"integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/better-sqlite3": {
@@ -3548,7 +3521,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -4987,7 +4959,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
"integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
"dev": true,
"funding": [
{
"type": "github",
@@ -8454,7 +8425,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8827,7 +8797,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8840,7 +8809,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -10676,7 +10644,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -10920,7 +10887,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11203,7 +11169,6 @@
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
"dev": true,
"license": "ISC"
},
"node_modules/unpipe": {
@@ -11314,7 +11279,6 @@
"integrity": "sha512-oBXvfSHEOL8jF+R9Am7h59Up07kVVGH1NrFGFoEG6bPDZP3tGpQhvkBpy5x7U6+E6wZCu9OihsWgJqDbQIm8LQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -11408,7 +11372,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
"version": "1.9.1",
"version": "1.10.4",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
@@ -27,7 +27,7 @@
"build": "vite build",
"preview": "vite preview",
"start": "npm run build && npm run server",
"release": "release-it"
"release": "./release.sh"
},
"keywords": [
"claude coode",
@@ -48,6 +48,7 @@
"@codemirror/lang-python": "^6.2.1",
"@codemirror/merge": "^6.11.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@octokit/rest": "^22.0.0",
"@replit/codemirror-minimap": "^0.5.2",
"@tailwindcss/typography": "^0.5.16",
"@uiw/react-codemirror": "^4.23.13",
@@ -75,8 +76,8 @@
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.0",
"react-router-dom": "^6.8.1",
"remark-gfm": "^4.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",

View File

@@ -550,6 +550,24 @@
<td><span class="badge badge-optional">Optional</span></td>
<td>GitHub token for private repos</td>
</tr>
<tr>
<td><code>branchName</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Custom branch name to use. If provided, <code>createBranch</code> is automatically enabled. Branch names are validated against Git naming rules. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>
</tr>
<tr>
<td><code>createBranch</code></td>
<td>boolean</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Create a new branch after successful completion (default: <code>false</code>). Automatically set to <code>true</code> if <code>branchName</code> is provided. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>
</tr>
<tr>
<td><code>createPR</code></td>
<td>boolean</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Create a pull request after successful completion (default: <code>false</code>). PR title and description auto-generated from commit messages. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>
</tr>
</tbody>
</table>
@@ -669,7 +687,15 @@ data: {"type":"done"}</code></pre>
"cacheCreationTokens": 0,
"totalTokens": 200
},
"projectPath": "/path/to/project"
"projectPath": "/path/to/project",
"branch": {
"name": "fix-authentication-bug-abc123",
"url": "https://github.com/user/repo/tree/fix-authentication-bug-abc123"
},
"pullRequest": {
"number": 42,
"url": "https://github.com/user/repo/pull/42"
}
}</code></pre>
</div>
@@ -700,6 +726,9 @@ data: {"type":"done"}</code></pre>
<h3>CI/CD Integration</h3>
<p>Integrate with GitHub Actions or other CI/CD pipelines.</p>
<h3>Create Branch and Pull Request</h3>
<p>Automatically create a new branch and pull request after the agent completes its work. Branch names are auto-generated from the message, and PR title/description are auto-generated from commit messages.</p>
</section>
</div>
@@ -741,6 +770,49 @@ data: {"type":"done"}</code></pre>
"githubToken": "${{ secrets.GITHUB_TOKEN }}"
}'</code></pre>
</div>
<div class="example-block">
<h4>Create Branch and PR</h4>
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
-H "Content-Type: application/json" \
-H "X-API-Key: ck_..." \
-d '{
"githubUrl": "https://github.com/user/repo",
"message": "Fix authentication bug",
"createBranch": true,
"createPR": true,
"stream": false
}'</code></pre>
</div>
<div class="example-block">
<h4>Custom Branch Name</h4>
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
-H "Content-Type: application/json" \
-H "X-API-Key: ck_..." \
-d '{
"githubUrl": "https://github.com/user/repo",
"message": "Add user authentication",
"branchName": "feature/user-auth",
"createPR": true,
"stream": false
}'</code></pre>
</div>
<div class="example-block">
<h4>Branch & PR Response</h4>
<pre><code class="language-json">{
"success": true,
"branch": {
"name": "feature/user-auth",
"url": "https://github.com/user/repo/tree/feature/user-auth"
},
"pullRequest": {
"number": 42,
"url": "https://github.com/user/repo/pull/42"
}
}</code></pre>
</div>
</div>
</div>
</div>

4
release.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/bash
# Load environment variables from .env
export $(grep -v '^#' .env | grep '^GITHUB_TOKEN=' | xargs)
exec npx release-it "$@"

View File

@@ -237,6 +237,71 @@ app.get('/api/config', authenticateToken, (req, res) => {
});
});
// System update endpoint
app.post('/api/system/update', authenticateToken, async (req, res) => {
try {
// Get the project root directory (parent of server directory)
const projectRoot = path.join(__dirname, '..');
console.log('Starting system update from directory:', projectRoot);
// Run the update command
const updateCommand = 'git checkout main && git pull && npm install';
const child = spawn('sh', ['-c', updateCommand], {
cwd: projectRoot,
env: process.env
});
let output = '';
let errorOutput = '';
child.stdout.on('data', (data) => {
const text = data.toString();
output += text;
console.log('Update output:', text);
});
child.stderr.on('data', (data) => {
const text = data.toString();
errorOutput += text;
console.error('Update error:', text);
});
child.on('close', (code) => {
if (code === 0) {
res.json({
success: true,
output: output || 'Update completed successfully',
message: 'Update completed. Please restart the server to apply changes.'
});
} else {
res.status(500).json({
success: false,
error: 'Update command failed',
output: output,
errorOutput: errorOutput
});
}
});
child.on('error', (error) => {
console.error('Update process error:', error);
res.status(500).json({
success: false,
error: error.message
});
});
} catch (error) {
console.error('System update error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();

View File

@@ -8,6 +8,7 @@ import { apiKeysDb, githubTokensDb } from '../database/db.js';
import { addProjectManually } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
import { Octokit } from '@octokit/rest';
const router = express.Router();
@@ -81,6 +82,217 @@ function normalizeGitHubUrl(url) {
return normalized.toLowerCase();
}
/**
* Parse GitHub URL to extract owner and repo
* @param {string} url - GitHub URL (HTTPS or SSH)
* @returns {{owner: string, repo: string}} - Parsed owner and repo
*/
function parseGitHubUrl(url) {
// Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git
// Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git
const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
if (!match) {
throw new Error('Invalid GitHub URL format');
}
return {
owner: match[1],
repo: match[2].replace(/\.git$/, '')
};
}
/**
* Auto-generate a branch name from a message
* @param {string} message - The agent message
* @returns {string} - Generated branch name
*/
function autogenerateBranchName(message) {
// Convert to lowercase, replace spaces/special chars with hyphens
let branchName = message
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
// Ensure non-empty fallback
if (!branchName) {
branchName = 'task';
}
// Generate timestamp suffix (last 6 chars of base36 timestamp)
const timestamp = Date.now().toString(36).slice(-6);
const suffix = `-${timestamp}`;
// Limit length to ensure total length including suffix fits within 50 characters
const maxBaseLength = 50 - suffix.length;
if (branchName.length > maxBaseLength) {
branchName = branchName.substring(0, maxBaseLength);
}
// Remove any trailing hyphen after truncation and ensure no leading hyphen
branchName = branchName.replace(/-$/, '').replace(/^-+/, '');
// If still empty or starts with hyphen after cleanup, use fallback
if (!branchName || branchName.startsWith('-')) {
branchName = 'task';
}
// Combine base name with timestamp suffix
branchName = `${branchName}${suffix}`;
// Final validation: ensure it matches safe pattern
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {
// Fallback to deterministic safe name
return `branch-${timestamp}`;
}
return branchName;
}
/**
* Validate a Git branch name
* @param {string} branchName - Branch name to validate
* @returns {{valid: boolean, error?: string}} - Validation result
*/
function validateBranchName(branchName) {
if (!branchName || branchName.trim() === '') {
return { valid: false, error: 'Branch name cannot be empty' };
}
// Git branch name rules
const invalidPatterns = [
{ pattern: /^\./, message: 'Branch name cannot start with a dot' },
{ pattern: /\.$/, message: 'Branch name cannot end with a dot' },
{ pattern: /\.\./, message: 'Branch name cannot contain consecutive dots (..)' },
{ pattern: /\s/, message: 'Branch name cannot contain spaces' },
{ pattern: /[~^:?*\[\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\' },
{ pattern: /@{/, message: 'Branch name cannot contain @{' },
{ pattern: /\/$/, message: 'Branch name cannot end with a slash' },
{ pattern: /^\//, message: 'Branch name cannot start with a slash' },
{ pattern: /\/\//, message: 'Branch name cannot contain consecutive slashes' },
{ pattern: /\.lock$/, message: 'Branch name cannot end with .lock' }
];
for (const { pattern, message } of invalidPatterns) {
if (pattern.test(branchName)) {
return { valid: false, error: message };
}
}
// Check for ASCII control characters
if (/[\x00-\x1F\x7F]/.test(branchName)) {
return { valid: false, error: 'Branch name cannot contain control characters' };
}
return { valid: true };
}
/**
* Get recent commit messages from a repository
* @param {string} projectPath - Path to the git repository
* @param {number} limit - Number of commits to retrieve (default: 5)
* @returns {Promise<string[]>} - Array of commit messages
*/
async function getCommitMessages(projectPath, limit = 5) {
return new Promise((resolve, reject) => {
const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {
cwd: projectPath,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
gitProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
gitProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
gitProcess.on('close', (code) => {
if (code === 0) {
const messages = stdout.trim().split('\n').filter(msg => msg.length > 0);
resolve(messages);
} else {
reject(new Error(`Failed to get commit messages: ${stderr}`));
}
});
gitProcess.on('error', (error) => {
reject(new Error(`Failed to execute git: ${error.message}`));
});
});
}
/**
* Create a new branch on GitHub using the API
* @param {Octokit} octokit - Octokit instance
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} branchName - Name of the new branch
* @param {string} baseBranch - Base branch to branch from (default: 'main')
* @returns {Promise<void>}
*/
async function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {
try {
// Get the SHA of the base branch
const { data: ref } = await octokit.git.getRef({
owner,
repo,
ref: `heads/${baseBranch}`
});
const baseSha = ref.object.sha;
// Create the new branch
await octokit.git.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: baseSha
});
console.log(`✅ Created branch '${branchName}' on GitHub`);
} catch (error) {
if (error.status === 422 && error.message.includes('Reference already exists')) {
console.log(` Branch '${branchName}' already exists on GitHub`);
} else {
throw error;
}
}
}
/**
* Create a pull request on GitHub
* @param {Octokit} octokit - Octokit instance
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} branchName - Head branch name
* @param {string} title - PR title
* @param {string} body - PR body/description
* @param {string} baseBranch - Base branch (default: 'main')
* @returns {Promise<{number: number, url: string}>} - PR number and URL
*/
async function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {
const { data: pr } = await octokit.pulls.create({
owner,
repo,
title,
head: branchName,
base: baseBranch,
body
});
console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);
return {
number: pr.number,
url: pr.html_url
};
}
/**
* Clone a GitHub repository to a directory
* @param {string} githubUrl - GitHub repository URL
@@ -361,27 +573,243 @@ class ResponseCollector {
/**
* POST /api/agent
*
* Trigger an AI agent (Claude or Cursor) to work on a project
* Trigger an AI agent (Claude or Cursor) to work on a project.
* Supports automatic GitHub branch and pull request creation after successful completion.
*
* Body:
* - githubUrl: string (conditionally required) - GitHub repository URL to clone
* - projectPath: string (conditionally required) - Path to existing project or where to clone
* - message: string (required) - Message to send to the AI agent
* - provider: string (optional) - 'claude' or 'cursor' (default: 'claude')
* - stream: boolean (optional) - Whether to stream responses (default: true)
* - model: string (optional) - Model to use (for Cursor)
* - cleanup: boolean (optional) - Whether to cleanup project after completion (default: true)
* - githubToken: string (optional) - GitHub token for private repos (overrides stored token)
* ================================================================================================
* REQUEST BODY PARAMETERS
* ================================================================================================
*
* Note: Either githubUrl OR projectPath must be provided. If both are provided, githubUrl will be cloned to projectPath.
* @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.
* Supported formats:
* - HTTPS: https://github.com/owner/repo
* - HTTPS with .git: https://github.com/owner/repo.git
* - SSH: git@github.com:owner/repo
* - SSH with .git: git@github.com:owner/repo.git
*
* @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.
* Behavior depends on usage:
* - If used alone: Must point to existing project directory
* - If used with githubUrl: Target location for cloning
* - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/
*
* @param {string} message - (Required) Task description for the AI agent. Used as:
* - Instructions for the agent
* - Source for auto-generated branch names (if createBranch=true and no branchName)
* - Fallback for PR title if no commits are made
*
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
* Default: 'claude'
*
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
* Default: true
* - true: Returns text/event-stream with incremental updates
* - false: Returns complete JSON response after completion
*
* @param {string} model - (Optional) Model identifier for Cursor provider.
* Only applicable when provider='cursor'.
* Examples: 'gpt-4', 'claude-3-opus', etc.
*
* @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.
* Default: true
* Behavior:
* - Only applies when cloning via githubUrl (not for existing projectPath)
* - Deletes cloned repository after 5 seconds
* - Also deletes associated Claude session directory
* - Remote branch and PR remain on GitHub if created
*
* @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.
* Overrides stored token from user settings.
* Required for:
* - Private repositories
* - Branch/PR creation features
* Token must have 'repo' scope for full functionality.
*
* @param {string} branchName - (Optional) Custom name for the Git branch.
* If provided, createBranch is automatically set to true.
* Validation rules (errors returned if violated):
* - Cannot be empty or whitespace only
* - Cannot start or end with dot (.)
* - Cannot contain consecutive dots (..)
* - Cannot contain spaces
* - Cannot contain special characters: ~ ^ : ? * [ \
* - Cannot contain @{
* - Cannot start or end with forward slash (/)
* - Cannot contain consecutive slashes (//)
* - Cannot end with .lock
* - Cannot contain ASCII control characters
* Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'
*
* @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.
* Default: false (or true if branchName is provided)
* Behavior:
* - Creates branch locally and pushes to remote
* - If branch exists locally: Checks out existing branch (no error)
* - If branch exists on remote: Uses existing branch (no error)
* - Branch name: Custom (if branchName provided) or auto-generated from message
* - Requires either githubUrl OR projectPath with GitHub remote
*
* @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.
* Default: false
* Behavior:
* - PR title: First commit message (or fallback to message parameter)
* - PR description: Auto-generated from all commit messages
* - Base branch: Always 'main' (currently hardcoded)
* - If PR already exists: GitHub returns error with details
* - Requires either githubUrl OR projectPath with GitHub remote
*
* ================================================================================================
* PATH HANDLING BEHAVIOR
* ================================================================================================
*
* Scenario 1: Only githubUrl provided
* Input: { githubUrl: "https://github.com/owner/repo" }
* Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/
* Cleanup: Yes (if cleanup=true)
*
* Scenario 2: Only projectPath provided
* Input: { projectPath: "/home/user/my-project" }
* Action: Uses existing project at specified path
* Validation: Path must exist and be accessible
* Cleanup: No (never cleanup existing projects)
*
* Scenario 3: Both githubUrl and projectPath provided
* Input: { githubUrl: "https://github.com/owner/repo", projectPath: "/custom/path" }
* Action: Clones githubUrl to projectPath location
* Validation:
* - If projectPath exists with git repo:
* - Compares remote URL with githubUrl
* - If URLs match: Reuses existing repo
* - If URLs differ: Returns error
* Cleanup: Yes (if cleanup=true)
*
* ================================================================================================
* GITHUB BRANCH/PR CREATION REQUIREMENTS
* ================================================================================================
*
* For createBranch or createPR to work, one of the following must be true:
*
* Option A: githubUrl provided
* - Repository URL directly specified
* - Works with both cloning and existing paths
*
* Option B: projectPath with GitHub remote
* - Project must be a Git repository
* - Must have 'origin' remote configured
* - Remote URL must point to github.com
* - System auto-detects GitHub URL via: git remote get-url origin
*
* Additional Requirements:
* - Valid GitHub token (from settings or githubToken parameter)
* - Token must have 'repo' scope for private repos
* - Project must have commits (for PR creation)
*
* ================================================================================================
* VALIDATION & ERROR HANDLING
* ================================================================================================
*
* Input Validations (400 Bad Request):
* - Either githubUrl OR projectPath must be provided (not neither)
* - message must be non-empty string
* - provider must be 'claude' or 'cursor'
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
* - branchName must pass Git naming rules (if provided)
*
* Runtime Validations (500 Internal Server Error or specific error in response):
* - projectPath must exist (if used alone)
* - GitHub URL format must be valid
* - Git remote URL must include github.com (for projectPath + branch/PR)
* - GitHub token must be available (for private repos and branch/PR)
* - Directory conflicts handled (existing path with different repo)
*
* Branch Name Validation Errors (returned in response, not HTTP error):
* Invalid names return: { branch: { error: "Invalid branch name: <reason>" } }
* Examples:
* - "my branch" → "Branch name cannot contain spaces"
* - ".feature" → "Branch name cannot start with a dot"
* - "feature.lock" → "Branch name cannot end with .lock"
*
* ================================================================================================
* RESPONSE FORMATS
* ================================================================================================
*
* Streaming Response (stream=true):
* Content-Type: text/event-stream
* Events:
* - { type: "status", message: "...", projectPath: "..." }
* - { type: "claude-response", data: {...} }
* - { type: "github-branch", branch: { name: "...", url: "..." } }
* - { type: "github-pr", pullRequest: { number: 42, url: "..." } }
* - { type: "github-error", error: "..." }
* - { type: "done" }
*
* Non-Streaming Response (stream=false):
* Content-Type: application/json
* {
* success: true,
* sessionId: "session-123",
* messages: [...], // Assistant messages only (filtered)
* tokens: {
* inputTokens: 150,
* outputTokens: 50,
* cacheReadTokens: 0,
* cacheCreationTokens: 0,
* totalTokens: 200
* },
* projectPath: "/path/to/project",
* branch: { // Only if createBranch=true
* name: "feature/xyz",
* url: "https://github.com/owner/repo/tree/feature/xyz"
* } | { error: "..." },
* pullRequest: { // Only if createPR=true
* number: 42,
* url: "https://github.com/owner/repo/pull/42"
* } | { error: "..." }
* }
*
* Error Response:
* HTTP Status: 400, 401, 500
* Content-Type: application/json
* { success: false, error: "Error description" }
*
* ================================================================================================
* EXAMPLES
* ================================================================================================
*
* Example 1: Clone and process with auto-cleanup
* POST /api/agent
* { "githubUrl": "https://github.com/user/repo", "message": "Fix bug" }
*
* Example 2: Use existing project with custom branch and PR
* POST /api/agent
* {
* "projectPath": "/home/user/project",
* "message": "Add feature",
* "branchName": "feature/new-feature",
* "createPR": true
* }
*
* Example 3: Clone to specific path with auto-generated branch
* POST /api/agent
* {
* "githubUrl": "https://github.com/user/repo",
* "projectPath": "/tmp/work",
* "message": "Refactor code",
* "createBranch": true,
* "cleanup": false
* }
*/
router.post('/', validateExternalApiKey, async (req, res) => {
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken } = req.body;
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName } = req.body;
// Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
// If branchName is provided, automatically enable createBranch
const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');
const createPR = req.body.createPR === true || req.body.createPR === 'true';
// Validate inputs
if (!githubUrl && !projectPath) {
return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
@@ -395,6 +823,12 @@ router.post('/', validateExternalApiKey, async (req, res) => {
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
}
// Validate GitHub branch/PR creation requirements
// Allow branch/PR creation with projectPath as long as it has a GitHub remote
if ((createBranch || createPR) && !githubUrl && !projectPath) {
return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });
}
let finalProjectPath = null;
let writer = null;
@@ -492,6 +926,187 @@ router.post('/', validateExternalApiKey, async (req, res) => {
}, writer);
}
// Handle GitHub branch and PR creation after successful agent completion
let branchInfo = null;
let prInfo = null;
if (createBranch || createPR) {
try {
console.log('🔄 Starting GitHub branch/PR creation workflow...');
// Get GitHub token
const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
if (!tokenToUse) {
throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');
}
// Initialize Octokit
const octokit = new Octokit({ auth: tokenToUse });
// Get GitHub URL - either from parameter or from git remote
let repoUrl = githubUrl;
if (!repoUrl) {
console.log('🔍 Getting GitHub URL from git remote...');
try {
repoUrl = await getGitRemoteUrl(finalProjectPath);
if (!repoUrl.includes('github.com')) {
throw new Error('Project does not have a GitHub remote configured');
}
console.log(`✅ Found GitHub remote: ${repoUrl}`);
} catch (error) {
throw new Error(`Failed to get GitHub remote URL: ${error.message}`);
}
}
// Parse GitHub URL to get owner and repo
const { owner, repo } = parseGitHubUrl(repoUrl);
console.log(`📦 Repository: ${owner}/${repo}`);
// Use provided branch name or auto-generate from message
const finalBranchName = branchName || autogenerateBranchName(message);
if (branchName) {
console.log(`🌿 Using provided branch name: ${finalBranchName}`);
// Validate custom branch name
const validation = validateBranchName(finalBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
} else {
console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);
}
if (createBranch) {
// Create and checkout the new branch locally
console.log('🔄 Creating local branch...');
const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {
cwd: finalProjectPath,
stdio: 'pipe'
});
await new Promise((resolve, reject) => {
let stderr = '';
checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });
checkoutProcess.on('close', (code) => {
if (code === 0) {
console.log(`✅ Created and checked out local branch '${finalBranchName}'`);
resolve();
} else {
// Branch might already exist locally, try to checkout
if (stderr.includes('already exists')) {
console.log(` Branch '${finalBranchName}' already exists locally, checking out...`);
const checkoutExisting = spawn('git', ['checkout', finalBranchName], {
cwd: finalProjectPath,
stdio: 'pipe'
});
checkoutExisting.on('close', (checkoutCode) => {
if (checkoutCode === 0) {
console.log(`✅ Checked out existing branch '${finalBranchName}'`);
resolve();
} else {
reject(new Error(`Failed to checkout existing branch: ${stderr}`));
}
});
} else {
reject(new Error(`Failed to create branch: ${stderr}`));
}
}
});
});
// Push the branch to remote
console.log('🔄 Pushing branch to remote...');
const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {
cwd: finalProjectPath,
stdio: 'pipe'
});
await new Promise((resolve, reject) => {
let stderr = '';
let stdout = '';
pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });
pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });
pushProcess.on('close', (code) => {
if (code === 0) {
console.log(`✅ Pushed branch '${finalBranchName}' to remote`);
resolve();
} else {
// Check if branch exists on remote but has different commits
if (stderr.includes('already exists') || stderr.includes('up-to-date')) {
console.log(` Branch '${finalBranchName}' already exists on remote, using existing branch`);
resolve();
} else {
reject(new Error(`Failed to push branch: ${stderr}`));
}
}
});
});
branchInfo = {
name: finalBranchName,
url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`
};
}
if (createPR) {
// Get commit messages to generate PR description
console.log('🔄 Generating PR title and description...');
const commitMessages = await getCommitMessages(finalProjectPath, 5);
// Use the first commit message as the PR title, or fallback to the agent message
const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;
// Generate PR body from commit messages
let prBody = '## Changes\n\n';
if (commitMessages.length > 0) {
prBody += commitMessages.map(msg => `- ${msg}`).join('\n');
} else {
prBody += `Agent task: ${message}`;
}
prBody += '\n\n---\n*This pull request was automatically created by Claude Code UI Agent.*';
console.log(`📝 PR Title: ${prTitle}`);
// Create the pull request
console.log('🔄 Creating pull request...');
prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');
}
// Send branch/PR info in response
if (stream) {
if (branchInfo) {
writer.send({
type: 'github-branch',
branch: branchInfo
});
}
if (prInfo) {
writer.send({
type: 'github-pr',
pullRequest: prInfo
});
}
}
} catch (error) {
console.error('❌ GitHub branch/PR creation error:', error);
// Send error but don't fail the entire request
if (stream) {
writer.send({
type: 'github-error',
error: error.message
});
}
// Store error info for non-streaming response
if (!stream) {
branchInfo = { error: error.message };
prInfo = { error: error.message };
}
}
}
// Handle response based on streaming mode
if (stream) {
// Streaming mode: end the SSE stream
@@ -501,13 +1116,23 @@ router.post('/', validateExternalApiKey, async (req, res) => {
const assistantMessages = writer.getAssistantMessages();
const tokenSummary = writer.getTotalTokens();
res.json({
const response = {
success: true,
sessionId: writer.getSessionId(),
messages: assistantMessages,
tokens: tokenSummary,
projectPath: finalProjectPath
});
};
// Add branch/PR info if created
if (branchInfo) {
response.branch = branchInfo;
}
if (prInfo) {
response.pullRequest = prInfo;
}
res.json(response);
}
// Clean up if requested

View File

@@ -20,6 +20,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
import Sidebar from './components/Sidebar';
import MainContent from './components/MainContent';
import MobileNav from './components/MobileNav';
@@ -42,7 +43,7 @@ function AppContent() {
const navigate = useNavigate();
const { sessionId } = useParams();
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false);
const [projects, setProjects] = useState([]);
@@ -60,6 +61,7 @@ function AppContent() {
const [showThinking, setShowThinking] = useLocalStorage('showThinking', true);
const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true);
const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false);
const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true);
// Session Protection System: Track sessions with active conversations to prevent
// automatic project updates from interrupting ongoing chats. When a user sends
// a message, the session is marked as "active" and project updates are paused
@@ -536,8 +538,60 @@ function AppContent() {
// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState('');
const [updateError, setUpdateError] = useState('');
if (!showVersionModal) return null;
// Clean up changelog by removing GitHub-specific metadata
const cleanChangelog = (body) => {
if (!body) return '';
return body
// Remove full commit hashes (40 character hex strings)
.replace(/\b[0-9a-f]{40}\b/gi, '')
// Remove short commit hashes (7-10 character hex strings at start of line or after dash/space)
.replace(/(?:^|\s|-)([0-9a-f]{7,10})\b/gi, '')
// Remove "Full Changelog" links
.replace(/\*\*Full Changelog\*\*:.*$/gim, '')
// Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1)
.replace(/https?:\/\/github\.com\/[^\/]+\/[^\/]+\/compare\/[^\s)]+/gi, '')
// Clean up multiple consecutive empty lines
.replace(/\n\s*\n\s*\n/g, '\n\n')
// Trim whitespace
.trim();
};
const handleUpdateNow = async () => {
setIsUpdating(true);
setUpdateOutput('Starting update...\n');
setUpdateError('');
try {
// Call the backend API to run the update command
const response = await authenticatedFetch('/api/system/update', {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
setUpdateOutput(prev => prev + data.output + '\n');
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
} else {
setUpdateError(data.error || 'Update failed');
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
}
} catch (error) {
setUpdateError(error.message);
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + error.message + '\n');
} finally {
setIsUpdating(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
@@ -546,9 +600,9 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
aria-label="Close version upgrade modal"
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6 space-y-4">
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-2xl mx-4 p-6 space-y-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -559,7 +613,9 @@ function AppContent() {
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">A new version is ready</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{releaseInfo?.title || 'A new version is ready'}
</p>
</div>
</div>
<button
@@ -584,18 +640,57 @@ function AppContent() {
</div>
</div>
{/* Upgrade Instructions */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">How to upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
{/* Changelog */}
{releaseInfo?.body && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">What's New:</h3>
{releaseInfo?.htmlUrl && (
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline flex items-center gap-1"
>
View full release
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600 max-h-64 overflow-y-auto">
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap prose prose-sm dark:prose-invert max-w-none">
{cleanChangelog(releaseInfo.body)}
</div>
</div>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Run this command in your Claude Code UI directory to update to the latest version.
</p>
</div>
)}
{/* Update Output */}
{updateOutput && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Update Progress:</h3>
<div className="bg-gray-900 dark:bg-gray-950 rounded-lg p-4 border border-gray-700 max-h-48 overflow-y-auto">
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap">{updateOutput}</pre>
</div>
</div>
)}
{/* Upgrade Instructions */}
{!isUpdating && !updateOutput && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Manual upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Or click "Update Now" to run the update automatically.
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
@@ -603,18 +698,34 @@ function AppContent() {
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Later
</button>
<button
onClick={() => {
// Copy command to clipboard
navigator.clipboard.writeText('git checkout main && git pull && npm install');
setShowVersionModal(false);
}}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Copy Command
{updateOutput ? 'Close' : 'Later'}
</button>
{!updateOutput && (
<>
<button
onClick={() => {
navigator.clipboard.writeText('git checkout main && git pull && npm install');
}}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Copy Command
</button>
<button
onClick={handleUpdateNow}
disabled={isUpdating}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed rounded-md transition-colors flex items-center justify-center gap-2"
>
{isUpdating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Updating...
</>
) : (
'Update Now'
)}
</button>
</>
)}
</div>
</div>
</div>
@@ -625,27 +736,78 @@ function AppContent() {
<div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */}
{!isMobile && (
<div className="w-80 flex-shrink-0 border-r border-border bg-card">
<div
className={`flex-shrink-0 border-r border-border bg-card transition-all duration-300 ${
sidebarVisible ? 'w-80' : 'w-14'
}`}
>
<div className="h-full overflow-hidden">
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
/>
{sidebarVisible ? (
<Sidebar
projects={projects}
selectedProject={selectedProject}
selectedSession={selectedSession}
onProjectSelect={handleProjectSelect}
onSessionSelect={handleSessionSelect}
onNewSession={handleNewSession}
onSessionDelete={handleSessionDelete}
onProjectDelete={handleProjectDelete}
isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
) : (
/* Collapsed Sidebar */
<div className="h-full flex flex-col items-center py-4 gap-4">
{/* Expand Button */}
<button
onClick={() => setSidebarVisible(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200 group"
aria-label="Show sidebar"
title="Show sidebar"
>
<svg
className="w-5 h-5 text-foreground group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
{/* Settings Icon */}
<button
onClick={() => setShowSettings(true)}
className="p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Settings"
title="Settings"
>
<SettingsIcon className="w-5 h-5 text-muted-foreground hover:text-foreground transition-colors" />
</button>
{/* Update Indicator */}
{updateAvailable && (
<button
onClick={() => setShowVersionModal(true)}
className="relative p-2 hover:bg-accent rounded-md transition-colors duration-200"
aria-label="Update available"
title="Update available"
>
<Sparkles className="w-5 h-5 text-blue-500" />
<span className="absolute top-1 right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</button>
)}
</div>
)}
</div>
</div>
)}
@@ -691,9 +853,11 @@ function AppContent() {
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
releaseInfo={releaseInfo}
onShowVersionModal={() => setShowVersionModal(true)}
isPWA={isPWA}
isMobile={isMobile}
onToggleSidebar={() => setSidebarVisible(false)}
/>
</div>
</div>

View File

@@ -416,21 +416,76 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="w-full">
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-blue-600 rounded flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
(() => {
// Minimize Grep and Glob tools since they happen frequently
const isSearchTool = ['Grep', 'Glob'].includes(message.toolName);
if (isSearchTool) {
return (
<>
<div className="group relative bg-gray-50/50 dark:bg-gray-800/30 border-l-2 border-blue-400 dark:border-blue-500 pl-3 py-2 my-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 flex-1 min-w-0">
<svg className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span className="font-medium flex-shrink-0">{message.toolName}</span>
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0"></span>
{message.toolInput && (() => {
try {
const input = JSON.parse(message.toolInput);
return (
<span className="font-mono truncate flex-1 min-w-0">
{input.pattern && <span>pattern: <span className="text-blue-600 dark:text-blue-400">{input.pattern}</span></span>}
{input.path && <span className="ml-2">in: {input.path}</span>}
</span>
);
} catch (e) {
return null;
}
})()}
</div>
{message.toolResult && (
<a
href={`#tool-result-${message.toolId}`}
className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
>
<span>results</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</a>
)}
</div>
</div>
</>
);
}
// Full display for other tools
return (
<div className="group relative bg-gradient-to-br from-blue-50/50 to-indigo-50/50 dark:from-blue-950/20 dark:to-indigo-950/20 border border-blue-100/30 dark:border-blue-800/30 rounded-lg p-3 mb-2">
{/* Decorative gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/3 to-indigo-500/3 dark:from-blue-400/3 dark:to-indigo-400/3 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="relative flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="relative w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 dark:from-blue-400 dark:to-indigo-500 rounded-lg flex items-center justify-center shadow-lg shadow-blue-500/20 dark:shadow-blue-400/20">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{/* Subtle pulse animation */}
<div className="absolute inset-0 rounded-lg bg-blue-500 dark:bg-blue-400 animate-pulse opacity-20"></div>
</div>
<div className="flex flex-col">
<span className="font-semibold text-gray-900 dark:text-white text-sm">
{message.toolName}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
{message.toolId}
</span>
</div>
<span className="font-medium text-blue-900 dark:text-blue-100">
Using {message.toolName}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono">
{message.toolId}
</span>
</div>
{onShowSettings && (
<button
@@ -438,10 +493,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
e.stopPropagation();
onShowSettings();
}}
className="p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm"
title="Tool Settings"
>
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover/btn:text-blue-600 dark:group-hover/btn:text-blue-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -453,12 +508,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const input = JSON.parse(message.toolInput);
if (input.file_path && input.old_string && input.new_string) {
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<details className="relative mt-3 group/details" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-4 h-4 transition-transform duration-200 group-open/details:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
📝 View edit diff for
<span className="flex items-center gap-2">
<span>View edit diff for</span>
</span>
<button
onClick={async (e) => {
e.preventDefault();
@@ -491,14 +548,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
onFileOpen(input.file_path);
}
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
className="px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm"
>
{input.file_path.split('/').pop()}
</button>
</summary>
<div className="mt-3">
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="mt-3 pl-6">
<div className="bg-white dark:bg-gray-900/50 border border-gray-200/60 dark:border-gray-700/60 rounded-lg overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/80 dark:to-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/60 backdrop-blur-sm">
<button
onClick={async () => {
if (!onFileOpen) return;
@@ -528,11 +585,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
onFileOpen(input.file_path);
}
}}
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
>
{input.file_path}
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium px-2 py-0.5 bg-gray-100 dark:bg-gray-700/50 rounded">
Diff
</span>
</div>
@@ -558,11 +615,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
</div>
{showRawParameters && (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
<details className="relative mt-3 pl-6 group/raw" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
View raw parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
{message.toolInput}
</pre>
</details>
@@ -575,11 +635,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Fall back to raw display if parsing fails
}
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200">
<details className="relative mt-3 group/params" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-4 h-4 transition-transform duration-200 group-open/params:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
View input parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
<pre className="mt-3 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
{message.toolInput}
</pre>
</details>
@@ -602,12 +665,15 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
if (input.file_path && input.content !== undefined) {
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<details className="relative mt-3 group/details" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-4 h-4 transition-transform duration-200 group-open/details:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
📄 Creating new file:
<span className="flex items-center gap-2">
<span className="text-lg leading-none">📄</span>
<span>Creating new file:</span>
</span>
<button
onClick={async (e) => {
e.preventDefault();
@@ -635,14 +701,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
});
}
}}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
className="px-2.5 py-1 rounded-md bg-white/60 dark:bg-gray-800/60 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 font-mono text-xs font-medium transition-all duration-200 shadow-sm"
>
{input.file_path.split('/').pop()}
</button>
</summary>
<div className="mt-3">
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="mt-3 pl-6">
<div className="bg-white dark:bg-gray-900/50 border border-gray-200/60 dark:border-gray-700/60 rounded-lg overflow-hidden shadow-sm">
<div className="flex items-center justify-between px-4 py-2.5 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800/80 dark:to-gray-800/40 border-b border-gray-200/60 dark:border-gray-700/60 backdrop-blur-sm">
<button
onClick={async () => {
if (!onFileOpen) return;
@@ -668,11 +734,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
});
}
}}
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate cursor-pointer font-medium transition-colors"
>
{input.file_path}
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">
New File
</span>
</div>
@@ -698,11 +764,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
</div>
{showRawParameters && (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
<details className="relative mt-3 pl-6 group/raw" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
View raw parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
{message.toolInput}
</pre>
</details>
@@ -722,21 +791,27 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const input = JSON.parse(message.toolInput);
if (input.todos && Array.isArray(input.todos)) {
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<details className="relative mt-3 group/todo" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-4 h-4 transition-transform duration-200 group-open/todo:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Updating Todo List
<span className="flex items-center gap-2">
<span className="text-lg leading-none"></span>
<span>Updating Todo List</span>
</span>
</summary>
<div className="mt-3">
<TodoList todos={input.todos} />
{showRawParameters && (
<details className="mt-3" open={autoExpandTools}>
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
<details className="relative mt-3 group/raw" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-xs font-medium text-gray-600 dark:text-gray-400 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-3 h-3 transition-transform duration-200 group-open/raw:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
View raw parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded overflow-x-auto text-blue-900 dark:text-blue-100">
<pre className="mt-2 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg overflow-x-auto text-gray-700 dark:text-gray-300 font-mono">
{message.toolInput}
</pre>
</details>
@@ -755,42 +830,17 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
try {
const input = JSON.parse(message.toolInput);
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Running command
</summary>
<div className="mt-3 space-y-2">
<div className="bg-gray-900 dark:bg-gray-950 text-gray-100 rounded-lg p-3 font-mono text-sm">
<div className="flex items-center gap-2 mb-2 text-gray-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-xs">Terminal</span>
</div>
<div className="whitespace-pre-wrap break-all text-green-400">
$ {input.command}
</div>
</div>
{input.description && (
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
{input.description}
</div>
)}
{showRawParameters && (
<details className="mt-2">
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
View raw parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
{message.toolInput}
</pre>
</details>
)}
<div className="my-2">
<div className="bg-gray-900 dark:bg-gray-950 rounded-md px-3 py-2 font-mono text-sm">
<span className="text-green-400">$</span>
<span className="text-gray-100 ml-2">{input.command}</span>
</div>
</details>
{input.description && (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 italic ml-1">
{input.description}
</div>
)}
</div>
);
} catch (e) {
// Fall back to regular display
@@ -849,14 +899,14 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
// Regular tool input display for other tools
return (
<details className="mt-2" open={autoExpandTools}>
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<details className="relative mt-3 group/params" open={autoExpandTools}>
<summary className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 p-2.5 rounded-lg hover:bg-white/50 dark:hover:bg-gray-800/50">
<svg className="w-4 h-4 transition-transform duration-200 group-open/params:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
View input parameters
</summary>
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
<pre className="mt-3 text-xs bg-gray-50 dark:bg-gray-800/50 border border-gray-200/60 dark:border-gray-700/60 p-3 rounded-lg whitespace-pre-wrap break-words overflow-hidden text-gray-700 dark:text-gray-300 font-mono">
{message.toolInput}
</pre>
</details>
@@ -864,35 +914,57 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
})()}
{/* Tool Result Section */}
{message.toolResult && (
<div className="mt-3 border-t border-blue-200 dark:border-blue-700 pt-3">
<div className="flex items-center gap-2 mb-2">
<div className={`w-4 h-4 rounded flex items-center justify-center ${
message.toolResult.isError
? 'bg-red-500'
: 'bg-green-500'
{message.toolResult && (() => {
// Hide tool results for Edit/Write/Bash unless there's an error
const shouldHideResult = !message.toolResult.isError &&
(message.toolName === 'Edit' || message.toolName === 'Write' || message.toolName === 'ApplyPatch' || message.toolName === 'Bash');
if (shouldHideResult) {
return null;
}
return (
<div
id={`tool-result-${message.toolId}`}
className={`relative mt-4 p-4 rounded-lg border backdrop-blur-sm scroll-mt-4 ${
message.toolResult.isError
? 'bg-gradient-to-br from-red-50 to-rose-50 dark:from-red-950/20 dark:to-rose-950/20 border-red-200/60 dark:border-red-800/60'
: 'bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200/60 dark:border-green-800/60'
}`}>
{/* Decorative gradient overlay */}
<div className={`absolute inset-0 rounded-lg opacity-50 ${
message.toolResult.isError
? 'bg-gradient-to-br from-red-500/5 to-rose-500/5 dark:from-red-400/5 dark:to-rose-400/5'
: 'bg-gradient-to-br from-green-500/5 to-emerald-500/5 dark:from-green-400/5 dark:to-emerald-400/5'
}`}></div>
<div className="relative flex items-center gap-2.5 mb-3">
<div className={`w-6 h-6 rounded-lg flex items-center justify-center shadow-md ${
message.toolResult.isError
? 'bg-gradient-to-br from-red-500 to-rose-600 dark:from-red-400 dark:to-rose-500 shadow-red-500/20'
: 'bg-gradient-to-br from-green-500 to-emerald-600 dark:from-green-400 dark:to-emerald-500 shadow-green-500/20'
}`}>
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{message.toolResult.isError ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
)}
</svg>
</div>
<span className={`text-sm font-medium ${
message.toolResult.isError
? 'text-red-700 dark:text-red-300'
: 'text-green-700 dark:text-green-300'
<span className={`text-sm font-semibold ${
message.toolResult.isError
? 'text-red-800 dark:text-red-200'
: 'text-green-800 dark:text-green-200'
}`}>
{message.toolResult.isError ? 'Tool Error' : 'Tool Result'}
</span>
</div>
<div className={`text-sm ${
message.toolResult.isError
? 'text-red-800 dark:text-red-200'
: 'text-green-800 dark:text-green-200'
<div className={`relative text-sm ${
message.toolResult.isError
? 'text-red-900 dark:text-red-100'
: 'text-green-900 dark:text-green-100'
}`}>
{(() => {
const content = String(message.toolResult.content || '');
@@ -957,6 +1029,57 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
}
// Special handling for Grep/Glob results with structured data
if ((message.toolName === 'Grep' || message.toolName === 'Glob') && message.toolResult?.toolUseResult) {
const toolData = message.toolResult.toolUseResult;
// Handle files_with_matches mode or any tool result with filenames array
if (toolData.filenames && Array.isArray(toolData.filenames) && toolData.filenames.length > 0) {
return (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="font-medium">
Found {toolData.numFiles || toolData.filenames.length} {(toolData.numFiles === 1 || toolData.filenames.length === 1) ? 'file' : 'files'}
</span>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto">
{toolData.filenames.map((filePath, index) => {
const fileName = filePath.split('/').pop();
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
return (
<div
key={index}
onClick={() => {
if (onFileOpen) {
onFileOpen(filePath);
}
}}
className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-green-100/50 dark:hover:bg-green-800/20 cursor-pointer transition-colors"
>
<svg className="w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm font-medium text-green-800 dark:text-green-200 truncate group-hover:text-green-900 dark:group-hover:text-green-100">
{fileName}
</div>
<div className="font-mono text-xs text-green-600/70 dark:text-green-400/70 truncate">
{dirPath}
</div>
</div>
<svg className="w-4 h-4 text-green-600 dark:text-green-400 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
);
})}
</div>
</div>
);
}
}
// Special handling for interactive prompts
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
const lines = content.split('\n');
@@ -1193,8 +1316,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
})()}
</div>
</div>
)}
);
})()}
</div>
);
})()
) : message.isInteractivePrompt ? (
// Special handling for interactive prompts
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
@@ -1286,21 +1412,31 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
if (input.file_path) {
const filename = input.file_path.split('/').pop();
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read{' '}
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
>
{filename}
</button>
<div className="bg-gray-50/50 dark:bg-gray-800/30 border-l-2 border-gray-400 dark:border-gray-500 pl-3 py-2 my-2">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span className="font-medium">Read</span>
<button
onClick={() => onFileOpen && onFileOpen(input.file_path)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-mono transition-colors"
>
{filename}
</button>
</div>
</div>
);
}
} catch (e) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📖 Read file
<div className="bg-gray-50/50 dark:bg-gray-800/30 border-l-2 border-gray-400 dark:border-gray-500 pl-3 py-2 my-2">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span className="font-medium">Read file</span>
</div>
</div>
);
}
@@ -1312,9 +1448,12 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const input = JSON.parse(message.toolInput);
if (input.todos && Array.isArray(input.todos)) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
📝 Update todo list
<div className="bg-gray-50/50 dark:bg-gray-800/30 border-l-2 border-gray-400 dark:border-gray-500 pl-3 py-2 my-2">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 mb-2">
<svg className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span className="font-medium">Update todo list</span>
</div>
<TodoList todos={input.todos} />
</div>
@@ -1322,16 +1461,26 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
}
} catch (e) {
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📝 Update todo list
<div className="bg-gray-50/50 dark:bg-gray-800/30 border-l-2 border-gray-400 dark:border-gray-500 pl-3 py-2 my-2">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
<span className="font-medium">Update todo list</span>
</div>
</div>
);
}
})()
) : message.isToolUse && message.toolName === 'TodoRead' ? (
// Simple TodoRead tool indicator
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
📋 Read todo list
<div className="bg-gray-50/50 dark:bg-gray-800/30 border-l-2 border-gray-400 dark:border-gray-500 pl-3 py-2 my-2">
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<svg className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<span className="font-medium">Read todo list</span>
</div>
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300">
@@ -2328,7 +2477,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
toolResults.set(part.tool_use_id, {
content: part.content,
isError: part.is_error,
timestamp: new Date(msg.timestamp || Date.now())
timestamp: new Date(msg.timestamp || Date.now()),
// Extract structured tool result data (e.g., for Grep, Glob)
toolUseResult: msg.toolUseResult || null
});
}
}
@@ -2404,7 +2555,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} else if (part.type === 'tool_use') {
// Get the corresponding tool result
const toolResult = toolResults.get(part.id);
converted.push({
type: 'assistant',
content: '',
@@ -2412,7 +2563,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
isToolUse: true,
toolName: part.name,
toolInput: JSON.stringify(part.input),
toolResult: toolResult ? (typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content)) : null,
toolResult: toolResult ? {
content: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult
} : null,
toolError: toolResult?.isError || false,
toolResultTimestamp: toolResult?.timestamp || new Date()
});
@@ -4549,19 +4704,20 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
/>
</svg>
</button>
</div>
{/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • Type / for slash commands"
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • Type / for slash commands"}
</div>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
}`}>
{sendByCtrlEnter
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files • / for commands"
: "Enter to send • Tab for modes • @ for files • / for commands"}
{/* Hint text inside input box at bottom */}
<div className="absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block">
{sendByCtrlEnter
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
</div>
<div className={`absolute bottom-1 left-12 right-14 text-xs text-gray-400 dark:text-gray-500 pointer-events-none sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
}`}>
{sendByCtrlEnter
? "Ctrl+Enter to send • Tab for modes • / for commands"
: "Enter to send • Tab for modes • / for commands"}
</div>
</div>
</form>
</div>

View File

@@ -13,7 +13,7 @@ import { showMinimap } from '@replit/codemirror-minimap';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
function CodeEditor({ file, onClose, projectPath }) {
function CodeEditor({ file, onClose, projectPath, isSidebar = false }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -321,14 +321,23 @@ function CodeEditor({ file, onClose, projectPath }) {
}
`}
</style>
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
{isSidebar ? (
<div className="w-full h-full flex items-center justify-center bg-white dark:bg-gray-900">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
) : (
<div className="fixed inset-0 z-50 md:bg-black/50 md:flex md:items-center md:justify-center">
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span className="text-gray-900 dark:text-white">Loading {file.name}...</span>
</div>
</div>
</div>
)}
</>
);
}
@@ -403,33 +412,32 @@ function CodeEditor({ file, onClose, projectPath }) {
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
<div className={isSidebar ?
'w-full h-full flex flex-col' :
`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
<div className={isSidebar ?
'bg-white dark:bg-gray-900 flex flex-col w-full h-full' :
`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
<span className="text-xs bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">{file.path}</p>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
</div>
</div>
@@ -496,13 +504,15 @@ function CodeEditor({ file, onClose, projectPath }) {
)}
</button>
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
{!isSidebar && (
<button
onClick={toggleFullscreen}
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
<button
onClick={onClose}
@@ -565,7 +575,6 @@ function CodeEditor({ file, onClose, projectPath }) {
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span>Lines: {content.split('\n').length}</span>
<span>Characters: {content.length}</span>
<span>Language: {file.name.split('.').pop()?.toUpperCase() || 'Text'}</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">

View File

@@ -11,7 +11,7 @@
* No session protection logic is implemented here - it's purely a props bridge.
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import ChatInterface from './ChatInterface';
import FileTree from './FileTree';
import CodeEditor from './CodeEditor';
@@ -28,13 +28,13 @@ import { useTaskMaster } from '../contexts/TaskMasterContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { api } from '../utils/api';
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
function MainContent({
selectedProject,
selectedSession,
activeTab,
setActiveTab,
ws,
sendMessage,
messages,
isMobile,
isPWA,
@@ -61,6 +61,9 @@ function MainContent({
const [editingFile, setEditingFile] = useState(null);
const [selectedTask, setSelectedTask] = useState(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [editorWidth, setEditorWidth] = useState(600);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef(null);
// PRD Editor state
const [showPRDEditor, setShowPRDEditor] = useState(false);
@@ -153,6 +156,52 @@ function MainContent({
console.log('Update task status:', taskId, newStatus);
refreshTasks?.();
};
// Handle resize functionality
const handleMouseDown = (e) => {
if (isMobile) return; // Disable resize on mobile
setIsResizing(true);
e.preventDefault();
};
useEffect(() => {
const handleMouseMove = (e) => {
if (!isResizing) return;
const container = resizeRef.current?.parentElement;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const newWidth = containerRect.right - e.clientX;
// Min width: 300px, Max width: 80% of container
const minWidth = 300;
const maxWidth = containerRect.width * 0.8;
if (newWidth >= minWidth && newWidth <= maxWidth) {
setEditorWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing]);
if (isLoading) {
return (
<div className="h-full flex flex-col">
@@ -234,10 +283,10 @@ function MainContent({
return (
<div className="h-full flex flex-col">
{/* Header with tabs */}
<div
<div
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between relative">
<div className="flex items-center space-x-2 sm:space-x-3">
{isMobile && (
<button
@@ -409,11 +458,13 @@ function MainContent({
</div>
</div>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
{/* Content Area with Right Sidebar */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Main Content */}
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''}`}>
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
<ErrorBoundary showDetails={true}>
<ChatInterface
selectedProject={selectedProject}
selectedSession={selectedSession}
ws={ws}
@@ -513,14 +564,45 @@ function MainContent({
onClearLogs={() => setServerLogs([])}
/> */}
</div>
</div>
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
{editingFile && !isMobile && (
<>
{/* Resize Handle */}
<div
ref={resizeRef}
onMouseDown={handleMouseDown}
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
title="Drag to resize"
>
{/* Visual indicator on hover */}
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
{/* Editor Sidebar */}
<div
className="flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden"
style={{ width: `${editorWidth}px` }}
>
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={true}
/>
</div>
</>
)}
</div>
{/* Code Editor Modal */}
{editingFile && (
{/* Code Editor Modal for Mobile */}
{editingFile && isMobile && (
<CodeEditor
file={editingFile}
onClose={handleCloseEditor}
projectPath={selectedProject?.path}
isSidebar={false}
/>
)}

View File

@@ -39,12 +39,12 @@ const formatTimeAgo = (dateString, currentTime) => {
return date.toLocaleDateString();
};
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
function Sidebar({
projects,
selectedProject,
selectedSession,
onProjectSelect,
onSessionSelect,
onNewSession,
onSessionDelete,
onProjectDelete,
@@ -54,9 +54,11 @@ function Sidebar({
updateAvailable,
latestVersion,
currentVersion,
releaseInfo,
onShowVersionModal,
isPWA,
isMobile
isMobile,
onToggleSidebar
}) {
const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null);
@@ -586,34 +588,24 @@ function Sidebar({
<p className="text-sm text-muted-foreground">AI coding assistant interface</p>
</div>
</div>
<div className="flex gap-2">
{onToggleSidebar && (
<Button
variant="ghost"
size="sm"
className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200"
onClick={onToggleSidebar}
title="Hide sidebar"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Button>
<Button
variant="default"
size="sm"
className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-4 h-4" />
</Button>
</div>
)}
</div>
{/* Mobile Header */}
@@ -913,9 +905,9 @@ function Sidebar({
</div>
)}
{/* Search Filter */}
{/* Search Filter and Actions */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
@@ -934,6 +926,39 @@ function Sidebar({
</button>
)}
</div>
{/* Action Buttons - Desktop only */}
{!isMobile && (
<div className="flex gap-2">
<Button
variant="default"
size="sm"
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90 transition-all duration-200"
onClick={() => setShowNewProject(true)}
title="Create new project (Ctrl+N)"
>
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
New Project
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 px-0 hover:bg-accent transition-colors duration-200 group"
onClick={async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}}
disabled={isRefreshing}
title="Refresh projects and sessions (Ctrl+R)"
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
</Button>
</div>
)}
</div>
)}
@@ -1611,8 +1636,10 @@ function Sidebar({
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
</div>
</Button>
</div>
@@ -1630,8 +1657,10 @@ function Sidebar({
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
{releaseInfo?.title || `Version ${latestVersion}`}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Update available</div>
</div>
</button>
</div>

View File

@@ -5,28 +5,39 @@ import { version } from '../../package.json';
export const useVersionCheck = (owner, repo) => {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestVersion, setLatestVersion] = useState(null);
const [releaseInfo, setReleaseInfo] = useState(null);
useEffect(() => {
const checkVersion = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
const data = await response.json();
// Handle the case where there might not be any releases
if (data.tag_name) {
const latest = data.tag_name.replace(/^v/, '');
setLatestVersion(latest);
setUpdateAvailable(version !== latest);
// Store release information
setReleaseInfo({
title: data.name || data.tag_name,
body: data.body || '',
htmlUrl: data.html_url || `https://github.com/${owner}/${repo}/releases/latest`,
publishedAt: data.published_at
});
} else {
// No releases found, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
setReleaseInfo(null);
}
} catch (error) {
console.error('Version check failed:', error);
// On error, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
setReleaseInfo(null);
}
};
@@ -35,5 +46,5 @@ export const useVersionCheck = (owner, repo) => {
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version };
return { updateAvailable, latestVersion, currentVersion: version, releaseInfo };
};

View File

@@ -825,4 +825,16 @@
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
}
/* Tool details chevron animation */
details[open] .details-chevron,
details[open] summary svg[class*="group-open"] {
transform: rotate(180deg);
}
/* Smooth chevron transition */
.details-chevron,
summary svg[class*="transition-transform"] {
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
}