Compare commits

..

41 Commits

Author SHA1 Message Date
viper151
9cd1b5811a Merge branch 'main' into fix/session-streamed-to-another-chat 2026-01-20 12:59:42 +01:00
Haileyesus Dessie
ee43adb311 Merge pull request #312 from EricBlanquer/feat/folder-browser-wizard
Add folder browser to ProjectCreationWizard
2026-01-20 12:01:02 +03:00
Eric Blanquer​
e1f2af1a34 feat: add folder browser to ProjectCreationWizard
- Add folder browser modal to navigate and select project folders
- Sort folders alphabetically (case-insensitive)
- Add toggle to show/hide hidden folders (hidden by default)
- Auto-advance to confirmation step when selecting a folder
- Place "Use this folder" button next to Cancel
2026-01-18 06:05:34 +01:00
Haileyesus Dessie
ddb26c7652 fix: resolve issue with redirecting to original session after response completion 2026-01-16 14:04:37 +03:00
Haileyesus Dessie
b3c6e95971 fix: don't stream response to another session 2026-01-16 14:04:12 +03:00
Haileyesus Dessie
f8d1ec7b9e Merge pull request #250 from ZhenhongDu/main
feat: Add codeblock highlight support in ChatInterface
2026-01-15 21:10:06 +03:00
Haileyesus Dessie
15e4db386f Merge pull request #296 from amacsmith/fix/filter-git-from-autocomplete
fix: filter VCS directories (.git, .svn, .hg) from file autocomplete
2026-01-14 15:32:22 +03:00
amacsmith
66e85fb2c1 fix: filter VCS directories from file autocomplete
Excludes .git, .svn, and .hg directories from the file tree returned
by getFileTree(). This prevents VCS internal files from appearing
in the @ file autocomplete dropdown.

The fix is applied server-side which:
- Reduces data transferred to the client
- Benefits all features using getFileTree()
- Provides consistent filtering across the application

Fixes #290

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:43:58 -05:00
viper151
42b2d5e1d9 Merge pull request #289 from siteboon/feat/show-grant-permission-button-in-chat-for-claude
Add inline permission grant for Claude tool errors
2026-01-12 15:12:04 +01:00
viper151
d3c4821258 Merge branch 'main' into feat/show-grant-permission-button-in-chat-for-claude 2026-01-12 12:53:58 +01:00
Haileyesus Dessie
72c4b0749e Merge pull request #277 from whittlelabs/feature/drag-sidebar-handle
feat: add draggable Quick Settings sidebar handle
2026-01-12 12:31:26 +03:00
Haileyesus Dessie
35e140b941 add a clarification comment about crypto.randomUUID() 2026-01-10 15:00:46 +03:00
Haileyesus Dessie
b70728254b fix: move safeJsonParse function to utils.js 2026-01-10 14:36:58 +03:00
Haileyesus Dessie
64ebbaf387 feat: setup canUseTool for claude messages 2026-01-10 14:35:51 +03:00
Haileyesus Dessie
cdaff9d146 Merge branch 'feat/show-grant-permission-button-in-chat-for-claude' of https://github.com/siteboon/claudecodeui into feat/show-grant-permission-button-in-chat-for-claude 2026-01-08 12:21:34 +03:00
Haileyesus Dessie
3f66179e72 fix: remove regex for tool permission extraction 2026-01-08 12:21:28 +03:00
viper151
c654f489af Merge branch 'main' into feat/show-grant-permission-button-in-chat-for-claude 2026-01-07 22:13:48 +01:00
viper151
97ebef016a Merge pull request #288 from siteboon/fix/move-to-correct-scroll-position-in-long-messages-chat
fix: normalize file path handling and improve scroll position restoration
2026-01-07 22:11:55 +01:00
Haileyesus Dessie
ef44942767 feat: add Bash command approval handling in Claude tool permissions 2026-01-07 22:31:17 +03:00
Haileyesus Dessie
7b63a68e7e feat: add grant permission for Claude tools in ChatInterface 2026-01-07 21:49:05 +03:00
Haileyesus Dessie
005033136b fix: normalize file path handling and improve scroll position restoration in ChatInterface 2026-01-07 20:59:41 +03:00
viper151
ee3917b3f9 Merge branch 'main' into main 2026-01-06 12:43:35 +01:00
viper151
8fb43d358c Merge pull request #283 from siteboon/fix/server-crash-when-opening-settings 2026-01-05 18:59:05 +01:00
Haileyesus Dessie
4c40a33255 fix: improve error handling and response structure in MCP CLI routes for codex 2026-01-05 20:54:26 +03:00
viper151
4086fdaa4e Merge pull request #275 from siteboon/fix/navigate-to-correct-session-id-using-codex
fix: navigate to the correct session ID when updating session state
2026-01-05 17:00:23 +01:00
viper151
124c1ac600 Merge branch 'main' into fix/navigate-to-correct-session-id-using-codex 2026-01-05 16:59:24 +01:00
Haileyesus Dessie
9efe433d99 fix: get codex sessions in windows; improve message counting logic; fix session navigation in ChatInterface 2026-01-05 16:35:20 +03:00
Haileyesus Dessie
189a1b174c Merge pull request #244 from ybalbert001/main
[FixBug] The Desktop version's "New Project" button is always hidden
2026-01-01 14:53:28 +03:00
Haileyesus Dessie
04a0ff311e Merge branch 'main' into main 2026-01-01 14:49:30 +03:00
Haileyesus Dessie
efae890e34 Update button title for creating new project 2026-01-01 14:46:09 +03:00
Keith Morris
ea33810a4f fix: add error handling and cleanup for draggable handle
- Add try-catch for localStorage JSON.parse to handle corrupted data
- Remove invalid localStorage key when parsing fails
- Add cleanup effect to reset body styles if component unmounts while dragging
2025-12-31 17:41:52 -05:00
Keith Morris
4fe6cc4272 feat: add draggable Quick Settings sidebar handle
Add ability to reposition the Quick Settings sidebar handle by dragging it vertically on both desktop and mobile devices. The handle combines toggle and drag functionality in a single compact button.

Key features:
- Drag handle vertically to reposition on screen
- Click to toggle sidebar open/closed
- Smart gesture detection (5px threshold distinguishes drag from click)
- Visual feedback with blue grip icon during drag
- Position persists in localStorage across sessions
- Constrained to 10-90% of viewport height
- Full touch support with proper scroll prevention on mobile
- Responsive positioning (top-based on desktop, bottom-based on mobile)
2025-12-31 14:54:43 -05:00
Haileyesus Dessie
ba70ad8e81 fix: navigate to the correct session ID when updating session state 2025-12-31 19:10:33 +03:00
simosmik
b066ec4c01 fix: change codex login for platform mode 2025-12-31 10:47:55 +00:00
simosmik
104e4260a7 Release 1.13.6 2025-12-31 08:00:36 +00:00
simosmik
8af982e706 feat: add update command to CLI for checking and installing the latest version 2025-12-31 07:59:13 +00:00
viper151
81c0773358 Merge branch 'main' into main 2025-12-31 08:54:50 +01:00
viper151
29783f609f Merge branch 'main' into main 2025-12-31 08:53:45 +01:00
Zhenhong Du
89c9aec5b7 feat: add codeblock highlight in ChatInterface 2025-12-01 12:01:30 +08:00
Zhenhong Du
e74a813093 add packages for code highlight in chatui 2025-12-01 11:57:44 +08:00
Yuanbo Li
73a0b5bebd [FixBug] The Desktop version's "New Project" button is wrapped by the conditional logic projects.length > 0, causing it to not display when there are no projects, preventing users from creating new projects. 2025-11-26 11:45:01 +08:00
17 changed files with 1709 additions and 198 deletions

View File

@@ -88,6 +88,11 @@ claude-code-ui
**To restart**: Stop with Ctrl+C and run `claude-code-ui` again. **To restart**: Stop with Ctrl+C and run `claude-code-ui` again.
**To update**:
```bash
cloudcli update
```
### CLI Usage ### CLI Usage
After global installation, you have access to both `claude-code-ui` and `cloudcli` commands: After global installation, you have access to both `claude-code-ui` and `cloudcli` commands:
@@ -97,6 +102,7 @@ After global installation, you have access to both `claude-code-ui` and `cloudcl
| `cloudcli` or `claude-code-ui` | | Start the server (default) | | `cloudcli` or `claude-code-ui` | | Start the server (default) |
| `cloudcli start` | | Start the server explicitly | | `cloudcli start` | | Start the server explicitly |
| `cloudcli status` | | Show configuration and data locations | | `cloudcli status` | | Show configuration and data locations |
| `cloudcli update` | | Update to the latest version |
| `cloudcli help` | | Show help information | | `cloudcli help` | | Show help information |
| `cloudcli version` | | Show version information | | `cloudcli version` | | Show version information |
| `--port <port>` | `-p` | Set server port (default: 3001) | | `--port <port>` | `-p` | Set server port (default: 3001) |

272
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.5", "version": "1.13.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.5", "version": "1.13.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29", "@anthropic-ai/claude-agent-sdk": "^0.1.29",
@@ -51,6 +51,7 @@
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
@@ -5057,6 +5058,19 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-selector": { "node_modules/file-selector": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
@@ -5136,6 +5150,14 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -5756,6 +5778,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6569,6 +6606,20 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -8840,6 +8891,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": { "node_modules/proc-log": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz",
@@ -9161,6 +9221,23 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-syntax-highlighter": {
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
"integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.3.1",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^3.6.0"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -9197,6 +9274,197 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
"integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==",
"license": "MIT",
"dependencies": {
"hastscript": "^6.0.0",
"parse-entities": "^2.0.0",
"prismjs": "~1.27.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/refractor/node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/refractor/node_modules/character-entities": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz",
"integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-entities-legacy": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz",
"integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/character-reference-invalid": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz",
"integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/comma-separated-tokens": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz",
"integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/hast-util-parse-selector": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz",
"integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/hastscript": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz",
"integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"comma-separated-tokens": "^1.0.0",
"hast-util-parse-selector": "^2.0.0",
"property-information": "^5.0.0",
"space-separated-tokens": "^1.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/refractor/node_modules/is-alphabetical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz",
"integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-alphanumerical": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz",
"integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==",
"license": "MIT",
"dependencies": {
"is-alphabetical": "^1.0.0",
"is-decimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-decimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
"integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/is-hexadecimal": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz",
"integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/parse-entities": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz",
"integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==",
"license": "MIT",
"dependencies": {
"character-entities": "^1.0.0",
"character-entities-legacy": "^1.0.0",
"character-reference-invalid": "^1.0.0",
"is-alphanumerical": "^1.0.0",
"is-decimal": "^1.0.0",
"is-hexadecimal": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/prismjs": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz",
"integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/refractor/node_modules/property-information": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz",
"integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/refractor/node_modules/space-separated-tokens": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz",
"integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/rehype-katex": { "node_modules/rehype-katex": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.13.5", "version": "1.13.6",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",
@@ -83,6 +83,7 @@
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",

View File

@@ -13,6 +13,9 @@
*/ */
import { query } from '@anthropic-ai/claude-agent-sdk'; import { query } from '@anthropic-ai/claude-agent-sdk';
// Used to mint unique approval request IDs when randomUUID is not available.
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
import crypto from 'crypto';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
@@ -20,6 +23,124 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
// Session tracking: Map of session IDs to active query instances // Session tracking: Map of session IDs to active query instances
const activeSessions = new Map(); const activeSessions = new Map();
// In-memory registry of pending tool approvals keyed by requestId.
// This does not persist approvals or share across processes; it exists so the
// SDK can pause tool execution while the UI decides what to do.
const pendingToolApprovals = new Map();
// Default approval timeout kept under the SDK's 60s control timeout.
// This does not change SDK limits; it only defines how long we wait for the UI,
// introduced to avoid hanging the run when no decision arrives.
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
// Generate a stable request ID for UI approval flows.
// This does not encode tool details or get shown to users; it exists so the UI
// can respond to the correct pending request without collisions.
function createRequestId() {
// if clause is used because randomUUID is not available in older Node.js versions
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString('hex');
}
// Wait for a UI approval decision, honoring SDK cancellation.
// This does not auto-approve or auto-deny; it only resolves with UI input,
// and it cleans up the pending map to avoid leaks, introduced to prevent
// replying after the SDK cancels the control request.
function waitForToolApproval(requestId, options = {}) {
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
return new Promise(resolve => {
let settled = false;
const finalize = (decision) => {
if (settled) return;
settled = true;
cleanup();
resolve(decision);
};
const cleanup = () => {
pendingToolApprovals.delete(requestId);
clearTimeout(timeout);
if (signal && abortHandler) {
signal.removeEventListener('abort', abortHandler);
}
};
// Timeout is local to this process; it does not override SDK timing.
// It exists to prevent the UI prompt from lingering indefinitely.
const timeout = setTimeout(() => {
onCancel?.('timeout');
finalize(null);
}, timeoutMs);
const abortHandler = () => {
// If the SDK cancels the control request, stop waiting to avoid
// replying after the process is no longer ready for writes.
onCancel?.('cancelled');
finalize({ cancelled: true });
};
if (signal) {
if (signal.aborted) {
onCancel?.('cancelled');
finalize({ cancelled: true });
return;
}
signal.addEventListener('abort', abortHandler, { once: true });
}
pendingToolApprovals.set(requestId, (decision) => {
finalize(decision);
});
});
}
// Resolve a pending approval. This does not validate the decision payload;
// validation and tool matching remain in canUseTool, which keeps this as a
// lightweight WebSocket -> SDK relay.
function resolveToolApproval(requestId, decision) {
const resolver = pendingToolApprovals.get(requestId);
if (resolver) {
resolver(decision);
}
}
// Match stored permission entries against a tool + input combo.
// This only supports exact tool names and the Bash(command:*) shorthand
// used by the UI; it intentionally does not implement full glob semantics,
// introduced to stay consistent with the UI's "Allow rule" format.
function matchesToolPermission(entry, toolName, input) {
if (!entry || !toolName) {
return false;
}
if (entry === toolName) {
return true;
}
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
if (toolName === 'Bash' && bashMatch) {
const allowedPrefix = bashMatch[1];
let command = '';
if (typeof input === 'string') {
command = input.trim();
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
command = input.command.trim();
}
if (!command) {
return false;
}
return command.startsWith(allowedPrefix);
}
return false;
}
/** /**
* Maps CLI options to SDK-compatible options format * Maps CLI options to SDK-compatible options format
@@ -52,30 +173,29 @@ function mapCliOptionsToSDK(options = {}) {
if (settings.skipPermissions && permissionMode !== 'plan') { if (settings.skipPermissions && permissionMode !== 'plan') {
// When skipping permissions, use bypassPermissions mode // When skipping permissions, use bypassPermissions mode
sdkOptions.permissionMode = 'bypassPermissions'; sdkOptions.permissionMode = 'bypassPermissions';
} else { }
// Map allowed tools
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode default tools // Map allowed tools (always set to avoid implicit "allow all" defaults).
if (permissionMode === 'plan') { // This does not grant permissions by itself; it just configures the SDK,
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch']; // introduced because leaving it undefined made the SDK treat it as "all tools allowed."
for (const tool of planModeTools) { let allowedTools = [...(settings.allowedTools || [])];
if (!allowedTools.includes(tool)) {
allowedTools.push(tool); // Add plan mode default tools
} if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
} }
} }
if (allowedTools.length > 0) {
sdkOptions.allowedTools = allowedTools;
}
// Map disallowed tools
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
sdkOptions.disallowedTools = settings.disallowedTools;
}
} }
sdkOptions.allowedTools = allowedTools;
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
// This does not override allowlists; it only feeds the canUseTool gate.
sdkOptions.disallowedTools = settings.disallowedTools || [];
// Map model (default to sonnet) // Map model (default to sonnet)
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
@@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
tempImagePaths = imageResult.tempImagePaths; tempImagePaths = imageResult.tempImagePaths;
tempDir = imageResult.tempDir; tempDir = imageResult.tempDir;
// Gate tool usage with explicit UI approval when not auto-approved.
// This does not render UI or persist permissions; it only bridges to the UI
// via WebSocket and waits for the response, introduced so tool calls pause
// instead of auto-running when the allowlist is empty.
sdkOptions.canUseTool = async (toolName, input, context) => {
if (sdkOptions.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input };
}
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isDisallowed) {
return { behavior: 'deny', message: 'Tool disallowed by settings' };
}
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
matchesToolPermission(entry, toolName, input)
);
if (isAllowed) {
return { behavior: 'allow', updatedInput: input };
}
const requestId = createRequestId();
ws.send({
type: 'claude-permission-request',
requestId,
toolName,
input,
sessionId: capturedSessionId || sessionId || null
});
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
// This does not retry or resurface the prompt; it just reflects the cancellation.
const decision = await waitForToolApproval(requestId, {
signal: context?.signal,
onCancel: (reason) => {
ws.send({
type: 'claude-permission-cancelled',
requestId,
reason,
sessionId: capturedSessionId || sessionId || null
});
}
});
if (!decision) {
return { behavior: 'deny', message: 'Permission request timed out' };
}
if (decision.cancelled) {
return { behavior: 'deny', message: 'Permission request cancelled' };
}
if (decision.allow) {
// rememberEntry only updates this run's in-memory allowlist to prevent
// repeated prompts in the same session; persistence is handled by the UI.
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
sdkOptions.allowedTools.push(decision.rememberEntry);
}
if (Array.isArray(sdkOptions.disallowedTools)) {
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
}
}
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
}
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
};
// Create SDK query instance // Create SDK query instance
const queryInstance = query({ const queryInstance = query({
prompt: finalCommand, prompt: finalCommand,
@@ -413,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
const transformedMessage = transformMessage(message); const transformedMessage = transformMessage(message);
ws.send({ ws.send({
type: 'claude-response', type: 'claude-response',
data: transformedMessage data: transformedMessage,
sessionId: capturedSessionId || sessionId || null
}); });
// Extract and send token budget updates from result messages // Extract and send token budget updates from result messages
@@ -423,7 +614,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
console.log('Token budget from modelUsage:', tokenBudget); console.log('Token budget from modelUsage:', tokenBudget);
ws.send({ ws.send({
type: 'token-budget', type: 'token-budget',
data: tokenBudget data: tokenBudget,
sessionId: capturedSessionId || sessionId || null
}); });
} }
} }
@@ -461,7 +653,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
// Send error to WebSocket // Send error to WebSocket
ws.send({ ws.send({
type: 'claude-error', type: 'claude-error',
error: error.message error: error.message,
sessionId: capturedSessionId || sessionId || null
}); });
throw error; throw error;
@@ -526,5 +719,6 @@ export {
queryClaudeSDK, queryClaudeSDK,
abortClaudeSDKSession, abortClaudeSDKSession,
isClaudeSDKSessionActive, isClaudeSDKSessionActive,
getActiveClaudeSDKSessions getActiveClaudeSDKSessions,
resolveToolApproval
}; };

View File

@@ -151,6 +151,7 @@ Usage:
Commands: Commands:
start Start the Claude Code UI server (default) start Start the Claude Code UI server (default)
status Show configuration and data locations status Show configuration and data locations
update Update to the latest version
help Show this help information help Show this help information
version Show version information version Show version information
@@ -186,8 +187,67 @@ function showVersion() {
console.log(`${packageJson.version}`); console.log(`${packageJson.version}`);
} }
// Compare semver versions, returns true if v1 > v2
function isNewerVersion(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (parts1[i] > parts2[i]) return true;
if (parts1[i] < parts2[i]) return false;
}
return false;
}
// Check for updates
async function checkForUpdates(silent = false) {
try {
const { execSync } = await import('child_process');
const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();
const currentVersion = packageJson.version;
if (isNewerVersion(latestVersion, currentVersion)) {
console.log(`\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);
console.log(` Run ${c.bright('cloudcli update')} to update\n`);
return { hasUpdate: true, latestVersion, currentVersion };
} else if (!silent) {
console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);
}
return { hasUpdate: false, latestVersion, currentVersion };
} catch (e) {
if (!silent) {
console.log(`${c.warn('[WARN]')} Could not check for updates`);
}
return { hasUpdate: false, error: e.message };
}
}
// Update the package
async function updatePackage() {
try {
const { execSync } = await import('child_process');
console.log(`${c.info('[INFO]')} Checking for updates...`);
const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);
if (!hasUpdate) {
console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);
return;
}
console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);
execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });
console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);
} catch (e) {
console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);
console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);
}
}
// Start the server // Start the server
async function startServer() { async function startServer() {
// Check for updates silently on startup
checkForUpdates(true);
// Import and run the server // Import and run the server
await import('./index.js'); await import('./index.js');
} }
@@ -250,6 +310,9 @@ async function main() {
case '--version': case '--version':
showVersion(); showVersion();
break; break;
case 'update':
await updatePackage();
break;
default: default:
console.error(`\n❌ Unknown command: ${command}`); console.error(`\n❌ Unknown command: ${command}`);
console.log(' Run "cloudcli help" for usage information.\n'); console.log(' Run "cloudcli help" for usage information.\n');

View File

@@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) {
// Send system info to frontend // Send system info to frontend
ws.send({ ws.send({
type: 'cursor-system', type: 'cursor-system',
data: response data: response,
sessionId: capturedSessionId || sessionId || null
}); });
} }
break; break;
@@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) {
// Forward user message // Forward user message
ws.send({ ws.send({
type: 'cursor-user', type: 'cursor-user',
data: response data: response,
sessionId: capturedSessionId || sessionId || null
}); });
break; break;
@@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) {
type: 'text_delta', type: 'text_delta',
text: textContent text: textContent
} }
} },
sessionId: capturedSessionId || sessionId || null
}); });
} }
break; break;
@@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) {
type: 'claude-response', type: 'claude-response',
data: { data: {
type: 'content_block_stop' type: 'content_block_stop'
} },
sessionId: capturedSessionId || sessionId || null
}); });
} }
@@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) {
// Forward any other message types // Forward any other message types
ws.send({ ws.send({
type: 'cursor-response', type: 'cursor-response',
data: response data: response,
sessionId: capturedSessionId || sessionId || null
}); });
} }
} catch (parseError) { } catch (parseError) {
@@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) {
// If not JSON, send as raw text // If not JSON, send as raw text
ws.send({ ws.send({
type: 'cursor-output', type: 'cursor-output',
data: line data: line,
sessionId: capturedSessionId || sessionId || null
}); });
} }
} }
@@ -193,7 +199,8 @@ async function spawnCursor(command, options = {}, ws) {
console.error('Cursor CLI stderr:', data.toString()); console.error('Cursor CLI stderr:', data.toString());
ws.send({ ws.send({
type: 'cursor-error', type: 'cursor-error',
error: data.toString() error: data.toString(),
sessionId: capturedSessionId || sessionId || null
}); });
}); });
@@ -229,7 +236,8 @@ async function spawnCursor(command, options = {}, ws) {
ws.send({ ws.send({
type: 'cursor-error', type: 'cursor-error',
error: error.message error: error.message,
sessionId: capturedSessionId || sessionId || null
}); });
reject(error); reject(error);
@@ -264,4 +272,4 @@ export {
abortCursorSession, abortCursorSession,
isCursorSessionActive, isCursorSessionActive,
getActiveCursorSessions getActiveCursorSessions
}; };

View File

@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
import mime from 'mime-types'; import mime from 'mime-types';
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js'; import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
import gitRoutes from './routes/git.js'; import gitRoutes from './routes/git.js';
@@ -496,7 +496,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
name: item.name, name: item.name,
type: 'directory' type: 'directory'
})) }))
.slice(0, 20); // Limit results .sort((a, b) => {
const aHidden = a.name.startsWith('.');
const bHidden = b.name.startsWith('.');
if (aHidden && !bHidden) return 1;
if (!aHidden && bHidden) return -1;
return a.name.localeCompare(b.name);
});
// Add common directories if browsing home directory // Add common directories if browsing home directory
const suggestions = []; const suggestions = [];
@@ -804,6 +810,18 @@ function handleChatConnection(ws) {
provider, provider,
success success
}); });
} else if (data.type === 'claude-permission-response') {
// Relay UI approval decisions back into the SDK control flow.
// This does not persist permissions; it only resolves the in-flight request,
// introduced so the SDK can resume once the user clicks Allow/Deny.
if (data.requestId) {
resolveToolApproval(data.requestId, {
allow: Boolean(data.allow),
updatedInput: data.updatedInput,
message: data.message,
rememberEntry: data.rememberEntry
});
}
} else if (data.type === 'cursor-abort') { } else if (data.type === 'cursor-abort') {
console.log('[DEBUG] Abort Cursor session:', data.sessionId); console.log('[DEBUG] Abort Cursor session:', data.sessionId);
const success = abortCursorSession(data.sessionId); const success = abortCursorSession(data.sessionId);
@@ -1625,10 +1643,13 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
// Debug: log all entries including hidden files // Debug: log all entries including hidden files
// Skip only heavy build directories // Skip heavy build directories and VCS directories
if (entry.name === 'node_modules' || if (entry.name === 'node_modules' ||
entry.name === 'dist' || entry.name === 'dist' ||
entry.name === 'build') continue; entry.name === 'build' ||
entry.name === '.git' ||
entry.name === '.svn' ||
entry.name === '.hg') continue;
const itemPath = path.join(dirPath, entry.name); const itemPath = path.join(dirPath, entry.name);
const item = { const item = {

View File

@@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
data: { data: {
used: totalTokens, used: totalTokens,
total: 200000 // Default context window for Codex models total: 200000 // Default context window for Codex models
} },
sessionId: currentSessionId
}); });
} }
} }
@@ -280,7 +281,8 @@ export async function queryCodex(command, options = {}, ws) {
// Send completion event // Send completion event
sendMessage(ws, { sendMessage(ws, {
type: 'codex-complete', type: 'codex-complete',
sessionId: currentSessionId sessionId: currentSessionId,
actualSessionId: thread.id
}); });
} catch (error) { } catch (error) {

View File

@@ -1206,7 +1206,12 @@ async function getCodexSessions(projectPath) {
const sessionData = await parseCodexSessionFile(filePath); const sessionData = await parseCodexSessionFile(filePath);
// Check if this session matches the project path // Check if this session matches the project path
if (sessionData && sessionData.cwd === projectPath) { // Handle Windows long paths with \\?\ prefix
const sessionCwd = sessionData?.cwd || '';
const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
sessions.push({ sessions.push({
id: sessionData.id, id: sessionData.id,
summary: sessionData.summary || 'Codex Session', summary: sessionData.summary || 'Codex Session',
@@ -1273,12 +1278,12 @@ async function parseCodexSessionFile(filePath) {
// Count messages and extract user messages for summary // Count messages and extract user messages for summary
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') { if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
messageCount++; messageCount++;
if (entry.payload.text) { if (entry.payload.message) {
lastUserMessage = entry.payload.text; lastUserMessage = entry.payload.message;
} }
} }
if (entry.type === 'response_item' && entry.payload?.type === 'message') { if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
messageCount++; messageCount++;
} }

View File

@@ -8,6 +8,17 @@ import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '.
const router = express.Router(); const router = express.Router();
function createCliResponder(res) {
let responded = false;
return (status, payload) => {
if (responded || res.headersSent) {
return;
}
responded = true;
res.status(status).json(payload);
};
}
router.get('/config', async (req, res) => { router.get('/config', async (req, res) => {
try { try {
const configPath = path.join(os.homedir(), '.codex', 'config.toml'); const configPath = path.join(os.homedir(), '.codex', 'config.toml');
@@ -88,24 +99,30 @@ router.delete('/sessions/:sessionId', async (req, res) => {
router.get('/mcp/cli/list', async (req, res) => { router.get('/mcp/cli/list', async (req, res) => {
try { try {
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) }); respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
} else { } else {
res.status(500).json({ error: 'Codex CLI command failed', details: stderr }); respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message }); res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
@@ -133,24 +150,30 @@ router.post('/mcp/cli/add', async (req, res) => {
cliArgs.push(...args); cliArgs.push(...args);
} }
const respond = createCliResponder(res);
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to add MCP server', details: error.message }); res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
@@ -161,24 +184,30 @@ router.delete('/mcp/cli/remove/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` }); respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
} else { } else {
res.status(400).json({ error: 'Codex CLI command failed', details: stderr }); respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message }); res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
@@ -189,24 +218,30 @@ router.get('/mcp/cli/get/:name', async (req, res) => {
try { try {
const { name } = req.params; const { name } = req.params;
const respond = createCliResponder(res);
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] }); const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdout?.on('data', (data) => { stdout += data.toString(); });
proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); });
proc.on('close', (code) => { proc.on('close', (code) => {
if (code === 0) { if (code === 0) {
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) }); respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
} else { } else {
res.status(404).json({ error: 'Codex CLI command failed', details: stderr }); respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
} }
}); });
proc.on('error', (error) => { proc.on('error', (error) => {
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message }); const isMissing = error?.code === 'ENOENT';
respond(isMissing ? 503 : 500, {
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
details: error.message,
code: error.code
});
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message }); res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });

View File

@@ -177,7 +177,9 @@ function AppContent() {
// If so, and the session is not active, trigger a message reload in ChatInterface // If so, and the session is not active, trigger a message reload in ChatInterface
if (latestMessage.changedFile && selectedSession && selectedProject) { if (latestMessage.changedFile && selectedSession && selectedProject) {
// Extract session ID from changedFile (format: "project-name/session-id.jsonl") // Extract session ID from changedFile (format: "project-name/session-id.jsonl")
const changedFileParts = latestMessage.changedFile.split('/'); const normalized = latestMessage.changedFile.replace(/\\/g, '/');
const changedFileParts = normalized.split('/');
if (changedFileParts.length >= 2) { if (changedFileParts.length >= 2) {
const filename = changedFileParts[changedFileParts.length - 1]; const filename = changedFileParts[changedFileParts.length - 1];
const changedSessionId = filename.replace('.jsonl', ''); const changedSessionId = filename.replace('.jsonl', '');

View File

@@ -16,11 +16,13 @@
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates. * This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
*/ */
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react'; import React, { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect, memo } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList'; import TodoList from './TodoList';
import ClaudeLogo from './ClaudeLogo.jsx'; import ClaudeLogo from './ClaudeLogo.jsx';
@@ -37,6 +39,7 @@ import Fuse from 'fuse.js';
import CommandMenu from './CommandMenu'; import CommandMenu from './CommandMenu';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
import { safeJsonParse } from '../lib/utils.js';
// Helper function to decode HTML entities in text // Helper function to decode HTML entities in text
function decodeHtmlEntities(text) { function decodeHtmlEntities(text) {
@@ -236,6 +239,102 @@ const safeLocalStorage = {
} }
}; };
const CLAUDE_SETTINGS_KEY = 'claude-settings';
function getClaudeSettings() {
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
if (!raw) {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name'
};
}
try {
const parsed = JSON.parse(raw);
return {
...parsed,
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
skipPermissions: Boolean(parsed.skipPermissions),
projectSortOrder: parsed.projectSortOrder || 'name'
};
} catch {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name'
};
}
}
function buildClaudeToolPermissionEntry(toolName, toolInput) {
if (!toolName) return null;
if (toolName !== 'Bash') return toolName;
const parsed = safeJsonParse(toolInput);
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
if (!command) return toolName;
const tokens = command.split(/\s+/);
if (tokens.length === 0) return toolName;
// For Bash, allow the command family instead of every Bash invocation.
if (tokens[0] === 'git' && tokens[1]) {
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
}
return `Bash(${tokens[0]}:*)`;
}
// Normalize tool inputs for display in the permission banner.
// This does not sanitize/redact secrets; it is strictly formatting so users
// can see the raw input that triggered the permission prompt.
function formatToolInputForDisplay(input) {
if (input === undefined || input === null) return '';
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
function getClaudePermissionSuggestion(message, provider) {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
const toolName = message?.toolName;
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
if (!entry) return null;
const settings = getClaudeSettings();
const isAllowed = settings.allowedTools.includes(entry);
return { toolName, entry, isAllowed };
}
function grantClaudeToolPermission(entry) {
if (!entry) return { success: false };
const settings = getClaudeSettings();
const alreadyAllowed = settings.allowedTools.includes(entry);
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
const nextDisallowed = settings.disallowedTools.filter(tool => tool !== entry);
const updatedSettings = {
...settings,
allowedTools: nextAllowed,
disallowedTools: nextDisallowed,
lastUpdated: new Date().toISOString()
};
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
return { success: true, alreadyAllowed, updatedSettings };
}
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.) // Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = { const markdownComponents = {
code: ({ node, inline, className, children, ...props }) => { code: ({ node, inline, className, children, ...props }) => {
@@ -244,6 +343,8 @@ const markdownComponents = {
const looksMultiline = /[\r\n]/.test(raw); const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode'); const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line
// Inline code rendering
if (shouldInline) { if (shouldInline) {
return ( return (
<code <code
@@ -256,6 +357,10 @@ const markdownComponents = {
</code> </code>
); );
} }
// Extract language from className (format: language-xxx)
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : 'text';
const textToCopy = raw; const textToCopy = raw;
const handleCopy = () => { const handleCopy = () => {
@@ -291,8 +396,17 @@ const markdownComponents = {
} catch {} } catch {}
}; };
// Code block with syntax highlighting
return ( return (
<div className="relative group my-2"> <div className="relative group my-2">
{/* Language label */}
{language && language !== 'text' && (
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">
{language}
</div>
)}
{/* Copy button */}
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
@@ -317,11 +431,25 @@ const markdownComponents = {
</span> </span>
)} )}
</button> </button>
<pre className="bg-gray-900 dark:bg-gray-900 border border-gray-700/40 rounded-lg p-3 overflow-x-auto">
<code className={`text-gray-100 dark:text-gray-100 text-sm font-mono ${className || ''}`} {...props}> {/* Syntax highlighted code */}
{children} <SyntaxHighlighter
</code> language={language}
</pre> style={oneDark}
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
}}
codeTagProps={{
style: {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
}
}}
>
{raw}
</SyntaxHighlighter>
</div> </div>
); );
}, },
@@ -356,7 +484,7 @@ const markdownComponents = {
}; };
// Memoized message component to prevent unnecessary re-renders // Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking, selectedProject }) => { const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => {
const isGrouped = prevMessage && prevMessage.type === message.type && const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') || ((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') || (prevMessage.type === 'user') ||
@@ -364,6 +492,13 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
(prevMessage.type === 'error')); (prevMessage.type === 'error'));
const messageRef = React.useRef(null); const messageRef = React.useRef(null);
const [isExpanded, setIsExpanded] = React.useState(false); const [isExpanded, setIsExpanded] = React.useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
React.useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
React.useEffect(() => { React.useEffect(() => {
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return; if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
@@ -1358,6 +1493,59 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</Markdown> </Markdown>
); );
})()} })()}
{permissionSuggestion && (
<div className="mt-4 border-t border-red-200/60 dark:border-red-800/60 pt-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
if (!onGrantToolPermission) return;
const result = onGrantToolPermission(permissionSuggestion);
if (result?.success) {
setPermissionGrantState('granted');
} else {
setPermissionGrantState('error');
}
}}
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
}`}
>
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
? 'Permission added'
: `Grant permission for ${permissionSuggestion.toolName}`}
</button>
{onShowSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShowSettings();
}}
className="text-xs text-red-700 dark:text-red-200 underline hover:text-red-800 dark:hover:text-red-100"
>
Open settings
</button>
)}
</div>
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
Adds <span className="font-mono">{permissionSuggestion.entry}</span> to Allowed Tools.
</div>
{permissionGrantState === 'error' && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
Unable to update permissions. Please try again.
</div>
)}
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
Permission saved. Retry the request to use the tool.
</div>
)}
</div>
)}
</div> </div>
</div> </div>
); );
@@ -1688,6 +1876,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const MESSAGES_PER_PAGE = 20; const MESSAGES_PER_PAGE = 20;
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default'); const [permissionMode, setPermissionMode] = useState('default');
// In-memory queue of tool permission prompts for the current UI view.
// These are not persisted and do not survive a page refresh; introduced so
// the UI can present pending approvals while the SDK waits.
const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
const [attachedImages, setAttachedImages] = useState([]); const [attachedImages, setAttachedImages] = useState([]);
const [uploadingImages, setUploadingImages] = useState(new Map()); const [uploadingImages, setUploadingImages] = useState(new Map());
const [imageErrors, setImageErrors] = useState(new Map()); const [imageErrors, setImageErrors] = useState(new Map());
@@ -1696,9 +1888,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const inputContainerRef = useRef(null); const inputContainerRef = useRef(null);
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
const isLoadingMoreRef = useRef(false);
const topLoadLockRef = useRef(false);
const pendingScrollRestoreRef = useRef(null);
// Streaming throttle buffers // Streaming throttle buffers
const streamBufferRef = useRef(''); const streamBufferRef = useRef('');
const streamTimerRef = useRef(null); const streamTimerRef = useRef(null);
// Track the session that this view expects when starting a brandnew chat
// (prevents background sessions from streaming into a different view).
const pendingViewSessionRef = useRef(null);
const commandQueryTimerRef = useRef(null); const commandQueryTimerRef = useRef(null);
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const [showFileDropdown, setShowFileDropdown] = useState(false); const [showFileDropdown, setShowFileDropdown] = useState(false);
@@ -1732,6 +1930,17 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [codexModel, setCodexModel] = useState(() => { const [codexModel, setCodexModel] = useState(() => {
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT; return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
}); });
// Track provider transitions so we only clear approvals when provider truly changes.
// This does not sync with the backend; it just prevents UI prompts from disappearing.
const lastProviderRef = useRef(provider);
const resetStreamingState = useCallback(() => {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
streamBufferRef.current = '';
}, []);
// Load permission mode for the current session // Load permission mode for the current session
useEffect(() => { useEffect(() => {
if (selectedSession?.id) { if (selectedSession?.id) {
@@ -1751,6 +1960,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
localStorage.setItem('selected-provider', selectedSession.__provider); localStorage.setItem('selected-provider', selectedSession.__provider);
} }
}, [selectedSession]); }, [selectedSession]);
// Clear pending permission prompts when switching providers; filter when switching sessions.
// This does not preserve prompts across provider changes; it exists to keep the
// Claude approval flow intact while preventing prompts from a different provider.
useEffect(() => {
if (lastProviderRef.current !== provider) {
setPendingPermissionRequests([]);
lastProviderRef.current = provider;
}
}, [provider]);
// When the selected session changes, drop prompts that belong to other sessions.
// This does not attempt to migrate prompts across sessions; it only filters,
// introduced so the UI does not show approvals for a session the user is no longer viewing.
useEffect(() => {
setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id));
}, [selectedSession?.id]);
// Load Cursor default model from config // Load Cursor default model from config
useEffect(() => { useEffect(() => {
@@ -2710,6 +2936,39 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return scrollHeight - scrollTop - clientHeight < 50; return scrollHeight - scrollTop - clientHeight < 50;
}, []); }, []);
const loadOlderMessages = useCallback(async (container) => {
if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false;
if (!hasMoreMessages || !selectedSession || !selectedProject) return false;
const sessionProvider = selectedSession.__provider || 'claude';
if (sessionProvider === 'cursor') return false;
isLoadingMoreRef.current = true;
const previousScrollHeight = container.scrollHeight;
const previousScrollTop = container.scrollTop;
try {
const moreMessages = await loadSessionMessages(
selectedProject.name,
selectedSession.id,
true,
sessionProvider
);
if (moreMessages.length > 0) {
pendingScrollRestoreRef.current = {
height: previousScrollHeight,
top: previousScrollTop
};
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
}
return true;
} finally {
isLoadingMoreRef.current = false;
}
}, [hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
// Handle scroll events to detect when user manually scrolls up and load more messages // Handle scroll events to detect when user manually scrolls up and load more messages
const handleScroll = useCallback(async () => { const handleScroll = useCallback(async () => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
@@ -2719,32 +2978,29 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Check if we should load more messages (scrolled near top) // Check if we should load more messages (scrolled near top)
const scrolledNearTop = container.scrollTop < 100; const scrolledNearTop = container.scrollTop < 100;
const provider = localStorage.getItem('selected-provider') || 'claude'; if (!scrolledNearTop) {
topLoadLockRef.current = false;
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') { } else if (!topLoadLockRef.current) {
// Save current scroll position const didLoad = await loadOlderMessages(container);
const previousScrollHeight = container.scrollHeight; if (didLoad) {
const previousScrollTop = container.scrollTop; topLoadLockRef.current = true;
// Load more messages
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true, selectedSession.__provider || 'claude');
if (moreMessages.length > 0) {
// Prepend new messages to the existing ones
setSessionMessages(prev => [...moreMessages, ...prev]);
// Restore scroll position after DOM update
setTimeout(() => {
if (scrollContainerRef.current) {
const newScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDiff = newScrollHeight - previousScrollHeight;
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
}
}, 0);
} }
} }
} }
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); }, [isNearBottom, loadOlderMessages]);
// Restore scroll position after paginated messages render
useLayoutEffect(() => {
if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;
const { height, top } = pendingScrollRestoreRef.current;
const container = scrollContainerRef.current;
const newScrollHeight = container.scrollHeight;
const scrollDiff = newScrollHeight - height;
container.scrollTop = top + Math.max(scrollDiff, 0);
pendingScrollRestoreRef.current = null;
}, [chatMessages.length]);
useEffect(() => { useEffect(() => {
// Load session messages when session changes // Load session messages when session changes
@@ -2759,6 +3015,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
if (sessionChanged) { if (sessionChanged) {
if (!isSystemSessionChange) {
// Clear any streaming leftovers from the previous session
resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]);
setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
}
// Reset pagination state when switching sessions // Reset pagination state when switching sessions
setMessagesOffset(0); setMessagesOffset(0);
setHasMoreMessages(false); setHasMoreMessages(false);
@@ -2828,17 +3093,22 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
} }
} else { } else {
// Only clear messages if this is NOT a system-initiated session change AND we're not loading // New session view (no selected session) - always reset UI state
// During system session changes or while loading, preserve the chat messages if (!isSystemSessionChange) {
if (!isSystemSessionChange && !isLoading) { resetStreamingState();
pendingViewSessionRef.current = null;
setChatMessages([]); setChatMessages([]);
setSessionMessages([]); setSessionMessages([]);
setClaudeStatus(null);
setCanAbortSession(false);
setIsLoading(false);
} }
setCurrentSessionId(null); setCurrentSessionId(null);
sessionStorage.removeItem('cursorSessionId'); sessionStorage.removeItem('cursorSessionId');
setMessagesOffset(0); setMessagesOffset(0);
setHasMoreMessages(false); setHasMoreMessages(false);
setTotalMessages(0); setTotalMessages(0);
setTokenBudget(null);
} }
// Mark loading as complete after messages are set // Mark loading as complete after messages are set
@@ -2849,7 +3119,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}; };
loadMessages(); loadMessages();
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]); }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, resetStreamingState]);
// External Message Update Handler: Reload messages when external CLI modifies current session // External Message Update Handler: Reload messages when external CLI modifies current session
// This triggers when App.jsx detects a JSONL file change for the currently-viewed session // This triggers when App.jsx detects a JSONL file change for the currently-viewed session
@@ -2873,7 +3143,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// convertedMessages will be automatically updated via useMemo // convertedMessages will be automatically updated via useMemo
// Smart scroll behavior: only auto-scroll if user is near bottom // Smart scroll behavior: only auto-scroll if user is near bottom
if (isNearBottom && autoScrollToBottom) { const shouldAutoScroll = autoScrollToBottom && isNearBottom();
if (shouldAutoScroll) {
setTimeout(() => scrollToBottom(), 200); setTimeout(() => scrollToBottom(), 200);
} }
// If user scrolled up, preserve their position (they're reading history) // If user scrolled up, preserve their position (they're reading history)
@@ -2887,6 +3158,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
}, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]); }, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]);
// When the user navigates to a specific session, clear any pending "new session" marker.
useEffect(() => {
if (selectedSession?.id) {
pendingViewSessionRef.current = null;
}
}, [selectedSession?.id]);
// Update chatMessages when convertedMessages changes // Update chatMessages when convertedMessages changes
useEffect(() => { useEffect(() => {
if (sessionMessages.length > 0) { if (sessionMessages.length > 0) {
@@ -2951,17 +3229,77 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Handle WebSocket messages // Handle WebSocket messages
if (messages.length > 0) { if (messages.length > 0) {
const latestMessage = messages[messages.length - 1]; const latestMessage = messages[messages.length - 1];
const messageData = latestMessage.data?.message || latestMessage.data;
// Filter messages by session ID to prevent cross-session interference // Filter messages by session ID to prevent cross-session interference
// Skip filtering for global messages that apply to all sessions // Skip filtering for global messages that apply to all sessions
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete', 'codex-complete']; const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type); const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
const lifecycleMessageTypes = new Set([
'claude-complete',
'codex-complete',
'cursor-result',
'session-aborted',
'claude-error',
'cursor-error',
'codex-error'
]);
// For new sessions (currentSessionId is null), allow messages through const isClaudeSystemInit = latestMessage.type === 'claude-response' &&
if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) { messageData &&
// Message is for a different session, ignore it messageData.type === 'system' &&
console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId); messageData.subtype === 'init';
return; const isCursorSystemInit = latestMessage.type === 'cursor-system' &&
latestMessage.data &&
latestMessage.data.type === 'system' &&
latestMessage.data.subtype === 'init';
const systemInitSessionId = isClaudeSystemInit
? messageData?.session_id
: isCursorSystemInit
? latestMessage.data?.session_id
: null;
const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
const isSystemInitForView = systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId);
const shouldBypassSessionFilter = isGlobalMessage || isSystemInitForView;
const isUnscopedError = !latestMessage.sessionId &&
pendingViewSessionRef.current &&
!pendingViewSessionRef.current.sessionId &&
(latestMessage.type === 'claude-error' || latestMessage.type === 'cursor-error' || latestMessage.type === 'codex-error');
const handleBackgroundLifecycle = (sessionId) => {
if (!sessionId) return;
if (onSessionInactive) {
onSessionInactive(sessionId);
}
if (onSessionNotProcessing) {
onSessionNotProcessing(sessionId);
}
};
if (!shouldBypassSessionFilter) {
if (!activeViewSessionId) {
// No session in view; ignore session-scoped traffic.
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
if (!isUnscopedError) {
return;
}
}
if (!latestMessage.sessionId && !isUnscopedError) {
// Drop unscoped messages to prevent cross-session bleed.
return;
}
if (latestMessage.sessionId !== activeViewSessionId) {
if (latestMessage.sessionId && lifecycleMessageTypes.has(latestMessage.type)) {
handleBackgroundLifecycle(latestMessage.sessionId);
}
// Message is for a different session, ignore it
console.log('??-?,? Skipping message for different session:', latestMessage.sessionId, 'current:', activeViewSessionId);
return;
}
} }
switch (latestMessage.type) { switch (latestMessage.type) {
@@ -2970,6 +3308,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Store it temporarily until conversation completes (prevents premature session association) // Store it temporarily until conversation completes (prevents premature session association)
if (latestMessage.sessionId && !currentSessionId) { if (latestMessage.sessionId && !currentSessionId) {
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
pendingViewSessionRef.current.sessionId = latestMessage.sessionId;
}
// Mark as system change to prevent clearing messages when session ID updates
setIsSystemSessionChange(true);
// Session Protection: Replace temporary "new-session-*" identifier with real session ID // Session Protection: Replace temporary "new-session-*" identifier with real session ID
// This maintains protection continuity - no gap between temp ID and real ID // This maintains protection continuity - no gap between temp ID and real ID
@@ -2977,6 +3321,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (onReplaceTemporarySession) { if (onReplaceTemporarySession) {
onReplaceTemporarySession(latestMessage.sessionId); onReplaceTemporarySession(latestMessage.sessionId);
} }
// Attach the real session ID to any pending permission requests so they
// do not disappear during the "new-session -> real-session" transition.
// This does not create or auto-approve requests; it only keeps UI state aligned.
setPendingPermissionRequests(prev => prev.map(req => (
req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId }
)));
} }
break; break;
@@ -2988,7 +3339,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
break; break;
case 'claude-response': case 'claude-response':
const messageData = latestMessage.data.message || latestMessage.data;
// Handle Cursor streaming format (content_block_delta / content_block_stop) // Handle Cursor streaming format (content_block_delta / content_block_stop)
if (messageData && typeof messageData === 'object' && messageData.type) { if (messageData && typeof messageData === 'object' && messageData.type) {
@@ -3057,7 +3407,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
latestMessage.data.subtype === 'init' && latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id && latestMessage.data.session_id &&
currentSessionId && currentSessionId &&
latestMessage.data.session_id !== currentSessionId) { latestMessage.data.session_id !== currentSessionId &&
isSystemInitForView) {
console.log('🔄 Claude CLI session duplication detected:', { console.log('🔄 Claude CLI session duplication detected:', {
originalSession: currentSessionId, originalSession: currentSessionId,
@@ -3080,7 +3431,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (latestMessage.data.type === 'system' && if (latestMessage.data.type === 'system' &&
latestMessage.data.subtype === 'init' && latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id && latestMessage.data.session_id &&
!currentSessionId) { !currentSessionId &&
isSystemInitForView) {
console.log('🔄 New session init detected:', { console.log('🔄 New session init detected:', {
newSession: latestMessage.data.session_id newSession: latestMessage.data.session_id
@@ -3101,7 +3453,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
latestMessage.data.subtype === 'init' && latestMessage.data.subtype === 'init' &&
latestMessage.data.session_id && latestMessage.data.session_id &&
currentSessionId && currentSessionId &&
latestMessage.data.session_id === currentSessionId) { latestMessage.data.session_id === currentSessionId &&
isSystemInitForView) {
console.log('🔄 System init message for current session, ignoring'); console.log('🔄 System init message for current session, ignoring');
return; // Don't process the message further return; // Don't process the message further
} }
@@ -3207,6 +3560,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}]); }]);
break; break;
case 'claude-permission-request': {
// Receive a tool approval request from the backend and surface it in the UI.
// This does not approve anything automatically; it only queues a prompt,
// introduced so the user can decide before the SDK continues.
if (provider !== 'claude' || !latestMessage.requestId) {
break;
}
setPendingPermissionRequests(prev => {
if (prev.some(req => req.requestId === latestMessage.requestId)) {
return prev;
}
return [
...prev,
{
requestId: latestMessage.requestId,
toolName: latestMessage.toolName || 'UnknownTool',
input: latestMessage.input,
context: latestMessage.context,
sessionId: latestMessage.sessionId || null,
receivedAt: new Date()
}
];
});
// Keep the session in a "waiting" state while approval is pending.
// This does not resume the run; it only updates the UI status so the
// user knows Claude is blocked on a decision.
setIsLoading(true);
setCanAbortSession(true);
setClaudeStatus({
text: 'Waiting for permission',
tokens: 0,
can_interrupt: true
});
break;
}
case 'claude-permission-cancelled': {
// Backend cancelled the approval (timeout or SDK cancel); remove the banner.
// We currently do not show a user-facing warning here; this is intentional
// to avoid noisy alerts when the SDK cancels in the background.
if (!latestMessage.requestId) {
break;
}
setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId));
break;
}
case 'claude-error': case 'claude-error':
setChatMessages(prev => [...prev, { setChatMessages(prev => [...prev, {
type: 'error', type: 'error',
@@ -3220,6 +3622,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
try { try {
const cdata = latestMessage.data; const cdata = latestMessage.data;
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) { if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
if (!isSystemInitForView) {
return;
}
// If we already have a session and this differs, switch (duplication/redirect) // If we already have a session and this differs, switch (duplication/redirect)
if (currentSessionId && cdata.session_id !== currentSessionId) { if (currentSessionId && cdata.session_id !== currentSessionId) {
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id }); console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
@@ -3403,6 +3808,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject && latestMessage.exitCode === 0) { if (selectedProject && latestMessage.exitCode === 0) {
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
} }
// Conversation finished; clear any stale permission prompts.
// This does not remove saved permissions; it only resets transient UI state.
setPendingPermissionRequests([]);
break; break;
case 'codex-response': case 'codex-response':
@@ -3530,8 +3938,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); const codexPendingSessionId = sessionStorage.getItem('pendingSessionId');
const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId;
if (codexPendingSessionId && !currentSessionId) { if (codexPendingSessionId && !currentSessionId) {
setCurrentSessionId(codexPendingSessionId); setCurrentSessionId(codexActualSessionId);
setIsSystemSessionChange(true);
if (onNavigateToSession) {
onNavigateToSession(codexActualSessionId);
}
sessionStorage.removeItem('pendingSessionId'); sessionStorage.removeItem('pendingSessionId');
console.log('Codex session complete, ID set to:', codexPendingSessionId); console.log('Codex session complete, ID set to:', codexPendingSessionId);
} }
@@ -3573,6 +3986,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
} }
// Abort ends the run; clear permission prompts to avoid dangling UI state.
// This does not change allowlists; it only clears the current banner.
setPendingPermissionRequests([]);
setChatMessages(prev => [...prev, { setChatMessages(prev => [...prev, {
type: 'assistant', type: 'assistant',
content: 'Session interrupted by user.', content: 'Session interrupted by user.',
@@ -3999,6 +4416,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Session Protection: Mark session as active to prevent automatic project updates during conversation // Session Protection: Mark session as active to prevent automatic project updates during conversation
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID // Use existing session if available; otherwise a temporary placeholder until backend provides real ID
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
if (!effectiveSessionId && !selectedSession?.id) {
// We are starting a brand-new session in this view. Track it so we only
// accept streaming updates for this run.
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
}
if (onSessionActive) { if (onSessionActive) {
onSessionActive(sessionToActivate); onSessionActive(sessionToActivate);
} }
@@ -4091,6 +4513,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]); }, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
const handleGrantToolPermission = useCallback((suggestion) => {
if (!suggestion || provider !== 'claude') {
return { success: false };
}
return grantClaudeToolPermission(suggestion.entry);
}, [provider]);
// Send a UI decision back to the server (single or batched request IDs).
// This does not validate tool inputs or permissions; the backend enforces rules.
// It exists so "Allow & remember" can resolve multiple queued prompts at once.
const handlePermissionDecision = useCallback((requestIds, decision) => {
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
const validIds = ids.filter(Boolean);
if (validIds.length === 0) {
return;
}
validIds.forEach((requestId) => {
sendMessage({
type: 'claude-permission-response',
requestId,
allow: Boolean(decision?.allow),
updatedInput: decision?.updatedInput,
message: decision?.message,
rememberEntry: decision?.rememberEntry
});
});
setPendingPermissionRequests(prev => {
const next = prev.filter(req => !validIds.includes(req.requestId));
if (next.length === 0) {
setClaudeStatus(null);
}
return next;
});
}, [sendMessage]);
// Store handleSubmit in ref so handleCustomCommand can access it // Store handleSubmit in ref so handleCustomCommand can access it
useEffect(() => { useEffect(() => {
handleSubmitRef.current = handleSubmit; handleSubmitRef.current = handleSubmit;
@@ -4410,6 +4869,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Messages Area - Scrollable Middle Section */} {/* Messages Area - Scrollable Middle Section */}
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
onWheel={handleScroll}
onTouchMove={handleScroll}
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative" className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
> >
{isLoadingSessionMessages && chatMessages.length === 0 ? ( {isLoadingSessionMessages && chatMessages.length === 0 ? (
@@ -4667,10 +5128,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
createDiff={createDiff} createDiff={createDiff}
onFileOpen={onFileOpen} onFileOpen={onFileOpen}
onShowSettings={onShowSettings} onShowSettings={onShowSettings}
onGrantToolPermission={handleGrantToolPermission}
autoExpandTools={autoExpandTools} autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters} showRawParameters={showRawParameters}
showThinking={showThinking} showThinking={showThinking}
selectedProject={selectedProject} selectedProject={selectedProject}
provider={provider}
/> />
); );
})} })}
@@ -4725,6 +5188,101 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div> </div>
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */} {/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
<div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3"> <div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3">
{pendingPermissionRequests.length > 0 && (
// Permission banner for tool approvals. This renders the input, allows
// "allow once" or "allow & remember", and supports batching similar requests.
// It does not persist permissions by itself; persistence is handled by
// the existing localStorage-based settings helpers, introduced to surface
// approvals before tool execution resumes.
<div className="mb-3 space-y-2">
{pendingPermissionRequests.map((request) => {
const rawInput = formatToolInputForDisplay(request.input);
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
const settings = getClaudeSettings();
const alreadyAllowed = permissionEntry
? settings.allowedTools.includes(permissionEntry)
: false;
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
// Group pending prompts that resolve to the same allow rule so
// a single "Allow & remember" can clear them in one click.
// This does not attempt fuzzy matching; it only batches identical rules.
const matchingRequestIds = permissionEntry
? pendingPermissionRequests
.filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry)
.map(item => item.requestId)
: [request.requestId];
return (
<div
key={request.requestId}
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
Permission required
</div>
<div className="text-xs text-amber-800 dark:text-amber-200">
Tool: <span className="font-mono">{request.toolName}</span>
</div>
</div>
{permissionEntry && (
<div className="text-xs text-amber-700 dark:text-amber-300">
Allow rule: <span className="font-mono">{permissionEntry}</span>
</div>
)}
</div>
{rawInput && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
View tool input
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
{rawInput}
</pre>
</details>
)}
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
>
Allow once
</button>
<button
type="button"
onClick={() => {
if (permissionEntry && !alreadyAllowed) {
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
}
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
}}
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
permissionEntry
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
: 'border-gray-300 text-gray-400 cursor-not-allowed'
}`}
disabled={!permissionEntry}
>
{rememberLabel}
</button>
<button
type="button"
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
>
Deny
</button>
</div>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<button <button
type="button" type="button"

View File

@@ -25,13 +25,15 @@ function LoginModal({
const getCommand = () => { const getCommand = () => {
if (customCommand) return customCommand; if (customCommand) return customCommand;
const isPlatform = import.meta.env.VITE_IS_PLATFORM === 'true';
switch (provider) { switch (provider) {
case 'claude': case 'claude':
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
case 'cursor': case 'cursor':
return 'cursor-agent login'; return 'cursor-agent login';
case 'codex': case 'codex':
return 'codex login'; return isPlatform ? 'codex login --device-auth' : 'codex login';
default: default:
return 'claude setup-token --dangerously-skip-permissions'; return 'claude setup-token --dangerously-skip-permissions';
} }

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react'; import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { api } from '../utils/api'; import { api } from '../utils/api';
@@ -7,7 +7,7 @@ import { api } from '../utils/api';
const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
// Wizard state // Wizard state
const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm
const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new' const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing'
// Form state // Form state
const [workspacePath, setWorkspacePath] = useState(''); const [workspacePath, setWorkspacePath] = useState('');
@@ -23,6 +23,11 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [loadingTokens, setLoadingTokens] = useState(false); const [loadingTokens, setLoadingTokens] = useState(false);
const [pathSuggestions, setPathSuggestions] = useState([]); const [pathSuggestions, setPathSuggestions] = useState([]);
const [showPathDropdown, setShowPathDropdown] = useState(false); const [showPathDropdown, setShowPathDropdown] = useState(false);
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
const [browserCurrentPath, setBrowserCurrentPath] = useState('~');
const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
// Load available GitHub tokens when needed // Load available GitHub tokens when needed
useEffect(() => { useEffect(() => {
@@ -155,6 +160,37 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
setShowPathDropdown(false); setShowPathDropdown(false);
}; };
const openFolderBrowser = async () => {
setShowFolderBrowser(true);
await loadBrowserFolders('~');
};
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
setBrowserCurrentPath(path);
const response = await api.browseFilesystem(path);
const data = await response.json();
setBrowserFolders(data.suggestions || []);
} catch (error) {
console.error('Error loading folders:', error);
} finally {
setLoadingFolders(false);
}
};
const selectFolder = (folderPath, advanceToConfirm = false) => {
setWorkspacePath(folderPath);
setShowFolderBrowser(false);
if (advanceToConfirm) {
setStep(3);
}
};
const navigateToFolder = async (folderPath) => {
await loadBrowserFolders(folderPath);
};
return ( return (
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4"> <div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto"> <div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
@@ -290,28 +326,39 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'} {workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
</label> </label>
<div className="relative"> <div className="relative flex gap-2">
<Input <div className="flex-1 relative">
type="text" <Input
value={workspacePath} type="text"
onChange={(e) => setWorkspacePath(e.target.value)} value={workspacePath}
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'} onChange={(e) => setWorkspacePath(e.target.value)}
className="w-full" placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
/> className="w-full"
{showPathDropdown && pathSuggestions.length > 0 && ( />
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"> {showPathDropdown && pathSuggestions.length > 0 && (
{pathSuggestions.map((suggestion, index) => ( <div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
<button {pathSuggestions.map((suggestion, index) => (
key={index} <button
onClick={() => selectPathSuggestion(suggestion)} key={index}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm" onClick={() => selectPathSuggestion(suggestion)}
> className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div> >
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div> <div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
</button> <div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
))} </button>
</div> ))}
)} </div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={openFolderBrowser}
className="px-3"
title="Browse folders"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div> </div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing' {workspaceType === 'existing'
@@ -563,6 +610,121 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</Button> </Button>
</div> </div>
</div> </div>
{/* Folder Browser Modal */}
{showFolderBrowser && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[70] p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] border border-gray-200 dark:border-gray-700 flex flex-col">
{/* Browser Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Select Folder
</h3>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHiddenFolders(!showHiddenFolders)}
className={`p-2 rounded-md transition-colors ${
showHiddenFolders
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}
>
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button>
<button
onClick={() => setShowFolderBrowser(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Folder List */}
<div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No folders found
</div>
) : (
<div className="space-y-1">
{/* Parent Directory */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
<button
onClick={() => {
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/';
navigateToFolder(parentPath);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderOpen className="w-5 h-5 text-gray-400" />
<span className="font-medium text-gray-700 dark:text-gray-300">..</span>
</button>
)}
{/* Folders */}
{browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, true)}
className="text-xs px-3"
>
Select
</Button>
</div>
))}
</div>
)}
</div>
{/* Browser Footer with Current Path */}
<div className="border-t border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-900/50 flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Path:</span>
<code className="text-sm font-mono text-gray-900 dark:text-white flex-1 truncate">
{browserCurrentPath}
</code>
</div>
<div className="flex items-center justify-end gap-2 p-4">
<Button
variant="outline"
onClick={() => setShowFolderBrowser(false)}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, true)}
>
Use this folder
</Button>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Maximize2, Maximize2,
Eye, Eye,
Settings2, Settings2,
Moon, Moon,
Sun, Sun,
@@ -12,7 +12,8 @@ import {
Brain, Brain,
Sparkles, Sparkles,
FileText, FileText,
Languages Languages,
GripVertical
} from 'lucide-react'; } from 'lucide-react';
import DarkModeToggle from './DarkModeToggle'; import DarkModeToggle from './DarkModeToggle';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
@@ -38,11 +39,170 @@ const QuickSettingsPanel = ({
}); });
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
// Draggable handle state
const [handlePosition, setHandlePosition] = useState(() => {
const saved = localStorage.getItem('quickSettingsHandlePosition');
if (saved) {
try {
const parsed = JSON.parse(saved);
return parsed.y ?? 50;
} catch {
// Remove corrupted data
localStorage.removeItem('quickSettingsHandlePosition');
return 50;
}
}
return 50; // Default to 50% (middle of screen)
});
const [isDragging, setIsDragging] = useState(false);
const [dragStartY, setDragStartY] = useState(0);
const [dragStartPosition, setDragStartPosition] = useState(0);
const [hasMoved, setHasMoved] = useState(false); // Track if user has moved during drag
const handleRef = useRef(null);
const constraintsRef = useRef({ min: 10, max: 90 }); // Percentage constraints
const dragThreshold = 5; // Pixels to move before it's considered a drag
useEffect(() => { useEffect(() => {
setLocalIsOpen(isOpen); setLocalIsOpen(isOpen);
}, [isOpen]); }, [isOpen]);
const handleToggle = () => { // Save handle position to localStorage when it changes
useEffect(() => {
localStorage.setItem('quickSettingsHandlePosition', JSON.stringify({ y: handlePosition }));
}, [handlePosition]);
// Calculate position from percentage
const getPositionStyle = useCallback(() => {
if (isMobile) {
// On mobile, convert percentage to pixels from bottom
const bottomPixels = (window.innerHeight * handlePosition) / 100;
return { bottom: `${bottomPixels}px` };
} else {
// On desktop, use top with percentage
return { top: `${handlePosition}%`, transform: 'translateY(-50%)' };
}
}, [handlePosition, isMobile]);
// Handle mouse/touch start
const handleDragStart = useCallback((e) => {
// Don't prevent default yet - we want to allow click if no drag happens
e.stopPropagation();
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
setDragStartY(clientY);
setDragStartPosition(handlePosition);
setHasMoved(false);
setIsDragging(false); // Don't set dragging until threshold is passed
}, [handlePosition]);
// Handle mouse/touch move
const handleDragMove = useCallback((e) => {
if (dragStartY === 0) return; // Not in a potential drag
const clientY = e.type.includes('touch') ? e.touches[0].clientY : e.clientY;
const deltaY = Math.abs(clientY - dragStartY);
// Check if we've moved past threshold
if (!isDragging && deltaY > dragThreshold) {
setIsDragging(true);
setHasMoved(true);
document.body.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
// Prevent body scroll on mobile during drag
if (e.type.includes('touch')) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
}
}
if (!isDragging) return;
// Prevent scrolling on touch move
if (e.type.includes('touch')) {
e.preventDefault();
}
const actualDeltaY = clientY - dragStartY;
// For top-based positioning (desktop), moving down increases top percentage
// For bottom-based positioning (mobile), we need to invert
let percentageDelta;
if (isMobile) {
// On mobile, moving down should decrease bottom position (increase percentage from top)
percentageDelta = -(actualDeltaY / window.innerHeight) * 100;
} else {
// On desktop, moving down should increase top position
percentageDelta = (actualDeltaY / window.innerHeight) * 100;
}
let newPosition = dragStartPosition + percentageDelta;
// Apply constraints
newPosition = Math.max(constraintsRef.current.min, Math.min(constraintsRef.current.max, newPosition));
setHandlePosition(newPosition);
}, [isDragging, dragStartY, dragStartPosition, isMobile, dragThreshold]);
// Handle mouse/touch end
const handleDragEnd = useCallback(() => {
setIsDragging(false);
setDragStartY(0);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Restore body scroll on mobile
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}, []);
// Cleanup body styles on unmount in case component unmounts while dragging
useEffect(() => {
return () => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, []);
// Set up global event listeners for drag
useEffect(() => {
if (dragStartY !== 0) {
// Mouse events
const handleMouseMove = (e) => handleDragMove(e);
const handleMouseUp = () => handleDragEnd();
// Touch events
const handleTouchMove = (e) => handleDragMove(e);
const handleTouchEnd = () => handleDragEnd();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [dragStartY, handleDragMove, handleDragEnd]);
const handleToggle = (e) => {
// Don't toggle if user was dragging
if (hasMoved) {
e.preventDefault();
setHasMoved(false);
return;
}
const newState = !localIsOpen; const newState = !localIsOpen;
setLocalIsOpen(newState); setLocalIsOpen(newState);
onToggle(newState); onToggle(newState);
@@ -50,24 +210,37 @@ const QuickSettingsPanel = ({
return ( return (
<> <>
{/* Pull Tab */} {/* Pull Tab - Combined drag handle and toggle button */}
<div <button
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${ ref={handleRef}
onClick={handleToggle}
onMouseDown={(e) => {
// Start drag on mousedown
handleDragStart(e);
}}
onTouchStart={(e) => {
// Start drag on touchstart
handleDragStart(e);
}}
className={`fixed ${
localIsOpen ? 'right-64' : 'right-0' localIsOpen ? 'right-64' : 'right-0'
} z-50 transition-all duration-150 ease-out`} } z-50 ${isDragging ? '' : 'transition-all duration-150 ease-out'} bg-white dark:bg-gray-800 border ${
isDragging ? 'border-blue-500 dark:border-blue-400' : 'border-gray-200 dark:border-gray-700'
} rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg ${
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
} touch-none`}
style={{ ...getPositionStyle(), touchAction: 'none', WebkitTouchCallout: 'none', WebkitUserSelect: 'none' }}
aria-label={isDragging ? 'Dragging handle' : localIsOpen ? 'Close settings panel' : 'Open settings panel'}
title={isDragging ? 'Dragging...' : 'Click to toggle, drag to move'}
> >
<button {isDragging ? (
onClick={handleToggle} <GripVertical className="h-5 w-5 text-blue-500 dark:text-blue-400" />
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-lg" ) : localIsOpen ? (
aria-label={localIsOpen ? 'Close settings panel' : 'Open settings panel'} <ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
> ) : (
{localIsOpen ? ( <ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" /> )}
) : ( </button>
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
</div>
{/* Panel */} {/* Panel */}
<div <div

View File

@@ -595,9 +595,44 @@ function Sidebar({
</div> </div>
</div> </div>
{/* Search Filter and Actions */} {/* Action Buttons - Desktop only - Always show when not loading */}
{!isLoading && !isMobile && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<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"
>
<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>
)}
{/* Search Filter - Only show when there are projects */}
{projects.length > 0 && !isLoading && ( {projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2"> <div className="px-3 md:px-4 py-2 border-b border-border">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input <Input
@@ -616,39 +651,6 @@ function Sidebar({
</button> </button>
)} )}
</div> </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> </div>
)} )}
@@ -1392,4 +1394,4 @@ function Sidebar({
); );
} }
export default Sidebar; export default Sidebar;

View File

@@ -3,4 +3,13 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function safeJsonParse(value) {
if (!value || typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}